From 5878990dc4981fde052b779e35524bd7977af55c Mon Sep 17 00:00:00 2001 From: whatevertogo Date: Wed, 15 Apr 2026 21:32:16 +0800 Subject: [PATCH 01/53] =?UTF-8?q?=E2=9C=A8=20feat(chat):=20=E5=A2=9E?= =?UTF-8?q?=E5=BC=BA=E5=B7=A5=E5=85=B7=E8=B0=83=E7=94=A8=E5=9D=97=EF=BC=8C?= =?UTF-8?q?=E6=94=AF=E6=8C=81=E5=AE=9E=E6=97=B6=E8=BE=93=E5=87=BA=E5=92=8C?= =?UTF-8?q?=E7=8A=B6=E6=80=81=E6=8C=87=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/session-runtime/src/turn/tool_cycle.rs | 112 +++++++++++++++++- .../src/components/Chat/ToolCallBlock.tsx | 42 +++++-- 2 files changed, 140 insertions(+), 14 deletions(-) diff --git a/crates/session-runtime/src/turn/tool_cycle.rs b/crates/session-runtime/src/turn/tool_cycle.rs index 72bfbf57..2fbafa6a 100644 --- a/crates/session-runtime/src/turn/tool_cycle.rs +++ b/crates/session-runtime/src/turn/tool_cycle.rs @@ -397,6 +397,7 @@ async fn invoke_single_tool( "tool start", ) .await; + broadcast_tool_start(&session_state, turn_id, agent, tool_call); // 构建工具上下文 let tool_ctx = ToolContext::new( @@ -447,6 +448,15 @@ async fn invoke_single_tool( "tool result", ) .await; + broadcast_tool_result( + &session_state, + turn_id, + agent, + &ToolExecutionResult { + duration_ms, + ..result.clone() + }, + ); let mut events = buffered_events .lock() @@ -472,6 +482,34 @@ fn broadcast_tool_output_delta( }); } +fn broadcast_tool_start( + session_state: &SessionState, + turn_id: &str, + agent: &AgentEventContext, + tool_call: &ToolCallRequest, +) { + session_state.broadcast_live_event(astrcode_core::AgentEvent::ToolCallStart { + turn_id: turn_id.to_string(), + agent: agent.clone(), + tool_call_id: tool_call.id.clone(), + tool_name: tool_call.name.clone(), + input: tool_call.args.clone(), + }); +} + +fn broadcast_tool_result( + session_state: &SessionState, + turn_id: &str, + agent: &AgentEventContext, + result: &ToolExecutionResult, +) { + session_state.broadcast_live_event(astrcode_core::AgentEvent::ToolCallResult { + turn_id: turn_id.to_string(), + agent: agent.clone(), + result: result.clone(), + }); +} + async fn emit_or_buffer_tool_event( event_sink: &Option>, events: &mut Vec, @@ -763,13 +801,35 @@ mod tests { "tool result should be durably recorded immediately" ); - let live_event = timeout(Duration::from_secs(1), live_receiver.recv()) + let live_start = timeout(Duration::from_secs(1), live_receiver.recv()) + .await + .expect("live receiver should get tool start in time") + .expect("live receiver should stay open"); + let live_delta = timeout(Duration::from_secs(1), live_receiver.recv()) .await .expect("live receiver should get stdout delta in time") .expect("live receiver should stay open"); + let live_result = timeout(Duration::from_secs(1), live_receiver.recv()) + .await + .expect("live receiver should get tool result in time") + .expect("live receiver should stay open"); assert!( matches!( - live_event, + live_start, + astrcode_core::AgentEvent::ToolCallStart { + turn_id, + tool_call_id, + tool_name, + .. + } if turn_id == "turn-live" + && tool_call_id == "call-live" + && tool_name == "streaming_probe" + ), + "tool start should go through the live channel immediately" + ); + assert!( + matches!( + live_delta, astrcode_core::AgentEvent::ToolCallDelta { turn_id, tool_call_id, @@ -784,6 +844,17 @@ mod tests { ), "stdout delta should go through the live channel immediately" ); + assert!( + matches!( + live_result, + astrcode_core::AgentEvent::ToolCallResult { turn_id, result, .. } + if turn_id == "turn-live" + && result.tool_call_id == "call-live" + && result.tool_name == "streaming_probe" + && result.output == "done" + ), + "tool result should go through the live channel immediately" + ); } #[tokio::test] @@ -847,13 +918,35 @@ mod tests { "buffered mode should not immediately append durable events" ); - let live_event = timeout(Duration::from_secs(1), live_receiver.recv()) + let live_start = timeout(Duration::from_secs(1), live_receiver.recv()) + .await + .expect("live receiver should get tool start in time") + .expect("live receiver should stay open"); + let live_delta = timeout(Duration::from_secs(1), live_receiver.recv()) .await .expect("live receiver should get stdout delta in time") .expect("live receiver should stay open"); + let live_result = timeout(Duration::from_secs(1), live_receiver.recv()) + .await + .expect("live receiver should get tool result in time") + .expect("live receiver should stay open"); assert!( matches!( - live_event, + live_start, + astrcode_core::AgentEvent::ToolCallStart { + turn_id, + tool_call_id, + tool_name, + .. + } if turn_id == "turn-buffered" + && tool_call_id == "call-buffered" + && tool_name == "streaming_probe" + ), + "buffered mode should still broadcast tool start live" + ); + assert!( + matches!( + live_delta, astrcode_core::AgentEvent::ToolCallDelta { turn_id, tool_call_id, @@ -868,5 +961,16 @@ mod tests { ), "buffered mode should keep live stdout forwarding" ); + assert!( + matches!( + live_result, + astrcode_core::AgentEvent::ToolCallResult { turn_id, result, .. } + if turn_id == "turn-buffered" + && result.tool_call_id == "call-buffered" + && result.tool_name == "streaming_probe" + && result.output == "done" + ), + "buffered mode should still broadcast tool result live" + ); } } diff --git a/frontend/src/components/Chat/ToolCallBlock.tsx b/frontend/src/components/Chat/ToolCallBlock.tsx index 05d258d0..ae1238d8 100644 --- a/frontend/src/components/Chat/ToolCallBlock.tsx +++ b/frontend/src/components/Chat/ToolCallBlock.tsx @@ -1,7 +1,7 @@ import { memo } from 'react'; import type { ToolCallMessage } from '../../types'; -import { pillDanger, pillNeutral, pillSuccess } from '../../lib/styles'; +import { chevronIcon, pillDanger, pillNeutral, pillSuccess } from '../../lib/styles'; import { cn } from '../../lib/utils'; interface ToolCallBlockProps { @@ -31,19 +31,41 @@ function statusLabel(status: ToolCallMessage['status']): string { } function ToolCallBlock({ message }: ToolCallBlockProps) { + const summary = message.output?.trim() || '调用工具'; + const bodyText = + message.error?.trim() || + message.output?.trim() || + (message.status === 'running' ? '工具已启动,实时输出见下方。' : '工具已完成。'); + const defaultOpen = message.status !== 'running' || Boolean(message.error); + return ( -
-
+
+ {message.toolName} - {message.output ?? '调用工具'} + {summary} {statusLabel(message.status)} + + + + + + +
+ {bodyText}
- {message.error ? ( -
- {message.error} -
- ) : null} -
+ ); } From acc3f78fd10c75130bfbfd71d653eb126b9f3c13 Mon Sep 17 00:00:00 2001 From: whatevertogo Date: Wed, 15 Apr 2026 22:07:21 +0800 Subject: [PATCH 02/53] =?UTF-8?q?=E2=9C=A8=20feat(terminal):=20preserve=20?= =?UTF-8?q?tool=20call=20input=20and=20render=20grouped=20tool=20streams?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit crates/protocol/src/http/terminal/v1.rs - Why: Add optional tool input field to terminal v1 DTO so tool call context is preserved in snapshots. - How: Extended TerminalToolCallBlockDto with optional input. crates/server/src/http/terminal_projection.rs - Why: Fix terminal projection block lifecycle across live and durable tool calls and support tool input propagation. - How: Reworked TurnBlockRefs state machine, preserved pending/historical block ids, carried tool input into tool call deltas, and tightened finalization of stream blocks. crates/session-runtime/src/turn/runner/step/mod.rs - Why: Prevent empty assistant final events from being persisted when there is no meaningful assistant output. - How: Only emit assistant final event when assistant content or reasoning content is persistable. crates/session-runtime/src/turn/runner/step/tests.rs - Why: Align test expectations with the new no-empty-assistant-final behavior. - How: Removed strict assistant-final presence assertion and added explicit empty-assistant-final guard. frontend/src/components/Chat/MessageList.tsx - Why: Display tool stream messages grouped under their parent tool call instead of as separate rows. - How: Collected adjacent toolStream messages for toolCall entries and updated row rendering traversal. frontend/src/components/Chat/ToolCallBlock.tsx - Why: Improve tool result rendering with structured args, JSON output, and stream output grouping. - How: Added tool stream rendering, shell/JSON formatting helpers, and richer fallback UI. frontend/src/lib/api/conversation.ts - Why: Keep tool call args from terminal blocks and avoid duplicate appended blocks during stream replay. - How: Map block input into tool call args and replace existing blocks by id when appending. --- crates/protocol/src/http/terminal/v1.rs | 2 + .../tests/fixtures/terminal/v1/snapshot.json | 3 + crates/protocol/tests/terminal_conformance.rs | 1 + crates/server/src/http/terminal_projection.rs | 278 +++++++++++++++--- .../src/turn/runner/step/mod.rs | 36 ++- .../src/turn/runner/step/tests.rs | 12 +- .../session-runtime/src/turn/test_support.rs | 9 - frontend/src/components/Chat/MessageList.tsx | 73 +++-- .../components/Chat/ToolCallBlock.test.tsx | 65 ++++ .../src/components/Chat/ToolCallBlock.tsx | 187 +++++++++++- .../src/components/Chat/ToolCodePanel.tsx | 36 +++ frontend/src/components/Chat/ToolJsonView.tsx | 23 +- .../src/components/Chat/ToolStreamBlock.tsx | 39 ++- .../Chat/useNestedScrollContainment.ts | 35 +++ frontend/src/lib/api/conversation.test.ts | 56 +++- frontend/src/lib/api/conversation.ts | 19 +- 16 files changed, 748 insertions(+), 126 deletions(-) create mode 100644 frontend/src/components/Chat/ToolCallBlock.test.tsx create mode 100644 frontend/src/components/Chat/ToolCodePanel.tsx create mode 100644 frontend/src/components/Chat/useNestedScrollContainment.ts diff --git a/crates/protocol/src/http/terminal/v1.rs b/crates/protocol/src/http/terminal/v1.rs index 4935dcb3..94325785 100644 --- a/crates/protocol/src/http/terminal/v1.rs +++ b/crates/protocol/src/http/terminal/v1.rs @@ -175,6 +175,8 @@ pub struct TerminalToolCallBlockDto { pub tool_name: String, pub status: TerminalBlockStatusDto, #[serde(default, skip_serializing_if = "Option::is_none")] + pub input: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] pub summary: Option, } diff --git a/crates/protocol/tests/fixtures/terminal/v1/snapshot.json b/crates/protocol/tests/fixtures/terminal/v1/snapshot.json index 7891bced..194ac0b8 100644 --- a/crates/protocol/tests/fixtures/terminal/v1/snapshot.json +++ b/crates/protocol/tests/fixtures/terminal/v1/snapshot.json @@ -38,6 +38,9 @@ "toolCallId": "tool-call-1", "toolName": "shell_command", "status": "complete", + "input": { + "command": "rg terminal" + }, "summary": "读取 protocol 上下文" }, { diff --git a/crates/protocol/tests/terminal_conformance.rs b/crates/protocol/tests/terminal_conformance.rs index dbee946e..fb6df037 100644 --- a/crates/protocol/tests/terminal_conformance.rs +++ b/crates/protocol/tests/terminal_conformance.rs @@ -88,6 +88,7 @@ fn terminal_snapshot_fixture_freezes_v1_hydration_shape() { tool_call_id: Some("tool-call-1".to_string()), tool_name: "shell_command".to_string(), status: TerminalBlockStatusDto::Complete, + input: Some(json!({ "command": "rg terminal" })), summary: Some("读取 protocol 上下文".to_string()), }), TerminalBlockDto::ToolStream(TerminalToolStreamBlockDto { diff --git a/crates/server/src/http/terminal_projection.rs b/crates/server/src/http/terminal_projection.rs index 5d80a7b9..cd9c0e95 100644 --- a/crates/server/src/http/terminal_projection.rs +++ b/crates/server/src/http/terminal_projection.rs @@ -21,6 +21,7 @@ use astrcode_protocol::http::{ TerminalToolStreamBlockDto, TerminalTranscriptErrorCodeDto, TerminalUserBlockDto, ToolOutputStreamDto, }; +use serde_json::Value; pub(crate) fn project_terminal_snapshot(facts: &TerminalFacts) -> TerminalSnapshotResponseDto { let child_lookup = child_summary_lookup(&facts.child_summaries); @@ -334,8 +335,14 @@ pub(crate) struct TerminalDeltaProjector { #[derive(Default, Clone)] struct TurnBlockRefs { - thinking: Option, - assistant: Option, + current_thinking: Option, + current_assistant: Option, + historical_thinking: Vec, + historical_assistant: Vec, + pending_thinking: Vec, + pending_assistant: Vec, + thinking_count: usize, + assistant_count: usize, } #[derive(Default, Clone)] @@ -381,6 +388,91 @@ impl ToolBlockRefs { } } +impl TurnBlockRefs { + fn current_or_next_block_id(&mut self, turn_id: &str, kind: BlockKind) -> String { + match kind { + BlockKind::Thinking => { + if let Some(block_id) = &self.current_thinking { + return block_id.clone(); + } + self.thinking_count += 1; + let block_id = turn_scoped_block_id(turn_id, "thinking", self.thinking_count); + self.current_thinking = Some(block_id.clone()); + block_id + }, + BlockKind::Assistant => { + if let Some(block_id) = &self.current_assistant { + return block_id.clone(); + } + self.assistant_count += 1; + let block_id = turn_scoped_block_id(turn_id, "assistant", self.assistant_count); + self.current_assistant = Some(block_id.clone()); + block_id + }, + } + } + + fn block_id_for_finalize(&mut self, turn_id: &str, kind: BlockKind) -> String { + match kind { + BlockKind::Thinking => { + if let Some(block_id) = self.pending_thinking.first().cloned() { + self.pending_thinking.remove(0); + return block_id; + } + self.current_or_next_block_id(turn_id, kind) + }, + BlockKind::Assistant => { + if let Some(block_id) = self.pending_assistant.first().cloned() { + self.pending_assistant.remove(0); + return block_id; + } + self.current_or_next_block_id(turn_id, kind) + }, + } + } + + fn split_after_live_tool_boundary(&mut self) { + if let Some(block_id) = self.current_thinking.take() { + self.pending_thinking.push(block_id); + } + if let Some(block_id) = self.current_assistant.take() { + self.pending_assistant.push(block_id); + } + } + + fn split_after_durable_tool_boundary(&mut self) { + if let Some(block_id) = self.current_thinking.take() { + self.historical_thinking.push(block_id); + } + if let Some(block_id) = self.current_assistant.take() { + self.historical_assistant.push(block_id); + } + } + + fn all_block_ids(&self) -> Vec { + let mut ids = Vec::new(); + ids.extend(self.historical_thinking.iter().cloned()); + ids.extend(self.historical_assistant.iter().cloned()); + ids.extend(self.pending_thinking.iter().cloned()); + ids.extend(self.pending_assistant.iter().cloned()); + if let Some(block_id) = &self.current_thinking { + ids.push(block_id.clone()); + } + if let Some(block_id) = &self.current_assistant { + ids.push(block_id.clone()); + } + ids + } +} + +fn turn_scoped_block_id(turn_id: &str, role: &str, ordinal: usize) -> String { + if ordinal <= 1 { + format!("turn:{turn_id}:{role}") + } else { + format!("turn:{turn_id}:{role}:{ordinal}") + } +} + impl TerminalDeltaProjector { pub(crate) fn new(child_lookup: HashMap) -> Self { Self { @@ -421,25 +513,10 @@ impl TerminalDeltaProjector { self.append_user_block(&block_id, turn_id, content) }, AgentEvent::ThinkingDelta { turn_id, delta, .. } => { - let block_id = format!("turn:{turn_id}:thinking"); - self.turn_blocks - .entry(turn_id.clone()) - .or_default() - .thinking = Some(block_id.clone()); - self.append_markdown_streaming_block(&block_id, turn_id, delta, BlockKind::Thinking) + self.append_markdown_streaming_block(turn_id, delta, BlockKind::Thinking) }, AgentEvent::ModelDelta { turn_id, delta, .. } => { - let block_id = format!("turn:{turn_id}:assistant"); - self.turn_blocks - .entry(turn_id.clone()) - .or_default() - .assistant = Some(block_id.clone()); - self.append_markdown_streaming_block( - &block_id, - turn_id, - delta, - BlockKind::Assistant, - ) + self.append_markdown_streaming_block(turn_id, delta, BlockKind::Assistant) }, AgentEvent::AssistantMessage { turn_id, @@ -453,8 +530,9 @@ impl TerminalDeltaProjector { turn_id, tool_call_id, tool_name, + input, .. - } => self.start_tool_call(turn_id, tool_call_id, tool_name), + } => self.start_tool_call(turn_id, tool_call_id, tool_name, Some(input), source), AgentEvent::ToolCallDelta { turn_id, tool_call_id, @@ -529,15 +607,19 @@ impl TerminalDeltaProjector { fn append_markdown_streaming_block( &mut self, - block_id: &str, turn_id: &str, delta: &str, kind: BlockKind, ) -> Vec { - if let Some(index) = self.block_index.get(block_id).copied() { + let block_id = self + .turn_blocks + .entry(turn_id.to_string()) + .or_default() + .current_or_next_block_id(turn_id, kind); + if let Some(index) = self.block_index.get(&block_id).copied() { self.append_markdown(index, delta); return vec![TerminalDeltaDto::PatchBlock { - block_id: block_id.to_string(), + block_id, patch: TerminalBlockPatchDto::AppendMarkdown { markdown: delta.to_string(), }, @@ -546,13 +628,13 @@ impl TerminalDeltaProjector { let block = match kind { BlockKind::Thinking => TerminalBlockDto::Thinking(TerminalThinkingBlockDto { - id: block_id.to_string(), + id: block_id.clone(), turn_id: Some(turn_id.to_string()), status: TerminalBlockStatusDto::Streaming, markdown: delta.to_string(), }), BlockKind::Assistant => TerminalBlockDto::Assistant(TerminalAssistantBlockDto { - id: block_id.to_string(), + id: block_id, turn_id: Some(turn_id.to_string()), status: TerminalBlockStatusDto::Streaming, markdown: delta.to_string(), @@ -567,12 +649,21 @@ impl TerminalDeltaProjector { content: &str, reasoning_content: Option<&str>, ) -> Vec { - let assistant_id = format!("turn:{turn_id}:assistant"); - let thinking_id = format!("turn:{turn_id}:thinking"); + let (assistant_id, thinking_id) = { + let turn_refs = self.turn_blocks.entry(turn_id.to_string()).or_default(); + ( + turn_refs.block_id_for_finalize(turn_id, BlockKind::Assistant), + reasoning_content + .filter(|value| !value.trim().is_empty()) + .map(|_| turn_refs.block_id_for_finalize(turn_id, BlockKind::Thinking)), + ) + }; let mut deltas = Vec::new(); - if let Some(reasoning_content) = reasoning_content.filter(|value| !value.trim().is_empty()) - { + if let (Some(reasoning_content), Some(thinking_id)) = ( + reasoning_content.filter(|value| !value.trim().is_empty()), + thinking_id, + ) { deltas.extend(self.ensure_full_markdown_block( &thinking_id, turn_id, @@ -649,6 +740,8 @@ impl TerminalDeltaProjector { turn_id: &str, tool_call_id: &str, tool_name: &str, + input: Option<&Value>, + source: ProjectionSource, ) -> Vec { let block_id = format!("tool:{tool_call_id}:call"); let refs = self @@ -660,12 +753,19 @@ impl TerminalDeltaProjector { if self.block_index.contains_key(&block_id) { return Vec::new(); } + let turn_refs = self.turn_blocks.entry(turn_id.to_string()).or_default(); + if source.is_live() { + turn_refs.split_after_live_tool_boundary(); + } else { + turn_refs.split_after_durable_tool_boundary(); + } self.push_block(TerminalBlockDto::ToolCall(TerminalToolCallBlockDto { id: block_id, turn_id: Some(turn_id.to_string()), tool_call_id: Some(tool_call_id.to_string()), tool_name: tool_name.to_string(), status: TerminalBlockStatusDto::Streaming, + input: input.cloned(), summary: None, })) } @@ -679,7 +779,7 @@ impl TerminalDeltaProjector { delta: &str, source: ProjectionSource, ) -> Vec { - let mut deltas = self.start_tool_call(turn_id, tool_call_id, tool_name); + let mut deltas = self.start_tool_call(turn_id, tool_call_id, tool_name, None, source); let refs = self .tool_blocks .entry(tool_call_id.to_string()) @@ -727,7 +827,13 @@ impl TerminalDeltaProjector { result: &ToolExecutionResult, source: ProjectionSource, ) -> Vec { - let mut deltas = self.start_tool_call(turn_id, &result.tool_call_id, &result.tool_name); + let mut deltas = self.start_tool_call( + turn_id, + &result.tool_call_id, + &result.tool_name, + None, + source, + ); let status = if result.ok { TerminalBlockStatusDto::Complete } else { @@ -866,13 +972,8 @@ impl TerminalDeltaProjector { return Vec::new(); }; let mut deltas = Vec::new(); - if let Some(thinking) = refs.thinking { - if let Some(delta) = self.complete_block(&thinking, TerminalBlockStatusDto::Complete) { - deltas.push(delta); - } - } - if let Some(assistant) = refs.assistant { - if let Some(delta) = self.complete_block(&assistant, TerminalBlockStatusDto::Complete) { + for block_id in refs.all_block_ids() { + if let Some(delta) = self.complete_block(&block_id, TerminalBlockStatusDto::Complete) { deltas.push(delta); } } @@ -999,6 +1100,10 @@ impl ProjectionSource { fn is_durable(self) -> bool { matches!(self, Self::Durable) } + + fn is_live(self) -> bool { + matches!(self, Self::Live) + } } fn block_id(block: &TerminalBlockDto) -> &str { @@ -1113,6 +1218,9 @@ mod tests { "toolCallId": "call-1", "toolName": "shell_command", "status": "complete", + "input": { + "command": "rg terminal" + }, "summary": "read files" }, { @@ -1206,7 +1314,10 @@ mod tests { "turnId": "turn-1", "toolCallId": "call-1", "toolName": "shell_command", - "status": "streaming" + "status": "streaming", + "input": { + "command": "pwd" + } } }, { @@ -1437,7 +1548,8 @@ mod tests { "turnId": "turn-1", "toolCallId": "call-1", "toolName": "web", - "status": "streaming" + "status": "streaming", + "input": {} } } ]) @@ -1560,6 +1672,92 @@ mod tests { ); } + #[test] + fn durable_multi_step_turn_keeps_final_assistant_after_tool_blocks() { + let mut projector = TerminalDeltaProjector::default(); + let agent = sample_agent_context(); + + projector.seed(&[ + record( + "1.1", + AgentEvent::AssistantMessage { + turn_id: "turn-1".to_string(), + agent: agent.clone(), + content: "好的,让我先浏览一下项目。".to_string(), + reasoning_content: None, + }, + ), + record( + "1.2", + AgentEvent::ToolCallStart { + turn_id: "turn-1".to_string(), + agent: agent.clone(), + tool_call_id: "call-1".to_string(), + tool_name: "listDir".to_string(), + input: json!({ "path": "." }), + }, + ), + record( + "1.3", + AgentEvent::ToolCallResult { + turn_id: "turn-1".to_string(), + agent: agent.clone(), + result: ToolExecutionResult { + tool_call_id: "call-1".to_string(), + tool_name: "listDir".to_string(), + ok: true, + output: "[{\"name\":\"crates\"}]".to_string(), + error: None, + metadata: None, + duration_ms: 1, + truncated: false, + }, + }, + ), + record( + "1.4", + AgentEvent::AssistantMessage { + turn_id: "turn-1".to_string(), + agent, + content: "现在我对项目有了全面的了解。".to_string(), + reasoning_content: None, + }, + ), + ]); + + assert_eq!( + serde_json::to_value(&projector.blocks).expect("blocks should encode"), + json!([ + { + "kind": "assistant", + "id": "turn:turn-1:assistant", + "turnId": "turn-1", + "status": "complete", + "markdown": "好的,让我先浏览一下项目。" + }, + { + "kind": "tool_call", + "id": "tool:call-1:call", + "turnId": "turn-1", + "toolCallId": "call-1", + "toolName": "listDir", + "status": "complete", + "input": { + "path": "." + }, + "summary": "[{\"name\":\"crates\"}]" + }, + { + "kind": "assistant", + "id": "turn:turn-1:assistant:2", + "turnId": "turn-1", + "status": "complete", + "markdown": "现在我对项目有了全面的了解。" + } + ]) + ); + } + #[test] fn classify_transcript_error_covers_all_supported_buckets() { assert_eq!( diff --git a/crates/session-runtime/src/turn/runner/step/mod.rs b/crates/session-runtime/src/turn/runner/step/mod.rs index dd6c0eaf..243bdde1 100644 --- a/crates/session-runtime/src/turn/runner/step/mod.rs +++ b/crates/session-runtime/src/turn/runner/step/mod.rs @@ -170,6 +170,18 @@ fn append_assistant_output( ) -> bool { let content = output.content.trim().to_string(); let has_tool_calls = !output.tool_calls.is_empty(); + let reasoning_content = output + .reasoning + .as_ref() + .map(|reasoning| reasoning.content.clone()); + let reasoning_signature = output + .reasoning + .as_ref() + .and_then(|reasoning| reasoning.signature.clone()); + let has_persistable_assistant_output = !content.is_empty() + || reasoning_content + .as_deref() + .is_some_and(|value| !value.trim().is_empty()); execution.messages.push(LlmMessage::Assistant { content: content.clone(), tool_calls: output.tool_calls.clone(), @@ -178,20 +190,16 @@ fn append_assistant_output( execution .micro_compact_state .record_assistant_activity(Instant::now()); - execution.events.push(assistant_final_event( - resources.turn_id, - resources.agent, - content, - output - .reasoning - .as_ref() - .map(|reasoning| reasoning.content.clone()), - output - .reasoning - .as_ref() - .and_then(|reasoning| reasoning.signature.clone()), - Some(Utc::now()), - )); + if has_persistable_assistant_output { + execution.events.push(assistant_final_event( + resources.turn_id, + resources.agent, + content, + reasoning_content, + reasoning_signature, + Some(Utc::now()), + )); + } has_tool_calls } diff --git a/crates/session-runtime/src/turn/runner/step/tests.rs b/crates/session-runtime/src/turn/runner/step/tests.rs index 46e855fd..c17778fc 100644 --- a/crates/session-runtime/src/turn/runner/step/tests.rs +++ b/crates/session-runtime/src/turn/runner/step/tests.rs @@ -32,8 +32,8 @@ use crate::{ request::AssemblePromptResult, runner::TurnExecutionRequestView, test_support::{ - NoopPromptFactsProvider, assert_contains_compact_summary, assert_has_assistant_final, - assert_has_turn_done, root_compact_applied_event, test_gateway, test_session_state, + NoopPromptFactsProvider, assert_contains_compact_summary, assert_has_turn_done, + root_compact_applied_event, test_gateway, test_session_state, }, tool_cycle::{ToolCycleOutcome, ToolCycleResult, ToolEventEmissionMode}, }, @@ -387,7 +387,13 @@ async fn run_single_step_returns_cancelled_when_tool_cycle_interrupts() { )); assert_eq!(execution.step_index, 0); assert_eq!(driver.counts.tool_cycle.load(Ordering::SeqCst), 1); - assert_has_assistant_final(&execution.events); + assert!( + execution + .events + .iter() + .all(|event| !matches!(&event.payload, StorageEventPayload::AssistantFinal { .. })), + "tool-only interrupted step should not persist an empty assistant final" + ); } #[tokio::test] diff --git a/crates/session-runtime/src/turn/test_support.rs b/crates/session-runtime/src/turn/test_support.rs index 32ee1b58..f2430ed4 100644 --- a/crates/session-runtime/src/turn/test_support.rs +++ b/crates/session-runtime/src/turn/test_support.rs @@ -382,15 +382,6 @@ pub(crate) fn assert_has_turn_done(events: &[StorageEvent]) { ); } -pub(crate) fn assert_has_assistant_final(events: &[StorageEvent]) { - assert!( - events - .iter() - .any(|event| matches!(&event.payload, StorageEventPayload::AssistantFinal { .. })), - "expected events to contain AssistantFinal" - ); -} - pub(crate) async fn append_root_turn_event_to_actor( actor: &Arc, event: StorageEvent, diff --git a/frontend/src/components/Chat/MessageList.tsx b/frontend/src/components/Chat/MessageList.tsx index 86c04415..a764bc73 100644 --- a/frontend/src/components/Chat/MessageList.tsx +++ b/frontend/src/components/Chat/MessageList.tsx @@ -1,5 +1,5 @@ import React, { Component, useCallback, useEffect, useRef } from 'react'; -import type { Message, SubRunViewData, ThreadItem } from '../../types'; +import type { Message, SubRunViewData, ThreadItem, ToolStreamMessage } from '../../types'; import { emptyStateSurface, errorSurface } from '../../lib/styles'; import { cn } from '../../lib/utils'; import AssistantMessage from './AssistantMessage'; @@ -224,6 +224,7 @@ export default function MessageList({ metrics?: Message, options?: { nested?: boolean; + groupedToolStreams?: ToolStreamMessage[]; } ) => { if (msg.kind === 'user') { @@ -245,7 +246,7 @@ export default function MessageList({ ); } if (msg.kind === 'toolCall') { - return ; + return ; } if (msg.kind === 'toolStream') { return ; @@ -272,6 +273,7 @@ export default function MessageList({ options?: { key?: string; nested?: boolean; + groupedToolStreams?: ToolStreamMessage[]; }, metricsOverride?: Message ) => { @@ -307,8 +309,11 @@ export default function MessageList({ options?: { nested?: boolean; } - ): React.ReactNode[] => - items.map((item, index) => { + ): React.ReactNode[] => { + const rendered: React.ReactNode[] = []; + + for (let index = 0; index < items.length; index += 1) { + const item = items[index]; if (item.kind === 'message') { const previousItem = items[index - 1]; const nextItem = items[index + 1]; @@ -316,10 +321,11 @@ export default function MessageList({ const nextMessage = nextItem?.kind === 'message' ? nextItem.message : null; if (item.message.kind === 'promptMetrics') { - return null; + continue; } let metricsToAttach: Message | undefined; + let groupedToolStreams: ToolStreamMessage[] | undefined; if (item.message.kind === 'assistant') { let hasMoreAssistantInTurn = false; const currentTurnId = item.message.turnId; @@ -365,21 +371,47 @@ export default function MessageList({ } } - return renderMessageRow( - item.message, - previousMessage, - nextMessage, - { - key: item.message.id, - nested: options?.nested, - }, - metricsToAttach + if (item.message.kind === 'toolCall') { + groupedToolStreams = []; + let cursor = index + 1; + while (cursor < items.length) { + const candidate = items[cursor]; + if (candidate.kind !== 'message' || candidate.message.kind !== 'toolStream') { + break; + } + if (candidate.message.toolCallId !== item.message.toolCallId) { + break; + } + groupedToolStreams.push(candidate.message); + cursor += 1; + } + if (groupedToolStreams.length > 0) { + index = cursor - 1; + } else { + groupedToolStreams = undefined; + } + } + + rendered.push( + renderMessageRow( + item.message, + previousMessage, + nextMessage, + { + key: item.message.id, + nested: options?.nested, + groupedToolStreams, + }, + metricsToAttach + ) ); + continue; } const subRunView = subRunViews.get(item.subRunId); if (!subRunView) { - return ( + rendered.push( + (
subRunId: {item.subRunId}
+ ) ); + continue; } const boundaryMessage = @@ -420,7 +454,8 @@ export default function MessageList({ /> ); - return ( + rendered.push( + (
{boundaryMessage ? ( {subRunBlock} @@ -428,8 +463,12 @@ export default function MessageList({ subRunBlock )}
+ ) ); - }), + } + + return rendered; + }, [onCancelSubRun, onOpenChildSession, onOpenSubRun, renderMessageRow, sessionId, subRunViews] ); diff --git a/frontend/src/components/Chat/ToolCallBlock.test.tsx b/frontend/src/components/Chat/ToolCallBlock.test.tsx new file mode 100644 index 00000000..bf30b983 --- /dev/null +++ b/frontend/src/components/Chat/ToolCallBlock.test.tsx @@ -0,0 +1,65 @@ +import { renderToStaticMarkup } from 'react-dom/server'; +import { describe, expect, it } from 'vitest'; + +import ToolCallBlock from './ToolCallBlock'; + +describe('ToolCallBlock', () => { + it('renders real tool args in the collapsed summary and grouped result output in the body', () => { + const html = renderToStaticMarkup( + + ); + + expect(html).toContain('已运行 readFile'); + expect(html).toContain('path="Cargo.toml"'); + expect(html).toContain('limit=220'); + expect(html).toContain('[workspace]'); + expect(html).toContain('调用参数'); + }); + + it('renders fallback result surface when no streamed output exists', () => { + const html = renderToStaticMarkup( + + ); + + expect(html).toContain('找到 12 个文件'); + expect(html).toContain('结果'); + }); +}); diff --git a/frontend/src/components/Chat/ToolCallBlock.tsx b/frontend/src/components/Chat/ToolCallBlock.tsx index ae1238d8..139027f8 100644 --- a/frontend/src/components/Chat/ToolCallBlock.tsx +++ b/frontend/src/components/Chat/ToolCallBlock.tsx @@ -1,11 +1,26 @@ import { memo } from 'react'; -import type { ToolCallMessage } from '../../types'; -import { chevronIcon, pillDanger, pillNeutral, pillSuccess } from '../../lib/styles'; +import type { ToolCallMessage, ToolStreamMessage } from '../../types'; +import { + extractStructuredArgs, + extractStructuredJsonOutput, + extractToolMetadataSummary, + extractToolShellDisplay, + formatToolCallSummary, +} from '../../lib/toolDisplay'; +import { + chevronIcon, + pillDanger, + pillNeutral, + pillSuccess, +} from '../../lib/styles'; import { cn } from '../../lib/utils'; +import ToolCodePanel from './ToolCodePanel'; +import ToolJsonView from './ToolJsonView'; interface ToolCallBlockProps { message: ToolCallMessage; + streams?: ToolStreamMessage[]; } function statusPill(status: ToolCallMessage['status']): string { @@ -22,28 +37,66 @@ function statusPill(status: ToolCallMessage['status']): string { function statusLabel(status: ToolCallMessage['status']): string { switch (status) { case 'ok': - return 'completed'; + return '成功'; case 'fail': - return 'failed'; + return '失败'; default: - return 'running'; + return '运行中'; } } -function ToolCallBlock({ message }: ToolCallBlockProps) { - const summary = message.output?.trim() || '调用工具'; - const bodyText = - message.error?.trim() || - message.output?.trim() || - (message.status === 'running' ? '工具已启动,实时输出见下方。' : '工具已完成。'); - const defaultOpen = message.status !== 'running' || Boolean(message.error); +function streamBadge(stream: ToolStreamMessage['stream']): string { + return stream === 'stderr' ? pillDanger : pillNeutral; +} + +function streamTitle(toolName: string, stream: ToolStreamMessage['stream'], hasShellCommand: boolean): string { + if (hasShellCommand && stream === 'stdout') { + return 'Shell'; + } + if (stream === 'stderr') { + return 'stderr'; + } + return toolName; +} + +function resultTextSurface(text: string, tone: 'normal' | 'error') { + const structuredResult = extractStructuredJsonOutput(text); + if (structuredResult) { + return ( + + ); + } + + return ( + + ); +} + +function ToolCallBlock({ message, streams = [] }: ToolCallBlockProps) { + const shellDisplay = extractToolShellDisplay(message.metadata); + const summary = formatToolCallSummary( + message.toolName, + message.args, + message.status, + message.metadata + ); + const structuredArgs = extractStructuredArgs(message.args); + const metadataSummary = extractToolMetadataSummary(message.metadata); + const fallbackResult = + message.error?.trim() || message.output?.trim() || metadataSummary?.message?.trim() || ''; + const structuredFallbackResult = extractStructuredJsonOutput(fallbackResult); + const defaultOpen = message.status === 'fail'; return (
- + {message.toolName} {summary} {statusLabel(message.status)} @@ -62,8 +115,110 @@ function ToolCallBlock({ message }: ToolCallBlockProps) { -
- {bodyText} +
+ {streams.length > 0 ? ( + streams.map((streamMessage) => ( +
+
+
+ + {streamTitle( + message.toolName, + streamMessage.stream, + Boolean(shellDisplay?.command) + )} + + + {streamMessage.stream === 'stderr' ? '错误输出' : '工具结果'} + +
+ {statusLabel(streamMessage.status)} +
+ {resultTextSurface( + streamMessage.content, + streamMessage.stream === 'stderr' ? 'error' : 'normal' + )} +
+ )) + ) : fallbackResult ? ( + structuredFallbackResult ? ( +
+
+
+ 结果 + + {structuredFallbackResult.summary} + +
+ {statusLabel(message.status)} +
+ +
+ ) : ( +
+
+
+ + {message.error ? '错误' : '结果'} + + + {shellDisplay?.command ? `$ ${shellDisplay.command}` : message.toolName} + +
+ {statusLabel(message.status)} +
+ {resultTextSurface(fallbackResult, message.error ? 'error' : 'normal')} +
+ ) + ) : ( +
+ {message.status === 'running' ? '等待工具输出...' : '该工具没有可展示的文本结果。'} +
+ )} + + {structuredArgs && ( +
+ + 调用参数 + {structuredArgs.summary} + + + + + + +
+ +
+
+ )} + + {(metadataSummary?.pills?.length || message.durationMs !== undefined || message.truncated) && ( +
+ {metadataSummary?.pills.map((pill) => ( + + {pill} + + ))} + {message.durationMs !== undefined && ( + {message.durationMs} ms + )} + {message.truncated && truncated} +
+ )}
); diff --git a/frontend/src/components/Chat/ToolCodePanel.tsx b/frontend/src/components/Chat/ToolCodePanel.tsx new file mode 100644 index 00000000..adca1994 --- /dev/null +++ b/frontend/src/components/Chat/ToolCodePanel.tsx @@ -0,0 +1,36 @@ +import { memo, useRef } from 'react'; + +import { codeBlockContent, codeBlockHeader, codeBlockShell } from '../../lib/styles'; +import { cn } from '../../lib/utils'; +import { useNestedScrollContainment } from './useNestedScrollContainment'; + +interface ToolCodePanelProps { + title: string; + tone?: 'normal' | 'error'; + content: string; +} + +function ToolCodePanel({ title, tone = 'normal', content }: ToolCodePanelProps) { + const contentRef = useRef(null); + useNestedScrollContainment(contentRef); + + return ( +
+
+ {title} +
+
+        {content}
+      
+
+ ); +} + +export default memo(ToolCodePanel); diff --git a/frontend/src/components/Chat/ToolJsonView.tsx b/frontend/src/components/Chat/ToolJsonView.tsx index c5c9d940..d1bbf147 100644 --- a/frontend/src/components/Chat/ToolJsonView.tsx +++ b/frontend/src/components/Chat/ToolJsonView.tsx @@ -1,13 +1,14 @@ -import { memo } from 'react'; +import { memo, useRef } from 'react'; import type { ReactNode } from 'react'; import type { UnknownRecord } from '../../lib/shared'; +import { useNestedScrollContainment } from './useNestedScrollContainment'; const MAX_CHILDREN_PER_NODE = 200; -const MAX_STRING_PREVIEW = 240; interface ToolJsonViewProps { value: UnknownRecord | unknown[]; summary: string; + defaultOpen?: boolean; } interface JsonNodeProps { @@ -34,13 +35,9 @@ function renderPrimitiveValue(value: unknown): ReactNode { } if (typeof value === 'string') { - const truncated = - value.length > MAX_STRING_PREVIEW - ? `${value.slice(0, MAX_STRING_PREVIEW)}... (${value.length} chars)` - : value; return ( - "{truncated}" + "{value}" ); } @@ -100,10 +97,16 @@ function JsonNode({ value, label, path, defaultOpen = false }: JsonNodeProps) { ); } -function ToolJsonView({ value, summary }: ToolJsonViewProps) { +function ToolJsonView({ value, summary, defaultOpen = false }: ToolJsonViewProps) { + const containerRef = useRef(null); + useNestedScrollContainment(containerRef); + return ( -
- +
+
{summary}
); diff --git a/frontend/src/components/Chat/ToolStreamBlock.tsx b/frontend/src/components/Chat/ToolStreamBlock.tsx index 4856caad..be07dd9a 100644 --- a/frontend/src/components/Chat/ToolStreamBlock.tsx +++ b/frontend/src/components/Chat/ToolStreamBlock.tsx @@ -1,8 +1,10 @@ import { memo } from 'react'; import type { ToolStreamMessage } from '../../types'; -import { pillDanger, pillNeutral, pillSuccess, terminalBlock } from '../../lib/styles'; -import { cn } from '../../lib/utils'; +import { extractStructuredJsonOutput } from '../../lib/toolDisplay'; +import { pillDanger, pillNeutral, pillSuccess } from '../../lib/styles'; +import ToolJsonView from './ToolJsonView'; +import ToolCodePanel from './ToolCodePanel'; interface ToolStreamBlockProps { message: ToolStreamMessage; @@ -35,22 +37,31 @@ function statusLabel(status: ToolStreamMessage['status']): string { } function ToolStreamBlock({ message }: ToolStreamBlockProps) { + const structuredResult = message.stream === 'stdout' + ? extractStructuredJsonOutput(message.content) + : null; + return (
-
- {streamLabel(message.stream)} +
+ + {message.stream === 'stdout' ? '结果' : streamLabel(message.stream)} + {statusLabel(message.status)}
-
-
-          {message.content}
-        
-
+ {structuredResult ? ( + + ) : ( + + )}
); } diff --git a/frontend/src/components/Chat/useNestedScrollContainment.ts b/frontend/src/components/Chat/useNestedScrollContainment.ts new file mode 100644 index 00000000..fde1227a --- /dev/null +++ b/frontend/src/components/Chat/useNestedScrollContainment.ts @@ -0,0 +1,35 @@ +import { useEffect } from 'react'; +import type { RefObject } from 'react'; + +export function useNestedScrollContainment(ref: RefObject) { + useEffect(() => { + const container = ref.current; + if (!container) { + return; + } + + const onWheel = (event: WheelEvent) => { + const canScroll = container.scrollHeight > container.clientHeight + 1; + if (!canScroll) { + return; + } + + const atTop = container.scrollTop <= 0 && event.deltaY < 0; + const atBottom = + container.scrollTop + container.clientHeight >= container.scrollHeight - 1 && + event.deltaY > 0; + + if (!atTop && !atBottom) { + event.stopPropagation(); + return; + } + + event.preventDefault(); + }; + + container.addEventListener('wheel', onWheel, { passive: false }); + return () => { + container.removeEventListener('wheel', onWheel); + }; + }, [ref]); +} diff --git a/frontend/src/lib/api/conversation.test.ts b/frontend/src/lib/api/conversation.test.ts index 7ceeaa29..de4db0b6 100644 --- a/frontend/src/lib/api/conversation.test.ts +++ b/frontend/src/lib/api/conversation.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest'; import type { ConversationSnapshotState } from './conversation'; -import { projectConversationState } from './conversation'; +import { applyConversationEnvelope, projectConversationState } from './conversation'; describe('projectConversationState', () => { it('keeps thinking blocks visible even when the same turn also has assistant output', () => { @@ -59,6 +59,10 @@ describe('projectConversationState', () => { toolCallId: 'call-1', toolName: 'web', status: 'streaming', + input: { + query: 'codex tui architecture', + maxResults: 5, + }, }, { id: 'tool-stream-1', @@ -81,6 +85,10 @@ describe('projectConversationState', () => { toolCallId: 'call-1', toolName: 'web', status: 'running', + args: { + query: 'codex tui architecture', + maxResults: 5, + }, output: undefined, }); expect(projection.messages[1]).toMatchObject({ @@ -92,4 +100,50 @@ describe('projectConversationState', () => { }); expect(projection.messageTree.rootThreadItems).toHaveLength(2); }); + + it('treats append_block as an idempotent upsert keyed by block id', () => { + const state: ConversationSnapshotState = { + cursor: 'cursor-1', + phase: 'callingTool', + blocks: [ + { + id: 'tool-call-1', + kind: 'tool_call', + turnId: 'turn-2', + toolCallId: 'call-1', + toolName: 'web', + status: 'streaming', + input: { + query: 'codex tui architecture', + }, + }, + ], + childSummaries: [], + }; + + applyConversationEnvelope(state, { + cursor: 'cursor-2', + kind: 'append_block', + block: { + id: 'tool-call-1', + kind: 'tool_call', + turnId: 'turn-2', + toolCallId: 'call-1', + toolName: 'web', + status: 'complete', + input: { + query: 'codex tui architecture', + }, + summary: '3 results', + }, + }); + + expect(state.cursor).toBe('cursor-2'); + expect(state.blocks).toHaveLength(1); + expect(state.blocks[0]).toMatchObject({ + id: 'tool-call-1', + status: 'complete', + summary: '3 results', + }); + }); }); diff --git a/frontend/src/lib/api/conversation.ts b/frontend/src/lib/api/conversation.ts index 7248f5ef..4cf68aed 100644 --- a/frontend/src/lib/api/conversation.ts +++ b/frontend/src/lib/api/conversation.ts @@ -265,7 +265,7 @@ function projectConversationMessages( toolCallId, toolName: pickString(block, 'toolName') ?? 'tool', status: parseToolStatus(block.status), - args: null, + args: block.input ?? null, output: pickOptionalString(block, 'summary') || undefined, timestamp: index, }); @@ -507,12 +507,27 @@ export function applyConversationEnvelope( if (!kind) { return; } + const envelopeCursor = pickOptionalString(envelope, 'cursor'); + if (envelopeCursor) { + state.cursor = envelopeCursor; + } switch (kind) { case 'append_block': { const block = asRecord(envelope.block); if (block) { - state.blocks.push(block); + const blockId = pickString(block, 'id'); + const existingIndex = blockId + ? state.blocks.findIndex((candidate) => pickString(candidate, 'id') === blockId) + : -1; + if (existingIndex >= 0) { + state.blocks[existingIndex] = { + ...state.blocks[existingIndex], + ...block, + }; + } else { + state.blocks.push(block); + } } return; } From 7894f0e6ff2e921edd64c090213dcb4f56bccae6 Mon Sep 17 00:00:00 2001 From: whatevertogo Date: Wed, 15 Apr 2026 22:47:11 +0800 Subject: [PATCH 03/53] feat: enhance prompt caching metrics and tool call handling - Added cache metrics to PromptBuildOutput in various modules to track cache reuse hits and misses. - Updated assemble_prompt_request to carry prompt cache reuse counts and integrated related tests. - Enhanced ToolCallBlock to support child session navigation and improved rendering of tool call results. - Implemented utility functions to calculate prompt reuse rates and updated relevant components to display these metrics. - Modified conversation handling to support metadata updates for tool calls, enabling better tracking of spawned sessions. - Improved sub-run view logic to recover nested spawned sub-runs from tool metadata. --- crates/adapter-llm/src/anthropic.rs | 1 + crates/adapter-prompt/src/core_port.rs | 20 ++- crates/application/src/agent/test_support.rs | 1 + crates/cli/src/state/conversation.rs | 5 + crates/core/src/lib.rs | 4 +- crates/core/src/ports.rs | 10 ++ crates/kernel/src/gateway/mod.rs | 4 + crates/protocol/src/http/terminal/v1.rs | 5 + crates/server/src/bootstrap/capabilities.rs | 1 + crates/server/src/http/terminal_projection.rs | 22 +++ crates/session-runtime/src/turn/events.rs | 105 +++++++++-- .../src/turn/manual_compact.rs | 1 + crates/session-runtime/src/turn/request.rs | 63 +++++++ .../src/turn/runner/step/mod.rs | 5 +- .../src/turn/runner/step/tests.rs | 2 + crates/session-runtime/src/turn/submit.rs | 1 + .../session-runtime/src/turn/test_support.rs | 1 + .../src/components/Chat/AssistantMessage.tsx | 34 ++-- .../components/Chat/PromptMetricsMessage.tsx | 27 ++- .../components/Chat/ToolCallBlock.test.tsx | 141 +++++++++++---- .../src/components/Chat/ToolCallBlock.tsx | 169 +++++++++++------- .../src/components/Chat/ToolCodePanel.tsx | 15 +- frontend/src/components/Chat/ToolJsonView.tsx | 17 +- frontend/src/lib/api/conversation.test.ts | 56 ++++++ frontend/src/lib/api/conversation.ts | 4 + frontend/src/lib/subRunView.test.ts | 92 ++++++++++ frontend/src/lib/subRunView.ts | 66 ++++++- frontend/src/lib/toolDisplay.ts | 31 ++++ frontend/src/lib/utils.ts | 25 ++- 29 files changed, 784 insertions(+), 144 deletions(-) diff --git a/crates/adapter-llm/src/anthropic.rs b/crates/adapter-llm/src/anthropic.rs index a838992b..fcd04e77 100644 --- a/crates/adapter-llm/src/anthropic.rs +++ b/crates/adapter-llm/src/anthropic.rs @@ -685,6 +685,7 @@ fn cache_control_if_allowed(remaining: &mut usize) -> Option bool { !matches!(layer, SystemPromptLayer::Dynamic) } diff --git a/crates/adapter-prompt/src/core_port.rs b/crates/adapter-prompt/src/core_port.rs index 53f71867..3087e70b 100644 --- a/crates/adapter-prompt/src/core_port.rs +++ b/crates/adapter-prompt/src/core_port.rs @@ -5,13 +5,14 @@ use astrcode_core::{ Result, SystemPromptBlock, - ports::{PromptBuildOutput, PromptBuildRequest, PromptProvider}, + ports::{PromptBuildCacheMetrics, PromptBuildOutput, PromptBuildRequest, PromptProvider}, }; use async_trait::async_trait; use serde_json::Value; use crate::{ PromptAgentProfileSummary, PromptContext, PromptDeclaration, PromptSkillSummary, + diagnostics::DiagnosticReason, layered_builder::{LayeredPromptBuilder, default_layered_prompt_builder}, }; @@ -95,6 +96,7 @@ impl PromptProvider for ComposerPromptProvider { Ok(PromptBuildOutput { system_prompt, system_prompt_blocks, + cache_metrics: summarize_prompt_cache_metrics(&output), metadata: serde_json::json!({ "extra_tools_count": output.plan.extra_tools.len(), "diagnostics_count": output.diagnostics.items.len(), @@ -161,6 +163,22 @@ fn build_prompt_vars(request: &PromptBuildRequest) -> std::collections::HashMap< vars } +fn summarize_prompt_cache_metrics(output: &crate::PromptBuildOutput) -> PromptBuildCacheMetrics { + let mut metrics = PromptBuildCacheMetrics::default(); + for diagnostic in &output.diagnostics.items { + match &diagnostic.reason { + DiagnosticReason::CacheReuseHit { .. } => { + metrics.reuse_hits = metrics.reuse_hits.saturating_add(1); + }, + DiagnosticReason::CacheReuseMiss { .. } => { + metrics.reuse_misses = metrics.reuse_misses.saturating_add(1); + }, + _ => {}, + } + } + metrics +} + fn insert_json_string( vars: &mut std::collections::HashMap, key: &str, diff --git a/crates/application/src/agent/test_support.rs b/crates/application/src/agent/test_support.rs index 19323d54..60b7b3f4 100644 --- a/crates/application/src/agent/test_support.rs +++ b/crates/application/src/agent/test_support.rs @@ -276,6 +276,7 @@ impl PromptProvider for TestPromptProvider { Ok(PromptBuildOutput { system_prompt: "test".to_string(), system_prompt_blocks: Vec::new(), + cache_metrics: Default::default(), metadata: Value::Null, }) } diff --git a/crates/cli/src/state/conversation.rs b/crates/cli/src/state/conversation.rs index cda85cec..8f7cf5ce 100644 --- a/crates/cli/src/state/conversation.rs +++ b/crates/cli/src/state/conversation.rs @@ -225,6 +225,11 @@ fn apply_block_patch( block.summary = Some(summary); } }, + AstrcodeConversationBlockPatchDto::ReplaceMetadata { metadata } => { + if let AstrcodeConversationBlockDto::ToolCall(block) = block { + block.metadata = Some(metadata); + } + }, AstrcodeConversationBlockPatchDto::SetStatus { status } => set_block_status(block, status), } } diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index c1719b3d..b0cc6cd3 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -111,8 +111,8 @@ pub use policy::{ }; pub use ports::{ EventStore, LlmEvent, LlmEventSink, LlmFinishReason, LlmOutput, LlmProvider, LlmRequest, - LlmUsage, ModelLimits, PromptAgentProfileSummary, PromptBuildOutput, PromptBuildRequest, - PromptDeclaration, PromptDeclarationKind, PromptDeclarationRenderTarget, + LlmUsage, ModelLimits, PromptAgentProfileSummary, PromptBuildCacheMetrics, PromptBuildOutput, + PromptBuildRequest, PromptDeclaration, PromptDeclarationKind, PromptDeclarationRenderTarget, PromptDeclarationSource, PromptFacts, PromptFactsProvider, PromptFactsRequest, PromptProvider, PromptSkillSummary, ResourceProvider, ResourceReadResult, ResourceRequestContext, }; diff --git a/crates/core/src/ports.rs b/crates/core/src/ports.rs index 4ff83f6f..a750fa5f 100644 --- a/crates/core/src/ports.rs +++ b/crates/core/src/ports.rs @@ -301,6 +301,14 @@ pub struct PromptBuildRequest { pub metadata: Value, } +/// Prompt 组装结果。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct PromptBuildCacheMetrics { + pub reuse_hits: u32, + pub reuse_misses: u32, +} + /// Prompt 组装结果。 #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -309,6 +317,8 @@ pub struct PromptBuildOutput { #[serde(default)] pub system_prompt_blocks: Vec, #[serde(default)] + pub cache_metrics: PromptBuildCacheMetrics, + #[serde(default)] pub metadata: Value, } diff --git a/crates/kernel/src/gateway/mod.rs b/crates/kernel/src/gateway/mod.rs index b3b1ebbc..dfe239ba 100644 --- a/crates/kernel/src/gateway/mod.rs +++ b/crates/kernel/src/gateway/mod.rs @@ -58,6 +58,10 @@ impl KernelGateway { self.llm.model_limits() } + pub fn supports_cache_metrics(&self) -> bool { + self.llm.supports_cache_metrics() + } + pub async fn invoke_tool( &self, call: &ToolCallRequest, diff --git a/crates/protocol/src/http/terminal/v1.rs b/crates/protocol/src/http/terminal/v1.rs index 94325785..8287c64e 100644 --- a/crates/protocol/src/http/terminal/v1.rs +++ b/crates/protocol/src/http/terminal/v1.rs @@ -108,6 +108,9 @@ pub enum TerminalBlockPatchDto { ReplaceSummary { summary: String, }, + ReplaceMetadata { + metadata: Value, + }, SetStatus { status: TerminalBlockStatusDto, }, @@ -178,6 +181,8 @@ pub struct TerminalToolCallBlockDto { pub input: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub summary: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub metadata: Option, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] diff --git a/crates/server/src/bootstrap/capabilities.rs b/crates/server/src/bootstrap/capabilities.rs index 51e9fb32..76266044 100644 --- a/crates/server/src/bootstrap/capabilities.rs +++ b/crates/server/src/bootstrap/capabilities.rs @@ -293,6 +293,7 @@ mod tests { Ok(PromptBuildOutput { system_prompt: "noop".to_string(), system_prompt_blocks: Vec::new(), + cache_metrics: Default::default(), metadata: Value::Null, }) } diff --git a/crates/server/src/http/terminal_projection.rs b/crates/server/src/http/terminal_projection.rs index cd9c0e95..6de6d97c 100644 --- a/crates/server/src/http/terminal_projection.rs +++ b/crates/server/src/http/terminal_projection.rs @@ -767,6 +767,7 @@ impl TerminalDeltaProjector { status: TerminalBlockStatusDto::Streaming, input: input.cloned(), summary: None, + metadata: None, })) } @@ -860,6 +861,16 @@ impl TerminalDeltaProjector { }, }); } + if let Some(metadata) = &result.metadata { + if self.replace_tool_metadata(index, metadata) { + deltas.push(TerminalDeltaDto::PatchBlock { + block_id: call_block_id.clone(), + patch: TerminalBlockPatchDto::ReplaceMetadata { + metadata: metadata.clone(), + }, + }); + } + } if let Some(delta) = self.complete_block(&call_block_id, status) { deltas.push(delta); } @@ -1061,6 +1072,17 @@ impl TerminalDeltaProjector { false } + fn replace_tool_metadata(&mut self, index: usize, metadata: &Value) -> bool { + if let TerminalBlockDto::ToolCall(block) = &mut self.blocks[index] { + if block.metadata.as_ref() == Some(metadata) { + return false; + } + block.metadata = Some(metadata.clone()); + return true; + } + false + } + fn set_status(&mut self, index: usize, status: TerminalBlockStatusDto) { match &mut self.blocks[index] { TerminalBlockDto::Thinking(block) => block.status = status, diff --git a/crates/session-runtime/src/turn/events.rs b/crates/session-runtime/src/turn/events.rs index 5d5ac46c..c21c745b 100644 --- a/crates/session-runtime/src/turn/events.rs +++ b/crates/session-runtime/src/turn/events.rs @@ -1,8 +1,9 @@ #[cfg(test)] use astrcode_core::ToolOutputStream; use astrcode_core::{ - AgentEventContext, CompactTrigger, PromptMetricsPayload, StorageEvent, StorageEventPayload, - ToolCallRequest, ToolExecutionResult, UserMessageOrigin, + AgentEventContext, CompactTrigger, LlmUsage, PromptMetricsPayload, StorageEvent, + StorageEventPayload, ToolCallRequest, ToolExecutionResult, UserMessageOrigin, + ports::PromptBuildCacheMetrics, }; use chrono::{DateTime, Utc}; @@ -134,6 +135,8 @@ pub(crate) fn prompt_metrics_event( step_index: usize, snapshot: PromptTokenSnapshot, truncated_tool_results: usize, + cache_metrics: PromptBuildCacheMetrics, + provider_cache_metrics_supported: bool, ) -> StorageEvent { StorageEvent { turn_id: Some(turn_id.to_string()), @@ -150,14 +153,44 @@ pub(crate) fn prompt_metrics_event( provider_output_tokens: None, cache_creation_input_tokens: None, cache_read_input_tokens: None, - provider_cache_metrics_supported: false, - prompt_cache_reuse_hits: 0, - prompt_cache_reuse_misses: 0, + provider_cache_metrics_supported, + prompt_cache_reuse_hits: cache_metrics.reuse_hits, + prompt_cache_reuse_misses: cache_metrics.reuse_misses, }, }, } } +pub(crate) fn apply_prompt_metrics_usage( + events: &mut [StorageEvent], + step_index: usize, + usage: Option, +) { + let Some(usage) = usage else { + return; + }; + + let step_index = saturating_u32(step_index); + let Some(StorageEvent { + payload: StorageEventPayload::PromptMetrics { metrics }, + .. + }) = events.iter_mut().rev().find(|event| { + matches!( + &event.payload, + StorageEventPayload::PromptMetrics { metrics } + if metrics.step_index == step_index + ) + }) + else { + return; + }; + + metrics.provider_input_tokens = Some(saturating_u32(usage.input_tokens)); + metrics.provider_output_tokens = Some(saturating_u32(usage.output_tokens)); + metrics.cache_creation_input_tokens = Some(saturating_u32(usage.cache_creation_input_tokens)); + metrics.cache_read_input_tokens = Some(saturating_u32(usage.cache_read_input_tokens)); +} + pub(crate) fn tool_call_event( turn_id: &str, agent: &AgentEventContext, @@ -238,16 +271,17 @@ pub(crate) fn tool_result_reference_applied_event( #[cfg(test)] mod tests { use astrcode_core::{ - AgentEventContext, CompactTrigger, StorageEventPayload, ToolCallRequest, - ToolExecutionResult, ToolOutputStream, UserMessageOrigin, + AgentEventContext, CompactTrigger, LlmUsage, StorageEventPayload, ToolCallRequest, + ToolExecutionResult, ToolOutputStream, UserMessageOrigin, ports::PromptBuildCacheMetrics, }; use chrono::{TimeZone, Utc}; use serde_json::json; use super::{ - CompactAppliedStats, assistant_final_event, compact_applied_event, error_event, - prompt_metrics_event, session_start_event, tool_call_delta_event, tool_call_event, - tool_result_event, turn_done_event, user_message_event, + CompactAppliedStats, apply_prompt_metrics_usage, assistant_final_event, + compact_applied_event, error_event, prompt_metrics_event, session_start_event, + tool_call_delta_event, tool_call_event, tool_result_event, turn_done_event, + user_message_event, }; use crate::context_window::token_usage::PromptTokenSnapshot; @@ -440,6 +474,11 @@ mod tests { threshold_tokens: 97_200, }, 3, + PromptBuildCacheMetrics { + reuse_hits: 4, + reuse_misses: 1, + }, + true, ); assert_eq!(event.turn_id.as_deref(), Some("turn-prompt-1")); @@ -457,9 +496,49 @@ mod tests { && metrics.provider_output_tokens.is_none() && metrics.cache_creation_input_tokens.is_none() && metrics.cache_read_input_tokens.is_none() - && !metrics.provider_cache_metrics_supported - && metrics.prompt_cache_reuse_hits == 0 - && metrics.prompt_cache_reuse_misses == 0 + && metrics.provider_cache_metrics_supported + && metrics.prompt_cache_reuse_hits == 4 + && metrics.prompt_cache_reuse_misses == 1 + )); + } + + #[test] + fn apply_prompt_metrics_usage_backfills_provider_cache_fields() { + let agent = AgentEventContext::root_execution("root-agent", "planner"); + let mut events = vec![prompt_metrics_event( + "turn-prompt-1", + &agent, + 2, + PromptTokenSnapshot { + context_tokens: 1_024, + budget_tokens: 900, + context_window: 128_000, + effective_window: 108_000, + threshold_tokens: 97_200, + }, + 0, + PromptBuildCacheMetrics::default(), + true, + )]; + + apply_prompt_metrics_usage( + &mut events, + 2, + Some(LlmUsage { + input_tokens: 900, + output_tokens: 120, + cache_creation_input_tokens: 700, + cache_read_input_tokens: 650, + }), + ); + + assert!(matches!( + &events[0].payload, + StorageEventPayload::PromptMetrics { metrics } + if metrics.provider_input_tokens == Some(900) + && metrics.provider_output_tokens == Some(120) + && metrics.cache_creation_input_tokens == Some(700) + && metrics.cache_read_input_tokens == Some(650) )); } diff --git a/crates/session-runtime/src/turn/manual_compact.rs b/crates/session-runtime/src/turn/manual_compact.rs index 005caae5..e1b00645 100644 --- a/crates/session-runtime/src/turn/manual_compact.rs +++ b/crates/session-runtime/src/turn/manual_compact.rs @@ -167,6 +167,7 @@ mod tests { Ok(PromptBuildOutput { system_prompt: "noop".to_string(), system_prompt_blocks: Vec::new(), + cache_metrics: Default::default(), metadata: serde_json::Value::Null, }) } diff --git a/crates/session-runtime/src/turn/request.rs b/crates/session-runtime/src/turn/request.rs index e6fbf3b3..1ddc5155 100644 --- a/crates/session-runtime/src/turn/request.rs +++ b/crates/session-runtime/src/turn/request.rs @@ -206,6 +206,8 @@ pub async fn assemble_prompt_request( request.step_index, snapshot, prune_outcome.stats.truncated_tool_results, + prompt_output.cache_metrics, + request.gateway.supports_cache_metrics(), )); let mut llm_request = LlmRequest::new(messages.clone(), request.tools, request.cancel.clone()) @@ -412,6 +414,63 @@ mod tests { assert_eq!(result.llm_request.messages.len(), 1); } + #[tokio::test] + async fn assemble_prompt_request_carries_prompt_cache_reuse_counts() { + let base_gateway = test_gateway(64_000); + let gateway = KernelGateway::new( + base_gateway.capabilities().clone(), + Arc::new(LocalNoopLlmProvider), + Arc::new(RecordingPromptProvider { + captured: Arc::new(Mutex::new(Vec::new())), + }), + Arc::new(LocalNoopResourceProvider), + ); + let mut micro_state = crate::context_window::micro_compact::MicroCompactState::default(); + let tracker = crate::context_window::file_access::FileAccessTracker::new(4); + let session_state = test_session_state(); + let mut replacement_state = ToolResultReplacementState::default(); + let settings = ContextWindowSettings::from(&ResolvedRuntimeConfig::default()); + + let result = assemble_prompt_request(AssemblePromptRequest { + gateway: &gateway, + prompt_facts_provider: &NoopPromptFactsProvider, + session_id: "session-1", + turn_id: "turn-1", + working_dir: Path::new("."), + messages: vec![LlmMessage::User { + content: "hello".to_string(), + origin: astrcode_core::UserMessageOrigin::User, + }], + cancel: astrcode_core::CancelToken::new(), + agent: &AgentEventContext::default(), + step_index: 0, + token_tracker: &TokenUsageTracker::default(), + tools: vec![ToolDefinition { + name: "readFile".to_string(), + description: "read".to_string(), + parameters: json!({"type":"object"}), + }] + .into(), + settings: &settings, + clearable_tools: &std::collections::HashSet::new(), + micro_compact_state: &mut micro_state, + file_access_tracker: &tracker, + session_state: &session_state, + tool_result_replacement_state: &mut replacement_state, + prompt_declarations: &[], + }) + .await + .expect("assembly should succeed"); + + assert!(matches!( + &result.events[0].payload, + StorageEventPayload::PromptMetrics { metrics } + if metrics.prompt_cache_reuse_hits == 2 + && metrics.prompt_cache_reuse_misses == 1 + && !metrics.provider_cache_metrics_supported + )); + } + #[derive(Debug)] struct RecordingPromptProvider { captured: Arc>>, @@ -428,6 +487,10 @@ mod tests { Ok(PromptBuildOutput { system_prompt: "recorded".to_string(), system_prompt_blocks: Vec::new(), + cache_metrics: astrcode_core::PromptBuildCacheMetrics { + reuse_hits: 2, + reuse_misses: 1, + }, metadata: serde_json::Value::Null, }) } diff --git a/crates/session-runtime/src/turn/runner/step/mod.rs b/crates/session-runtime/src/turn/runner/step/mod.rs index 243bdde1..95c0f095 100644 --- a/crates/session-runtime/src/turn/runner/step/mod.rs +++ b/crates/session-runtime/src/turn/runner/step/mod.rs @@ -21,7 +21,9 @@ use crate::turn::{ OUTPUT_CONTINUATION_PROMPT, OutputContinuationDecision, continuation_transition, decide_output_continuation, }, - events::{assistant_final_event, turn_done_event, user_message_event}, + events::{ + apply_prompt_metrics_usage, assistant_final_event, turn_done_event, user_message_event, + }, loop_control::{ AUTO_CONTINUE_NUDGE, BudgetContinuationDecision, TurnLoopTransition, TurnStopCause, decide_budget_continuation, @@ -73,6 +75,7 @@ async fn run_single_step_with( let llm_finished_at = Instant::now(); record_llm_usage(execution, &output); + apply_prompt_metrics_usage(&mut execution.events, execution.step_index, output.usage); let has_tool_calls = append_assistant_output(execution, resources, &output); warn_if_output_truncated(resources, execution, &output); diff --git a/crates/session-runtime/src/turn/runner/step/tests.rs b/crates/session-runtime/src/turn/runner/step/tests.rs index c17778fc..2ac6b4c4 100644 --- a/crates/session-runtime/src/turn/runner/step/tests.rs +++ b/crates/session-runtime/src/turn/runner/step/tests.rs @@ -146,6 +146,8 @@ fn assembled_prompt(messages: Vec) -> AssemblePromptResult { threshold_tokens: 80, }, 0, + Default::default(), + false, )], auto_compacted: false, tool_result_budget_stats: crate::turn::tool_result_budget::ToolResultBudgetStats::default(), diff --git a/crates/session-runtime/src/turn/submit.rs b/crates/session-runtime/src/turn/submit.rs index a83f1155..314dade0 100644 --- a/crates/session-runtime/src/turn/submit.rs +++ b/crates/session-runtime/src/turn/submit.rs @@ -660,6 +660,7 @@ mod tests { Ok(PromptBuildOutput { system_prompt: "noop".to_string(), system_prompt_blocks: Vec::new(), + cache_metrics: Default::default(), metadata: serde_json::Value::Null, }) } diff --git a/crates/session-runtime/src/turn/test_support.rs b/crates/session-runtime/src/turn/test_support.rs index f2430ed4..058acf6a 100644 --- a/crates/session-runtime/src/turn/test_support.rs +++ b/crates/session-runtime/src/turn/test_support.rs @@ -61,6 +61,7 @@ impl PromptProvider for NoopPromptProvider { Ok(PromptBuildOutput { system_prompt: "noop".to_string(), system_prompt_blocks: Vec::new(), + cache_metrics: Default::default(), metadata: Value::Null, }) } diff --git a/frontend/src/components/Chat/AssistantMessage.tsx b/frontend/src/components/Chat/AssistantMessage.tsx index ec02a8ce..a5c6af64 100644 --- a/frontend/src/components/Chat/AssistantMessage.tsx +++ b/frontend/src/components/Chat/AssistantMessage.tsx @@ -11,7 +11,11 @@ import { expandableBody, ghostIconButton, } from '../../lib/styles'; -import { cn, calculateCacheHitRatePercent } from '../../lib/utils'; +import { + calculateCacheHitRatePercent, + calculatePromptReuseRatePercent, + cn, +} from '../../lib/utils'; interface AssistantMessageProps { message: AssistantMessageType; @@ -208,21 +212,25 @@ function formatTokenCount(value?: number): string { } function getCacheIndicator(metrics?: PromptMetricsMessage): React.ReactNode { - const hitRate = calculateCacheHitRatePercent(metrics); - if (hitRate === null) { - return null; + const providerHitRate = calculateCacheHitRatePercent(metrics); + if (providerHitRate !== null) { + if (providerHitRate >= 80) { + return 🟢 KV 缓存 {providerHitRate}%; + } + if (providerHitRate >= 30) { + return 🟡 KV 缓存 {providerHitRate}%; + } + if (providerHitRate > 0) { + return 🟠 KV 缓存 {providerHitRate}%; + } + return 🔴 KV 缓存 0%; } - // 缓存命中率分级 - if (hitRate >= 80) { - return 🟢 缓存 {hitRate}%; - } else if (hitRate >= 30) { - return 🟡 缓存 {hitRate}%; - } else if (hitRate > 0) { - return 🟠 缓存 {hitRate}%; - } else { - return 🔴 无缓存; + const promptReuseRate = calculatePromptReuseRatePercent(metrics); + if (promptReuseRate === null) { + return null; } + return 🧩 Prompt 复用 {promptReuseRate}%; } function AssistantMessage({ diff --git a/frontend/src/components/Chat/PromptMetricsMessage.tsx b/frontend/src/components/Chat/PromptMetricsMessage.tsx index ce9f4937..03b540e3 100644 --- a/frontend/src/components/Chat/PromptMetricsMessage.tsx +++ b/frontend/src/components/Chat/PromptMetricsMessage.tsx @@ -2,7 +2,10 @@ import { memo } from 'react'; import type { PromptMetricsMessage as PromptMetricsMessageType } from '../../types'; import { pillInfo } from '../../lib/styles'; -import { calculateCacheHitRatePercent } from '../../lib/utils'; +import { + calculateCacheHitRatePercent, + calculatePromptReuseRatePercent, +} from '../../lib/utils'; interface PromptMetricsMessageProps { message: PromptMetricsMessageType; @@ -16,7 +19,8 @@ function formatTokenCount(value?: number): string { } function PromptMetricsMessage({ message }: PromptMetricsMessageProps) { - const hitRate = calculateCacheHitRatePercent(message); + const providerHitRate = calculateCacheHitRatePercent(message); + const promptReuseRate = calculatePromptReuseRatePercent(message); return (
@@ -45,17 +49,32 @@ function PromptMetricsMessage({ message }: PromptMetricsMessageProps) {
-
Cache 读 / 写
+
KV Cache 读 / 写
{formatTokenCount(message.cacheReadInputTokens)} /{' '} {formatTokenCount(message.cacheCreationInputTokens)}
+
+
Prompt 复用 命中 / 未命中
+
+ {formatTokenCount(message.promptCacheReuseHits)} /{' '} + {formatTokenCount(message.promptCacheReuseMisses)} +
+
压缩阈值 {formatTokenCount(message.thresholdTokens)} 截断工具结果 {message.truncatedToolResults} - {hitRate === null ? null : 缓存命中 {hitRate}% } + + Provider Cache{' '} + {message.providerCacheMetricsSupported + ? providerHitRate === null + ? '已启用,当前 step 无读缓存' + : `命中 ${providerHitRate}%` + : '未上报'} + + {promptReuseRate === null ? null : Prompt 复用 {promptReuseRate}%}
); diff --git a/frontend/src/components/Chat/ToolCallBlock.test.tsx b/frontend/src/components/Chat/ToolCallBlock.test.tsx index bf30b983..4600b4bc 100644 --- a/frontend/src/components/Chat/ToolCallBlock.test.tsx +++ b/frontend/src/components/Chat/ToolCallBlock.test.tsx @@ -1,37 +1,70 @@ import { renderToStaticMarkup } from 'react-dom/server'; import { describe, expect, it } from 'vitest'; +import { ChatScreenProvider, type ChatScreenContextValue } from './ChatScreenContext'; import ToolCallBlock from './ToolCallBlock'; +const chatContextValue: ChatScreenContextValue = { + projectName: 'Astrcode', + sessionId: 'session-1', + sessionTitle: 'Test Session', + isChildSession: false, + workingDir: 'D:/GitObjectsOwn/Astrcode', + phase: 'idle', + activeSubRunPath: [], + activeSubRunTitle: null, + activeSubRunBreadcrumbs: [], + isSidebarOpen: true, + toggleSidebar: () => {}, + onOpenSubRun: () => {}, + onCloseSubRun: () => {}, + onNavigateSubRunPath: () => {}, + onOpenChildSession: () => {}, + onSubmitPrompt: () => {}, + onInterrupt: () => {}, + onCancelSubRun: () => {}, + listComposerOptions: async () => [], + modelRefreshKey: 0, + getCurrentModel: async () => ({ + profileName: 'default', + model: 'test-model', + providerKind: 'openai', + }), + listAvailableModels: async () => [], + setModel: async () => {}, +}; + describe('ToolCallBlock', () => { it('renders real tool args in the collapsed summary and grouped result output in the body', () => { const html = renderToStaticMarkup( - + + }} + streams={[ + { + id: 'tool-stream-1', + kind: 'toolStream', + toolCallId: 'call-1', + stream: 'stdout', + status: 'ok', + content: '[workspace]\nmembers = [\n "crates/core"\n]\n', + timestamp: Date.now(), + }, + ]} + /> + ); expect(html).toContain('已运行 readFile'); @@ -39,27 +72,61 @@ describe('ToolCallBlock', () => { expect(html).toContain('limit=220'); expect(html).toContain('[workspace]'); expect(html).toContain('调用参数'); + expect(html).toContain('max-h-[min(58vh,560px)]'); }); it('renders fallback result surface when no streamed output exists', () => { const html = renderToStaticMarkup( - + + + ); expect(html).toContain('找到 12 个文件'); expect(html).toContain('结果'); }); + + it('renders child session navigation action when spawn metadata exposes an open session', () => { + const html = renderToStaticMarkup( + + + + ); + + expect(html).toContain('打开子会话'); + }); }); diff --git a/frontend/src/components/Chat/ToolCallBlock.tsx b/frontend/src/components/Chat/ToolCallBlock.tsx index 139027f8..cf35cb11 100644 --- a/frontend/src/components/Chat/ToolCallBlock.tsx +++ b/frontend/src/components/Chat/ToolCallBlock.tsx @@ -1,7 +1,8 @@ -import { memo } from 'react'; +import { memo, useRef } from 'react'; import type { ToolCallMessage, ToolStreamMessage } from '../../types'; import { + extractToolChildSessionTarget, extractStructuredArgs, extractStructuredJsonOutput, extractToolMetadataSummary, @@ -10,13 +11,16 @@ import { } from '../../lib/toolDisplay'; import { chevronIcon, + infoButton, pillDanger, pillNeutral, pillSuccess, } from '../../lib/styles'; import { cn } from '../../lib/utils'; +import { useChatScreenContext } from './ChatScreenContext'; import ToolCodePanel from './ToolCodePanel'; import ToolJsonView from './ToolJsonView'; +import { useNestedScrollContainment } from './useNestedScrollContainment'; interface ToolCallBlockProps { message: ToolCallMessage; @@ -67,17 +71,27 @@ function resultTextSurface(text: string, tone: 'normal' | 'error') { value={structuredResult.value} summary={structuredResult.summary} defaultOpen={true} + scrollMode="inherit" /> ); } return ( - + ); } function ToolCallBlock({ message, streams = [] }: ToolCallBlockProps) { + const { onOpenChildSession, onOpenSubRun } = useChatScreenContext(); + const viewportRef = useRef(null); + useNestedScrollContainment(viewportRef); const shellDisplay = extractToolShellDisplay(message.metadata); + const childSessionTarget = extractToolChildSessionTarget(message.metadata); const summary = formatToolCallSummary( message.toolName, message.args, @@ -99,6 +113,25 @@ function ToolCallBlock({ message, streams = [] }: ToolCallBlockProps) { {message.toolName} {summary} + {childSessionTarget && ( + + )} {statusLabel(message.status)}
- {streams.length > 0 ? ( - streams.map((streamMessage) => ( -
-
-
- - {streamTitle( - message.toolName, - streamMessage.stream, - Boolean(shellDisplay?.command) - )} - - - {streamMessage.stream === 'stderr' ? '错误输出' : '工具结果'} - -
- {statusLabel(streamMessage.status)} -
- {resultTextSurface( - streamMessage.content, - streamMessage.stream === 'stderr' ? 'error' : 'normal' - )} -
- )) - ) : fallbackResult ? ( - structuredFallbackResult ? ( -
-
-
- 结果 - - {structuredFallbackResult.summary} - -
- {statusLabel(message.status)} +
+
+ {streams.length > 0 ? ( + streams.map((streamMessage) => ( +
+
+
+ + {streamTitle( + message.toolName, + streamMessage.stream, + Boolean(shellDisplay?.command) + )} + + + {streamMessage.stream === 'stderr' ? '错误输出' : '工具结果'} + +
+ + {statusLabel(streamMessage.status)} + +
+ {resultTextSurface( + streamMessage.content, + streamMessage.stream === 'stderr' ? 'error' : 'normal' + )} +
+ )) + ) : fallbackResult ? ( + structuredFallbackResult ? ( +
+
+
+ 结果 + + {structuredFallbackResult.summary} + +
+ {statusLabel(message.status)} +
+ +
+ ) : ( +
+
+
+ + {message.error ? '错误' : '结果'} + + + {shellDisplay?.command ? `$ ${shellDisplay.command}` : message.toolName} + +
+ {statusLabel(message.status)} +
+ {resultTextSurface(fallbackResult, message.error ? 'error' : 'normal')} +
+ ) + ) : ( +
+ {message.status === 'running' ? '等待工具输出...' : '该工具没有可展示的文本结果。'}
- -
- ) : ( -
-
-
- - {message.error ? '错误' : '结果'} - - - {shellDisplay?.command ? `$ ${shellDisplay.command}` : message.toolName} - -
- {statusLabel(message.status)} -
- {resultTextSurface(fallbackResult, message.error ? 'error' : 'normal')} -
- ) - ) : ( -
- {message.status === 'running' ? '等待工具输出...' : '该工具没有可展示的文本结果。'} + )}
- )} +
{structuredArgs && (
diff --git a/frontend/src/components/Chat/ToolCodePanel.tsx b/frontend/src/components/Chat/ToolCodePanel.tsx index adca1994..396c6f6b 100644 --- a/frontend/src/components/Chat/ToolCodePanel.tsx +++ b/frontend/src/components/Chat/ToolCodePanel.tsx @@ -8,11 +8,18 @@ interface ToolCodePanelProps { title: string; tone?: 'normal' | 'error'; content: string; + scrollMode?: 'self' | 'inherit'; } -function ToolCodePanel({ title, tone = 'normal', content }: ToolCodePanelProps) { +function ToolCodePanel({ + title, + tone = 'normal', + content, + scrollMode = 'self', +}: ToolCodePanelProps) { const contentRef = useRef(null); - useNestedScrollContainment(contentRef); + const inactiveRef = useRef(null); + useNestedScrollContainment(scrollMode === 'self' ? contentRef : inactiveRef); return (
@@ -23,7 +30,9 @@ function ToolCodePanel({ title, tone = 'normal', content }: ToolCodePanelProps) ref={contentRef} className={cn( codeBlockContent, - 'max-h-[420px] overflow-auto whitespace-pre-wrap overflow-wrap-anywhere', + scrollMode === 'self' + ? 'max-h-[420px] overflow-auto whitespace-pre-wrap overflow-wrap-anywhere' + : 'overflow-visible whitespace-pre-wrap overflow-wrap-anywhere', tone === 'error' ? 'text-danger' : 'text-code-text' )} > diff --git a/frontend/src/components/Chat/ToolJsonView.tsx b/frontend/src/components/Chat/ToolJsonView.tsx index d1bbf147..0932d6de 100644 --- a/frontend/src/components/Chat/ToolJsonView.tsx +++ b/frontend/src/components/Chat/ToolJsonView.tsx @@ -9,6 +9,7 @@ interface ToolJsonViewProps { value: UnknownRecord | unknown[]; summary: string; defaultOpen?: boolean; + scrollMode?: 'self' | 'inherit'; } interface JsonNodeProps { @@ -97,14 +98,24 @@ function JsonNode({ value, label, path, defaultOpen = false }: JsonNodeProps) { ); } -function ToolJsonView({ value, summary, defaultOpen = false }: ToolJsonViewProps) { +function ToolJsonView({ + value, + summary, + defaultOpen = false, + scrollMode = 'self', +}: ToolJsonViewProps) { const containerRef = useRef(null); - useNestedScrollContainment(containerRef); + const inactiveRef = useRef(null); + useNestedScrollContainment(scrollMode === 'self' ? containerRef : inactiveRef); return (
{summary}
diff --git a/frontend/src/lib/api/conversation.test.ts b/frontend/src/lib/api/conversation.test.ts index de4db0b6..9f00ca67 100644 --- a/frontend/src/lib/api/conversation.test.ts +++ b/frontend/src/lib/api/conversation.test.ts @@ -146,4 +146,60 @@ describe('projectConversationState', () => { summary: '3 results', }); }); + + it('applies replace_metadata patches onto existing tool call blocks', () => { + const state: ConversationSnapshotState = { + cursor: 'cursor-1', + phase: 'callingTool', + blocks: [ + { + id: 'tool-call-1', + kind: 'tool_call', + turnId: 'turn-2', + toolCallId: 'call-1', + toolName: 'spawn', + status: 'streaming', + input: { + prompt: 'explore repo', + }, + }, + ], + childSummaries: [], + }; + + applyConversationEnvelope(state, { + cursor: 'cursor-2', + kind: 'patch_block', + blockId: 'tool-call-1', + patch: { + kind: 'replace_metadata', + metadata: { + openSessionId: 'session-child-1', + agentRef: { + agentId: 'agent-child-1', + subRunId: 'subrun-child-1', + openSessionId: 'session-child-1', + }, + }, + }, + }); + + const projection = projectConversationState(state); + + expect(state.cursor).toBe('cursor-2'); + expect(state.blocks[0]?.metadata).toMatchObject({ + openSessionId: 'session-child-1', + }); + expect(projection.messages[0]).toMatchObject({ + kind: 'toolCall', + metadata: { + openSessionId: 'session-child-1', + agentRef: { + agentId: 'agent-child-1', + subRunId: 'subrun-child-1', + openSessionId: 'session-child-1', + }, + }, + }); + }); }); diff --git a/frontend/src/lib/api/conversation.ts b/frontend/src/lib/api/conversation.ts index 4cf68aed..7ae8fcb3 100644 --- a/frontend/src/lib/api/conversation.ts +++ b/frontend/src/lib/api/conversation.ts @@ -267,6 +267,7 @@ function projectConversationMessages( status: parseToolStatus(block.status), args: block.input ?? null, output: pickOptionalString(block, 'summary') || undefined, + metadata: block.metadata ?? undefined, timestamp: index, }); return; @@ -461,6 +462,9 @@ function applyBlockPatch(block: ConversationRecord, patch: ConversationRecord): case 'replace_summary': block.summary = pickOptionalString(patch, 'summary') ?? null; break; + case 'replace_metadata': + block.metadata = patch.metadata; + break; case 'set_status': block.status = pickString(patch, 'status') ?? block.status; break; diff --git a/frontend/src/lib/subRunView.test.ts b/frontend/src/lib/subRunView.test.ts index e906f2e7..88b26468 100644 --- a/frontend/src/lib/subRunView.test.ts +++ b/frontend/src/lib/subRunView.test.ts @@ -1395,6 +1395,98 @@ describe('buildSubRunView', () => { expect(rootViews[0]?.title).toBe('planner'); }); + it('recovers nested spawned sub-runs from tool metadata when lifecycle events are missing', () => { + const messages: Message[] = [ + { + ...makeSubRunStartFixture({ + id: 'subrun-parent-start', + turnId: 'turn-root', + parentTurnId: 'turn-root', + agentId: 'agent-parent', + subRunId: 'subrun-parent', + agentProfile: 'planner', + depth: 1, + timestamp: 1, + }), + }, + { + id: 'spawn-tool-call-nested', + kind: 'toolCall', + turnId: 'turn-parent', + agentId: 'agent-parent', + parentTurnId: 'turn-root', + subRunId: 'subrun-parent', + agentProfile: 'planner', + toolCallId: 'call-nested', + toolName: 'spawn', + status: 'ok', + args: { prompt: 'task-child' }, + output: 'spawn 已在后台启动。', + metadata: { + agentRef: { + agentId: 'agent-child', + subRunId: 'subrun-child', + openSessionId: 'session-child', + }, + }, + timestamp: 2, + }, + ]; + + const tree = buildSubRunThreadTree(messages); + const parentView = buildSubRunView(tree, 'subrun-parent'); + const childView = buildSubRunView(tree, 'subrun-child'); + + expect(parentView?.directChildSubRunIds).toEqual(['subrun-child']); + expect(childView?.parentSubRunId).toBe('subrun-parent'); + expect(childView?.childSessionId).toBe('session-child'); + expect(childView?.title).toBe('agent-child'); + }); + + it('updates sub-run tree fingerprint when spawn metadata arrives later on the same tool call', () => { + const initialMessages: Message[] = [ + { + id: 'spawn-tool-call-late', + kind: 'toolCall', + turnId: 'turn-root', + toolCallId: 'call-late', + toolName: 'spawn', + status: 'running', + args: { prompt: 'task-child' }, + output: 'spawn 启动中', + timestamp: 1, + }, + ]; + const tree = buildSubRunThreadTree(initialMessages); + + const nextMessages: Message[] = [ + { + id: 'spawn-tool-call-late', + kind: 'toolCall', + turnId: 'turn-root', + toolCallId: 'call-late', + toolName: 'spawn', + status: 'ok', + args: { prompt: 'task-child' }, + output: 'spawn 已在后台启动。', + metadata: { + agentRef: { + agentId: 'agent-child', + subRunId: 'subrun-child', + openSessionId: 'session-child', + }, + }, + timestamp: 1, + }, + ]; + + const patched = patchSubRunThreadTreeMessages(tree, nextMessages); + + expect(patched).toBeNull(); + const rebuilt = buildSubRunThreadTree(nextMessages); + expect(listRootSubRunViews(rebuilt).map((view) => view.subRunId)).toEqual(['subrun-child']); + }); + it('patches thread tree incrementally when only message content changes', () => { const messages: Message[] = [ { diff --git a/frontend/src/lib/subRunView.ts b/frontend/src/lib/subRunView.ts index ab0a5bf1..dd527f24 100644 --- a/frontend/src/lib/subRunView.ts +++ b/frontend/src/lib/subRunView.ts @@ -117,7 +117,7 @@ function buildMessageFingerprint(message: Message): string { if (message.kind === 'toolCall') { return `${message.id}:tool:${message.status}:${message.output?.length ?? 0}:${ message.error?.length ?? 0 - }`; + }:${message.metadata === undefined ? '' : JSON.stringify(message.metadata)}`; } if (message.kind === 'toolStream') { return `${message.id}:toolStream:${message.toolCallId}:${message.stream}:${message.status}:${message.content.length}`; @@ -155,9 +155,63 @@ function remapMessageReference( if (next.kind !== previous.kind) { return null; } + if (hasSubRunTopologyChange(previous, next)) { + return null; + } return next; } +function sameSpawnedAgentRef( + left: SpawnedAgentRef | null, + right: SpawnedAgentRef | null +): boolean { + if (left === right) { + return true; + } + if (!left || !right) { + return false; + } + return ( + left.subRunId === right.subRunId && + left.agentId === right.agentId && + left.childSessionId === right.childSessionId + ); +} + +function sameChildRefTopology( + left: ChildSessionNotificationMessage['childRef'], + right: ChildSessionNotificationMessage['childRef'] +): boolean { + return ( + left.agentId === right.agentId && + left.subRunId === right.subRunId && + left.parentAgentId === right.parentAgentId && + left.parentSubRunId === right.parentSubRunId && + left.openSessionId === right.openSessionId + ); +} + +function hasSubRunTopologyChange(previous: Message, next: Message): boolean { + if ( + previous.subRunId !== next.subRunId || + previous.agentId !== next.agentId || + previous.parentSubRunId !== next.parentSubRunId || + previous.childSessionId !== next.childSessionId + ) { + return true; + } + + if (previous.kind === 'toolCall' && next.kind === 'toolCall') { + return !sameSpawnedAgentRef(pickSpawnedAgentRef(previous), pickSpawnedAgentRef(next)); + } + + if (previous.kind === 'childSessionNotification' && next.kind === 'childSessionNotification') { + return !sameChildRefTopology(previous.childRef, next.childRef); + } + + return false; +} + function patchThreadItems( items: ThreadItem[], nextById: ReadonlyMap, @@ -426,7 +480,7 @@ function buildSubRunIndex(messages: Message[]): SubRunIndex { record.ownBodyEntries.push({ index, message }); }); - rootEntries.forEach(({ index, message }) => { + messages.forEach((message, index) => { const spawnedAgentRef = pickSpawnedAgentRef(message); if (!spawnedAgentRef) { return; @@ -443,6 +497,14 @@ function buildSubRunIndex(messages: Message[]): SubRunIndex { if (!record.childSessionId && spawnedAgentRef.childSessionId) { record.childSessionId = spawnedAgentRef.childSessionId; } + const parentSubRunId = message.subRunId ? aliases.get(message.subRunId) ?? message.subRunId : null; + if ( + !record.parentSubRunId && + parentSubRunId && + parentSubRunId !== canonicalSubRunId + ) { + record.parentSubRunId = parentSubRunId; + } }); const orderedRecords = [...records.values()].sort( diff --git a/frontend/src/lib/toolDisplay.ts b/frontend/src/lib/toolDisplay.ts index 3e3b85f2..f9183849 100644 --- a/frontend/src/lib/toolDisplay.ts +++ b/frontend/src/lib/toolDisplay.ts @@ -19,6 +19,12 @@ export interface ToolMetadataSummary { pills: string[]; } +export interface ToolChildSessionTarget { + openSessionId?: string; + subRunId?: string; + agentId?: string; +} + export interface StructuredJsonOutput { value: UnknownRecord | unknown[]; summary: string; @@ -220,6 +226,31 @@ export function extractToolMetadataSummary(metadata: unknown): ToolMetadataSumma return { message: message ?? undefined, pills }; } +export function extractToolChildSessionTarget( + metadata: unknown +): ToolChildSessionTarget | null { + const container = asRecord(metadata); + if (!container) { + return null; + } + + const agentRef = asRecord(container.agentRef); + const openSessionId = + pickString(container, 'openSessionId') ?? pickString(agentRef ?? {}, 'openSessionId'); + const subRunId = pickString(agentRef ?? {}, 'subRunId'); + const agentId = pickString(agentRef ?? {}, 'agentId'); + + if (!openSessionId && !subRunId) { + return null; + } + + return { + openSessionId: openSessionId ?? undefined, + subRunId: subRunId ?? undefined, + agentId: agentId ?? undefined, + }; +} + function summarizeJsonContainer(value: UnknownRecord | unknown[]): string { if (Array.isArray(value)) { return `Array (${value.length} items)`; diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts index 1a635991..b93f01ea 100644 --- a/frontend/src/lib/utils.ts +++ b/frontend/src/lib/utils.ts @@ -7,11 +7,17 @@ export function cn(...inputs: ClassValue[]) { } /** - * 计算 prompt 缓存命中率百分比(0–100),无有效数据时返回 null。 + * 计算 provider KV cache 命中率百分比(0–100),无有效数据时返回 null。 */ export function calculateCacheHitRatePercent( - metrics?: Pick + metrics?: Pick< + PromptMetricsMessage, + 'providerInputTokens' | 'cacheReadInputTokens' | 'providerCacheMetricsSupported' + > ): number | null { + if (!metrics?.providerCacheMetricsSupported) { + return null; + } if (!metrics?.providerInputTokens || metrics.providerInputTokens <= 0) { return null; } @@ -20,3 +26,18 @@ export function calculateCacheHitRatePercent( ); return Math.min(Math.max(rawRate, 0), 100); } + +/** + * 计算 prompt composer 复用命中率百分比(0–100),无有效数据时返回 null。 + */ +export function calculatePromptReuseRatePercent( + metrics?: Pick +): number | null { + const hits = metrics?.promptCacheReuseHits ?? 0; + const misses = metrics?.promptCacheReuseMisses ?? 0; + const total = hits + misses; + if (total <= 0) { + return null; + } + return Math.round((hits / total) * 100); +} From e103be86a722b9ba486932300b08136bd84a86cb Mon Sep 17 00:00:00 2001 From: whatevertogo Date: Wed, 15 Apr 2026 22:49:41 +0800 Subject: [PATCH 04/53] =?UTF-8?q?=E2=9C=A8=20feat:=20=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E4=BB=A3=E7=A0=81=E6=A0=BC=E5=BC=8F=EF=BC=8C=E7=AE=80=E5=8C=96?= =?UTF-8?q?=E5=AF=BC=E5=85=A5=E5=92=8C=E5=87=BD=E6=95=B0=E5=AE=9A=E4=B9=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/components/Chat/AssistantMessage.tsx | 18 ++++++++++-------- frontend/src/components/Chat/MessageList.tsx | 4 ---- .../components/Chat/PromptMetricsMessage.tsx | 5 +---- frontend/src/components/Chat/ToolCallBlock.tsx | 18 +++++++++--------- .../src/components/Chat/ToolStreamBlock.tsx | 5 ++--- frontend/src/lib/subRunView.ts | 15 +++++---------- frontend/src/lib/toolDisplay.ts | 4 +--- 7 files changed, 28 insertions(+), 41 deletions(-) diff --git a/frontend/src/components/Chat/AssistantMessage.tsx b/frontend/src/components/Chat/AssistantMessage.tsx index a5c6af64..fa87d9a2 100644 --- a/frontend/src/components/Chat/AssistantMessage.tsx +++ b/frontend/src/components/Chat/AssistantMessage.tsx @@ -11,11 +11,7 @@ import { expandableBody, ghostIconButton, } from '../../lib/styles'; -import { - calculateCacheHitRatePercent, - calculatePromptReuseRatePercent, - cn, -} from '../../lib/utils'; +import { calculateCacheHitRatePercent, calculatePromptReuseRatePercent, cn } from '../../lib/utils'; interface AssistantMessageProps { message: AssistantMessageType; @@ -215,10 +211,14 @@ function getCacheIndicator(metrics?: PromptMetricsMessage): React.ReactNode { const providerHitRate = calculateCacheHitRatePercent(metrics); if (providerHitRate !== null) { if (providerHitRate >= 80) { - return 🟢 KV 缓存 {providerHitRate}%; + return ( + 🟢 KV 缓存 {providerHitRate}% + ); } if (providerHitRate >= 30) { - return 🟡 KV 缓存 {providerHitRate}%; + return ( + 🟡 KV 缓存 {providerHitRate}% + ); } if (providerHitRate > 0) { return 🟠 KV 缓存 {providerHitRate}%; @@ -230,7 +230,9 @@ function getCacheIndicator(metrics?: PromptMetricsMessage): React.ReactNode { if (promptReuseRate === null) { return null; } - return 🧩 Prompt 复用 {promptReuseRate}%; + return ( + 🧩 Prompt 复用 {promptReuseRate}% + ); } function AssistantMessage({ diff --git a/frontend/src/components/Chat/MessageList.tsx b/frontend/src/components/Chat/MessageList.tsx index a764bc73..fd5f48c0 100644 --- a/frontend/src/components/Chat/MessageList.tsx +++ b/frontend/src/components/Chat/MessageList.tsx @@ -411,7 +411,6 @@ export default function MessageList({ const subRunView = subRunViews.get(item.subRunId); if (!subRunView) { rendered.push( - (
subRunId: {item.subRunId}
- ) ); continue; } @@ -455,7 +453,6 @@ export default function MessageList({ ); rendered.push( - (
{boundaryMessage ? ( {subRunBlock} @@ -463,7 +460,6 @@ export default function MessageList({ subRunBlock )}
- ) ); } diff --git a/frontend/src/components/Chat/PromptMetricsMessage.tsx b/frontend/src/components/Chat/PromptMetricsMessage.tsx index 03b540e3..cc116020 100644 --- a/frontend/src/components/Chat/PromptMetricsMessage.tsx +++ b/frontend/src/components/Chat/PromptMetricsMessage.tsx @@ -2,10 +2,7 @@ import { memo } from 'react'; import type { PromptMetricsMessage as PromptMetricsMessageType } from '../../types'; import { pillInfo } from '../../lib/styles'; -import { - calculateCacheHitRatePercent, - calculatePromptReuseRatePercent, -} from '../../lib/utils'; +import { calculateCacheHitRatePercent, calculatePromptReuseRatePercent } from '../../lib/utils'; interface PromptMetricsMessageProps { message: PromptMetricsMessageType; diff --git a/frontend/src/components/Chat/ToolCallBlock.tsx b/frontend/src/components/Chat/ToolCallBlock.tsx index cf35cb11..c3e99e95 100644 --- a/frontend/src/components/Chat/ToolCallBlock.tsx +++ b/frontend/src/components/Chat/ToolCallBlock.tsx @@ -9,13 +9,7 @@ import { extractToolShellDisplay, formatToolCallSummary, } from '../../lib/toolDisplay'; -import { - chevronIcon, - infoButton, - pillDanger, - pillNeutral, - pillSuccess, -} from '../../lib/styles'; +import { chevronIcon, infoButton, pillDanger, pillNeutral, pillSuccess } from '../../lib/styles'; import { cn } from '../../lib/utils'; import { useChatScreenContext } from './ChatScreenContext'; import ToolCodePanel from './ToolCodePanel'; @@ -53,7 +47,11 @@ function streamBadge(stream: ToolStreamMessage['stream']): string { return stream === 'stderr' ? pillDanger : pillNeutral; } -function streamTitle(toolName: string, stream: ToolStreamMessage['stream'], hasShellCommand: boolean): string { +function streamTitle( + toolName: string, + stream: ToolStreamMessage['stream'], + hasShellCommand: boolean +): string { if (hasShellCommand && stream === 'stdout') { return 'Shell'; } @@ -249,7 +247,9 @@ function ToolCallBlock({ message, streams = [] }: ToolCallBlockProps) {
)} - {(metadataSummary?.pills?.length || message.durationMs !== undefined || message.truncated) && ( + {(metadataSummary?.pills?.length || + message.durationMs !== undefined || + message.truncated) && (
{metadataSummary?.pills.map((pill) => ( diff --git a/frontend/src/components/Chat/ToolStreamBlock.tsx b/frontend/src/components/Chat/ToolStreamBlock.tsx index be07dd9a..af2ff4ec 100644 --- a/frontend/src/components/Chat/ToolStreamBlock.tsx +++ b/frontend/src/components/Chat/ToolStreamBlock.tsx @@ -37,9 +37,8 @@ function statusLabel(status: ToolStreamMessage['status']): string { } function ToolStreamBlock({ message }: ToolStreamBlockProps) { - const structuredResult = message.stream === 'stdout' - ? extractStructuredJsonOutput(message.content) - : null; + const structuredResult = + message.stream === 'stdout' ? extractStructuredJsonOutput(message.content) : null; return (
diff --git a/frontend/src/lib/subRunView.ts b/frontend/src/lib/subRunView.ts index dd527f24..c5af5e1c 100644 --- a/frontend/src/lib/subRunView.ts +++ b/frontend/src/lib/subRunView.ts @@ -161,10 +161,7 @@ function remapMessageReference( return next; } -function sameSpawnedAgentRef( - left: SpawnedAgentRef | null, - right: SpawnedAgentRef | null -): boolean { +function sameSpawnedAgentRef(left: SpawnedAgentRef | null, right: SpawnedAgentRef | null): boolean { if (left === right) { return true; } @@ -497,12 +494,10 @@ function buildSubRunIndex(messages: Message[]): SubRunIndex { if (!record.childSessionId && spawnedAgentRef.childSessionId) { record.childSessionId = spawnedAgentRef.childSessionId; } - const parentSubRunId = message.subRunId ? aliases.get(message.subRunId) ?? message.subRunId : null; - if ( - !record.parentSubRunId && - parentSubRunId && - parentSubRunId !== canonicalSubRunId - ) { + const parentSubRunId = message.subRunId + ? (aliases.get(message.subRunId) ?? message.subRunId) + : null; + if (!record.parentSubRunId && parentSubRunId && parentSubRunId !== canonicalSubRunId) { record.parentSubRunId = parentSubRunId; } }); diff --git a/frontend/src/lib/toolDisplay.ts b/frontend/src/lib/toolDisplay.ts index f9183849..e8dd1248 100644 --- a/frontend/src/lib/toolDisplay.ts +++ b/frontend/src/lib/toolDisplay.ts @@ -226,9 +226,7 @@ export function extractToolMetadataSummary(metadata: unknown): ToolMetadataSumma return { message: message ?? undefined, pills }; } -export function extractToolChildSessionTarget( - metadata: unknown -): ToolChildSessionTarget | null { +export function extractToolChildSessionTarget(metadata: unknown): ToolChildSessionTarget | null { const container = asRecord(metadata); if (!container) { return null; From d5779446186e1c8a375ead587a4a1a7f974e1b24 Mon Sep 17 00:00:00 2001 From: whatevertogo Date: Wed, 15 Apr 2026 22:54:42 +0800 Subject: [PATCH 05/53] =?UTF-8?q?=E2=9C=A8=20feat(regressions):=20?= =?UTF-8?q?=E6=9B=B4=E6=96=B0=E5=9B=9E=E5=BD=92=E6=B5=8B=E8=AF=95=EF=BC=8C?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=B7=B2=E5=88=A0=E9=99=A4=E4=BE=9D=E8=B5=96?= =?UTF-8?q?=E5=B9=B6=E5=A2=9E=E5=BC=BA=E6=B5=8B=E8=AF=95=E8=A6=86=E7=9B=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/DebugWorkbenchApp.tsx | 6 +-- .../components/Chat/ToolCallBlock.test.tsx | 15 +++--- frontend/src/lib/debugWorkbench.ts | 4 +- frontend/src/lib/logger.ts | 4 ++ scripts/regressions.mjs | 46 +++++++++++++++++-- 5 files changed, 58 insertions(+), 17 deletions(-) diff --git a/frontend/src/DebugWorkbenchApp.tsx b/frontend/src/DebugWorkbenchApp.tsx index 1c6c2ca2..56f6db56 100644 --- a/frontend/src/DebugWorkbenchApp.tsx +++ b/frontend/src/DebugWorkbenchApp.tsx @@ -30,7 +30,7 @@ const TREND_CHART_WIDTH = 720; const TREND_CHART_HEIGHT = 168; function ratioTone(value?: number | null): string { - if (value == null) { + if (value === null || value === undefined) { return 'bg-surface text-text-secondary'; } if (value >= 7_000) { @@ -468,7 +468,7 @@ export default function DebugWorkbenchApp() { type="button" className={cn( 'rounded-2xl border px-3 py-3 text-left transition-colors', - active + active === true ? 'border-accent bg-accent-soft/20' : 'border-border bg-white/75 hover:bg-surface' )} @@ -593,7 +593,7 @@ export default function DebugWorkbenchApp() { : '请在左侧选择会话' } > - {sessionLoading && trace == null ? ( + {sessionLoading && trace === null ? (
正在同步当前会话 trace…
) : null} {trace?.parentSessionId ? ( diff --git a/frontend/src/components/Chat/ToolCallBlock.test.tsx b/frontend/src/components/Chat/ToolCallBlock.test.tsx index 4600b4bc..721c5fe2 100644 --- a/frontend/src/components/Chat/ToolCallBlock.test.tsx +++ b/frontend/src/components/Chat/ToolCallBlock.test.tsx @@ -23,14 +23,15 @@ const chatContextValue: ChatScreenContextValue = { onSubmitPrompt: () => {}, onInterrupt: () => {}, onCancelSubRun: () => {}, - listComposerOptions: async () => [], + listComposerOptions: () => Promise.resolve([]), modelRefreshKey: 0, - getCurrentModel: async () => ({ - profileName: 'default', - model: 'test-model', - providerKind: 'openai', - }), - listAvailableModels: async () => [], + getCurrentModel: () => + Promise.resolve({ + profileName: 'default', + model: 'test-model', + providerKind: 'openai', + }), + listAvailableModels: () => Promise.resolve([]), setModel: async () => {}, }; diff --git a/frontend/src/lib/debugWorkbench.ts b/frontend/src/lib/debugWorkbench.ts index 1dbf45d6..e46e2997 100644 --- a/frontend/src/lib/debugWorkbench.ts +++ b/frontend/src/lib/debugWorkbench.ts @@ -13,7 +13,7 @@ export interface SparklinePoint { const GOVERNANCE_TREND_WINDOW_MS = 5 * 60 * 1000; export function formatRatioBps(value?: number | null): string { - if (value == null) { + if (value === null || value === undefined) { return '—'; } return `${(value / 100).toFixed(2)}%`; @@ -38,7 +38,7 @@ export function buildGovernanceSparklinePoints( return samples .map((sample) => { const value = selector(sample); - if (value == null) { + if (value === null || value === undefined) { return null; } const normalizedX = diff --git a/frontend/src/lib/logger.ts b/frontend/src/lib/logger.ts index 8f6c5180..c7b2400e 100644 --- a/frontend/src/lib/logger.ts +++ b/frontend/src/lib/logger.ts @@ -103,6 +103,9 @@ function log(level: LogLevel, source: string, ...args: unknown[]): void { sendToServer(payload); + // Why: 浏览器端 logger 需要在本地开发和服务端日志桥接失效时保留可见输出, + // 这里的 console 是受控的 fallback,不是临时调试残留。 + /* eslint-disable no-console */ switch (level) { case 'debug': console.debug(message, ...details); @@ -117,6 +120,7 @@ function log(level: LogLevel, source: string, ...args: unknown[]): void { console.error(message, ...details); return; } + /* eslint-enable no-console */ } export const logger = { diff --git a/scripts/regressions.mjs b/scripts/regressions.mjs index e4e5853d..c33a4544 100644 --- a/scripts/regressions.mjs +++ b/scripts/regressions.mjs @@ -1,11 +1,30 @@ import { repoRoot, runWithInheritedOutput } from './hook-utils.mjs'; -// 阶段 0 基线回归套件:覆盖会话执行、插件能力、prompt 构建三条关键链路。 +// 阶段 0 基线回归套件:覆盖当前架构下的三条关键链路。 +// Why: 旧脚本仍引用已删除的 astrcode-runtime / astrcode-runtime-prompt, +// 会在 CI 中直接失败;这里改为锚定现存 crate 的等价回归测试。 const checks = [ { - name: 'session execution regression', + name: 'session step execution regression', command: 'cargo', - args: ['test', '-p', 'astrcode-runtime', '--lib', 'service::execution::tests'], + args: [ + 'test', + '-p', + 'astrcode-session-runtime', + '--lib', + 'turn::runner::step::tests::run_single_step_returns_cancelled_when_tool_cycle_interrupts', + ], + }, + { + name: 'tool cycle live/durable regression', + command: 'cargo', + args: [ + 'test', + '-p', + 'astrcode-session-runtime', + '--lib', + 'turn::tool_cycle::tests::invoke_single_tool_emits_structured_and_live_events_immediately', + ], }, { name: 'plugin capability regression', @@ -13,9 +32,26 @@ const checks = [ args: ['test', '-p', 'astrcode-plugin', '--test', 'v4_stdio_e2e'], }, { - name: 'prompt build regression', + name: 'prompt metrics regression', + command: 'cargo', + args: [ + 'test', + '-p', + 'astrcode-session-runtime', + '--lib', + 'turn::request::tests::assemble_prompt_request_emits_prompt_metrics_for_final_prompt', + ], + }, + { + name: 'prompt build cache regression', command: 'cargo', - args: ['test', '-p', 'astrcode-runtime-prompt', '--lib'], + args: [ + 'test', + '-p', + 'astrcode-adapter-prompt', + '--lib', + 'layered_builder::tests::inherited_cache_reuses_compact_summary_without_reusing_recent_tail', + ], }, ]; From c891e1b3182def093cf0d06705a76f9000c92ac4 Mon Sep 17 00:00:00 2001 From: whatevertogo Date: Wed, 15 Apr 2026 23:01:45 +0800 Subject: [PATCH 06/53] =?UTF-8?q?=E2=9C=A8=20docs:=20=E6=9B=B4=E6=96=B0?= =?UTF-8?q?=E6=8F=90=E4=BA=A4=E5=89=8D=E9=AA=8C=E8=AF=81=E6=AD=A5=E9=AA=A4?= =?UTF-8?q?=EF=BC=8C=E5=A2=9E=E5=8A=A0=E5=89=8D=E7=AB=AF=E4=BB=A3=E7=A0=81?= =?UTF-8?q?=E6=A0=BC=E5=BC=8F=E5=8C=96=E5=92=8C=E6=A3=80=E6=9F=A5=E8=A6=81?= =?UTF-8?q?=E6=B1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 10 ++++++++-- CLAUDE.md | 8 +++++++- crates/cli/src/ui/cells.rs | 6 +----- crates/protocol/tests/terminal_conformance.rs | 1 + 4 files changed, 17 insertions(+), 8 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index a74517ed..c074d266 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -39,7 +39,7 @@ node scripts/check-crate-boundaries.mjs --strict # 严格模式 - `server` 是唯一组合根,通过 `bootstrap_server_runtime()` 组装所有组件 - `application` 不依赖任何 `adapter-*`,只依赖 `core` + `kernel` + `session-runtime` -- 治理层使用 `AppGovernance`(`astrcode-application`),不使用旧 `RuntimeGovernance`(`astrcode-runtime`) +- 治理层使用 `AppGovernance`(`astrcode-application`) - 能力语义统一使用 `CapabilitySpec`(`astrcode-core`),传输层使用 `CapabilityDescriptor`(`astrcode-protocol`) ## 代码规范 @@ -50,13 +50,19 @@ node scripts/check-crate-boundaries.mjs --strict # 严格模式 ## 提交前验证 -每次提交前按顺序执行: +改了后端rust代码每次提交前按顺序执行: 1. `cargo fmt --all` — 格式化代码 2. `cargo clippy --all-targets --all-features -- -D warnings` — 修复所有警告 3. `cargo test --workspace` — 确保所有测试通过 4. 确认变更内容后写出描述性提交信息 +改了前端代码每次提交前按顺序执行: +1. `npm run format` — 格式化代码 +2. `npm run lint` — 修复所有 lint 错误 +3. `npm run typecheck` — 确保没有类型错误 +4. `npm run format:check` — 确保格式正确 + ## Gotchas - 前端css不允许出现webview相关内容这会导致应用端无法下滑窗口 diff --git a/CLAUDE.md b/CLAUDE.md index db21f28c..c074d266 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -50,13 +50,19 @@ node scripts/check-crate-boundaries.mjs --strict # 严格模式 ## 提交前验证 -每次提交前按顺序执行: +改了后端rust代码每次提交前按顺序执行: 1. `cargo fmt --all` — 格式化代码 2. `cargo clippy --all-targets --all-features -- -D warnings` — 修复所有警告 3. `cargo test --workspace` — 确保所有测试通过 4. 确认变更内容后写出描述性提交信息 +改了前端代码每次提交前按顺序执行: +1. `npm run format` — 格式化代码 +2. `npm run lint` — 修复所有 lint 错误 +3. `npm run typecheck` — 确保没有类型错误 +4. `npm run format:check` — 确保格式正确 + ## Gotchas - 前端css不允许出现webview相关内容这会导致应用端无法下滑窗口 diff --git a/crates/cli/src/ui/cells.rs b/crates/cli/src/ui/cells.rs index e8cf157b..8c2d5e8e 100644 --- a/crates/cli/src/ui/cells.rs +++ b/crates/cli/src/ui/cells.rs @@ -213,11 +213,7 @@ fn prefixed_line( capabilities: TerminalCapabilities, width: usize, ) -> WrappedLine { - let prefix = if matches!(style, WrappedLineStyle::Header | WrappedLineStyle::Accent) { - " " - } else { - " " - }; + let prefix = " "; let available = width.saturating_sub(display_width(prefix, capabilities)); let text = truncate_to_width(content, available.max(1), capabilities); WrappedLine { diff --git a/crates/protocol/tests/terminal_conformance.rs b/crates/protocol/tests/terminal_conformance.rs index fb6df037..59f39684 100644 --- a/crates/protocol/tests/terminal_conformance.rs +++ b/crates/protocol/tests/terminal_conformance.rs @@ -90,6 +90,7 @@ fn terminal_snapshot_fixture_freezes_v1_hydration_shape() { status: TerminalBlockStatusDto::Complete, input: Some(json!({ "command": "rg terminal" })), summary: Some("读取 protocol 上下文".to_string()), + metadata: None, }), TerminalBlockDto::ToolStream(TerminalToolStreamBlockDto { id: "block-tool-stream-1".to_string(), From 138f74f00c652a135a961e0434416796368332fd Mon Sep 17 00:00:00 2001 From: whatevertogo Date: Wed, 15 Apr 2026 23:13:22 +0800 Subject: [PATCH 07/53] =?UTF-8?q?=E2=9C=A8=20feat:=20=E6=B7=BB=E5=8A=A0=20?= =?UTF-8?q?Unix=20=E4=BF=A1=E5=8F=B7=E5=8F=91=E9=80=81=E5=8A=9F=E8=83=BD?= =?UTF-8?q?=EF=BC=8C=E4=BC=98=E5=8C=96=E5=AD=90=E8=BF=9B=E7=A8=8B=E7=AE=A1?= =?UTF-8?q?=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Cargo.lock | 1 + crates/adapter-mcp/Cargo.toml | 3 +++ crates/adapter-mcp/src/transport/stdio.rs | 29 ++++++++++++++++++----- 3 files changed, 27 insertions(+), 6 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 0d0b3061..b66f5099 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -178,6 +178,7 @@ dependencies = [ "async-trait", "base64 0.22.1", "futures-util", + "libc", "log", "notify", "reqwest", diff --git a/crates/adapter-mcp/Cargo.toml b/crates/adapter-mcp/Cargo.toml index a61535dd..5008ad88 100644 --- a/crates/adapter-mcp/Cargo.toml +++ b/crates/adapter-mcp/Cargo.toml @@ -19,3 +19,6 @@ serde_json.workspace = true thiserror.workspace = true tokio.workspace = true uuid.workspace = true + +[target.'cfg(unix)'.dependencies] +libc = "0.2" diff --git a/crates/adapter-mcp/src/transport/stdio.rs b/crates/adapter-mcp/src/transport/stdio.rs index 8a6f6bfa..40a342c9 100644 --- a/crates/adapter-mcp/src/transport/stdio.rs +++ b/crates/adapter-mcp/src/transport/stdio.rs @@ -51,6 +51,27 @@ impl StdioTransport { env, } } + + #[cfg(unix)] + fn send_unix_signal(child: &Child, signal: i32, signal_name: &str) { + let Some(pid) = child.id() else { + info!( + "skip sending {} to MCP server because the process id is unavailable", + signal_name + ); + return; + }; + + // libc::kill 是 Unix 发送进程信号的底层系统调用,这里只在 unix 平台使用。 + let result = unsafe { libc::kill(pid as i32, signal) }; + if result != 0 { + let error = std::io::Error::last_os_error(); + info!( + "failed to send {} to MCP server pid {}: {}", + signal_name, pid, error + ); + } + } } #[async_trait] @@ -184,9 +205,7 @@ impl McpTransport for StdioTransport { use tokio::time::{Duration, timeout}; // SIGINT - unsafe { - libc::kill(child.id() as i32, libc::SIGINT); - } + Self::send_unix_signal(&child, libc::SIGINT, "SIGINT"); match timeout(Duration::from_secs(5), child.wait()).await { Ok(Ok(_)) => { @@ -197,9 +216,7 @@ impl McpTransport for StdioTransport { } // SIGTERM - unsafe { - libc::kill(child.id() as i32, libc::SIGTERM); - } + Self::send_unix_signal(&child, libc::SIGTERM, "SIGTERM"); match timeout(Duration::from_secs(5), child.wait()).await { Ok(Ok(_)) => { From b35e05e65b1e8fe6539efe4ffab93289f9a64b41 Mon Sep 17 00:00:00 2001 From: whatevertogo Date: Thu, 16 Apr 2026 12:20:58 +0800 Subject: [PATCH 08/53] =?UTF-8?q?=E2=9C=A8=20feat(conversation):=20?= =?UTF-8?q?=E6=94=B6=E5=8F=A3=E5=B7=A5=E5=85=B7=E8=B0=83=E7=94=A8=E6=9D=83?= =?UTF-8?q?=E5=A8=81=E8=AF=BB=E6=A8=A1=E5=9E=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit protocol / conversation contract - Files: crates/protocol/src/http/conversation/v1.rs, crates/protocol/src/http/mod.rs, crates/protocol/tests/conversation_conformance.rs, crates/protocol/tests/fixtures/conversation/v1/snapshot.json, crates/protocol/tests/fixtures/conversation/v1/delta_patch_tool_stream.json, crates/protocol/tests/fixtures/conversation/v1/delta_rehydrate_required.json, crates/protocol/tests/terminal_conformance.rs, crates/protocol/tests/fixtures/terminal/v1/delta_patch_tool_metadata.json - Why: 旧 conversation surface 仍夹带 terminal alias 和 sibling tool stream 语义,工具展示合同不稳定,无法冻结 hydration/delta/reconnect 行为。 - How: 独立定义 conversation v1 DTO,把 tool display 收敛为单个 tool call block + stream patch,并补齐协议 conformance 测试与 fixture。 session-runtime query - Files: crates/session-runtime/src/query/conversation.rs, crates/session-runtime/src/query/mod.rs, crates/session-runtime/src/query/service.rs, crates/session-runtime/src/lib.rs - Why: authoritative tool display 真相长期停留在 server/frontend 侧拼装,违反单 session read model 边界。 - How: 新增 conversation query 子域,输出 snapshot/replay facts、tool block 聚合、live/durable 去重、child handoff 与 transcript error 分类,并从 service/lib 对外暴露稳定查询接口。 application conversation use cases - Files: crates/application/src/ports/app_session.rs, crates/application/src/terminal/mod.rs, crates/application/src/terminal_use_cases.rs - Why: application 之前继续上传 transcript/replay 原始材料,导致上层必须理解事件级细节才能恢复 conversation 真相。 - How: 改为直接消费 conversation snapshot/stream replay facts,调整 terminal summary、cursor 校验与 child summary 逻辑,只保留稳定 facts 编排职责。 server projection and routes - Files: crates/server/src/http/routes/conversation.rs, crates/server/src/http/terminal_projection.rs - Why: server route/projector 不应继续承担状态型 tool 聚合与 replay/live 对齐规则。 - How: 把 server 收回为 DTO 映射、rehydrate signalling 与 SSE framing 薄层,删除大部分 projector 内的工具聚合逻辑并改用 session-runtime authoritative facts。 tool execution coverage - Files: crates/session-runtime/src/turn/tool_cycle.rs - Why: 新的 tool display contract 需要证明 stderr 流同时覆盖 durable 与 live 通道,否则工具失败场景仍可能回归。 - How: 增加 streaming stderr probe tool 与相关测试,验证即时事件、持久化 delta 和 tool result 的一致性。 cli transcript rendering - Files: crates/cli/src/state/conversation.rs, crates/cli/src/state/transcript_cell.rs, crates/cli/src/ui/cells.rs - Why: CLI 仍保留旧的本地 regroup 思维,不能完整消费工具 block 的 streams、error、duration、truncated 与 child session 字段。 - How: 让 transcript cell 直接建模 tool call 的完整展示字段,并在 UI 中以内嵌 stdout/stderr/error/duration 渲染统一工具块。 frontend conversation model - Files: frontend/src/lib/api/conversation.ts, frontend/src/lib/api/conversation.test.ts, frontend/src/types.ts, frontend/src/lib/subRunView.ts, frontend/src/lib/toolDisplay.ts - Why: 前端此前依赖 tool_stream sibling regroup、metadata fallback 和 spawn 特判来重建工具展示与子会话拓扑,重连后容易漂移。 - How: 改为直接维护 authoritative tool block store,引入显式 childRef/streams 类型,补齐 patch 应用逻辑与 reconnect/并发工具测试,并删除 metadata fallback 推断路径。 frontend chat rendering - Files: frontend/src/components/Chat/MessageList.tsx, frontend/src/components/Chat/ToolCallBlock.tsx, frontend/src/components/Chat/ToolCallBlock.test.tsx, frontend/src/components/Chat/SubRunBlock.tsx, frontend/src/components/Chat/ToolStreamBlock.tsx - Why: UI 仍把 tool stream 当作独立 message 渲染,导致工具输出和终态字段分裂在多个块里。 - How: 删除 ToolStreamBlock 与 groupedToolStreams 扫描逻辑,让 ToolCallBlock 直接渲染嵌入式 stdout/stderr、失败态和 childRef 跳转。 docs and review artifacts - Files: README.md, PROJECT_ARCHITECTURE.md, CODE_REVIEW_ISSUES.md - Why: 实现已经收紧 conversation/tool display 边界,但仓库文档和评审结论还未反映新的权威读模型约束。 - How: 更新 README 与架构文档,明确 conversation surface、tool block contract 和前后端职责,并记录本轮代码评审发现。 openspec sync - Files: openspec/changes/2026-04-15-stabilize-conversation-surface/proposal.md, openspec/changes/2026-04-15-stabilize-conversation-surface/tasks.md, openspec/changes/archive/2026-04-16-refactor-tool-call-display-pipeline/.openspec.yaml, openspec/changes/archive/2026-04-16-refactor-tool-call-display-pipeline/design.md, openspec/changes/archive/2026-04-16-refactor-tool-call-display-pipeline/proposal.md, openspec/changes/archive/2026-04-16-refactor-tool-call-display-pipeline/specs/application-use-cases/spec.md, openspec/changes/archive/2026-04-16-refactor-tool-call-display-pipeline/specs/session-runtime-subdomain-boundaries/spec.md, openspec/changes/archive/2026-04-16-refactor-tool-call-display-pipeline/specs/terminal-chat-read-model/spec.md, openspec/changes/archive/2026-04-16-refactor-tool-call-display-pipeline/specs/terminal-chat-surface/spec.md, openspec/changes/archive/2026-04-16-refactor-tool-call-display-pipeline/tasks.md, openspec/specs/application-use-cases/spec.md, openspec/specs/session-runtime-subdomain-boundaries/spec.md, openspec/specs/terminal-chat-read-model/spec.md, openspec/specs/terminal-chat-surface/spec.md - Why: 这次重构改变了 conversation/tool display 的稳定边界,需要把 change、归档与主 specs 同步到实现后的状态。 - How: 归档旧 change,补齐 proposal/design/tasks,并同步 main specs 中 application、session-runtime 与 terminal chat surface 的要求。 agent runtime defaults - Files: crates/core/src/config.rs - Why: 现有单轮 spawn 默认预算过低,无法承载多视角 review 工作流。 - How: 将 DEFAULT_MAX_SPAWN_PER_TURN 从 3 提升到 6,与新的协作工作流保持一致。 --- CODE_REVIEW_ISSUES.md | 75 + PROJECT_ARCHITECTURE.md | 13 +- README.md | 355 +-- crates/application/src/ports/app_session.rs | 30 +- crates/application/src/terminal/mod.rs | 19 +- crates/application/src/terminal_use_cases.rs | 156 +- crates/cli/src/state/conversation.rs | 34 +- crates/cli/src/state/transcript_cell.rs | 37 +- crates/cli/src/ui/cells.rs | 71 +- crates/core/src/config.rs | 2 +- crates/protocol/src/http/conversation/v1.rs | 363 ++- crates/protocol/src/http/mod.rs | 2 +- .../tests/conversation_conformance.rs | 134 + .../v1/delta_patch_tool_stream.json | 11 + .../v1/delta_rehydrate_required.json | 13 + .../fixtures/conversation/v1/snapshot.json | 50 + .../v1/delta_patch_tool_metadata.json | 17 + crates/protocol/tests/terminal_conformance.rs | 32 + crates/server/src/http/routes/conversation.rs | 409 ++- crates/server/src/http/terminal_projection.rs | 2355 +++-------------- crates/session-runtime/src/lib.rs | 32 +- .../session-runtime/src/query/conversation.rs | 1768 +++++++++++++ crates/session-runtime/src/query/mod.rs | 11 + crates/session-runtime/src/query/service.rs | 62 +- crates/session-runtime/src/turn/tool_cycle.rs | 171 ++ frontend/src/components/Chat/MessageList.tsx | 50 +- frontend/src/components/Chat/SubRunBlock.tsx | 6 +- .../components/Chat/ToolCallBlock.test.tsx | 79 +- .../src/components/Chat/ToolCallBlock.tsx | 36 +- .../src/components/Chat/ToolStreamBlock.tsx | 68 - frontend/src/lib/api/conversation.test.ts | 309 ++- frontend/src/lib/api/conversation.ts | 94 +- frontend/src/lib/subRunView.ts | 41 +- frontend/src/lib/toolDisplay.ts | 29 - frontend/src/types.ts | 52 +- .../proposal.md | 25 - .../tasks.md | 21 - .../.openspec.yaml | 2 +- .../design.md | 295 +++ .../proposal.md | 99 + .../specs/application-use-cases/spec.md | 54 + .../spec.md | 35 + .../specs/terminal-chat-read-model/spec.md | 64 + .../specs/terminal-chat-surface/spec.md | 41 + .../tasks.md | 78 + openspec/specs/application-use-cases/spec.md | 11 +- .../spec.md | 14 + .../specs/terminal-chat-read-model/spec.md | 23 +- openspec/specs/terminal-chat-surface/spec.md | 20 +- 49 files changed, 4979 insertions(+), 2789 deletions(-) create mode 100644 CODE_REVIEW_ISSUES.md create mode 100644 crates/protocol/tests/conversation_conformance.rs create mode 100644 crates/protocol/tests/fixtures/conversation/v1/delta_patch_tool_stream.json create mode 100644 crates/protocol/tests/fixtures/conversation/v1/delta_rehydrate_required.json create mode 100644 crates/protocol/tests/fixtures/conversation/v1/snapshot.json create mode 100644 crates/protocol/tests/fixtures/terminal/v1/delta_patch_tool_metadata.json create mode 100644 crates/session-runtime/src/query/conversation.rs delete mode 100644 frontend/src/components/Chat/ToolStreamBlock.tsx delete mode 100644 openspec/changes/2026-04-15-stabilize-conversation-surface/proposal.md delete mode 100644 openspec/changes/2026-04-15-stabilize-conversation-surface/tasks.md rename openspec/changes/{2026-04-15-stabilize-conversation-surface => archive/2026-04-16-refactor-tool-call-display-pipeline}/.openspec.yaml (50%) create mode 100644 openspec/changes/archive/2026-04-16-refactor-tool-call-display-pipeline/design.md create mode 100644 openspec/changes/archive/2026-04-16-refactor-tool-call-display-pipeline/proposal.md create mode 100644 openspec/changes/archive/2026-04-16-refactor-tool-call-display-pipeline/specs/application-use-cases/spec.md create mode 100644 openspec/changes/archive/2026-04-16-refactor-tool-call-display-pipeline/specs/session-runtime-subdomain-boundaries/spec.md create mode 100644 openspec/changes/archive/2026-04-16-refactor-tool-call-display-pipeline/specs/terminal-chat-read-model/spec.md create mode 100644 openspec/changes/archive/2026-04-16-refactor-tool-call-display-pipeline/specs/terminal-chat-surface/spec.md create mode 100644 openspec/changes/archive/2026-04-16-refactor-tool-call-display-pipeline/tasks.md diff --git a/CODE_REVIEW_ISSUES.md b/CODE_REVIEW_ISSUES.md new file mode 100644 index 00000000..a7be8776 --- /dev/null +++ b/CODE_REVIEW_ISSUES.md @@ -0,0 +1,75 @@ +# Code Review — dev (staged changes) + +## Summary +Files reviewed: 47 | New issues: 1 High, 3 Medium, 8 Low | Perspectives: 4/4 +Verified false positives filtered: 3 (from agents) + +--- + +## Security +No security issues found. + +Session ID validation, auth enforcement, cursor format validation, and child session resolution all properly secured. No SQL/shell injection paths, no hardcoded secrets. + +--- + +## Code Quality +| Sev | Issue | File:Line | Consequence | +|-----|-------|-----------|-------------| +| Low | `SessionStateEventSink::new` failure silently drops tool metadata | `crates/session-runtime/src/turn/tool_cycle.rs:378-387` | Lost tool metadata events with no diagnostic output | + +No critical/high issues. Agent-reported "off-by-one" in `reconcile_tool_chunk` and "race condition" in `complete_turn` verified as **false positives** — the dedup logic correctly handles cross-chunk pending bytes, and `&mut self` in Rust prevents concurrent mutation. + +--- + +## Tests + +**New test files present**: +- `crates/protocol/tests/conversation_conformance.rs` (134 lines) +- `crates/protocol/tests/terminal_conformance.rs` (32 lines) +- 4 new JSON fixtures in `crates/protocol/tests/fixtures/` +- Updated `frontend/src/components/Chat/ToolCallBlock.test.tsx` +- Updated `frontend/src/lib/api/conversation.test.ts` + +| Sev | Untested scenario | Location | +|-----|------------------|----------| +| High | `tool_result_summary` 4 branches (ok+output, ok-empty, fail+error, fail-empty) | `crates/session-runtime/src/query/conversation.rs:1234-1261` | +| Medium | `reconcile_tool_chunk` edge cases: durable chunks shorter/different boundaries than live | `crates/session-runtime/src/query/conversation.rs:254-278` | +| Medium | `classify_transcript_error` 4 classification branches | `crates/session-runtime/src/query/conversation.rs:1263-1274` | +| Medium | `ChildSessionNotificationKind` -> `ConversationChildHandoffKind` mapping (6->3) | `crates/session-runtime/src/query/conversation.rs:855-869` | +| Low | `execute_concurrent_safe` with `max_concurrency` limit | `crates/session-runtime/src/turn/tool_cycle.rs:278-317` | +| Low | Cancellation during concurrent tool execution | `crates/session-runtime/src/turn/tool_cycle.rs:217-229` | +| Low | `replace_tool_*` no-change detection returning false for unchanged values | `crates/session-runtime/src/query/conversation.rs:994-1058` | +| Low | `complete_turn` for unknown turn_id / turn with only tool blocks | `crates/session-runtime/src/query/conversation.rs:900-928` | +| Low | Frontend `UpdateControlState`, `UpsertChildSummary`, `RehydrateRequired` delta kinds | `frontend/src/lib/api/conversation.test.ts` | +| Low | `ensure_full_markdown_block` replace vs append non-prefix case | `crates/session-runtime/src/query/conversation.rs:586-635` | +| Low | `invoke_single_tool` fallback event buffering on emit failure | `crates/session-runtime/src/turn/tool_cycle.rs:513-527` | + +--- + +## Architecture + +Agent-reported "frontend status parsing bug" verified as **false positive** — `ConversationBlockStatusDto` uses `#[serde(rename_all = "snake_case")]` and serializes to plain string `"complete"`, not an object. Frontend `parseToolStatus` correctly matches `case 'complete'`. + +No architecture issues found. Cross-layer consistency verified: +- Protocol DTO changes reflected in frontend types and CLI +- ToolStreamBlock removal consistent across all layers +- Server terminal_projection correctly delegates to session-runtime facts +- New `AppSessionPort` methods match session-runtime signatures +- Architecture doc updates match implementation + +--- + +## Must Fix Before Merge +*(None — no critical or high-severity issues that would block merge)* + +--- + +## Pre-Existing Issues (not blocking) +- None identified within scope of this diff + +--- + +## Low-Confidence Observations +- The new `conversation.rs` module (1768 lines) is a large single-file module. As it stabilizes, consider splitting into submodules (tool aggregation, markdown handling, child summaries) for maintainability. +- `child_summary_lookup` inserts the same DTO under multiple keys (child_session_id, open_session_id, session_id). Currently safe but worth noting if session ID semantics diverge in future. diff --git a/PROJECT_ARCHITECTURE.md b/PROJECT_ARCHITECTURE.md index db87aecc..38696e5b 100644 --- a/PROJECT_ARCHITECTURE.md +++ b/PROJECT_ARCHITECTURE.md @@ -222,7 +222,7 @@ agent delegation experience 也遵循同样的分层边界: - allowed:replay/live 订阅语义、scope/filter、状态来源整合 - forbidden:同步快照投影算法、turn 推进、副作用 - `query` - - allowed:拉取、快照、投影、durable replay 后的只读恢复 + - allowed:拉取、快照、投影、durable replay 后的只读恢复、conversation/tool display authoritative read model - forbidden:推进、副作用、长时间持有运行态协调逻辑 - `factory` - allowed:执行输入或执行对象构造 @@ -230,8 +230,8 @@ agent delegation experience 也遵循同样的分层边界: `application` 在这个边界上继续保持: -- allowed:参数校验、权限检查、错误归类、跨 session 编排、稳定 `SessionRuntime` API 调用 -- forbidden:单 session 终态投影细节、durable append 细节、observe 快照拼装细节 +- allowed:参数校验、权限检查、错误归类、跨 session 编排、稳定 `SessionRuntime` API 调用、conversation/tool display facts 编排 +- forbidden:单 session 终态投影细节、durable append 细节、observe 快照拼装细节、tool block 聚合与 replay/live 去重规则 ### 4.6 `adapter-*` @@ -271,7 +271,11 @@ agent delegation experience 也遵循同样的分层边界: - `conversation v1` 是当前唯一的产品读协议,统一承担 snapshot、stream、child summaries、slash candidates 与 control state 的 authoritative read model。 - 旧 `/api/sessions/*/view`、`/history`、`/events` 产品读面已删除;客户端不得再通过本地 reducer 重放原始事件来重建 UI 真相。 -- `server` 内部的 `terminal_projection` 负责把 `application` 返回的 `ConversationFacts` 投影成 `protocol` DTO;它是纯 projection,不做业务校验。 +- `conversation` contract 必须直接表达后端收敛后的 tool display 语义: + - 一个 tool call 只对应一个 authoritative tool block + - stdout/stderr 通过同一 block 的 stream patch 增量更新 + - error、duration、truncated 与 childRef 都是一等字段 +- `server` 内部的 `terminal_projection` 负责把 `application` 返回的 `ConversationFacts` 投影成 `protocol` DTO;它是纯 projection,不做业务校验,也不重新承担 tool 聚合。 - `client` crate 只负责: - bootstrap exchange 后的 typed HTTP/SSE facade - 结构化错误归一化 @@ -295,6 +299,7 @@ agent delegation experience 也遵循同样的分层边界: - `cli` 不允许在本地复制平行业务语义: - slash command 只是输入壳,语义必须映射到稳定 server contract - child pane / transcript 只能消费 authoritative conversation surface + - 不得通过 sibling `tool_stream`、metadata fallback 或本地 regroup 恢复工具展示真相 ## 5. 关键不变量 diff --git a/README.md b/README.md index 2841aabd..87504e04 100644 --- a/README.md +++ b/README.md @@ -1,30 +1,52 @@ # AstrCode -一个 AI 编程助手应用,支持桌面端(Tauri)和浏览器端,基于 HTTP/SSE Server 架构实现前后端解耦。 +一个 AI 编程助手,支持桌面端(Tauri)、浏览器端和终端(CLI),基于 Rust + React 构建的 HTTP/SSE 分层架构。 ## 功能特性 -- **多模型支持**:支持 OpenAI 兼容 API(DeepSeek、OpenAI 等),运行时可切换 Profile 和 Model -- **流式响应**:实时显示 AI 生成的代码和文本 -- **多工具调用**:内置文件操作、代码搜索、Shell 执行等工具 -- **会话管理**:支持多会话切换、按项目分组、会话历史浏览 -- **插件系统**:支持 stdio 插件扩展能力 -- **双模式运行**: +- **多模型支持**:支持 Anthropic Claude、OpenAI 兼容 API(DeepSeek、OpenAI 等),运行时切换 Profile 和 Model +- **流式响应**:实时显示 AI 生成的代码和文本,支持 thinking 内容展示 +- **内置工具集**:文件读写、编辑、搜索、Shell 执行、Skill 加载等 +- **Agent 协作**:支持主/子 Agent 模式,内置 spawn / send / observe / close 工具链 +- **Skill 系统**:Claude 风格两阶段 Skill 加载,支持项目级、用户级和内置 Skill +- **MCP 支持**:完整的 Model Context Protocol 接入,支持 stdio / HTTP / SSE 传输 +- **插件系统**:基于 stdio JSON-RPC 的插件扩展,提供 Rust SDK(未完善) +- **会话管理**:多会话切换、按项目分组、事件溯源持久化、会话历史浏览 +- **三种运行模式**: - **桌面端**:Tauri 打包,自动管理本地 Server - **浏览器端**:独立运行 Server,浏览器访问 + - **终端**:ratatui TUI 界面,本地或远程连接 Server + +## 技术栈 + +| 层级 | 技术 | +|------|------| +| 后端 | Rust (nightly), Axum, Tokio, Tower | +| 前端 | React 18, TypeScript, Vite, Tailwind CSS | +| 桌面端 | Tauri 2 | +| 终端 | ratatui, crossterm | +| 通信 | HTTP/SSE, JSON-RPC (stdio) | +| 持久化 | JSONL 事件日志, 文件系统存储 | +| CI | GitHub Actions, cargo-deny | ## 内置工具 | 工具 | 描述 | |------|------| | `Skill` | 按需加载 Claude 风格 `SKILL.md` 指南与 `references/` / `scripts/` 等资源 | -| `read_file` | 读取文件内容 | -| `write_file` | 写入或创建文件,并返回结构化 diff metadata | -| `edit_file` | 精确替换文件内容(唯一匹配验证),并返回结构化 diff metadata | -| `list_dir` | 列出目录内容 | -| `find_files` | Glob 模式文件搜索 | +| `readFile` | 读取文件内容 | +| `writeFile` | 写入或创建文件,并返回结构化 diff metadata | +| `editFile` | 精确替换文件内容(唯一匹配验证),并返回结构化 diff metadata | +| `apply_patch` | 应用 unified diff 格式的多文件批量变更 | +| `listDir` | 列出目录内容 | +| `findFiles` | Glob 模式文件搜索 | | `grep` | 正则表达式内容搜索 | | `shell` | 执行 Shell 命令,stdout/stderr 以流式事件增量展示 | +| `tool_search` | 搜索可用工具 | +| `spawn` | 创建子 Agent | +| `send` | 向 Agent 发送消息 | +| `observe` | 观察 Agent 状态 | +| `close` | 关闭 Agent | ## 快速开始 @@ -44,11 +66,11 @@ npm install cd frontend && npm install ``` -执行根目录或 `frontend` 的 `npm install` 时,会自动把仓库的 `core.hooksPath` 指向 `.githooks/`。仓库现在按三层校验运行: +执行 `npm install` 时,会自动把仓库的 `core.hooksPath` 指向 `.githooks/`。三层校验: -- `pre-commit`:只做快检查,自动格式化 Rust / 前端改动,修复已暂存 TS/TSX 的 ESLint 问题,并阻止大文件、冲突标记和明显密钥泄漏进入提交。 -- `pre-push`:做中等检查,运行 `cargo check --workspace`、`cargo test --workspace --exclude astrcode --lib` 和前端 `typecheck`。 -- GitHub Actions:保留完整校验,执行格式检查、clippy、全量 Rust 测试、前端 lint/format 校验,以及依赖审查与发布构建流程。 +- `pre-commit`:快速检查 — 自动格式化 Rust / 前端改动,修复已暂存 TS/TSX 的 ESLint 问题,阻止大文件、冲突标记和密钥泄漏 +- `pre-push`:中等检查 — `cargo check --workspace`、`cargo test --workspace --exclude astrcode --lib` 和前端 `typecheck` +- GitHub Actions:完整校验 — 格式检查、clippy、全量 Rust 测试、前端 lint/format、依赖审查与发布构建 ### 开发模式 @@ -98,11 +120,6 @@ cd frontend && npm run build "id": "deepseek-chat", "maxTokens": 8096, "contextLimit": 128000 - }, - { - "id": "deepseek-reasoner", - "maxTokens": 8096, - "contextLimit": 128000 } ] } @@ -110,16 +127,6 @@ cd frontend && npm run build } ``` -`runtime` 用于放置 AstrCode 自己的进程级运行参数,例如: - -```json -{ - "runtime": { - "maxToolConcurrency": 10 - } -} -``` - ### API Key 配置 `apiKey` 字段支持三种方式: @@ -128,33 +135,14 @@ cd frontend && npm run build 2. **明文字面量**:直接填写 API Key(如 `sk-xxxx`) 3. **字面量前缀**:`literal:MY_VALUE`,用于强制把看起来像环境变量名的字符串按普通文本处理 -推荐优先使用 `env:...`,这样配置文件的含义最明确,不会让用户误以为 AstrCode 会自动把任意裸字符串当成环境变量读取。 - -### 模型 limits 配置 +推荐优先使用 `env:...`,配置含义最明确。 -`models` 现在是对象列表,而不再是纯字符串数组: +### 模型配置 -- OpenAI-compatible profile 必须为每个模型手动设置 `maxTokens` 和 `contextLimit` -- Anthropic profile 会在运行时通过 `GET /v1/models/{model_id}` 自动获取 `max_input_tokens` 和 `max_tokens` -- 如果 Anthropic 远端探测失败,但本地模型对象里同时写了 `maxTokens` 和 `contextLimit`,运行时会回退到本地值 +`models` 为对象列表,每个模型需要配置 `maxTokens` 和 `contextLimit`: -这让上下文窗口和最大输出 token 的来源保持单一且清晰,不再由 provider 内部各自硬编码。 - -### 内建环境变量 - -项目自定义环境变量按类别集中维护在 `crates/application/src/config/constants.rs`,底层稳定常量源头在 `crates/core/src/env.rs`,避免低层 crate 反向依赖应用编排层。 - -| 类别 | 环境变量 | 作用 | -|------|------|------| -| Home / 测试隔离 | `ASTRCODE_HOME_DIR` | 覆盖 Astrcode 的 home 目录 | -| Home / 测试隔离 | `ASTRCODE_TEST_HOME` | 为测试隔离临时 home 目录 | -| Plugin | `ASTRCODE_PLUGIN_DIRS` | 追加插件发现目录,按系统路径分隔符解析 | -| Provider 默认值 | `DEEPSEEK_API_KEY` | DeepSeek 默认 profile 的 API Key | -| Provider 默认值 | `ANTHROPIC_API_KEY` | Anthropic 默认 profile 的 API Key | -| Runtime | `ASTRCODE_MAX_TOOL_CONCURRENCY` | `runtime.maxToolConcurrency` 未设置时的并发工具上限兜底 | -| Build / Tauri | `TAURI_ENV_TARGET_TRIPLE` | 构建 sidecar 时指定目标 triple | - -像 `OPENAI_API_KEY` 这类自定义 profile 使用的环境变量仍然允许自由命名,但不属于平台内建环境变量目录。 +- **OpenAI-compatible profile**:手动设置 `maxTokens` 和 `contextLimit` +- **Anthropic profile**:`contextLimit` 默认 200,000,`maxTokens` 默认 8,192;若配置中显式设置了这些值则使用配置值 ### 多 Profile 配置 @@ -165,118 +153,176 @@ cd frontend && npm run build "profiles": [ { "name": "deepseek", + "providerKind": "openai-compatible", "baseUrl": "https://api.deepseek.com", "apiKey": "env:DEEPSEEK_API_KEY", - "models": [ - { - "id": "deepseek-chat", - "maxTokens": 8096, - "contextLimit": 128000 - } - ] + "models": [{ "id": "deepseek-chat", "maxTokens": 8096, "contextLimit": 128000 }] + }, + { + "name": "anthropic", + "providerKind": "anthropic", + "baseUrl": "https://api.anthropic.com", + "apiKey": "env:ANTHROPIC_API_KEY", + "models": [{ "id": "claude-sonnet-4-5-20250514" }] }, { "name": "openai", + "providerKind": "openai-compatible", "baseUrl": "https://api.openai.com", "apiKey": "env:OPENAI_API_KEY", "models": [ - { - "id": "gpt-4o", - "maxTokens": 16384, - "contextLimit": 200000 - }, - { - "id": "gpt-4o-mini", - "maxTokens": 16384, - "contextLimit": 128000 - } + { "id": "gpt-4o", "maxTokens": 16384, "contextLimit": 200000 }, + { "id": "gpt-4o-mini", "maxTokens": 16384, "contextLimit": 128000 } ] } ] } ``` +### Runtime 配置 + +`runtime` 用于放置 AstrCode 进程级运行参数: + +```json +{ + "runtime": { + "maxToolConcurrency": 10 + } +} +``` + +### 内建环境变量 + +项目自定义环境变量按类别集中维护在 `crates/application/src/config/constants.rs`: + +| 类别 | 环境变量 | 作用 | +|------|----------|------| +| Home / 测试隔离 | `ASTRCODE_HOME_DIR` | 覆盖 AstrCode 的 home 目录 | +| Home / 测试隔离 | `ASTRCODE_TEST_HOME` | 为测试隔离临时 home 目录 | +| Plugin | `ASTRCODE_PLUGIN_DIRS` | 追加插件发现目录,按系统路径分隔符解析 | +| Provider 默认值 | `DEEPSEEK_API_KEY` | DeepSeek 默认 profile 的 API Key | +| Provider 默认值 | `ANTHROPIC_API_KEY` | Anthropic 默认 profile 的 API Key | +| Runtime | `ASTRCODE_MAX_TOOL_CONCURRENCY` | 并发工具上限兜底 | +| Build / Tauri | `TAURI_ENV_TARGET_TRIPLE` | 构建 sidecar 时指定目标 triple | + ## 项目结构 ``` AstrCode/ ├── crates/ -│ ├── core/ # 领域语义、强类型 ID、端口契约、稳定配置模型 -│ ├── protocol/ # HTTP / SSE / Plugin DTO +│ ├── core/ # 领域模型、强类型 ID、端口契约、CapabilitySpec、稳定配置 +│ ├── protocol/ # HTTP/SSE/Plugin DTO 与 wire 类型 │ ├── kernel/ # 全局控制面:surface / registry / agent tree / events │ ├── session-runtime/ # 单会话真相:state / turn / replay / context window │ ├── application/ # 用例编排、执行控制、治理与观测 │ ├── server/ # Axum HTTP/SSE 边界与唯一组合根 -│ ├── adapter-storage/ # EventStore、ConfigStore 等存储实现 -│ ├── adapter-llm/ # LLM provider 适配 -│ ├── adapter-prompt/ # Prompt provider 适配 -│ ├── adapter-tools/ # 内置工具与 capability 桥接 -│ ├── adapter-skills/ # Skill 加载与物化 -│ ├── adapter-mcp/ # MCP 传输、连接管理与资源接入 -│ ├── adapter-agents/ # Agent profile 加载 -│ ├── plugin/ # stdio 插件模型与宿主基础设施 -│ └── sdk/ # 插件作者 API -├── examples/ # 示例插件与示例 manifest -├── src-tauri/ # Tauri 薄壳:sidecar 管理、窗口控制 -├── frontend/ # React + TypeScript + Vite UI -│ ├── src/ -│ │ ├── components/ # React 组件 -│ │ ├── hooks/ # 自定义 hooks -│ │ └── lib/ # 工具函数 -└── scripts/ # 开发脚本 +│ ├── adapter-storage/ # JSONL 事件日志持久化与文件系统存储 +│ ├── adapter-llm/ # LLM provider(Anthropic / OpenAI-compatible) +│ ├── adapter-prompt/ # Prompt 组装(贡献者模式 + 分层缓存构建) +│ ├── adapter-tools/ # 内置工具定义与 Agent 协作工具 +│ ├── adapter-skills/ # Skill 发现、解析、物化与目录管理 +│ ├── adapter-mcp/ # MCP 协议支持(stdio/HTTP/SSE 传输 + 能力桥接) +│ ├── adapter-agents/ # Agent profile 加载与注册表(builtin/user/project 级) +│ ├── client/ # 类型化 HTTP/SSE 客户端 SDK +│ ├── cli/ # 终端 TUI 客户端(ratatui) +│ ├── plugin/ # stdio JSON-RPC 插件宿主基础设施 +│ ├── sdk/ # 插件开发者 Rust SDK +│ └── debug-workbench/ # 运行时调试读模型 +├── examples/ # 示例插件与示例 manifest +├── src-tauri/ # Tauri 薄壳:sidecar 管理、窗口控制、bootstrap 注入 +├── frontend/ # React + TypeScript + Vite + Tailwind CSS +│ └── src/ +│ ├── components/ # React 组件(Chat / Sidebar / Settings / Debug) +│ ├── hooks/ # 自定义 hooks(useAgent 等) +│ └── lib/ # API 客户端、SSE 事件处理、工具函数 +└── scripts/ # 开发脚本(Git hooks、crate 边界检查等) ``` ## 架构 -### HTTP/SSE Server 架构 - -系统采用前后端分离架构,Server 是唯一的业务入口: +### 分层架构概览 ``` -┌─────────────────────────────────────────────────────────┐ -│ Frontend │ -│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ -│ │ React UI │───▶│ useAgent │───▶│ HostBridge │ │ -│ └─────────────┘ │ fetch/SSE │ │ (桌面/浏览器)│ │ -│ └──────┬──────┘ └─────────────┘ │ -└────────────────────────────┼────────────────────────────┘ - │ HTTP/SSE - ▼ -┌────────────────────────────────────────────────────────┐ -│ astrcode-server │ -│ ┌─────────────┐ ┌──────────────┐ ┌────────────┐ │ -│ │ Axum Router│───▶│ application │───▶│ kernel │ │ -│ │ /api/* │ │ App / Gov. │ │ surface │ │ -│ └─────────────┘ └──────┬───────┘ └─────┬──────┘ │ -│ ┌─────────────┐ │ │ │ -│ │Protocol DTO │◀──────────┘ ┌──────▼──────┐ │ -│ └─────────────┘ │session- │ │ -│ ┌─────────────┐ │runtime │ │ -│ │Auth/Bootstrap│ │turn/replay │ │ -│ └─────────────┘ └─────────────┘ │ -└────────────────────────────────────────────────────────┘ +┌─────────────────────────────────────────────────────────────┐ +│ 前端(三种接入方式) │ +│ ┌──────────┐ ┌──────────┐ ┌──────────────────────────┐ │ +│ │ Tauri UI │ │ Browser │ │ CLI (ratatui TUI) │ │ +│ │ HostBrdg │ │ fetch/SSE│ │ client crate + launcher │ │ +│ └────┬─────┘ └────┬─────┘ └────────────┬─────────────┘ │ +└───────┼──────────────┼──────────────────────┼────────────────┘ + │ │ HTTP/SSE │ HTTP/SSE + ▼ ▼ ▼ +┌─────────────────────────────────────────────────────────────┐ +│ astrcode-server │ +│ ┌──────────────┐ ┌──────────────┐ ┌──────────────────┐ │ +│ │ Axum Router │─▶│ application │─▶│ kernel │ │ +│ │ /api/* │ │ App / Gov. │ │ surface / events │ │ +│ └──────────────┘ └──────┬───────┘ └────────┬─────────┘ │ +│ ┌──────────────┐ │ │ │ +│ │ Protocol DTO │◀────────┤ ┌────────▼────────┐ │ +│ └──────────────┘ │ │ session-runtime │ │ +│ ┌──────────────┐ │ │ turn / replay │ │ +│ │ Auth/Bootstrp│ │ │ context window │ │ +│ └──────────────┘ │ └─────────────────┘ │ +│ ▼ │ +│ ┌──────────────────────────────────────────────────────┐ │ +│ │ adapter-* : storage | llm | prompt | tools | skills │ │ +│ │ | mcp | agents │ │ +│ └──────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────┘ ``` -### 当前核心分层 +### 核心分层职责 + +- **`core`**:领域语义、强类型 ID、端口契约、`CapabilitySpec`、稳定配置模型。不依赖传输层或具体实现。 +- **`protocol`**:HTTP/SSE/Plugin 的 DTO 与 wire 类型,仅依赖 `core`。 +- **`kernel`**:全局控制面 — capability router/registry、agent tree、统一事件协调。 +- **`session-runtime`**:单会话真相 — turn 执行、事件回放、compact、context window、mailbox 推进。 +- **`application`**:用例编排入口(`App`)+ 治理入口(`AppGovernance`),负责参数校验、权限、策略、reload 编排。 +- **`server`**:HTTP/SSE 边界与唯一组合根(`bootstrap/runtime.rs`),只负责 DTO 映射和装配。 +- **`adapter-*`**:端口实现层,不持有业务真相,不偷渡业务策略。 + +### Agent 协作 + +- 内置 Agent profile:explore、plan、reviewer、execute +- Agent 文件来源:builtin + 用户级(`~/.astrcode/agents`)+ 项目级(`.astrcode/agents`,祖先链扫描) +- 子 Agent spawn 时按 task-scoped capability grant 裁剪能力面 +- Agent 工具链:`spawn` -> `send` -> `observe` -> `close` 全生命周期管理 + +### Skill 系统 + +- 两阶段加载:system prompt 先展示 skill 索引,命中后再调用 `Skill` tool 加载完整 `SKILL.md` +- 目录格式:`skill-name/SKILL.md`(Markdown + YAML frontmatter) +- 加载来源:builtin(运行时物化到 `~/.astrcode/runtime/builtin-skills/`)+ 项目级 + 用户级 +- 资产目录(`references/`、`scripts/`)随 skill 一起索引 + +### MCP 支持 + +- 完整 MCP 协议实现:JSON-RPC 消息、工具/prompt/资源/skill 桥接 +- 传输方式:stdio、HTTP、SSE +- 连接管理:状态机、自动重连、热加载 +- 配置集成:通过 config.json 声明 MCP server,reload 时统一刷新 -- `application::App` 是同步业务用例入口,`application::AppGovernance` 是治理与 reload 入口。 -- `kernel` 维护统一 capability surface、tool/router、agent tree 与全局控制合同。 -- `session-runtime` 只回答单个 session 的真相,包括 turn 执行、重放、compact、context window 和 mailbox 推进。 -- `server` 只负责 DTO 映射、HTTP/SSE 状态码与组合根,不再暴露旧式 `RuntimeService` 门面。 +### 插件系统 -### Reload 与观测语义 +- 基于 stdio JSON-RPC 双向通信 +- 插件生命周期管理(discovered -> loaded -> failed -> disabled) +- 能力路由与权限检查 +- 流式执行支持 +- 提供 Rust SDK(`crates/sdk`),包含 `ToolHandler`、`HookRegistry`、`PluginContext`、`StreamWriter` -- `POST /api/config/reload` 现在走统一治理入口,而不是“只重读配置文件”。 -- 一次 reload 会串起:配置重载、MCP 配置刷新、plugin 重新发现、skill base 更新,以及 kernel capability surface 的一次性替换。 -- 如果存在运行中的 session,请求会被拒绝并返回冲突错误,避免半刷新期间出现执行语义漂移。 -- runtime status 接口返回真实 observability 快照,包含 `sessionRehydrate`、`sseCatchUp`、`turnExecution`、`subrunExecution` 与 `executionDiagnostics`,不再长期返回零值占位。 +### 会话持久化 -### Skill 架构 +- JSONL 格式追加写入(append-only event log) +- 存储路径:`~/.astrcode/projects//sessions//` +- 文件锁并发保护(`active-turn.lock`) +- Query / Command 逻辑分离 -- skill 采用 Claude 风格的两阶段模型:system prompt 先给模型看 skill 索引,命中后再调用内置 `Skill` tool 加载完整 `SKILL.md`。 -- skill 目录格式固定为 `skill-name/SKILL.md`,会读取 `name` 和 `description`,其余 Claude 风格 frontmatter 字段会被忽略。 -- 项目级 skill 会从工作目录祖先链上的 `.claude/skills/` 与 `.astrcode/skills/` 一并解析;用户级 skill 会从 `~/.claude/skills/` 与 `~/.astrcode/skills/` 解析。 -- `references/`、`scripts/` 等目录会作为 skill 资产一起索引;builtin skill 整目录会在运行时物化到 `~/.astrcode/runtime/builtin-skills/`,方便 shell 直接执行其中脚本。 +### 治理与重载 + +- `POST /api/config/reload` 走统一治理入口,串起:配置重载 -> MCP 刷新 -> plugin 重新发现 -> skill 更新 -> kernel capability surface 原子替换 +- 运行中存在 session 时拒绝 reload,避免半刷新导致执行语义漂移 +- capability surface 替换失败时保留旧状态继续服务 ### Tauri 桌面端 @@ -293,13 +339,13 @@ Tauri 仅作为"薄壳",负责: | `/api/auth/exchange` | POST | Token 认证交换 | | `/api/sessions` | GET/POST | 会话列表/创建 | | `/api/sessions/{id}/messages` | GET | 获取会话消息 | -| `/api/sessions/{id}/prompts` | POST | 提交 prompt | +| `/api/sessions/{id}/prompts` | POST | 提交 prompt(支持 `tokenBudget` / `maxSteps` / `manualCompact` 执行控制) | | `/api/sessions/{id}/interrupt` | POST | 中断会话 | | `/api/sessions/{id}/events` | GET (SSE) | 实时事件流 | | `/api/sessions/{id}` | DELETE | 删除会话 | | `/api/projects` | DELETE | 删除项目(所有会话) | | `/api/config` | GET | 获取配置 | -| `/api/config/reload` | POST | 通过治理入口重载配置与统一能力面 | +| `/api/config/reload` | POST | 统一治理重载 | | `/api/config/active-selection` | POST | 保存当前选择 | | `/api/models/current` | GET | 当前模型信息 | | `/api/models` | GET | 可用模型列表 | @@ -322,18 +368,12 @@ Tauri 仅作为"薄壳",负责: | `turnDone` | 对话回合结束 | | `error` | 错误信息 | -会话提交与 agent 执行入口都支持可选执行控制: - -- `tokenBudget`:只覆盖本次执行的 token 预算,不污染全局配置 -- `maxSteps`:只覆盖本次 turn 的最大 step 数 -- `manualCompact`:用于显式登记手动 compact;当 session 忙碌时会由服务端登记并在当前 turn 结束后执行 - ## 开发指南 ### 代码检查 ```bash -# 本地 push 前检查 +# 本地 push 前快速检查 cargo check --workspace cargo test --workspace --exclude astrcode --lib cd frontend && npm run typecheck @@ -344,12 +384,6 @@ cargo clippy --all-targets --all-features -- -D warnings cargo test --workspace --exclude astrcode node scripts/check-crate-boundaries.mjs cd frontend && npm run typecheck && npm run lint && npm run format:check - -# 前端检查 -cd frontend -npm run typecheck -npm run lint -npm run format:check ``` ### 代码格式化 @@ -383,27 +417,36 @@ cargo deny check bans ## CI/CD -项目使用 4 个 GitHub Actions workflow,分工如下: +项目使用 4 个 GitHub Actions workflow: -- `rust-check`:完整 Rust 质量门禁,执行 `cargo fmt --all -- --check`、`cargo clippy --all-targets --all-features -- -D warnings`、`cargo test --workspace --exclude astrcode` -- `frontend-check`:完整前端门禁,执行 `cd frontend && npm run typecheck && npm run lint && npm run format:check` -- `dependency-audit`:当 `Cargo.lock` 或 `deny.toml` 变更时执行 `cargo deny check bans` -- `tauri-build`:在发布 tag 时构建 Tauri 桌面端 +| Workflow | 触发条件 | 执行内容 | +|----------|----------|----------| +| `rust-check` | push/PR 到 master(Rust 文件变更) | fmt、clippy、crate 边界检查、回归测试、全量测试(Ubuntu + Windows) | +| `frontend-check` | push/PR 到 master(前端文件变更) | typecheck、lint、format 检查 | +| `dependency-audit` | `Cargo.lock` / `deny.toml` 变更 | `cargo deny check bans` | +| `tauri-build` | 发布 tag (`v*`) | 三平台(Ubuntu/Windows/macOS)Tauri 构建 | ## 许可证 本项目采用 **Apache License 2.0 with Commons Clause** 许可证。 -- ✅ 个人使用、学习和研究:**允许** -- ✅ 非商业开源项目使用:**允许** -- ⚠️ **商业用途**:需先获得作者许可,请联系作者 +- 允许个人使用、学习和研究 +- 允许非商业开源项目使用 +- **商业用途**需先获得作者许可 + +联系方式: + +- Email: 1879483647@qq.com +- GitHub Issues: https://github.com/whatevertogo/Astrcode/issues 详见 [LICENSE](LICENSE) 文件了解详情。 ## 致谢 - [Tauri](https://tauri.app/) - 跨平台桌面应用框架 -- [React](https://react.dev/) - 前端框架 -- [Vite](https://vitejs.dev/) - 构建工具 - [Axum](https://github.com/tokio-rs/axum) - Web 框架 - [Tokio](https://tokio.rs/) - 异步运行时 +- [React](https://react.dev/) - 前端框架 +- [Vite](https://vitejs.dev/) - 构建工具 +- [Tailwind CSS](https://tailwindcss.com/) - CSS 框架 +- [ratatui](https://ratatui.rs/) - 终端 UI 框架 diff --git a/crates/application/src/ports/app_session.rs b/crates/application/src/ports/app_session.rs index d92ee649..1292b0b7 100644 --- a/crates/application/src/ports/app_session.rs +++ b/crates/application/src/ports/app_session.rs @@ -3,8 +3,9 @@ use astrcode_core::{ SessionMeta, StoredEvent, }; use astrcode_session_runtime::{ - AgentPromptSubmission, SessionCatalogEvent, SessionControlStateSnapshot, SessionReplay, - SessionRuntime, SessionTranscriptSnapshot, + AgentPromptSubmission, ConversationSnapshotFacts, ConversationStreamReplayFacts, + SessionCatalogEvent, SessionControlStateSnapshot, SessionReplay, SessionRuntime, + SessionTranscriptSnapshot, }; use async_trait::async_trait; use tokio::sync::broadcast; @@ -39,6 +40,10 @@ pub trait AppSessionPort: Send + Sync { &self, session_id: &str, ) -> astrcode_core::Result; + async fn conversation_snapshot( + &self, + session_id: &str, + ) -> astrcode_core::Result; async fn session_control_state( &self, session_id: &str, @@ -56,6 +61,11 @@ pub trait AppSessionPort: Send + Sync { session_id: &str, last_event_id: Option<&str>, ) -> astrcode_core::Result; + async fn conversation_stream_replay( + &self, + session_id: &str, + last_event_id: Option<&str>, + ) -> astrcode_core::Result; } #[async_trait] @@ -117,6 +127,13 @@ impl AppSessionPort for SessionRuntime { self.session_transcript_snapshot(session_id).await } + async fn conversation_snapshot( + &self, + session_id: &str, + ) -> astrcode_core::Result { + self.conversation_snapshot(session_id).await + } + async fn session_control_state( &self, session_id: &str, @@ -145,4 +162,13 @@ impl AppSessionPort for SessionRuntime { ) -> astrcode_core::Result { self.session_replay(session_id, last_event_id).await } + + async fn conversation_stream_replay( + &self, + session_id: &str, + last_event_id: Option<&str>, + ) -> astrcode_core::Result { + self.conversation_stream_replay(session_id, last_event_id) + .await + } } diff --git a/crates/application/src/terminal/mod.rs b/crates/application/src/terminal/mod.rs index 26899e6d..3f9ae9d7 100644 --- a/crates/application/src/terminal/mod.rs +++ b/crates/application/src/terminal/mod.rs @@ -1,7 +1,11 @@ -use astrcode_core::{ChildSessionNode, Phase, SessionEventRecord}; +use astrcode_core::{ChildSessionNode, Phase}; +use astrcode_session_runtime::{ + ConversationSnapshotFacts as RuntimeConversationSnapshotFacts, + ConversationStreamReplayFacts as RuntimeConversationStreamReplayFacts, +}; use chrono::{DateTime, Utc}; -use crate::{ComposerOptionKind, SessionReplay, SessionTranscriptSnapshot}; +use crate::ComposerOptionKind; #[derive(Debug, Clone, PartialEq, Eq, Default)] pub enum ConversationFocus { @@ -74,7 +78,7 @@ pub type ConversationResumeCandidateFacts = TerminalResumeCandidateFacts; pub struct TerminalFacts { pub active_session_id: String, pub session_title: String, - pub transcript: SessionTranscriptSnapshot, + pub transcript: RuntimeConversationSnapshotFacts, pub control: TerminalControlFacts, pub child_summaries: Vec, pub slash_candidates: Vec, @@ -85,8 +89,7 @@ pub type ConversationFacts = TerminalFacts; #[derive(Debug)] pub struct TerminalStreamReplayFacts { pub active_session_id: String, - pub seed_records: Vec, - pub replay: SessionReplay, + pub replay: RuntimeConversationStreamReplayFacts, pub control: TerminalControlFacts, pub child_summaries: Vec, pub slash_candidates: Vec, @@ -119,8 +122,10 @@ pub enum TerminalStreamFacts { pub type ConversationStreamFacts = TerminalStreamFacts; -pub(crate) fn latest_transcript_cursor(records: &[SessionEventRecord]) -> Option { - records.last().map(|record| record.event_id.clone()) +pub(crate) fn latest_transcript_cursor( + snapshot: &RuntimeConversationSnapshotFacts, +) -> Option { + snapshot.cursor.clone() } pub fn truncate_terminal_summary(content: &str) -> String { diff --git a/crates/application/src/terminal_use_cases.rs b/crates/application/src/terminal_use_cases.rs index 89f3c310..18339dd9 100644 --- a/crates/application/src/terminal_use_cases.rs +++ b/crates/application/src/terminal_use_cases.rs @@ -1,11 +1,13 @@ use std::{cmp::Reverse, collections::HashSet, path::Path}; -use astrcode_core::{AgentEvent, SessionEventRecord}; -use astrcode_session_runtime::SessionControlStateSnapshot; +use astrcode_session_runtime::{ + ConversationBlockFacts, ConversationChildHandoffBlockFacts, ConversationErrorBlockFacts, + ConversationSnapshotFacts, ConversationSystemNoteBlockFacts, SessionControlStateSnapshot, + ToolCallBlockFacts, +}; use crate::{ App, ApplicationError, ComposerOptionKind, ComposerOptionsRequest, SessionMeta, - SessionTranscriptSnapshot, terminal::{ ConversationFocus, TerminalChildSummaryFacts, TerminalControlFacts, TerminalFacts, TerminalRehydrateFacts, TerminalRehydrateReason, TerminalResumeCandidateFacts, @@ -26,7 +28,7 @@ impl App { .await?; let transcript = self .session_runtime - .session_transcript_snapshot(&focus_session_id) + .conversation_snapshot(&focus_session_id) .await?; let session_title = self .session_runtime @@ -77,14 +79,15 @@ impl App { validate_cursor_format(requested_cursor)?; let transcript = self .session_runtime - .session_transcript_snapshot(&focus_session_id) + .conversation_snapshot(&focus_session_id) .await?; - if cursor_is_after_head(requested_cursor, transcript.cursor.as_deref())? { + let latest_cursor = latest_transcript_cursor(&transcript); + if cursor_is_after_head(requested_cursor, latest_cursor.as_deref())? { return Ok(TerminalStreamFacts::RehydrateRequired( TerminalRehydrateFacts { session_id: session_id.to_string(), requested_cursor: requested_cursor.to_string(), - latest_cursor: transcript.cursor, + latest_cursor, reason: TerminalRehydrateReason::CursorExpired, }, )); @@ -93,13 +96,8 @@ impl App { let replay = self .session_runtime - .session_replay(&focus_session_id, last_event_id) + .conversation_stream_replay(&focus_session_id, last_event_id) .await?; - let seed_records = self - .session_runtime - .session_transcript_snapshot(&focus_session_id) - .await? - .records; let control = self.terminal_control_facts(session_id).await?; let child_summaries = self .conversation_child_summaries(session_id, &focus) @@ -109,7 +107,6 @@ impl App { Ok(TerminalStreamFacts::Replay(Box::new( TerminalStreamReplayFacts { active_session_id: session_id.to_string(), - seed_records, replay, control, child_summaries, @@ -194,7 +191,7 @@ impl App { .find(|meta| meta.session_id == node.child_session_id); let child_transcript = self .session_runtime - .session_transcript_snapshot(&node.child_session_id) + .conversation_snapshot(&node.child_session_id) .await?; Ok::<_, ApplicationError>(TerminalChildSummaryFacts { node, @@ -432,47 +429,68 @@ fn slash_candidate_matches(candidate: &TerminalSlashCandidateFacts, query: &str) .any(|keyword| keyword.to_lowercase().contains(query)) } -fn latest_terminal_summary(transcript: &SessionTranscriptSnapshot) -> Option { - transcript - .records +fn latest_terminal_summary(snapshot: &ConversationSnapshotFacts) -> Option { + snapshot + .blocks .iter() .rev() - .find_map(summary_from_record) + .find_map(summary_from_block) + .or_else(|| latest_transcript_cursor(snapshot).map(|cursor| format!("cursor:{cursor}"))) +} + +fn summary_from_block(block: &ConversationBlockFacts) -> Option { + match block { + ConversationBlockFacts::Assistant(block) => summary_from_markdown(&block.markdown), + ConversationBlockFacts::ToolCall(block) => summary_from_tool_call(block), + ConversationBlockFacts::ChildHandoff(block) => summary_from_child_handoff(block), + ConversationBlockFacts::Error(block) => summary_from_error_block(block), + ConversationBlockFacts::SystemNote(block) => summary_from_system_note(block), + ConversationBlockFacts::User(_) | ConversationBlockFacts::Thinking(_) => None, + } +} + +fn summary_from_markdown(markdown: &str) -> Option { + (!markdown.trim().is_empty()).then(|| truncate_terminal_summary(markdown)) +} + +fn summary_from_tool_call(block: &ToolCallBlockFacts) -> Option { + block + .summary + .as_deref() + .filter(|summary| !summary.trim().is_empty()) + .map(truncate_terminal_summary) .or_else(|| { - latest_transcript_cursor(&transcript.records).map(|cursor| format!("cursor:{cursor}")) + block + .error + .as_deref() + .filter(|error| !error.trim().is_empty()) + .map(truncate_terminal_summary) }) + .or_else(|| summary_from_markdown(&block.streams.stderr)) + .or_else(|| summary_from_markdown(&block.streams.stdout)) } -fn summary_from_record(record: &SessionEventRecord) -> Option { - match &record.event { - AgentEvent::AssistantMessage { content, .. } if !content.trim().is_empty() => { - Some(truncate_terminal_summary(content)) - }, - AgentEvent::ToolCallResult { result, .. } if !result.output.trim().is_empty() => { - Some(truncate_terminal_summary(&result.output)) - }, - AgentEvent::ToolCallResult { result, .. } => result - .error - .as_deref() - .filter(|error| !error.trim().is_empty()) - .map(truncate_terminal_summary), - AgentEvent::ChildSessionNotification { notification, .. } => notification - .delivery - .as_ref() - .map(|delivery| delivery.payload.message()) - .filter(|message| !message.trim().is_empty()) - .map(truncate_terminal_summary), - AgentEvent::Error { message, .. } if !message.trim().is_empty() => { - Some(truncate_terminal_summary(message)) - }, - _ => None, - } +fn summary_from_child_handoff(block: &ConversationChildHandoffBlockFacts) -> Option { + block + .message + .as_deref() + .filter(|message| !message.trim().is_empty()) + .map(truncate_terminal_summary) +} + +fn summary_from_error_block(block: &ConversationErrorBlockFacts) -> Option { + summary_from_markdown(&block.message) +} + +fn summary_from_system_note(block: &ConversationSystemNoteBlockFacts) -> Option { + summary_from_markdown(&block.markdown) } #[cfg(test)] mod tests { use std::{path::Path, sync::Arc, time::Duration}; + use astrcode_core::AgentEvent; use astrcode_session_runtime::SessionRuntime; use async_trait::async_trait; use tokio::time::timeout; @@ -622,7 +640,7 @@ mod tests { else { panic!("fresh stream should start from replay facts"); }; - let mut live_receiver = replay.replay.live_receiver; + let mut live_receiver = replay.replay.replay.live_receiver; let accepted = harness .app @@ -668,16 +686,13 @@ mod tests { .terminal_snapshot_facts(&session.session_id) .await .expect("terminal snapshot should build"); - assert!(snapshot.transcript.records.iter().any(|record| matches!( - &record.event, - AgentEvent::AssistantMessage { - content, - reasoning_content, - .. - } if content == "流式完成" - && reasoning_content - .as_ref() - .is_some_and(|reasoning| reasoning == "先整理") + assert!(snapshot.transcript.blocks.iter().any(|block| matches!( + block, + ConversationBlockFacts::Assistant(block) if block.markdown == "流式完成" + ))); + assert!(snapshot.transcript.blocks.iter().any(|block| matches!( + block, + ConversationBlockFacts::Thinking(block) if block.markdown == "先整理" ))); } @@ -703,7 +718,7 @@ mod tests { .expect("terminal snapshot should build"); assert_eq!(facts.active_session_id, session.session_id); - assert!(!facts.transcript.records.is_empty()); + assert!(!facts.transcript.blocks.is_empty()); assert!(facts.transcript.cursor.is_some()); assert!( facts @@ -750,11 +765,7 @@ mod tests { .terminal_snapshot_facts(&session.session_id) .await .expect("snapshot should build"); - let cursor = snapshot - .transcript - .records - .first() - .map(|record| record.event_id.clone()); + let cursor = snapshot.transcript.cursor.clone(); let facts = harness .app @@ -765,7 +776,16 @@ mod tests { match facts { TerminalStreamFacts::Replay(replay) => { assert_eq!(replay.active_session_id, session.session_id); - assert!(replay.replay.history.len() <= snapshot.transcript.records.len()); + assert!(replay.replay.replay.history.is_empty()); + assert!(replay.replay.replay_frames.is_empty()); + assert_eq!( + replay + .replay + .seed_records + .last() + .map(|record| record.event_id.as_str()), + snapshot.transcript.cursor.as_deref() + ); }, TerminalStreamFacts::RehydrateRequired(_) => { panic!("valid cursor should not require rehydrate"); @@ -1030,13 +1050,13 @@ mod tests { .expect("conversation focus snapshot should build"); assert_eq!(facts.active_session_id, parent.session_id); - assert!(facts.transcript.records.iter().any(|record| matches!( - &record.event, - AgentEvent::UserMessage { content, .. } if content == "child prompt" + assert!(facts.transcript.blocks.iter().any(|block| matches!( + block, + ConversationBlockFacts::User(block) if block.markdown == "child prompt" ))); - assert!(facts.transcript.records.iter().all(|record| !matches!( - &record.event, - AgentEvent::UserMessage { content, .. } if content == "parent prompt" + assert!(facts.transcript.blocks.iter().all(|block| !matches!( + block, + ConversationBlockFacts::User(block) if block.markdown == "parent prompt" ))); assert!(facts.child_summaries.is_empty()); } diff --git a/crates/cli/src/state/conversation.rs b/crates/cli/src/state/conversation.rs index 8f7cf5ce..ef319f36 100644 --- a/crates/cli/src/state/conversation.rs +++ b/crates/cli/src/state/conversation.rs @@ -183,7 +183,6 @@ fn block_id_of(block: &AstrcodeConversationBlockDto) -> &str { AstrcodeConversationBlockDto::Assistant(block) => &block.id, AstrcodeConversationBlockDto::Thinking(block) => &block.id, AstrcodeConversationBlockDto::ToolCall(block) => &block.id, - AstrcodeConversationBlockDto::ToolStream(block) => &block.id, AstrcodeConversationBlockDto::Error(block) => &block.id, AstrcodeConversationBlockDto::SystemNote(block) => &block.id, AstrcodeConversationBlockDto::ChildHandoff(block) => &block.id, @@ -200,7 +199,6 @@ fn apply_block_patch( AstrcodeConversationBlockDto::Thinking(block) => block.markdown.push_str(&markdown), AstrcodeConversationBlockDto::SystemNote(block) => block.markdown.push_str(&markdown), AstrcodeConversationBlockDto::User(block) => block.markdown.push_str(&markdown), - AstrcodeConversationBlockDto::ToolStream(block) => block.content.push_str(&markdown), AstrcodeConversationBlockDto::ToolCall(_) | AstrcodeConversationBlockDto::Error(_) | AstrcodeConversationBlockDto::ChildHandoff(_) => {}, @@ -210,14 +208,17 @@ fn apply_block_patch( AstrcodeConversationBlockDto::Thinking(block) => block.markdown = markdown, AstrcodeConversationBlockDto::SystemNote(block) => block.markdown = markdown, AstrcodeConversationBlockDto::User(block) => block.markdown = markdown, - AstrcodeConversationBlockDto::ToolStream(block) => block.content = markdown, AstrcodeConversationBlockDto::ToolCall(_) | AstrcodeConversationBlockDto::Error(_) | AstrcodeConversationBlockDto::ChildHandoff(_) => {}, }, - AstrcodeConversationBlockPatchDto::AppendToolStream { chunk, .. } => { - if let AstrcodeConversationBlockDto::ToolStream(block) = block { - block.content.push_str(&chunk); + AstrcodeConversationBlockPatchDto::AppendToolStream { stream, chunk } => { + if let AstrcodeConversationBlockDto::ToolCall(block) = block { + if format!("{stream:?}").eq_ignore_ascii_case("stderr") { + block.streams.stderr.push_str(&chunk); + } else { + block.streams.stdout.push_str(&chunk); + } } }, AstrcodeConversationBlockPatchDto::ReplaceSummary { summary } => { @@ -230,6 +231,26 @@ fn apply_block_patch( block.metadata = Some(metadata); } }, + AstrcodeConversationBlockPatchDto::ReplaceError { error } => { + if let AstrcodeConversationBlockDto::ToolCall(block) = block { + block.error = error; + } + }, + AstrcodeConversationBlockPatchDto::ReplaceDuration { duration_ms } => { + if let AstrcodeConversationBlockDto::ToolCall(block) = block { + block.duration_ms = Some(duration_ms); + } + }, + AstrcodeConversationBlockPatchDto::ReplaceChildRef { child_ref } => { + if let AstrcodeConversationBlockDto::ToolCall(block) = block { + block.child_ref = Some(child_ref); + } + }, + AstrcodeConversationBlockPatchDto::SetTruncated { truncated } => { + if let AstrcodeConversationBlockDto::ToolCall(block) = block { + block.truncated = truncated; + } + }, AstrcodeConversationBlockPatchDto::SetStatus { status } => set_block_status(block, status), } } @@ -242,7 +263,6 @@ fn set_block_status( AstrcodeConversationBlockDto::Assistant(block) => block.status = status, AstrcodeConversationBlockDto::Thinking(block) => block.status = status, AstrcodeConversationBlockDto::ToolCall(block) => block.status = status, - AstrcodeConversationBlockDto::ToolStream(block) => block.status = status, AstrcodeConversationBlockDto::User(_) | AstrcodeConversationBlockDto::Error(_) | AstrcodeConversationBlockDto::SystemNote(_) diff --git a/crates/cli/src/state/transcript_cell.rs b/crates/cli/src/state/transcript_cell.rs index 0b7b03b7..7a764524 100644 --- a/crates/cli/src/state/transcript_cell.rs +++ b/crates/cli/src/state/transcript_cell.rs @@ -34,11 +34,12 @@ pub enum TranscriptCellKind { tool_name: String, summary: String, status: TranscriptCellStatus, - }, - ToolStream { - stream: String, - content: String, - status: TranscriptCellStatus, + stdout: String, + stderr: String, + error: Option, + duration_ms: Option, + truncated: bool, + child_session_id: Option, }, Error { code: String, @@ -88,16 +89,26 @@ impl TranscriptCell { summary: block .summary .clone() + .or_else(|| block.error.clone()) + .or_else(|| { + if block.streams.stdout.is_empty() && block.streams.stderr.is_empty() { + None + } else { + Some("工具输出已更新".to_string()) + } + }) + .clone() .unwrap_or_else(|| "正在执行工具调用".to_string()), status: block.status.into(), - }, - }, - AstrcodeConversationBlockDto::ToolStream(block) => Self { - id: block.id.clone(), - kind: TranscriptCellKind::ToolStream { - stream: format!("{:?}", block.stream), - content: block.content.clone(), - status: block.status.into(), + stdout: block.streams.stdout.clone(), + stderr: block.streams.stderr.clone(), + error: block.error.clone(), + duration_ms: block.duration_ms, + truncated: block.truncated, + child_session_id: block + .child_ref + .as_ref() + .map(|child_ref| child_ref.open_session_id.clone()), }, }, AstrcodeConversationBlockDto::Error(block) => Self { diff --git a/crates/cli/src/ui/cells.rs b/crates/cli/src/ui/cells.rs index 8c2d5e8e..f7a98c18 100644 --- a/crates/cli/src/ui/cells.rs +++ b/crates/cli/src/ui/cells.rs @@ -59,6 +59,12 @@ impl RenderableCell for TranscriptCell { tool_name, summary, status, + stdout, + stderr, + error, + duration_ms, + truncated, + child_session_id, } => render_labeled_cell( width, capabilities, @@ -69,27 +75,17 @@ impl RenderableCell for TranscriptCell { tool_name, status_suffix(*status) ), - summary, - WrappedLineStyle::Warning, - WrappedLineStyle::Dim, - ), - TranscriptCellKind::ToolStream { - stream, - content, - status, - } => render_labeled_cell( - width, - capabilities, - theme, - &format!( - "{} {}{}", - theme.glyph("│", "|"), - stream.to_lowercase(), - status_suffix(*status) + &tool_call_body( + summary, + stdout, + stderr, + error.as_deref(), + *duration_ms, + *truncated, + child_session_id.as_deref(), ), - content, WrappedLineStyle::Warning, - WrappedLineStyle::Plain, + WrappedLineStyle::Dim, ), TranscriptCellKind::Error { code, message } => render_labeled_cell( width, @@ -159,6 +155,43 @@ impl RenderableCell for TranscriptCell { } } +fn tool_call_body( + summary: &str, + stdout: &str, + stderr: &str, + error: Option<&str>, + duration_ms: Option, + truncated: bool, + child_session_id: Option<&str>, +) -> String { + let mut sections = Vec::new(); + if !summary.trim().is_empty() { + sections.push(summary.trim().to_string()); + } + if !stdout.trim().is_empty() { + sections.push(format!("stdout:\n{}", stdout.trim_end())); + } + if !stderr.trim().is_empty() { + sections.push(format!("stderr:\n{}", stderr.trim_end())); + } + if let Some(error) = error.filter(|value| !value.trim().is_empty()) { + sections.push(format!("error: {}", error.trim())); + } + if let Some(duration_ms) = duration_ms { + sections.push(format!("duration: {duration_ms} ms")); + } + if truncated { + sections.push("truncated: true".to_string()); + } + if let Some(child_session_id) = child_session_id.filter(|value| !value.trim().is_empty()) { + sections.push(format!("child session: {child_session_id}")); + } + if sections.is_empty() { + return "正在执行工具调用".to_string(); + } + sections.join("\n\n") +} + fn render_user_cell( body: &str, width: usize, diff --git a/crates/core/src/config.rs b/crates/core/src/config.rs index 34ab48c8..f84ef334 100644 --- a/crates/core/src/config.rs +++ b/crates/core/src/config.rs @@ -18,7 +18,7 @@ const ENV_REFERENCE_PREFIX: &str = "env:"; /// 默认受控子会话最大深度。 pub const DEFAULT_MAX_SUBRUN_DEPTH: usize = 2; /// 默认单轮最多新建的子代理数量。 -pub const DEFAULT_MAX_SPAWN_PER_TURN: usize = 3; +pub const DEFAULT_MAX_SPAWN_PER_TURN: usize = 6; /// 默认最大安全工具并发数。 pub const DEFAULT_MAX_TOOL_CONCURRENCY: usize = 10; diff --git a/crates/protocol/src/http/conversation/v1.rs b/crates/protocol/src/http/conversation/v1.rs index 13c4c55d..f2177c34 100644 --- a/crates/protocol/src/http/conversation/v1.rs +++ b/crates/protocol/src/http/conversation/v1.rs @@ -1,31 +1,336 @@ //! Conversation v1 HTTP / SSE DTO。 //! -//! 当前 authoritative conversation read model 线缆形状与既有 terminal surface -//! 保持一致,但协议职责已经提升为跨 Web/Desktop/TUI 的统一读取面。 - -pub use crate::http::terminal::v1::{ - TerminalAssistantBlockDto as ConversationAssistantBlockDto, - TerminalBannerDto as ConversationBannerDto, - TerminalBannerErrorCodeDto as ConversationBannerErrorCodeDto, - TerminalBlockDto as ConversationBlockDto, TerminalBlockPatchDto as ConversationBlockPatchDto, - TerminalBlockStatusDto as ConversationBlockStatusDto, - TerminalChildHandoffBlockDto as ConversationChildHandoffBlockDto, - TerminalChildHandoffKindDto as ConversationChildHandoffKindDto, - TerminalChildSummaryDto as ConversationChildSummaryDto, - TerminalControlStateDto as ConversationControlStateDto, - TerminalCursorDto as ConversationCursorDto, TerminalDeltaDto as ConversationDeltaDto, - TerminalErrorBlockDto as ConversationErrorBlockDto, - TerminalErrorEnvelopeDto as ConversationErrorEnvelopeDto, - TerminalSlashActionKindDto as ConversationSlashActionKindDto, - TerminalSlashCandidateDto as ConversationSlashCandidateDto, - TerminalSlashCandidatesResponseDto as ConversationSlashCandidatesResponseDto, - TerminalSnapshotResponseDto as ConversationSnapshotResponseDto, - TerminalStreamEnvelopeDto as ConversationStreamEnvelopeDto, - TerminalSystemNoteBlockDto as ConversationSystemNoteBlockDto, - TerminalSystemNoteKindDto as ConversationSystemNoteKindDto, - TerminalThinkingBlockDto as ConversationThinkingBlockDto, - TerminalToolCallBlockDto as ConversationToolCallBlockDto, - TerminalToolStreamBlockDto as ConversationToolStreamBlockDto, - TerminalTranscriptErrorCodeDto as ConversationTranscriptErrorCodeDto, - TerminalUserBlockDto as ConversationUserBlockDto, -}; +//! conversation 是 authoritative hydration / delta 合同,直接表达后端收敛后的 +//! conversation/tool display 语义,不再借 terminal alias 维持假性独立。 + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use crate::http::{AgentLifecycleDto, ChildAgentRefDto, PhaseDto, ToolOutputStreamDto}; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(transparent)] +pub struct ConversationCursorDto(pub String); + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct ConversationSnapshotResponseDto { + pub session_id: String, + pub session_title: String, + pub cursor: ConversationCursorDto, + pub phase: PhaseDto, + pub control: ConversationControlStateDto, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub blocks: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub child_summaries: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub slash_candidates: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub banner: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct ConversationSlashCandidatesResponseDto { + pub items: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct ConversationStreamEnvelopeDto { + pub session_id: String, + pub cursor: ConversationCursorDto, + #[serde(flatten)] + pub delta: ConversationDeltaDto, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde( + tag = "kind", + rename_all = "snake_case", + rename_all_fields = "camelCase" +)] +pub enum ConversationDeltaDto { + AppendBlock { + block: ConversationBlockDto, + }, + PatchBlock { + block_id: String, + patch: ConversationBlockPatchDto, + }, + CompleteBlock { + block_id: String, + status: ConversationBlockStatusDto, + }, + UpdateControlState { + control: ConversationControlStateDto, + }, + UpsertChildSummary { + child: ConversationChildSummaryDto, + }, + RemoveChildSummary { + child_session_id: String, + }, + ReplaceSlashCandidates { + candidates: Vec, + }, + SetBanner { + banner: ConversationBannerDto, + }, + ClearBanner, + RehydrateRequired { + error: ConversationErrorEnvelopeDto, + }, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde( + tag = "kind", + rename_all = "snake_case", + rename_all_fields = "camelCase" +)] +pub enum ConversationBlockPatchDto { + AppendMarkdown { + markdown: String, + }, + ReplaceMarkdown { + markdown: String, + }, + AppendToolStream { + stream: ToolOutputStreamDto, + chunk: String, + }, + ReplaceSummary { + summary: String, + }, + ReplaceMetadata { + metadata: Value, + }, + ReplaceError { + error: Option, + }, + ReplaceDuration { + duration_ms: u64, + }, + ReplaceChildRef { + child_ref: ChildAgentRefDto, + }, + SetTruncated { + truncated: bool, + }, + SetStatus { + status: ConversationBlockStatusDto, + }, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum ConversationBlockStatusDto { + Streaming, + Complete, + Failed, + Cancelled, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum ConversationBlockDto { + User(ConversationUserBlockDto), + Assistant(ConversationAssistantBlockDto), + Thinking(ConversationThinkingBlockDto), + ToolCall(ConversationToolCallBlockDto), + Error(ConversationErrorBlockDto), + SystemNote(ConversationSystemNoteBlockDto), + ChildHandoff(ConversationChildHandoffBlockDto), +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct ConversationUserBlockDto { + pub id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub turn_id: Option, + pub markdown: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct ConversationAssistantBlockDto { + pub id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub turn_id: Option, + pub status: ConversationBlockStatusDto, + pub markdown: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct ConversationThinkingBlockDto { + pub id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub turn_id: Option, + pub status: ConversationBlockStatusDto, + pub markdown: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] +#[serde(rename_all = "camelCase")] +pub struct ConversationToolStreamsDto { + #[serde(default, skip_serializing_if = "String::is_empty")] + pub stdout: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub stderr: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct ConversationToolCallBlockDto { + pub id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub turn_id: Option, + pub tool_call_id: String, + pub tool_name: String, + pub status: ConversationBlockStatusDto, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub input: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub summary: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub error: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub duration_ms: Option, + #[serde(default)] + pub truncated: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub metadata: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub child_ref: Option, + #[serde(default, skip_serializing_if = "is_default_tool_streams")] + pub streams: ConversationToolStreamsDto, +} + +fn is_default_tool_streams(streams: &ConversationToolStreamsDto) -> bool { + streams == &ConversationToolStreamsDto::default() +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum ConversationTranscriptErrorCodeDto { + ProviderError, + ContextWindowExceeded, + ToolFatal, + RateLimit, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct ConversationErrorBlockDto { + pub id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub turn_id: Option, + pub code: ConversationTranscriptErrorCodeDto, + pub message: String, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum ConversationSystemNoteKindDto { + Compact, + SystemNote, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct ConversationSystemNoteBlockDto { + pub id: String, + pub note_kind: ConversationSystemNoteKindDto, + pub markdown: String, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum ConversationChildHandoffKindDto { + Delegated, + Progress, + Returned, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct ConversationChildHandoffBlockDto { + pub id: String, + pub handoff_kind: ConversationChildHandoffKindDto, + pub child: ConversationChildSummaryDto, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub message: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct ConversationChildSummaryDto { + pub child_session_id: String, + pub child_agent_id: String, + pub title: String, + pub lifecycle: AgentLifecycleDto, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub latest_output_summary: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub child_ref: Option, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum ConversationSlashActionKindDto { + InsertText, + ExecuteCommand, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct ConversationSlashCandidateDto { + pub id: String, + pub title: String, + pub description: String, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub keywords: Vec, + pub action_kind: ConversationSlashActionKindDto, + pub action_value: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct ConversationControlStateDto { + pub phase: PhaseDto, + pub can_submit_prompt: bool, + pub can_request_compact: bool, + #[serde(default)] + pub compact_pending: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub active_turn_id: Option, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum ConversationBannerErrorCodeDto { + AuthExpired, + CursorExpired, + StreamDisconnected, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct ConversationErrorEnvelopeDto { + pub code: ConversationBannerErrorCodeDto, + pub message: String, + #[serde(default)] + pub rehydrate_required: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub details: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct ConversationBannerDto { + pub error: ConversationErrorEnvelopeDto, +} diff --git a/crates/protocol/src/http/mod.rs b/crates/protocol/src/http/mod.rs index 063dc329..5dc25fbb 100644 --- a/crates/protocol/src/http/mod.rs +++ b/crates/protocol/src/http/mod.rs @@ -52,7 +52,7 @@ pub use conversation::v1::{ ConversationSlashCandidateDto, ConversationSlashCandidatesResponseDto, ConversationSnapshotResponseDto, ConversationStreamEnvelopeDto, ConversationSystemNoteBlockDto, ConversationSystemNoteKindDto, ConversationThinkingBlockDto, ConversationToolCallBlockDto, - ConversationToolStreamBlockDto, ConversationTranscriptErrorCodeDto, ConversationUserBlockDto, + ConversationToolStreamsDto, ConversationTranscriptErrorCodeDto, ConversationUserBlockDto, }; pub use debug::{ DebugAgentNodeKindDto, RuntimeDebugOverviewDto, RuntimeDebugTimelineDto, diff --git a/crates/protocol/tests/conversation_conformance.rs b/crates/protocol/tests/conversation_conformance.rs new file mode 100644 index 00000000..cd26728b --- /dev/null +++ b/crates/protocol/tests/conversation_conformance.rs @@ -0,0 +1,134 @@ +use astrcode_protocol::http::{ + AgentLifecycleDto, ChildAgentRefDto, ChildSessionLineageKindDto, + ConversationBannerErrorCodeDto, ConversationBlockDto, ConversationBlockPatchDto, + ConversationBlockStatusDto, ConversationControlStateDto, ConversationCursorDto, + ConversationDeltaDto, ConversationErrorEnvelopeDto, ConversationSnapshotResponseDto, + ConversationStreamEnvelopeDto, ConversationToolCallBlockDto, ConversationToolStreamsDto, + PhaseDto, +}; +use serde_json::json; + +fn fixture(name: &str) -> serde_json::Value { + let path = match name { + "snapshot" => include_str!("fixtures/conversation/v1/snapshot.json"), + "delta_patch_tool_stream" => { + include_str!("fixtures/conversation/v1/delta_patch_tool_stream.json") + }, + "delta_rehydrate_required" => { + include_str!("fixtures/conversation/v1/delta_rehydrate_required.json") + }, + other => panic!("unknown fixture {other}"), + }; + serde_json::from_str(path).expect("fixture should be valid JSON") +} + +#[test] +fn conversation_snapshot_fixture_freezes_authoritative_tool_block_shape() { + let expected = ConversationSnapshotResponseDto { + session_id: "session-root".to_string(), + session_title: "Conversation session".to_string(), + cursor: ConversationCursorDto("cursor:opaque:v1:session-root/42==".to_string()), + phase: PhaseDto::CallingTool, + control: ConversationControlStateDto { + phase: PhaseDto::CallingTool, + can_submit_prompt: false, + can_request_compact: true, + compact_pending: false, + active_turn_id: Some("turn-42".to_string()), + }, + blocks: vec![ConversationBlockDto::ToolCall( + ConversationToolCallBlockDto { + id: "block-tool-call-1".to_string(), + turn_id: Some("turn-42".to_string()), + tool_call_id: "tool-call-1".to_string(), + tool_name: "spawn_agent".to_string(), + status: ConversationBlockStatusDto::Failed, + input: Some(json!({ "task": "inspect repo" })), + summary: Some("permission denied".to_string()), + error: Some("permission denied".to_string()), + duration_ms: Some(88), + truncated: true, + metadata: Some(json!({ + "display": { + "kind": "terminal", + "command": "python worker.py" + } + })), + child_ref: Some(ChildAgentRefDto { + agent_id: "agent-child-1".to_string(), + session_id: "session-root".to_string(), + sub_run_id: "subrun-child-1".to_string(), + parent_agent_id: Some("agent-root".to_string()), + parent_sub_run_id: Some("subrun-root".to_string()), + lineage_kind: ChildSessionLineageKindDto::Spawn, + status: AgentLifecycleDto::Running, + open_session_id: "session-child-1".to_string(), + }), + streams: ConversationToolStreamsDto { + stdout: "searching repo\n".to_string(), + stderr: "permission denied\n".to_string(), + }, + }, + )], + child_summaries: Vec::new(), + slash_candidates: Vec::new(), + banner: None, + }; + + let fixture = fixture("snapshot"); + let decoded: ConversationSnapshotResponseDto = + serde_json::from_value(fixture.clone()).expect("fixture should decode"); + assert_eq!(decoded, expected); + assert_eq!( + serde_json::to_value(&decoded).expect("snapshot should encode"), + fixture + ); +} + +#[test] +fn conversation_delta_fixtures_freeze_tool_patch_and_rehydrate_shapes() { + let patch_fixture = fixture("delta_patch_tool_stream"); + let patch_decoded: ConversationStreamEnvelopeDto = + serde_json::from_value(patch_fixture.clone()).expect("patch fixture should decode"); + assert_eq!( + patch_decoded, + ConversationStreamEnvelopeDto { + session_id: "session-root".to_string(), + cursor: ConversationCursorDto("cursor:opaque:v1:session-root/44==".to_string()), + delta: ConversationDeltaDto::PatchBlock { + block_id: "block-tool-call-1".to_string(), + patch: ConversationBlockPatchDto::AppendToolStream { + stream: astrcode_protocol::http::ToolOutputStreamDto::Stderr, + chunk: "line 1\nline 2".to_string(), + }, + }, + } + ); + assert_eq!( + serde_json::to_value(&patch_decoded).expect("patch should encode"), + patch_fixture + ); + + let rehydrate_fixture = fixture("delta_rehydrate_required"); + let rehydrate_decoded: ConversationStreamEnvelopeDto = + serde_json::from_value(rehydrate_fixture.clone()).expect("rehydrate fixture should decode"); + assert_eq!( + rehydrate_decoded, + ConversationStreamEnvelopeDto { + session_id: "session-root".to_string(), + cursor: ConversationCursorDto("cursor:opaque:v1:session-root/45==".to_string()), + delta: ConversationDeltaDto::RehydrateRequired { + error: ConversationErrorEnvelopeDto { + code: ConversationBannerErrorCodeDto::CursorExpired, + message: "cursor 已失效,请重新获取 snapshot".to_string(), + rehydrate_required: true, + details: Some(json!({ "cursor": "cursor:opaque:v1:session-root/12==" })), + }, + }, + } + ); + assert_eq!( + serde_json::to_value(&rehydrate_decoded).expect("rehydrate should encode"), + rehydrate_fixture + ); +} diff --git a/crates/protocol/tests/fixtures/conversation/v1/delta_patch_tool_stream.json b/crates/protocol/tests/fixtures/conversation/v1/delta_patch_tool_stream.json new file mode 100644 index 00000000..0df3bfa1 --- /dev/null +++ b/crates/protocol/tests/fixtures/conversation/v1/delta_patch_tool_stream.json @@ -0,0 +1,11 @@ +{ + "sessionId": "session-root", + "cursor": "cursor:opaque:v1:session-root/44==", + "kind": "patch_block", + "blockId": "block-tool-call-1", + "patch": { + "kind": "append_tool_stream", + "stream": "stderr", + "chunk": "line 1\nline 2" + } +} diff --git a/crates/protocol/tests/fixtures/conversation/v1/delta_rehydrate_required.json b/crates/protocol/tests/fixtures/conversation/v1/delta_rehydrate_required.json new file mode 100644 index 00000000..6ee31df3 --- /dev/null +++ b/crates/protocol/tests/fixtures/conversation/v1/delta_rehydrate_required.json @@ -0,0 +1,13 @@ +{ + "sessionId": "session-root", + "cursor": "cursor:opaque:v1:session-root/45==", + "kind": "rehydrate_required", + "error": { + "code": "cursor_expired", + "message": "cursor 已失效,请重新获取 snapshot", + "rehydrateRequired": true, + "details": { + "cursor": "cursor:opaque:v1:session-root/12==" + } + } +} diff --git a/crates/protocol/tests/fixtures/conversation/v1/snapshot.json b/crates/protocol/tests/fixtures/conversation/v1/snapshot.json new file mode 100644 index 00000000..86c475f4 --- /dev/null +++ b/crates/protocol/tests/fixtures/conversation/v1/snapshot.json @@ -0,0 +1,50 @@ +{ + "sessionId": "session-root", + "sessionTitle": "Conversation session", + "cursor": "cursor:opaque:v1:session-root/42==", + "phase": "callingTool", + "control": { + "phase": "callingTool", + "canSubmitPrompt": false, + "canRequestCompact": true, + "compactPending": false, + "activeTurnId": "turn-42" + }, + "blocks": [ + { + "kind": "tool_call", + "id": "block-tool-call-1", + "turnId": "turn-42", + "toolCallId": "tool-call-1", + "toolName": "spawn_agent", + "status": "failed", + "input": { + "task": "inspect repo" + }, + "summary": "permission denied", + "error": "permission denied", + "durationMs": 88, + "truncated": true, + "metadata": { + "display": { + "kind": "terminal", + "command": "python worker.py" + } + }, + "childRef": { + "agentId": "agent-child-1", + "sessionId": "session-root", + "subRunId": "subrun-child-1", + "parentAgentId": "agent-root", + "parentSubRunId": "subrun-root", + "lineageKind": "spawn", + "status": "running", + "openSessionId": "session-child-1" + }, + "streams": { + "stdout": "searching repo\n", + "stderr": "permission denied\n" + } + } + ] +} diff --git a/crates/protocol/tests/fixtures/terminal/v1/delta_patch_tool_metadata.json b/crates/protocol/tests/fixtures/terminal/v1/delta_patch_tool_metadata.json new file mode 100644 index 00000000..f4908011 --- /dev/null +++ b/crates/protocol/tests/fixtures/terminal/v1/delta_patch_tool_metadata.json @@ -0,0 +1,17 @@ +{ + "sessionId": "session-root", + "cursor": "cursor:opaque:v1:session-root/44.5==", + "kind": "patch_block", + "blockId": "block-tool-call-1", + "patch": { + "kind": "replace_metadata", + "metadata": { + "openSessionId": "session-child-1", + "agentRef": { + "agentId": "agent-child-1", + "subRunId": "subrun-1", + "openSessionId": "session-child-1" + } + } + } +} diff --git a/crates/protocol/tests/terminal_conformance.rs b/crates/protocol/tests/terminal_conformance.rs index 59f39684..6ba3c57e 100644 --- a/crates/protocol/tests/terminal_conformance.rs +++ b/crates/protocol/tests/terminal_conformance.rs @@ -18,6 +18,9 @@ fn fixture(name: &str) -> Value { "snapshot" => include_str!("fixtures/terminal/v1/snapshot.json"), "delta_append_block" => include_str!("fixtures/terminal/v1/delta_append_block.json"), "delta_patch_block" => include_str!("fixtures/terminal/v1/delta_patch_block.json"), + "delta_patch_tool_metadata" => { + include_str!("fixtures/terminal/v1/delta_patch_tool_metadata.json") + }, "delta_patch_replace_markdown" => { include_str!("fixtures/terminal/v1/delta_patch_replace_markdown.json") }, @@ -204,6 +207,35 @@ fn terminal_delta_fixtures_freeze_append_patch_and_rehydrate_shapes() { patch_fixture ); + let tool_metadata_fixture = fixture("delta_patch_tool_metadata"); + let tool_metadata_decoded: TerminalStreamEnvelopeDto = + serde_json::from_value(tool_metadata_fixture.clone()) + .expect("tool metadata patch fixture should decode"); + assert_eq!( + tool_metadata_decoded, + TerminalStreamEnvelopeDto { + session_id: "session-root".to_string(), + cursor: TerminalCursorDto("cursor:opaque:v1:session-root/44.5==".to_string()), + delta: TerminalDeltaDto::PatchBlock { + block_id: "block-tool-call-1".to_string(), + patch: TerminalBlockPatchDto::ReplaceMetadata { + metadata: json!({ + "openSessionId": "session-child-1", + "agentRef": { + "agentId": "agent-child-1", + "subRunId": "subrun-1", + "openSessionId": "session-child-1" + } + }), + }, + }, + } + ); + assert_eq!( + serde_json::to_value(&tool_metadata_decoded).expect("tool metadata patch should encode"), + tool_metadata_fixture + ); + let replace_fixture = fixture("delta_patch_replace_markdown"); let replace_decoded: TerminalStreamEnvelopeDto = serde_json::from_value(replace_fixture.clone()).expect("replace fixture should decode"); diff --git a/crates/server/src/http/routes/conversation.rs b/crates/server/src/http/routes/conversation.rs index b9cdba34..f34ab50c 100644 --- a/crates/server/src/http/routes/conversation.rs +++ b/crates/server/src/http/routes/conversation.rs @@ -1,14 +1,15 @@ -use std::{convert::Infallible, pin::Pin, time::Duration}; +use std::{collections::HashMap, convert::Infallible, pin::Pin, time::Duration}; use astrcode_application::{ ApplicationError, ConversationFocus, TerminalChildSummaryFacts, TerminalControlFacts, TerminalSlashCandidateFacts, TerminalStreamFacts, TerminalStreamReplayFacts, }; -use astrcode_core::{AgentEvent, SessionEventRecord}; +use astrcode_core::AgentEvent; use astrcode_protocol::http::conversation::v1::{ - ConversationDeltaDto, ConversationSlashCandidatesResponseDto, ConversationSnapshotResponseDto, - ConversationStreamEnvelopeDto, + ConversationChildSummaryDto, ConversationDeltaDto, ConversationSlashCandidatesResponseDto, + ConversationSnapshotResponseDto, ConversationStreamEnvelopeDto, }; +use astrcode_session_runtime::ConversationStreamProjector as RuntimeConversationStreamProjector; use async_stream::stream; use axum::{ Json, @@ -27,10 +28,10 @@ use crate::{ auth::is_authorized, routes::sessions::validate_session_path_id, terminal_projection::{ - TerminalDeltaProjector, project_terminal_child_summary_deltas, - project_terminal_control_delta, project_terminal_rehydrate_envelope, - project_terminal_slash_candidates, project_terminal_snapshot, - project_terminal_stream_replay, seeded_terminal_stream_projector, + project_child_summary, project_conversation_child_summary_deltas, + project_conversation_control_delta, project_conversation_frame, + project_conversation_rehydrate_envelope, project_conversation_slash_candidates, + project_conversation_snapshot, }, }; @@ -153,7 +154,7 @@ pub(crate) async fn conversation_snapshot( .await .map_err(ConversationRouteError::from)?; - Ok(Json(project_terminal_snapshot(&facts))) + Ok(Json(project_conversation_snapshot(&facts))) } pub(crate) async fn conversation_stream( @@ -183,7 +184,7 @@ pub(crate) async fn conversation_stream( state, session_id, cursor, focus, *facts, )), TerminalStreamFacts::RehydrateRequired(rehydrate) => Ok(single_envelope_stream( - project_terminal_rehydrate_envelope(&rehydrate), + project_conversation_rehydrate_envelope(&rehydrate), )), } } @@ -203,7 +204,7 @@ pub(crate) async fn conversation_slash_candidates( .await .map_err(ConversationRouteError::from)?; - Ok(Json(project_terminal_slash_candidates(&candidates))) + Ok(Json(project_conversation_slash_candidates(&candidates))) } fn require_conversation_auth( @@ -228,8 +229,8 @@ fn build_conversation_stream( let mut stream_state = ConversationStreamProjectorState::new(session_id.clone(), cursor, &facts); let initial_envelopes = stream_state.seed_initial_replay(&facts); - let mut durable_receiver = facts.replay.receiver; - let mut live_receiver = facts.replay.live_receiver; + let mut durable_receiver = facts.replay.replay.receiver; + let mut live_receiver = facts.replay.replay.live_receiver; let app = state.app.clone(); let session_id_for_stream = session_id.clone(); let mut live_receiver_open = true; @@ -285,13 +286,13 @@ fn build_conversation_stream( for envelope in stream_state.recover_from(&recovered) { yield Ok::(to_conversation_sse_event(envelope)); } - durable_receiver = recovered.replay.receiver; - live_receiver = recovered.replay.live_receiver; + durable_receiver = recovered.replay.replay.receiver; + live_receiver = recovered.replay.replay.live_receiver; live_receiver_open = true; } Ok(TerminalStreamFacts::RehydrateRequired(rehydrate)) => { yield Ok::(to_conversation_sse_event( - project_terminal_rehydrate_envelope(&rehydrate), + project_conversation_rehydrate_envelope(&rehydrate), )); break; } @@ -337,7 +338,7 @@ fn build_conversation_stream( ) } -fn project_terminal_control_deltas( +fn project_conversation_control_deltas( previous: &TerminalControlFacts, current: &TerminalControlFacts, ) -> Vec { @@ -369,9 +370,7 @@ impl ConversationAuthoritativeFacts { struct ConversationStreamProjectorState { session_id: String, - projector: TerminalDeltaProjector, - last_sent_cursor: Option, - fallback_live_cursor: Option, + projector: RuntimeConversationStreamProjector, control: TerminalControlFacts, child_summaries: Vec, slash_candidates: Vec, @@ -385,9 +384,7 @@ impl ConversationStreamProjectorState { ) -> Self { Self { session_id, - projector: seeded_terminal_stream_projector(facts), - last_sent_cursor, - fallback_live_cursor: fallback_live_cursor(facts), + projector: RuntimeConversationStreamProjector::new(last_sent_cursor, &facts.replay), control: facts.control.clone(), child_summaries: facts.child_summaries.clone(), slash_candidates: facts.slash_candidates.clone(), @@ -395,33 +392,46 @@ impl ConversationStreamProjectorState { } fn last_sent_cursor(&self) -> Option<&str> { - self.last_sent_cursor.as_deref() + self.projector.last_sent_cursor() } fn seed_initial_replay( &mut self, facts: &TerminalStreamReplayFacts, ) -> Vec { - let envelopes = project_terminal_stream_replay(facts, self.last_sent_cursor.as_deref()); - self.observe_durable_envelopes(&envelopes); + let child_lookup = child_summary_lookup(&facts.child_summaries); + let envelopes = self + .projector + .seed_initial_replay(&facts.replay) + .into_iter() + .map(|frame| project_conversation_frame(self.session_id.as_str(), frame, &child_lookup)) + .collect::>(); + let _ = self.projector.recover_from(&facts.replay); envelopes } fn project_durable_record( &mut self, - record: &SessionEventRecord, + record: &astrcode_core::SessionEventRecord, ) -> Vec { - let deltas = self.projector.project_record(record); - self.wrap_durable_deltas(record.event_id.as_str(), deltas) + let child_lookup = child_summary_lookup(&self.child_summaries); + self.projector + .project_durable_record(record) + .into_iter() + .map(|frame| project_conversation_frame(self.session_id.as_str(), frame, &child_lookup)) + .collect() } fn project_live_event(&mut self, event: &AgentEvent) -> Vec { - let cursor = self.live_cursor(); self.projector .project_live_event(event) .into_iter() - .map(|delta| { - make_conversation_envelope(self.session_id.as_str(), cursor.as_str(), delta) + .map(|frame| { + project_conversation_frame( + self.session_id.as_str(), + frame, + &child_summary_lookup(&self.child_summaries), + ) }) .collect() } @@ -431,14 +441,15 @@ impl ConversationStreamProjectorState { cursor: &str, refreshed: ConversationAuthoritativeFacts, ) -> Vec { - let mut deltas = project_terminal_control_deltas(&self.control, &refreshed.control); - deltas.extend(project_terminal_child_summary_deltas( + let mut deltas = project_conversation_control_deltas(&self.control, &refreshed.control); + deltas.extend(project_conversation_child_summary_deltas( &self.child_summaries, &refreshed.child_summaries, )); if self.slash_candidates != refreshed.slash_candidates { deltas.push(ConversationDeltaDto::ReplaceSlashCandidates { - candidates: project_terminal_slash_candidates(&refreshed.slash_candidates).items, + candidates: project_conversation_slash_candidates(&refreshed.slash_candidates) + .items, }); } @@ -452,13 +463,19 @@ impl ConversationStreamProjectorState { &mut self, recovered: &TerminalStreamReplayFacts, ) -> Vec { - let mut envelopes = - project_terminal_stream_replay(recovered, self.last_sent_cursor.as_deref()); - self.observe_durable_envelopes(&envelopes); - self.projector = seeded_terminal_stream_projector(recovered); - self.fallback_live_cursor = fallback_live_cursor(recovered); - - let recovery_cursor = self.live_cursor(); + let child_lookup = child_summary_lookup(&recovered.child_summaries); + let mut envelopes = self + .projector + .recover_from(&recovered.replay) + .into_iter() + .map(|frame| project_conversation_frame(self.session_id.as_str(), frame, &child_lookup)) + .collect::>(); + + let recovery_cursor = self + .projector + .last_sent_cursor() + .unwrap_or("0.0") + .to_string(); envelopes.extend(self.apply_authoritative_refresh( recovery_cursor.as_str(), ConversationAuthoritativeFacts::from_replay(recovered), @@ -475,7 +492,6 @@ impl ConversationStreamProjectorState { return Vec::new(); } let cursor_owned = cursor.to_string(); - self.last_sent_cursor = Some(cursor_owned.clone()); deltas .into_iter() .map(|delta| { @@ -483,33 +499,6 @@ impl ConversationStreamProjectorState { }) .collect() } - - fn observe_durable_envelopes(&mut self, envelopes: &[ConversationStreamEnvelopeDto]) { - if let Some(cursor) = envelopes.last().map(|envelope| envelope.cursor.0.clone()) { - self.last_sent_cursor = Some(cursor); - } - } - - fn live_cursor(&self) -> String { - self.last_sent_cursor - .clone() - .or_else(|| self.fallback_live_cursor.clone()) - .unwrap_or_else(|| "0.0".to_string()) - } -} - -fn fallback_live_cursor(facts: &TerminalStreamReplayFacts) -> Option { - facts - .seed_records - .last() - .map(|record| record.event_id.clone()) - .or_else(|| { - facts - .replay - .history - .last() - .map(|record| record.event_id.clone()) - }) } async fn refresh_conversation_authoritative_facts( @@ -524,8 +513,23 @@ async fn refresh_conversation_authoritative_facts( }) } +fn child_summary_lookup( + summaries: &[TerminalChildSummaryFacts], +) -> HashMap { + let mut lookup = HashMap::new(); + for summary in summaries { + let dto = project_child_summary(summary); + lookup.insert(summary.node.child_session_id.clone(), dto.clone()); + if let Some(child_ref) = &dto.child_ref { + lookup.insert(child_ref.open_session_id.clone(), dto.clone()); + lookup.insert(child_ref.session_id.clone(), dto.clone()); + } + } + lookup +} + fn control_state_delta(control: &TerminalControlFacts) -> ConversationDeltaDto { - project_terminal_control_delta(control) + project_conversation_control_delta(control) } fn single_envelope_stream(envelope: ConversationStreamEnvelopeDto) -> ConversationSse { @@ -598,3 +602,266 @@ fn parse_focus_query(raw: Option<&str>) -> Result> + Send>>; type ConversationSse = Sse>; + +#[cfg(test)] +mod tests { + use astrcode_application::{ + TerminalChildSummaryFacts, TerminalControlFacts, TerminalStreamReplayFacts, + }; + use astrcode_core::{ + AgentEventContext, AgentLifecycleStatus, ChildSessionLineageKind, ChildSessionNode, + ChildSessionStatusSource, Phase, SessionEventRecord, ToolExecutionResult, ToolOutputStream, + }; + use astrcode_session_runtime::{ + ConversationBlockPatchFacts, ConversationDeltaFacts, ConversationDeltaFrameFacts, + ConversationStreamReplayFacts as RuntimeConversationStreamReplayFacts, SessionReplay, + }; + use serde_json::json; + use tokio::sync::broadcast; + + use super::{AgentEvent, ConversationAuthoritativeFacts, ConversationStreamProjectorState}; + + #[test] + fn recover_from_replays_only_missing_records_and_advances_cursor() { + let initial = sample_stream_facts( + vec![record( + "1.1", + AgentEvent::UserMessage { + turn_id: "turn-1".to_string(), + agent: sample_agent_context(), + content: "check pipeline".to_string(), + }, + )], + vec![record( + "1.2", + AgentEvent::ToolCallStart { + turn_id: "turn-1".to_string(), + agent: sample_agent_context(), + tool_call_id: "call-1".to_string(), + tool_name: "shell_command".to_string(), + input: json!({ "command": "pwd" }), + }, + )], + ); + let mut state = ConversationStreamProjectorState::new( + "session-root".to_string(), + Some("1.1".to_string()), + &initial, + ); + + let initial_envelopes = state.seed_initial_replay(&initial); + assert_eq!(initial_envelopes.len(), 1); + assert_eq!(initial_envelopes[0].cursor.0, "1.2"); + + let recovered = sample_stream_facts( + vec![ + record( + "1.1", + AgentEvent::UserMessage { + turn_id: "turn-1".to_string(), + agent: sample_agent_context(), + content: "check pipeline".to_string(), + }, + ), + record( + "1.2", + AgentEvent::ToolCallStart { + turn_id: "turn-1".to_string(), + agent: sample_agent_context(), + tool_call_id: "call-1".to_string(), + tool_name: "shell_command".to_string(), + input: json!({ "command": "pwd" }), + }, + ), + ], + vec![record( + "1.3", + AgentEvent::ToolCallDelta { + turn_id: "turn-1".to_string(), + agent: sample_agent_context(), + tool_call_id: "call-1".to_string(), + tool_name: "shell_command".to_string(), + stream: ToolOutputStream::Stdout, + delta: "D:/GitObjectsOwn/Astrcode\n".to_string(), + }, + )], + ); + + let recovered_envelopes = state.recover_from(&recovered); + assert_eq!(recovered_envelopes.len(), 1); + assert_eq!(recovered_envelopes[0].cursor.0, "1.3"); + assert_eq!( + serde_json::to_value(&recovered_envelopes[0]) + .expect("recovered envelope should encode"), + json!({ + "sessionId": "session-root", + "cursor": "1.3", + "kind": "patch_block", + "blockId": "tool:call-1:call", + "patch": { + "kind": "append_tool_stream", + "stream": "stdout", + "chunk": "D:/GitObjectsOwn/Astrcode\n" + } + }) + ); + + let live_envelopes = state.project_live_event(&AgentEvent::ToolCallResult { + turn_id: "turn-1".to_string(), + agent: sample_agent_context(), + result: ToolExecutionResult { + tool_call_id: "call-1".to_string(), + tool_name: "shell_command".to_string(), + ok: true, + output: "D:/GitObjectsOwn/Astrcode\n".to_string(), + error: None, + metadata: None, + duration_ms: 8, + truncated: false, + }, + }); + assert!( + live_envelopes + .iter() + .all(|envelope| envelope.cursor.0 == "1.3"), + "live cursor should stay anchored to last durable cursor after recovery" + ); + } + + #[test] + fn authoritative_refresh_emits_child_summary_delta_on_current_cursor() { + let facts = sample_stream_facts(Vec::new(), Vec::new()); + let mut state = ConversationStreamProjectorState::new( + "session-root".to_string(), + Some("1.4".to_string()), + &facts, + ); + + let refreshed = ConversationAuthoritativeFacts { + control: facts.control.clone(), + child_summaries: vec![sample_child_summary()], + slash_candidates: facts.slash_candidates.clone(), + }; + + let envelopes = state.apply_authoritative_refresh("1.4", refreshed); + assert_eq!(envelopes.len(), 1); + assert_eq!( + serde_json::to_value(&envelopes[0]).expect("child summary envelope should encode"), + json!({ + "sessionId": "session-root", + "cursor": "1.4", + "kind": "upsert_child_summary", + "child": { + "childSessionId": "session-child-1", + "childAgentId": "agent-child-1", + "title": "Repo inspector", + "lifecycle": "running", + "latestOutputSummary": "正在检查 conversation projector", + "childRef": { + "agentId": "agent-child-1", + "sessionId": "session-root", + "subRunId": "subrun-child-1", + "parentAgentId": "agent-root", + "parentSubRunId": "subrun-root", + "lineageKind": "spawn", + "status": "running", + "openSessionId": "session-child-1" + } + } + }) + ); + } + + fn sample_stream_facts( + seed_records: Vec, + history: Vec, + ) -> TerminalStreamReplayFacts { + let (_, receiver) = broadcast::channel(8); + let (_, live_receiver) = broadcast::channel(8); + + TerminalStreamReplayFacts { + active_session_id: "session-root".to_string(), + replay: RuntimeConversationStreamReplayFacts { + cursor: history.last().map(|record| record.event_id.clone()), + phase: Phase::CallingTool, + seed_records: seed_records.clone(), + replay_frames: history + .iter() + .map(|record| ConversationDeltaFrameFacts { + cursor: record.event_id.clone(), + delta: match &record.event { + AgentEvent::ToolCallDelta { + tool_call_id, + stream, + delta, + .. + } => ConversationDeltaFacts::PatchBlock { + block_id: format!("tool:{tool_call_id}:call"), + patch: ConversationBlockPatchFacts::AppendToolStream { + stream: *stream, + chunk: delta.clone(), + }, + }, + _ => ConversationDeltaFacts::AppendBlock { + block: astrcode_session_runtime::ConversationBlockFacts::User( + astrcode_session_runtime::ConversationUserBlockFacts { + id: "noop".to_string(), + turn_id: None, + markdown: String::new(), + }, + ), + }, + }, + }) + .collect(), + replay: SessionReplay { + history, + receiver, + live_receiver, + }, + }, + control: TerminalControlFacts { + phase: Phase::CallingTool, + active_turn_id: Some("turn-1".to_string()), + manual_compact_pending: false, + }, + child_summaries: Vec::new(), + slash_candidates: Vec::new(), + } + } + + fn sample_child_summary() -> TerminalChildSummaryFacts { + TerminalChildSummaryFacts { + node: ChildSessionNode { + agent_id: "agent-child-1".to_string(), + session_id: "session-root".to_string(), + child_session_id: "session-child-1".to_string(), + sub_run_id: "subrun-child-1".to_string(), + parent_session_id: "session-root".to_string(), + parent_agent_id: Some("agent-root".to_string()), + parent_sub_run_id: Some("subrun-root".to_string()), + parent_turn_id: "turn-1".to_string(), + lineage_kind: ChildSessionLineageKind::Spawn, + status: AgentLifecycleStatus::Running, + status_source: ChildSessionStatusSource::Durable, + created_by_tool_call_id: Some("call-2".to_string()), + lineage_snapshot: None, + }, + phase: Phase::CallingTool, + title: Some("Repo inspector".to_string()), + display_name: Some("repo-inspector".to_string()), + recent_output: Some("正在检查 conversation projector".to_string()), + } + } + + fn sample_agent_context() -> AgentEventContext { + AgentEventContext::root_execution("agent-root", "default") + } + + fn record(event_id: &str, event: AgentEvent) -> SessionEventRecord { + SessionEventRecord { + event_id: event_id.to_string(), + event, + } + } +} diff --git a/crates/server/src/http/terminal_projection.rs b/crates/server/src/http/terminal_projection.rs index 6de6d97c..88ab5dff 100644 --- a/crates/server/src/http/terminal_projection.rs +++ b/crates/server/src/http/terminal_projection.rs @@ -2,36 +2,39 @@ use std::collections::HashMap; use astrcode_application::{ TerminalChildSummaryFacts, TerminalControlFacts, TerminalFacts, TerminalRehydrateFacts, - TerminalSlashAction, TerminalSlashCandidateFacts, TerminalStreamReplayFacts, - terminal::truncate_terminal_summary, + TerminalSlashAction, TerminalSlashCandidateFacts, }; use astrcode_core::{ - AgentEvent, AgentLifecycleStatus, ChildAgentRef, ChildSessionLineageKind, SessionEventRecord, - ToolExecutionResult, ToolOutputStream, + AgentLifecycleStatus, ChildAgentRef, ChildSessionLineageKind, ToolOutputStream, }; use astrcode_protocol::http::{ - AgentLifecycleDto, ChildAgentRefDto, ChildSessionLineageKindDto, PhaseDto, - TerminalAssistantBlockDto, TerminalBannerDto, TerminalBannerErrorCodeDto, TerminalBlockDto, - TerminalBlockPatchDto, TerminalBlockStatusDto, TerminalChildHandoffBlockDto, - TerminalChildHandoffKindDto, TerminalChildSummaryDto, TerminalControlStateDto, - TerminalCursorDto, TerminalDeltaDto, TerminalErrorBlockDto, TerminalErrorEnvelopeDto, - TerminalSlashActionKindDto, TerminalSlashCandidateDto, TerminalSlashCandidatesResponseDto, - TerminalSnapshotResponseDto, TerminalStreamEnvelopeDto, TerminalSystemNoteBlockDto, - TerminalSystemNoteKindDto, TerminalThinkingBlockDto, TerminalToolCallBlockDto, - TerminalToolStreamBlockDto, TerminalTranscriptErrorCodeDto, TerminalUserBlockDto, - ToolOutputStreamDto, + AgentLifecycleDto, ChildAgentRefDto, ChildSessionLineageKindDto, ConversationAssistantBlockDto, + ConversationBannerDto, ConversationBannerErrorCodeDto, ConversationBlockDto, + ConversationBlockPatchDto, ConversationBlockStatusDto, ConversationChildHandoffBlockDto, + ConversationChildHandoffKindDto, ConversationChildSummaryDto, ConversationControlStateDto, + ConversationCursorDto, ConversationDeltaDto, ConversationErrorBlockDto, + ConversationErrorEnvelopeDto, ConversationSlashActionKindDto, ConversationSlashCandidateDto, + ConversationSlashCandidatesResponseDto, ConversationSnapshotResponseDto, + ConversationStreamEnvelopeDto, ConversationSystemNoteBlockDto, ConversationSystemNoteKindDto, + ConversationThinkingBlockDto, ConversationToolCallBlockDto, ConversationToolStreamsDto, + ConversationTranscriptErrorCodeDto, ConversationUserBlockDto, PhaseDto, ToolOutputStreamDto, +}; +use astrcode_session_runtime::{ + ConversationBlockFacts, ConversationBlockPatchFacts, ConversationBlockStatus, + ConversationChildHandoffBlockFacts, ConversationChildHandoffKind, ConversationDeltaFacts, + ConversationDeltaFrameFacts, ConversationSystemNoteKind, ConversationTranscriptErrorKind, + ToolCallBlockFacts, }; -use serde_json::Value; -pub(crate) fn project_terminal_snapshot(facts: &TerminalFacts) -> TerminalSnapshotResponseDto { +pub(crate) fn project_conversation_snapshot( + facts: &TerminalFacts, +) -> ConversationSnapshotResponseDto { let child_lookup = child_summary_lookup(&facts.child_summaries); - let mut projector = TerminalDeltaProjector::new(child_lookup); - projector.seed(&facts.transcript.records); - TerminalSnapshotResponseDto { + ConversationSnapshotResponseDto { session_id: facts.active_session_id.clone(), session_title: facts.session_title.clone(), - cursor: TerminalCursorDto( + cursor: ConversationCursorDto( facts .transcript .cursor @@ -40,7 +43,12 @@ pub(crate) fn project_terminal_snapshot(facts: &TerminalFacts) -> TerminalSnapsh ), phase: to_phase_dto(facts.control.phase), control: project_control_state(&facts.control), - blocks: projector.blocks, + blocks: facts + .transcript + .blocks + .iter() + .map(|block| project_block(block, &child_lookup)) + .collect(), child_summaries: facts .child_summaries .iter() @@ -55,18 +63,32 @@ pub(crate) fn project_terminal_snapshot(facts: &TerminalFacts) -> TerminalSnapsh } } -pub(crate) fn project_terminal_control_delta(control: &TerminalControlFacts) -> TerminalDeltaDto { - TerminalDeltaDto::UpdateControlState { +pub(crate) fn project_conversation_frame( + session_id: &str, + frame: ConversationDeltaFrameFacts, + child_lookup: &HashMap, +) -> ConversationStreamEnvelopeDto { + ConversationStreamEnvelopeDto { + session_id: session_id.to_string(), + cursor: ConversationCursorDto(frame.cursor), + delta: project_delta(frame.delta, child_lookup), + } +} + +pub(crate) fn project_conversation_control_delta( + control: &TerminalControlFacts, +) -> ConversationDeltaDto { + ConversationDeltaDto::UpdateControlState { control: project_control_state(control), } } -pub(crate) fn project_terminal_rehydrate_banner( +pub(crate) fn project_conversation_rehydrate_banner( rehydrate: &TerminalRehydrateFacts, -) -> TerminalBannerDto { - TerminalBannerDto { - error: TerminalErrorEnvelopeDto { - code: TerminalBannerErrorCodeDto::CursorExpired, +) -> ConversationBannerDto { + ConversationBannerDto { + error: ConversationErrorEnvelopeDto { + code: ConversationBannerErrorCodeDto::CursorExpired, message: format!( "cursor '{}' is no longer valid for session '{}'", rehydrate.requested_cursor, rehydrate.session_id @@ -81,125 +103,35 @@ pub(crate) fn project_terminal_rehydrate_banner( } } -pub(crate) fn project_terminal_rehydrate_envelope( +pub(crate) fn project_conversation_rehydrate_envelope( rehydrate: &TerminalRehydrateFacts, -) -> TerminalStreamEnvelopeDto { - TerminalStreamEnvelopeDto { +) -> ConversationStreamEnvelopeDto { + ConversationStreamEnvelopeDto { session_id: rehydrate.session_id.clone(), - cursor: TerminalCursorDto( + cursor: ConversationCursorDto( rehydrate .latest_cursor .clone() .unwrap_or_else(|| rehydrate.requested_cursor.clone()), ), - delta: TerminalDeltaDto::RehydrateRequired { - error: project_terminal_rehydrate_banner(rehydrate).error, + delta: ConversationDeltaDto::RehydrateRequired { + error: project_conversation_rehydrate_banner(rehydrate).error, }, } } -pub(crate) fn project_terminal_slash_candidates( +pub(crate) fn project_conversation_slash_candidates( candidates: &[TerminalSlashCandidateFacts], -) -> TerminalSlashCandidatesResponseDto { - TerminalSlashCandidatesResponseDto { +) -> ConversationSlashCandidatesResponseDto { + ConversationSlashCandidatesResponseDto { items: candidates.iter().map(project_slash_candidate).collect(), } } -pub(crate) fn project_terminal_stream_replay( - facts: &TerminalStreamReplayFacts, - last_event_id: Option<&str>, -) -> Vec { - let mut projector = TerminalDeltaProjector::new(child_summary_lookup(&facts.child_summaries)); - let seed_records = transcript_before_cursor(&facts.seed_records, last_event_id); - projector.seed(seed_records); - - let mut deltas = Vec::new(); - for record in &facts.replay.history { - for delta in projector.project_record(record) { - deltas.push(TerminalStreamEnvelopeDto { - session_id: facts.active_session_id.clone(), - cursor: TerminalCursorDto(record.event_id.clone()), - delta, - }); - } - } - deltas -} - -pub(crate) fn seeded_terminal_stream_projector( - facts: &TerminalStreamReplayFacts, -) -> TerminalDeltaProjector { - let mut projector = TerminalDeltaProjector::new(child_summary_lookup(&facts.child_summaries)); - projector.seed(&facts.seed_records); - projector -} - -fn transcript_before_cursor<'a>( - records: &'a [SessionEventRecord], - last_event_id: Option<&str>, -) -> &'a [SessionEventRecord] { - let Some(last_event_id) = last_event_id else { - return &[]; - }; - let Some(index) = records - .iter() - .position(|record| record.event_id == last_event_id) - else { - return &[]; - }; - &records[..=index] -} - -fn child_summary_lookup( - summaries: &[TerminalChildSummaryFacts], -) -> HashMap { - summaries - .iter() - .map(|summary| { - ( - summary.node.child_session_id.clone(), - project_child_summary(summary), - ) - }) - .collect() -} - -fn project_control_state(control: &TerminalControlFacts) -> TerminalControlStateDto { - let can_submit_prompt = matches!( - control.phase, - astrcode_core::Phase::Idle | astrcode_core::Phase::Done | astrcode_core::Phase::Interrupted - ); - TerminalControlStateDto { - phase: to_phase_dto(control.phase), - can_submit_prompt, - can_request_compact: !control.manual_compact_pending, - compact_pending: control.manual_compact_pending, - active_turn_id: control.active_turn_id.clone(), - } -} - -pub(crate) fn project_child_summary( - summary: &TerminalChildSummaryFacts, -) -> TerminalChildSummaryDto { - TerminalChildSummaryDto { - child_session_id: summary.node.child_session_id.clone(), - child_agent_id: summary.node.agent_id.clone(), - title: summary - .title - .clone() - .or_else(|| summary.display_name.clone()) - .unwrap_or_else(|| summary.node.child_session_id.clone()), - lifecycle: to_lifecycle_dto(summary.node.status), - latest_output_summary: summary.recent_output.clone(), - child_ref: Some(to_child_ref_dto(summary.node.child_ref())), - } -} - -pub(crate) fn project_terminal_child_summary_deltas( +pub(crate) fn project_conversation_child_summary_deltas( previous: &[TerminalChildSummaryFacts], current: &[TerminalChildSummaryFacts], -) -> Vec { +) -> Vec { let previous_by_id = previous .iter() .map(|summary| { @@ -227,7 +159,7 @@ pub(crate) fn project_terminal_child_summary_deltas( .collect::>(); removed_ids.sort(); for child_session_id in removed_ids { - deltas.push(TerminalDeltaDto::RemoveChildSummary { child_session_id }); + deltas.push(ConversationDeltaDto::RemoveChildSummary { child_session_id }); } let mut current_ids = current_by_id.keys().cloned().collect::>(); @@ -237,7 +169,7 @@ pub(crate) fn project_terminal_child_summary_deltas( .get(&child_session_id) .expect("current child summary should exist"); if previous_by_id.get(&child_session_id) != Some(current_child) { - deltas.push(TerminalDeltaDto::UpsertChildSummary { + deltas.push(ConversationDeltaDto::UpsertChildSummary { child: current_child.clone(), }); } @@ -246,31 +178,256 @@ pub(crate) fn project_terminal_child_summary_deltas( deltas } -fn project_slash_candidate(candidate: &TerminalSlashCandidateFacts) -> TerminalSlashCandidateDto { +fn project_delta( + delta: ConversationDeltaFacts, + child_lookup: &HashMap, +) -> ConversationDeltaDto { + match delta { + ConversationDeltaFacts::AppendBlock { block } => ConversationDeltaDto::AppendBlock { + block: project_block(&block, child_lookup), + }, + ConversationDeltaFacts::PatchBlock { block_id, patch } => { + ConversationDeltaDto::PatchBlock { + block_id, + patch: project_patch(patch), + } + }, + ConversationDeltaFacts::CompleteBlock { block_id, status } => { + ConversationDeltaDto::CompleteBlock { + block_id, + status: to_block_status_dto(status), + } + }, + } +} + +fn project_patch(patch: ConversationBlockPatchFacts) -> ConversationBlockPatchDto { + match patch { + ConversationBlockPatchFacts::AppendMarkdown { markdown } => { + ConversationBlockPatchDto::AppendMarkdown { markdown } + }, + ConversationBlockPatchFacts::ReplaceMarkdown { markdown } => { + ConversationBlockPatchDto::ReplaceMarkdown { markdown } + }, + ConversationBlockPatchFacts::AppendToolStream { stream, chunk } => { + ConversationBlockPatchDto::AppendToolStream { + stream: to_stream_dto(stream), + chunk, + } + }, + ConversationBlockPatchFacts::ReplaceSummary { summary } => { + ConversationBlockPatchDto::ReplaceSummary { summary } + }, + ConversationBlockPatchFacts::ReplaceMetadata { metadata } => { + ConversationBlockPatchDto::ReplaceMetadata { metadata } + }, + ConversationBlockPatchFacts::ReplaceError { error } => { + ConversationBlockPatchDto::ReplaceError { error } + }, + ConversationBlockPatchFacts::ReplaceDuration { duration_ms } => { + ConversationBlockPatchDto::ReplaceDuration { duration_ms } + }, + ConversationBlockPatchFacts::ReplaceChildRef { child_ref } => { + ConversationBlockPatchDto::ReplaceChildRef { + child_ref: to_child_ref_dto(child_ref), + } + }, + ConversationBlockPatchFacts::SetTruncated { truncated } => { + ConversationBlockPatchDto::SetTruncated { truncated } + }, + ConversationBlockPatchFacts::SetStatus { status } => ConversationBlockPatchDto::SetStatus { + status: to_block_status_dto(status), + }, + } +} + +fn project_block( + block: &ConversationBlockFacts, + child_lookup: &HashMap, +) -> ConversationBlockDto { + match block { + ConversationBlockFacts::User(block) => { + ConversationBlockDto::User(ConversationUserBlockDto { + id: block.id.clone(), + turn_id: block.turn_id.clone(), + markdown: block.markdown.clone(), + }) + }, + ConversationBlockFacts::Assistant(block) => { + ConversationBlockDto::Assistant(ConversationAssistantBlockDto { + id: block.id.clone(), + turn_id: block.turn_id.clone(), + status: to_block_status_dto(block.status), + markdown: block.markdown.clone(), + }) + }, + ConversationBlockFacts::Thinking(block) => { + ConversationBlockDto::Thinking(ConversationThinkingBlockDto { + id: block.id.clone(), + turn_id: block.turn_id.clone(), + status: to_block_status_dto(block.status), + markdown: block.markdown.clone(), + }) + }, + ConversationBlockFacts::ToolCall(block) => { + ConversationBlockDto::ToolCall(project_tool_call_block(block)) + }, + ConversationBlockFacts::Error(block) => { + ConversationBlockDto::Error(ConversationErrorBlockDto { + id: block.id.clone(), + turn_id: block.turn_id.clone(), + code: to_error_code_dto(block.code), + message: block.message.clone(), + }) + }, + ConversationBlockFacts::SystemNote(block) => { + ConversationBlockDto::SystemNote(ConversationSystemNoteBlockDto { + id: block.id.clone(), + note_kind: match block.note_kind { + ConversationSystemNoteKind::Compact => ConversationSystemNoteKindDto::Compact, + ConversationSystemNoteKind::SystemNote => { + ConversationSystemNoteKindDto::SystemNote + }, + }, + markdown: block.markdown.clone(), + }) + }, + ConversationBlockFacts::ChildHandoff(block) => { + ConversationBlockDto::ChildHandoff(project_child_handoff_block(block, child_lookup)) + }, + } +} + +fn project_tool_call_block(block: &ToolCallBlockFacts) -> ConversationToolCallBlockDto { + ConversationToolCallBlockDto { + id: block.id.clone(), + turn_id: block.turn_id.clone(), + tool_call_id: block.tool_call_id.clone(), + tool_name: block.tool_name.clone(), + status: to_block_status_dto(block.status), + input: block.input.clone(), + summary: block.summary.clone(), + error: block.error.clone(), + duration_ms: block.duration_ms, + truncated: block.truncated, + metadata: block.metadata.clone(), + child_ref: block.child_ref.clone().map(to_child_ref_dto), + streams: ConversationToolStreamsDto { + stdout: block.streams.stdout.clone(), + stderr: block.streams.stderr.clone(), + }, + } +} + +fn project_child_handoff_block( + block: &ConversationChildHandoffBlockFacts, + child_lookup: &HashMap, +) -> ConversationChildHandoffBlockDto { + let child = child_lookup + .get(block.child_ref.open_session_id.as_str()) + .cloned() + .or_else(|| { + child_lookup + .get(block.child_ref.session_id.as_str()) + .cloned() + }) + .unwrap_or_else(|| fallback_child_summary(&block.child_ref)); + + ConversationChildHandoffBlockDto { + id: block.id.clone(), + handoff_kind: match block.handoff_kind { + ConversationChildHandoffKind::Delegated => ConversationChildHandoffKindDto::Delegated, + ConversationChildHandoffKind::Progress => ConversationChildHandoffKindDto::Progress, + ConversationChildHandoffKind::Returned => ConversationChildHandoffKindDto::Returned, + }, + child, + message: block.message.clone(), + } +} + +fn fallback_child_summary(child_ref: &ChildAgentRef) -> ConversationChildSummaryDto { + ConversationChildSummaryDto { + child_session_id: child_ref.open_session_id.clone(), + child_agent_id: child_ref.agent_id.clone(), + title: child_ref.agent_id.clone(), + lifecycle: to_lifecycle_dto(child_ref.status), + latest_output_summary: None, + child_ref: Some(to_child_ref_dto(child_ref.clone())), + } +} + +fn child_summary_lookup( + summaries: &[TerminalChildSummaryFacts], +) -> HashMap { + let mut lookup = HashMap::new(); + for summary in summaries { + let dto = project_child_summary(summary); + lookup.insert(summary.node.child_session_id.clone(), dto.clone()); + if let Some(child_ref) = &dto.child_ref { + lookup.insert(child_ref.open_session_id.clone(), dto.clone()); + lookup.insert(child_ref.session_id.clone(), dto.clone()); + } + } + lookup +} + +pub(crate) fn project_child_summary( + summary: &TerminalChildSummaryFacts, +) -> ConversationChildSummaryDto { + ConversationChildSummaryDto { + child_session_id: summary.node.child_session_id.clone(), + child_agent_id: summary.node.agent_id.clone(), + title: summary + .title + .clone() + .or_else(|| summary.display_name.clone()) + .unwrap_or_else(|| summary.node.child_session_id.clone()), + lifecycle: to_lifecycle_dto(summary.node.status), + latest_output_summary: summary.recent_output.clone(), + child_ref: Some(to_child_ref_dto(summary.node.child_ref())), + } +} + +fn project_control_state(control: &TerminalControlFacts) -> ConversationControlStateDto { + let can_submit_prompt = matches!( + control.phase, + astrcode_core::Phase::Idle | astrcode_core::Phase::Done | astrcode_core::Phase::Interrupted + ); + ConversationControlStateDto { + phase: to_phase_dto(control.phase), + can_submit_prompt, + can_request_compact: !control.manual_compact_pending, + compact_pending: control.manual_compact_pending, + active_turn_id: control.active_turn_id.clone(), + } +} + +fn project_slash_candidate( + candidate: &TerminalSlashCandidateFacts, +) -> ConversationSlashCandidateDto { let (action_kind, action_value) = match &candidate.action { TerminalSlashAction::CreateSession => ( - TerminalSlashActionKindDto::ExecuteCommand, + ConversationSlashActionKindDto::ExecuteCommand, "/new".to_string(), ), TerminalSlashAction::OpenResume => ( - TerminalSlashActionKindDto::ExecuteCommand, + ConversationSlashActionKindDto::ExecuteCommand, "/resume".to_string(), ), TerminalSlashAction::RequestCompact => ( - TerminalSlashActionKindDto::ExecuteCommand, + ConversationSlashActionKindDto::ExecuteCommand, "/compact".to_string(), ), TerminalSlashAction::OpenSkillPalette => ( - TerminalSlashActionKindDto::ExecuteCommand, + ConversationSlashActionKindDto::ExecuteCommand, "/skill".to_string(), ), TerminalSlashAction::InsertText { text } => { - (TerminalSlashActionKindDto::InsertText, text.clone()) + (ConversationSlashActionKindDto::InsertText, text.clone()) }, }; - let _ = candidate.kind; - TerminalSlashCandidateDto { + ConversationSlashCandidateDto { id: candidate.id.clone(), title: candidate.title.clone(), description: candidate.description.clone(), @@ -324,1912 +481,24 @@ fn to_child_ref_dto(child_ref: ChildAgentRef) -> ChildAgentRefDto { } } -#[derive(Default)] -pub(crate) struct TerminalDeltaProjector { - blocks: Vec, - block_index: HashMap, - turn_blocks: HashMap, - tool_blocks: HashMap, - child_lookup: HashMap, -} - -#[derive(Default, Clone)] -struct TurnBlockRefs { - current_thinking: Option, - current_assistant: Option, - historical_thinking: Vec, - historical_assistant: Vec, - pending_thinking: Vec, - pending_assistant: Vec, - thinking_count: usize, - assistant_count: usize, -} - -#[derive(Default, Clone)] -struct ToolBlockRefs { - turn_id: Option, - call: Option, - stdout: Option, - stderr: Option, - pending_live_stdout_bytes: usize, - pending_live_stderr_bytes: usize, -} - -#[derive(Clone, Copy)] -enum BlockKind { - Thinking, - Assistant, -} - -impl ToolBlockRefs { - fn reconcile_tool_chunk( - &mut self, - stream: ToolOutputStream, - delta: &str, - source: ProjectionSource, - ) -> String { - let pending_live_bytes = match stream { - ToolOutputStream::Stdout => &mut self.pending_live_stdout_bytes, - ToolOutputStream::Stderr => &mut self.pending_live_stderr_bytes, - }; - - if matches!(source, ProjectionSource::Live) { - *pending_live_bytes += delta.len(); - return delta.to_string(); - } - - if *pending_live_bytes == 0 { - return delta.to_string(); - } - - let consumed = (*pending_live_bytes).min(delta.len()); - *pending_live_bytes -= consumed; - delta[consumed..].to_string() - } -} - -impl TurnBlockRefs { - fn current_or_next_block_id(&mut self, turn_id: &str, kind: BlockKind) -> String { - match kind { - BlockKind::Thinking => { - if let Some(block_id) = &self.current_thinking { - return block_id.clone(); - } - self.thinking_count += 1; - let block_id = turn_scoped_block_id(turn_id, "thinking", self.thinking_count); - self.current_thinking = Some(block_id.clone()); - block_id - }, - BlockKind::Assistant => { - if let Some(block_id) = &self.current_assistant { - return block_id.clone(); - } - self.assistant_count += 1; - let block_id = turn_scoped_block_id(turn_id, "assistant", self.assistant_count); - self.current_assistant = Some(block_id.clone()); - block_id - }, - } - } - - fn block_id_for_finalize(&mut self, turn_id: &str, kind: BlockKind) -> String { - match kind { - BlockKind::Thinking => { - if let Some(block_id) = self.pending_thinking.first().cloned() { - self.pending_thinking.remove(0); - return block_id; - } - self.current_or_next_block_id(turn_id, kind) - }, - BlockKind::Assistant => { - if let Some(block_id) = self.pending_assistant.first().cloned() { - self.pending_assistant.remove(0); - return block_id; - } - self.current_or_next_block_id(turn_id, kind) - }, - } - } - - fn split_after_live_tool_boundary(&mut self) { - if let Some(block_id) = self.current_thinking.take() { - self.pending_thinking.push(block_id); - } - if let Some(block_id) = self.current_assistant.take() { - self.pending_assistant.push(block_id); - } - } - - fn split_after_durable_tool_boundary(&mut self) { - if let Some(block_id) = self.current_thinking.take() { - self.historical_thinking.push(block_id); - } - if let Some(block_id) = self.current_assistant.take() { - self.historical_assistant.push(block_id); - } - } - - fn all_block_ids(&self) -> Vec { - let mut ids = Vec::new(); - ids.extend(self.historical_thinking.iter().cloned()); - ids.extend(self.historical_assistant.iter().cloned()); - ids.extend(self.pending_thinking.iter().cloned()); - ids.extend(self.pending_assistant.iter().cloned()); - if let Some(block_id) = &self.current_thinking { - ids.push(block_id.clone()); - } - if let Some(block_id) = &self.current_assistant { - ids.push(block_id.clone()); - } - ids - } -} - -fn turn_scoped_block_id(turn_id: &str, role: &str, ordinal: usize) -> String { - if ordinal <= 1 { - format!("turn:{turn_id}:{role}") - } else { - format!("turn:{turn_id}:{role}:{ordinal}") - } -} - -impl TerminalDeltaProjector { - pub(crate) fn new(child_lookup: HashMap) -> Self { - Self { - child_lookup, - ..Default::default() - } - } - - pub(crate) fn seed(&mut self, history: &[SessionEventRecord]) { - for record in history { - let _ = self.project_record(record); - } - } - - pub(crate) fn project_record(&mut self, record: &SessionEventRecord) -> Vec { - self.project_event( - &record.event, - ProjectionSource::Durable, - Some(&record.event_id), - ) - } - - pub(crate) fn project_live_event(&mut self, event: &AgentEvent) -> Vec { - self.project_event(event, ProjectionSource::Live, None) - } - - fn project_event( - &mut self, - event: &AgentEvent, - source: ProjectionSource, - durable_event_id: Option<&str>, - ) -> Vec { - match event { - AgentEvent::UserMessage { - turn_id, content, .. - } if source.is_durable() => { - let block_id = format!("turn:{turn_id}:user"); - self.append_user_block(&block_id, turn_id, content) - }, - AgentEvent::ThinkingDelta { turn_id, delta, .. } => { - self.append_markdown_streaming_block(turn_id, delta, BlockKind::Thinking) - }, - AgentEvent::ModelDelta { turn_id, delta, .. } => { - self.append_markdown_streaming_block(turn_id, delta, BlockKind::Assistant) - }, - AgentEvent::AssistantMessage { - turn_id, - content, - reasoning_content, - .. - } if source.is_durable() => { - self.finalize_assistant_block(turn_id, content, reasoning_content.as_deref()) - }, - AgentEvent::ToolCallStart { - turn_id, - tool_call_id, - tool_name, - input, - .. - } => self.start_tool_call(turn_id, tool_call_id, tool_name, Some(input), source), - AgentEvent::ToolCallDelta { - turn_id, - tool_call_id, - tool_name, - stream, - delta, - .. - } => self.append_tool_stream(turn_id, tool_call_id, tool_name, *stream, delta, source), - AgentEvent::ToolCallResult { - turn_id, result, .. - } => self.complete_tool_call(turn_id.as_str(), result, source), - AgentEvent::CompactApplied { - turn_id, summary, .. - } if source.is_durable() => { - let block_id = format!( - "system:compact:{}", - turn_id - .clone() - .or_else(|| durable_event_id.map(ToString::to_string)) - .unwrap_or_else(|| "session".to_string()) - ); - self.append_system_note(&block_id, TerminalSystemNoteKindDto::Compact, summary) - }, - AgentEvent::ChildSessionNotification { notification, .. } => { - if source.is_durable() { - self.append_child_handoff(notification) - } else { - Vec::new() - } - }, - AgentEvent::Error { - turn_id, - code, - message, - .. - } if source.is_durable() => self.append_error(turn_id.as_deref(), code, message), - AgentEvent::TurnDone { turn_id, .. } if source.is_durable() => { - self.complete_turn(turn_id) - }, - AgentEvent::PhaseChanged { .. } - | AgentEvent::SessionStarted { .. } - | AgentEvent::PromptMetrics { .. } - | AgentEvent::SubRunStarted { .. } - | AgentEvent::SubRunFinished { .. } - | AgentEvent::AgentMailboxQueued { .. } - | AgentEvent::AgentMailboxBatchStarted { .. } - | AgentEvent::AgentMailboxBatchAcked { .. } - | AgentEvent::AgentMailboxDiscarded { .. } - | AgentEvent::UserMessage { .. } - | AgentEvent::AssistantMessage { .. } - | AgentEvent::CompactApplied { .. } - | AgentEvent::Error { .. } - | AgentEvent::TurnDone { .. } => Vec::new(), - } - } - - fn append_user_block( - &mut self, - block_id: &str, - turn_id: &str, - content: &str, - ) -> Vec { - if self.block_index.contains_key(block_id) { - return Vec::new(); - } - self.push_block(TerminalBlockDto::User(TerminalUserBlockDto { - id: block_id.to_string(), - turn_id: Some(turn_id.to_string()), - markdown: content.to_string(), - })) - } - - fn append_markdown_streaming_block( - &mut self, - turn_id: &str, - delta: &str, - kind: BlockKind, - ) -> Vec { - let block_id = self - .turn_blocks - .entry(turn_id.to_string()) - .or_default() - .current_or_next_block_id(turn_id, kind); - if let Some(index) = self.block_index.get(&block_id).copied() { - self.append_markdown(index, delta); - return vec![TerminalDeltaDto::PatchBlock { - block_id, - patch: TerminalBlockPatchDto::AppendMarkdown { - markdown: delta.to_string(), - }, - }]; - } - - let block = match kind { - BlockKind::Thinking => TerminalBlockDto::Thinking(TerminalThinkingBlockDto { - id: block_id.clone(), - turn_id: Some(turn_id.to_string()), - status: TerminalBlockStatusDto::Streaming, - markdown: delta.to_string(), - }), - BlockKind::Assistant => TerminalBlockDto::Assistant(TerminalAssistantBlockDto { - id: block_id, - turn_id: Some(turn_id.to_string()), - status: TerminalBlockStatusDto::Streaming, - markdown: delta.to_string(), - }), - }; - self.push_block(block) - } - - fn finalize_assistant_block( - &mut self, - turn_id: &str, - content: &str, - reasoning_content: Option<&str>, - ) -> Vec { - let (assistant_id, thinking_id) = { - let turn_refs = self.turn_blocks.entry(turn_id.to_string()).or_default(); - ( - turn_refs.block_id_for_finalize(turn_id, BlockKind::Assistant), - reasoning_content - .filter(|value| !value.trim().is_empty()) - .map(|_| turn_refs.block_id_for_finalize(turn_id, BlockKind::Thinking)), - ) - }; - let mut deltas = Vec::new(); - - if let (Some(reasoning_content), Some(thinking_id)) = ( - reasoning_content.filter(|value| !value.trim().is_empty()), - thinking_id, - ) { - deltas.extend(self.ensure_full_markdown_block( - &thinking_id, - turn_id, - reasoning_content, - BlockKind::Thinking, - )); - if let Some(delta) = self.complete_block(&thinking_id, TerminalBlockStatusDto::Complete) - { - deltas.push(delta); - } - } - - deltas.extend(self.ensure_full_markdown_block( - &assistant_id, - turn_id, - content, - BlockKind::Assistant, - )); - if let Some(delta) = self.complete_block(&assistant_id, TerminalBlockStatusDto::Complete) { - deltas.push(delta); - } - deltas - } - - fn ensure_full_markdown_block( - &mut self, - block_id: &str, - turn_id: &str, - content: &str, - kind: BlockKind, - ) -> Vec { - if let Some(index) = self.block_index.get(block_id).copied() { - let existing = self.block_markdown(index); - self.replace_markdown(index, content); - if content.starts_with(&existing) { - let suffix = &content[existing.len()..]; - if suffix.is_empty() { - return Vec::new(); - } - return vec![TerminalDeltaDto::PatchBlock { - block_id: block_id.to_string(), - patch: TerminalBlockPatchDto::AppendMarkdown { - markdown: suffix.to_string(), - }, - }]; - } - return vec![TerminalDeltaDto::PatchBlock { - block_id: block_id.to_string(), - patch: TerminalBlockPatchDto::ReplaceMarkdown { - markdown: content.to_string(), - }, - }]; - } - - let block = match kind { - BlockKind::Thinking => TerminalBlockDto::Thinking(TerminalThinkingBlockDto { - id: block_id.to_string(), - turn_id: Some(turn_id.to_string()), - status: TerminalBlockStatusDto::Streaming, - markdown: content.to_string(), - }), - BlockKind::Assistant => TerminalBlockDto::Assistant(TerminalAssistantBlockDto { - id: block_id.to_string(), - turn_id: Some(turn_id.to_string()), - status: TerminalBlockStatusDto::Streaming, - markdown: content.to_string(), - }), - }; - self.push_block(block) - } - - fn start_tool_call( - &mut self, - turn_id: &str, - tool_call_id: &str, - tool_name: &str, - input: Option<&Value>, - source: ProjectionSource, - ) -> Vec { - let block_id = format!("tool:{tool_call_id}:call"); - let refs = self - .tool_blocks - .entry(tool_call_id.to_string()) - .or_default(); - refs.turn_id = Some(turn_id.to_string()); - refs.call = Some(block_id.clone()); - if self.block_index.contains_key(&block_id) { - return Vec::new(); - } - let turn_refs = self.turn_blocks.entry(turn_id.to_string()).or_default(); - if source.is_live() { - turn_refs.split_after_live_tool_boundary(); - } else { - turn_refs.split_after_durable_tool_boundary(); - } - self.push_block(TerminalBlockDto::ToolCall(TerminalToolCallBlockDto { - id: block_id, - turn_id: Some(turn_id.to_string()), - tool_call_id: Some(tool_call_id.to_string()), - tool_name: tool_name.to_string(), - status: TerminalBlockStatusDto::Streaming, - input: input.cloned(), - summary: None, - metadata: None, - })) - } - - fn append_tool_stream( - &mut self, - turn_id: &str, - tool_call_id: &str, - tool_name: &str, - stream: ToolOutputStream, - delta: &str, - source: ProjectionSource, - ) -> Vec { - let mut deltas = self.start_tool_call(turn_id, tool_call_id, tool_name, None, source); - let refs = self - .tool_blocks - .entry(tool_call_id.to_string()) - .or_default(); - let chunk = refs.reconcile_tool_chunk(stream, delta, source); - if chunk.is_empty() { - return deltas; - } - let block_id = match stream { - ToolOutputStream::Stdout => refs - .stdout - .get_or_insert_with(|| format!("tool:{tool_call_id}:stdout")) - .clone(), - ToolOutputStream::Stderr => refs - .stderr - .get_or_insert_with(|| format!("tool:{tool_call_id}:stderr")) - .clone(), - }; - if let Some(index) = self.block_index.get(&block_id).copied() { - self.append_tool_stream_content(index, &chunk); - deltas.push(TerminalDeltaDto::PatchBlock { - block_id, - patch: TerminalBlockPatchDto::AppendToolStream { - stream: to_stream_dto(stream), - chunk, - }, - }); - return deltas; - } - deltas.extend( - self.push_block(TerminalBlockDto::ToolStream(TerminalToolStreamBlockDto { - id: block_id, - parent_tool_call_id: Some(tool_call_id.to_string()), - stream: to_stream_dto(stream), - status: TerminalBlockStatusDto::Streaming, - content: chunk, - })), - ); - deltas - } - - fn complete_tool_call( - &mut self, - turn_id: &str, - result: &ToolExecutionResult, - source: ProjectionSource, - ) -> Vec { - let mut deltas = self.start_tool_call( - turn_id, - &result.tool_call_id, - &result.tool_name, - None, - source, - ); - let status = if result.ok { - TerminalBlockStatusDto::Complete - } else { - TerminalBlockStatusDto::Failed - }; - let summary = tool_result_summary(result); - let refs = self - .tool_blocks - .entry(result.tool_call_id.clone()) - .or_default(); - if source.is_durable() { - refs.pending_live_stdout_bytes = 0; - refs.pending_live_stderr_bytes = 0; - } - let refs = refs.clone(); - - if let Some(call_block_id) = refs.call { - if let Some(index) = self.block_index.get(&call_block_id).copied() { - if self.replace_tool_summary(index, &summary) { - deltas.push(TerminalDeltaDto::PatchBlock { - block_id: call_block_id.clone(), - patch: TerminalBlockPatchDto::ReplaceSummary { - summary: summary.clone(), - }, - }); - } - if let Some(metadata) = &result.metadata { - if self.replace_tool_metadata(index, metadata) { - deltas.push(TerminalDeltaDto::PatchBlock { - block_id: call_block_id.clone(), - patch: TerminalBlockPatchDto::ReplaceMetadata { - metadata: metadata.clone(), - }, - }); - } - } - if let Some(delta) = self.complete_block(&call_block_id, status) { - deltas.push(delta); - } - } - } - - if let Some(stdout) = refs.stdout { - if let Some(delta) = self.complete_block(&stdout, status) { - deltas.push(delta); - } - } - if let Some(stderr) = refs.stderr { - if let Some(delta) = self.complete_block(&stderr, status) { - deltas.push(delta); - } - } - - deltas - } - - fn append_system_note( - &mut self, - block_id: &str, - note_kind: TerminalSystemNoteKindDto, - markdown: &str, - ) -> Vec { - if self.block_index.contains_key(block_id) { - return Vec::new(); - } - self.push_block(TerminalBlockDto::SystemNote(TerminalSystemNoteBlockDto { - id: block_id.to_string(), - note_kind, - markdown: markdown.to_string(), - })) - } - - fn append_child_handoff( - &mut self, - notification: &astrcode_core::ChildSessionNotification, - ) -> Vec { - let block_id = format!("child:{}", notification.notification_id); - if self.block_index.contains_key(&block_id) { - return Vec::new(); - } - let child = self - .child_lookup - .get(¬ification.child_ref.open_session_id) - .cloned() - .unwrap_or_else(|| TerminalChildSummaryDto { - child_session_id: notification.child_ref.open_session_id.clone(), - child_agent_id: notification.child_ref.agent_id.clone(), - title: notification.child_ref.open_session_id.clone(), - lifecycle: to_lifecycle_dto(notification.status), - latest_output_summary: notification - .delivery - .as_ref() - .map(|delivery| delivery.payload.message().to_string()), - child_ref: Some(to_child_ref_dto(notification.child_ref.clone())), - }); - self.push_block(TerminalBlockDto::ChildHandoff( - TerminalChildHandoffBlockDto { - id: block_id, - handoff_kind: match notification.kind { - astrcode_core::ChildSessionNotificationKind::Started - | astrcode_core::ChildSessionNotificationKind::Resumed => { - TerminalChildHandoffKindDto::Delegated - }, - astrcode_core::ChildSessionNotificationKind::ProgressSummary - | astrcode_core::ChildSessionNotificationKind::Waiting => { - TerminalChildHandoffKindDto::Progress - }, - astrcode_core::ChildSessionNotificationKind::Delivered - | astrcode_core::ChildSessionNotificationKind::Closed - | astrcode_core::ChildSessionNotificationKind::Failed => { - TerminalChildHandoffKindDto::Returned - }, - }, - child, - message: notification - .delivery - .as_ref() - .map(|delivery| delivery.payload.message().to_string()), - }, - )) - } - - fn append_error( - &mut self, - turn_id: Option<&str>, - code: &str, - message: &str, - ) -> Vec { - if code == "interrupted" { - return Vec::new(); - } - let block_id = format!("turn:{}:error", turn_id.unwrap_or("session")); - if self.block_index.contains_key(&block_id) { - return Vec::new(); - } - self.push_block(TerminalBlockDto::Error(TerminalErrorBlockDto { - id: block_id, - turn_id: turn_id.map(ToString::to_string), - code: classify_transcript_error(message), - message: message.to_string(), - })) - } - - fn complete_turn(&mut self, turn_id: &str) -> Vec { - let Some(refs) = self.turn_blocks.get(turn_id).cloned() else { - return Vec::new(); - }; - let mut deltas = Vec::new(); - for block_id in refs.all_block_ids() { - if let Some(delta) = self.complete_block(&block_id, TerminalBlockStatusDto::Complete) { - deltas.push(delta); - } - } - let tool_blocks = self - .tool_blocks - .values() - .filter(|tool| tool.turn_id.as_deref() == Some(turn_id)) - .cloned() - .collect::>(); - for tool in tool_blocks { - if let Some(call) = &tool.call { - if let Some(delta) = self.complete_block(call, TerminalBlockStatusDto::Complete) { - deltas.push(delta); - } - } - if let Some(stdout) = &tool.stdout { - if let Some(delta) = self.complete_block(stdout, TerminalBlockStatusDto::Complete) { - deltas.push(delta); - } - } - if let Some(stderr) = &tool.stderr { - if let Some(delta) = self.complete_block(stderr, TerminalBlockStatusDto::Complete) { - deltas.push(delta); - } - } - } - deltas - } - - fn push_block(&mut self, block: TerminalBlockDto) -> Vec { - let id = block_id(&block).to_string(); - self.block_index.insert(id, self.blocks.len()); - self.blocks.push(block.clone()); - vec![TerminalDeltaDto::AppendBlock { block }] - } - - fn complete_block( - &mut self, - block_id: &str, - status: TerminalBlockStatusDto, - ) -> Option { - if let Some(index) = self.block_index.get(block_id).copied() { - if self.block_status(index) == Some(status) { - return None; - } - self.set_status(index, status); - return Some(TerminalDeltaDto::CompleteBlock { - block_id: block_id.to_string(), - status, - }); - } - None - } - - fn append_markdown(&mut self, index: usize, markdown: &str) { - match &mut self.blocks[index] { - TerminalBlockDto::Thinking(block) => block.markdown.push_str(markdown), - TerminalBlockDto::Assistant(block) => block.markdown.push_str(markdown), - _ => {}, - } - } - - fn replace_markdown(&mut self, index: usize, markdown: &str) { - match &mut self.blocks[index] { - TerminalBlockDto::Thinking(block) => block.markdown = markdown.to_string(), - TerminalBlockDto::Assistant(block) => block.markdown = markdown.to_string(), - _ => {}, - } - } - - fn append_tool_stream_content(&mut self, index: usize, chunk: &str) { - if let TerminalBlockDto::ToolStream(block) = &mut self.blocks[index] { - block.content.push_str(chunk); - } - } - - fn replace_tool_summary(&mut self, index: usize, summary: &str) -> bool { - if let TerminalBlockDto::ToolCall(block) = &mut self.blocks[index] { - if block.summary.as_deref() == Some(summary) { - return false; - } - block.summary = Some(summary.to_string()); - return true; - } - false - } - - fn replace_tool_metadata(&mut self, index: usize, metadata: &Value) -> bool { - if let TerminalBlockDto::ToolCall(block) = &mut self.blocks[index] { - if block.metadata.as_ref() == Some(metadata) { - return false; - } - block.metadata = Some(metadata.clone()); - return true; - } - false - } - - fn set_status(&mut self, index: usize, status: TerminalBlockStatusDto) { - match &mut self.blocks[index] { - TerminalBlockDto::Thinking(block) => block.status = status, - TerminalBlockDto::Assistant(block) => block.status = status, - TerminalBlockDto::ToolCall(block) => block.status = status, - TerminalBlockDto::ToolStream(block) => block.status = status, - _ => {}, - } - } - - fn block_markdown(&self, index: usize) -> String { - match &self.blocks[index] { - TerminalBlockDto::Thinking(block) => block.markdown.clone(), - TerminalBlockDto::Assistant(block) => block.markdown.clone(), - _ => String::new(), - } - } - - fn block_status(&self, index: usize) -> Option { - match &self.blocks[index] { - TerminalBlockDto::Thinking(block) => Some(block.status), - TerminalBlockDto::Assistant(block) => Some(block.status), - TerminalBlockDto::ToolCall(block) => Some(block.status), - TerminalBlockDto::ToolStream(block) => Some(block.status), - _ => None, - } - } -} - -#[derive(Clone, Copy)] -enum ProjectionSource { - Durable, - Live, -} - -impl ProjectionSource { - fn is_durable(self) -> bool { - matches!(self, Self::Durable) - } - - fn is_live(self) -> bool { - matches!(self, Self::Live) - } -} - -fn block_id(block: &TerminalBlockDto) -> &str { - match block { - TerminalBlockDto::User(block) => &block.id, - TerminalBlockDto::Assistant(block) => &block.id, - TerminalBlockDto::Thinking(block) => &block.id, - TerminalBlockDto::ToolCall(block) => &block.id, - TerminalBlockDto::ToolStream(block) => &block.id, - TerminalBlockDto::Error(block) => &block.id, - TerminalBlockDto::SystemNote(block) => &block.id, - TerminalBlockDto::ChildHandoff(block) => &block.id, - } -} - -fn tool_result_summary(result: &ToolExecutionResult) -> String { - if result.ok { - if !result.output.trim().is_empty() { - truncate_terminal_summary(&result.output) - } else { - format!("{} completed", result.tool_name) - } - } else if let Some(error) = &result.error { - truncate_terminal_summary(error) - } else if !result.output.trim().is_empty() { - truncate_terminal_summary(&result.output) - } else { - format!("{} failed", result.tool_name) - } -} - -fn classify_transcript_error(message: &str) -> TerminalTranscriptErrorCodeDto { - let lower = message.to_lowercase(); - if lower.contains("context window") || lower.contains("token limit") { - TerminalTranscriptErrorCodeDto::ContextWindowExceeded - } else if lower.contains("rate limit") { - TerminalTranscriptErrorCodeDto::RateLimit - } else if lower.contains("tool") { - TerminalTranscriptErrorCodeDto::ToolFatal - } else { - TerminalTranscriptErrorCodeDto::ProviderError +fn to_block_status_dto(status: ConversationBlockStatus) -> ConversationBlockStatusDto { + match status { + ConversationBlockStatus::Streaming => ConversationBlockStatusDto::Streaming, + ConversationBlockStatus::Complete => ConversationBlockStatusDto::Complete, + ConversationBlockStatus::Failed => ConversationBlockStatusDto::Failed, + ConversationBlockStatus::Cancelled => ConversationBlockStatusDto::Cancelled, } } -#[cfg(test)] -mod tests { - use astrcode_application::{ - ComposerOptionKind, SessionReplay, SessionTranscriptSnapshot, TerminalChildSummaryFacts, - TerminalControlFacts, TerminalFacts, TerminalRehydrateFacts, TerminalRehydrateReason, - TerminalSlashAction, TerminalSlashCandidateFacts, TerminalStreamReplayFacts, - }; - use astrcode_core::{ - AgentEventContext, AgentLifecycleStatus, ChildSessionLineageKind, ChildSessionNode, - ChildSessionNotification, ChildSessionNotificationKind, ChildSessionStatusSource, - CompactTrigger, ParentDelivery, ParentDeliveryOrigin, ParentDeliveryPayload, - ParentDeliveryTerminalSemantics, Phase, ProgressParentDeliveryPayload, SessionEventRecord, - ToolExecutionResult, ToolOutputStream, - }; - use serde_json::json; - use tokio::sync::broadcast; - - use super::{ - AgentEvent, TerminalDeltaProjector, TerminalTranscriptErrorCodeDto, - classify_transcript_error, project_terminal_control_delta, - project_terminal_rehydrate_banner, project_terminal_snapshot, - project_terminal_stream_replay, - }; - - #[test] - fn project_terminal_snapshot_freezes_terminal_block_mapping() { - let snapshot = project_terminal_snapshot(&sample_terminal_facts()); - - assert_eq!( - serde_json::to_value(snapshot).expect("snapshot should encode"), - json!({ - "sessionId": "session-root", - "sessionTitle": "Terminal session", - "cursor": "1.11", - "phase": "streaming", - "control": { - "phase": "streaming", - "canSubmitPrompt": false, - "canRequestCompact": true, - "compactPending": false, - "activeTurnId": "turn-1" - }, - "blocks": [ - { - "kind": "user", - "id": "turn:turn-1:user", - "turnId": "turn-1", - "markdown": "实现 terminal v1" - }, - { - "kind": "thinking", - "id": "turn:turn-1:thinking", - "turnId": "turn-1", - "status": "complete", - "markdown": "先整理协议" - }, - { - "kind": "assistant", - "id": "turn:turn-1:assistant", - "turnId": "turn-1", - "status": "complete", - "markdown": "正在实现 terminal v1。" - }, - { - "kind": "tool_call", - "id": "tool:call-1:call", - "turnId": "turn-1", - "toolCallId": "call-1", - "toolName": "shell_command", - "status": "complete", - "input": { - "command": "rg terminal" - }, - "summary": "read files" - }, - { - "kind": "tool_stream", - "id": "tool:call-1:stdout", - "parentToolCallId": "call-1", - "stream": "stdout", - "status": "complete", - "content": "read files" - }, - { - "kind": "system_note", - "id": "system:compact:turn-1", - "noteKind": "compact", - "markdown": "已压缩上下文" - }, - { - "kind": "child_handoff", - "id": "child:child-note-1", - "handoffKind": "returned", - "child": child_summary_json("子任务已完成"), - "message": "子任务已完成" - }, - { - "kind": "error", - "id": "turn:turn-1:error", - "turnId": "turn-1", - "code": "rate_limit", - "message": "provider rate limit on this turn" - } - ], - "childSummaries": [child_summary_json("子任务已完成")], - "slashCandidates": [ - { - "id": "slash-compact", - "title": "/compact", - "description": "压缩当前会话", - "keywords": ["compact", "summary"], - "actionKind": "execute_command", - "actionValue": "/compact" - }, - { - "id": "slash-skill-review", - "title": "Review skill", - "description": "插入 review skill", - "keywords": ["skill", "review"], - "actionKind": "insert_text", - "actionValue": "/skill review" - } - ] - }) - ); - } - - #[test] - fn project_terminal_stream_replay_freezes_patch_and_completion_semantics() { - let deltas = project_terminal_stream_replay(&sample_stream_replay_facts(), Some("1.2")); - - assert_eq!( - serde_json::to_value(deltas).expect("stream deltas should encode"), - json!([ - { - "sessionId": "session-root", - "cursor": "1.3", - "kind": "patch_block", - "blockId": "turn:turn-1:thinking", - "patch": { - "kind": "append_markdown", - "markdown": "整理" - } - }, - { - "sessionId": "session-root", - "cursor": "1.4", - "kind": "append_block", - "block": { - "kind": "assistant", - "id": "turn:turn-1:assistant", - "turnId": "turn-1", - "status": "streaming", - "markdown": "执行中" - } - }, - { - "sessionId": "session-root", - "cursor": "1.5", - "kind": "append_block", - "block": { - "kind": "tool_call", - "id": "tool:call-1:call", - "turnId": "turn-1", - "toolCallId": "call-1", - "toolName": "shell_command", - "status": "streaming", - "input": { - "command": "pwd" - } - } - }, - { - "sessionId": "session-root", - "cursor": "1.6", - "kind": "append_block", - "block": { - "kind": "tool_stream", - "id": "tool:call-1:stdout", - "parentToolCallId": "call-1", - "stream": "stdout", - "status": "streaming", - "content": "line 1\n" - } - }, - { - "sessionId": "session-root", - "cursor": "1.7", - "kind": "patch_block", - "blockId": "tool:call-1:call", - "patch": { - "kind": "replace_summary", - "summary": "line 1" - } - }, - { - "sessionId": "session-root", - "cursor": "1.7", - "kind": "complete_block", - "blockId": "tool:call-1:call", - "status": "complete" - }, - { - "sessionId": "session-root", - "cursor": "1.7", - "kind": "complete_block", - "blockId": "tool:call-1:stdout", - "status": "complete" - }, - { - "sessionId": "session-root", - "cursor": "1.8", - "kind": "append_block", - "block": { - "kind": "child_handoff", - "id": "child:child-note-2", - "handoffKind": "progress", - "child": child_summary_json("子任务进行中"), - "message": "子任务进行中" - } - }, - { - "sessionId": "session-root", - "cursor": "1.9", - "kind": "append_block", - "block": { - "kind": "error", - "id": "turn:turn-1:error", - "turnId": "turn-1", - "code": "tool_fatal", - "message": "tool fatal: shell exited" - } - }, - { - "sessionId": "session-root", - "cursor": "1.10", - "kind": "complete_block", - "blockId": "turn:turn-1:thinking", - "status": "complete" - }, - { - "sessionId": "session-root", - "cursor": "1.10", - "kind": "complete_block", - "blockId": "turn:turn-1:assistant", - "status": "complete" - } - ]) - ); - } - - #[test] - fn finalize_assistant_block_emits_replace_markdown_when_final_content_diverges() { - let mut projector = TerminalDeltaProjector::default(); - let thinking_delta = record( - "1.1", - AgentEvent::ThinkingDelta { - turn_id: "turn-1".to_string(), - agent: sample_agent_context(), - delta: "旧前缀".to_string(), - }, - ); - projector.seed(&[thinking_delta]); - - let assistant_message = record( - "1.2", - AgentEvent::AssistantMessage { - turn_id: "turn-1".to_string(), - agent: sample_agent_context(), - content: "最终回复".to_string(), - reasoning_content: Some("全新推理".to_string()), - }, - ); - - assert_eq!( - serde_json::to_value(projector.project_record(&assistant_message)) - .expect("deltas should encode"), - json!([ - { - "kind": "patch_block", - "blockId": "turn:turn-1:thinking", - "patch": { - "kind": "replace_markdown", - "markdown": "全新推理" - } - }, - { - "kind": "complete_block", - "blockId": "turn:turn-1:thinking", - "status": "complete" - }, - { - "kind": "append_block", - "block": { - "kind": "assistant", - "id": "turn:turn-1:assistant", - "turnId": "turn-1", - "status": "streaming", - "markdown": "最终回复" - } - }, - { - "kind": "complete_block", - "blockId": "turn:turn-1:assistant", - "status": "complete" - } - ]) - ); - } - - #[test] - fn live_events_project_streaming_reasoning_and_assistant_blocks() { - let mut projector = TerminalDeltaProjector::default(); - let agent = sample_agent_context(); - - assert_eq!( - serde_json::to_value(projector.project_live_event(&AgentEvent::ThinkingDelta { - turn_id: "turn-1".to_string(), - agent: agent.clone(), - delta: "先".to_string(), - })) - .expect("thinking live deltas should encode"), - json!([ - { - "kind": "append_block", - "block": { - "kind": "thinking", - "id": "turn:turn-1:thinking", - "turnId": "turn-1", - "status": "streaming", - "markdown": "先" - } - } - ]) - ); - - assert_eq!( - serde_json::to_value(projector.project_live_event(&AgentEvent::ThinkingDelta { - turn_id: "turn-1".to_string(), - agent: agent.clone(), - delta: "整理协议".to_string(), - })) - .expect("thinking append deltas should encode"), - json!([ - { - "kind": "patch_block", - "blockId": "turn:turn-1:thinking", - "patch": { - "kind": "append_markdown", - "markdown": "整理协议" - } - } - ]) - ); - - assert_eq!( - serde_json::to_value(projector.project_live_event(&AgentEvent::ModelDelta { - turn_id: "turn-1".to_string(), - agent, - delta: "正在输出".to_string(), - })) - .expect("assistant live deltas should encode"), - json!([ - { - "kind": "append_block", - "block": { - "kind": "assistant", - "id": "turn:turn-1:assistant", - "turnId": "turn-1", - "status": "streaming", - "markdown": "正在输出" - } - } - ]) - ); - } - - #[test] - fn live_tool_streams_render_immediately_and_durable_replay_does_not_duplicate_chunks() { - let mut projector = TerminalDeltaProjector::default(); - let agent = sample_agent_context(); - - assert_eq!( - serde_json::to_value(projector.project_live_event(&AgentEvent::ToolCallStart { - turn_id: "turn-1".to_string(), - agent: agent.clone(), - tool_call_id: "call-1".to_string(), - tool_name: "web".to_string(), - input: serde_json::json!({}), - })) - .expect("tool call start should encode"), - json!([ - { - "kind": "append_block", - "block": { - "kind": "tool_call", - "id": "tool:call-1:call", - "turnId": "turn-1", - "toolCallId": "call-1", - "toolName": "web", - "status": "streaming", - "input": {} - } - } - ]) - ); - - assert_eq!( - serde_json::to_value(projector.project_live_event(&AgentEvent::ToolCallDelta { - turn_id: "turn-1".to_string(), - agent: agent.clone(), - tool_call_id: "call-1".to_string(), - tool_name: "web".to_string(), - stream: ToolOutputStream::Stdout, - delta: "first chunk\n".to_string(), - })) - .expect("tool stream delta should encode"), - json!([ - { - "kind": "append_block", - "block": { - "kind": "tool_stream", - "id": "tool:call-1:stdout", - "parentToolCallId": "call-1", - "stream": "stdout", - "status": "streaming", - "content": "first chunk\n" - } - } - ]) - ); - - assert_eq!( - serde_json::to_value(projector.project_live_event(&AgentEvent::ToolCallResult { - turn_id: "turn-1".to_string(), - agent: sample_agent_context(), - result: ToolExecutionResult { - tool_call_id: "call-1".to_string(), - tool_name: "web".to_string(), - ok: true, - output: "first chunk\n".to_string(), - error: None, - metadata: None, - duration_ms: 0, - truncated: false, - }, - })) - .expect("live tool result should encode"), - json!([ - { - "kind": "patch_block", - "blockId": "tool:call-1:call", - "patch": { - "kind": "replace_summary", - "summary": "first chunk" - } - }, - { - "kind": "complete_block", - "blockId": "tool:call-1:call", - "status": "complete" - }, - { - "kind": "complete_block", - "blockId": "tool:call-1:stdout", - "status": "complete" - } - ]) - ); - - assert_eq!( - serde_json::to_value(projector.project_record(&record( - "1.1", - AgentEvent::ToolCallStart { - turn_id: "turn-1".to_string(), - agent: sample_agent_context(), - tool_call_id: "call-1".to_string(), - tool_name: "web".to_string(), - input: serde_json::json!({}), - } - ))) - .expect("durable tool call start should encode"), - json!([]) - ); - - assert_eq!( - serde_json::to_value(projector.project_record(&record( - "1.2", - AgentEvent::ToolCallDelta { - turn_id: "turn-1".to_string(), - agent: sample_agent_context(), - tool_call_id: "call-1".to_string(), - tool_name: "web".to_string(), - stream: ToolOutputStream::Stdout, - delta: "first chunk\n".to_string(), - } - ))) - .expect("durable delta after live completion should still skip duplicated chunk"), - json!([]) - ); - - assert_eq!( - serde_json::to_value(projector.project_record(&record( - "1.3", - AgentEvent::ToolCallResult { - turn_id: "turn-1".to_string(), - agent: sample_agent_context(), - result: ToolExecutionResult { - tool_call_id: "call-1".to_string(), - tool_name: "web".to_string(), - ok: true, - output: "first chunk\n".to_string(), - error: None, - metadata: None, - duration_ms: 0, - truncated: false, - }, - } - ))) - .expect("durable result should collapse to no-op after matching live completion"), - json!([]) - ); - } - - #[test] - fn durable_multi_step_turn_keeps_final_assistant_after_tool_blocks() { - let mut projector = TerminalDeltaProjector::default(); - let agent = sample_agent_context(); - - projector.seed(&[ - record( - "1.1", - AgentEvent::AssistantMessage { - turn_id: "turn-1".to_string(), - agent: agent.clone(), - content: "好的,让我先浏览一下项目。".to_string(), - reasoning_content: None, - }, - ), - record( - "1.2", - AgentEvent::ToolCallStart { - turn_id: "turn-1".to_string(), - agent: agent.clone(), - tool_call_id: "call-1".to_string(), - tool_name: "listDir".to_string(), - input: json!({ "path": "." }), - }, - ), - record( - "1.3", - AgentEvent::ToolCallResult { - turn_id: "turn-1".to_string(), - agent: agent.clone(), - result: ToolExecutionResult { - tool_call_id: "call-1".to_string(), - tool_name: "listDir".to_string(), - ok: true, - output: "[{\"name\":\"crates\"}]".to_string(), - error: None, - metadata: None, - duration_ms: 1, - truncated: false, - }, - }, - ), - record( - "1.4", - AgentEvent::AssistantMessage { - turn_id: "turn-1".to_string(), - agent, - content: "现在我对项目有了全面的了解。".to_string(), - reasoning_content: None, - }, - ), - ]); - - assert_eq!( - serde_json::to_value(&projector.blocks).expect("blocks should encode"), - json!([ - { - "kind": "assistant", - "id": "turn:turn-1:assistant", - "turnId": "turn-1", - "status": "complete", - "markdown": "好的,让我先浏览一下项目。" - }, - { - "kind": "tool_call", - "id": "tool:call-1:call", - "turnId": "turn-1", - "toolCallId": "call-1", - "toolName": "listDir", - "status": "complete", - "input": { - "path": "." - }, - "summary": "[{\"name\":\"crates\"}]" - }, - { - "kind": "assistant", - "id": "turn:turn-1:assistant:2", - "turnId": "turn-1", - "status": "complete", - "markdown": "现在我对项目有了全面的了解。" - } - ]) - ); - } - - #[test] - fn classify_transcript_error_covers_all_supported_buckets() { - assert_eq!( - classify_transcript_error("context window exceeded"), - TerminalTranscriptErrorCodeDto::ContextWindowExceeded - ); - assert_eq!( - classify_transcript_error("provider rate limit hit"), - TerminalTranscriptErrorCodeDto::RateLimit - ); - assert_eq!( - classify_transcript_error("tool process exited with failure"), - TerminalTranscriptErrorCodeDto::ToolFatal - ); - assert_eq!( - classify_transcript_error("unexpected provider response"), - TerminalTranscriptErrorCodeDto::ProviderError - ); - } - - #[test] - fn project_terminal_control_and_rehydrate_errors_use_terminal_contract() { - let control_delta = project_terminal_control_delta(&TerminalControlFacts { - phase: Phase::Idle, - active_turn_id: None, - manual_compact_pending: true, - }); - assert_eq!( - serde_json::to_value(control_delta).expect("control delta should encode"), - json!({ - "kind": "update_control_state", - "control": { - "phase": "idle", - "canSubmitPrompt": true, - "canRequestCompact": false, - "compactPending": true - } - }) - ); - - let banner = project_terminal_rehydrate_banner(&TerminalRehydrateFacts { - session_id: "session-root".to_string(), - requested_cursor: "1.2".to_string(), - latest_cursor: Some("1.10".to_string()), - reason: TerminalRehydrateReason::CursorExpired, - }); - assert_eq!( - serde_json::to_value(banner).expect("banner should encode"), - json!({ - "error": { - "code": "cursor_expired", - "message": "cursor '1.2' is no longer valid for session 'session-root'", - "rehydrateRequired": true, - "details": { - "requestedCursor": "1.2", - "latestCursor": "1.10", - "reason": "CursorExpired" - } - } - }) - ); - } - - fn sample_terminal_facts() -> TerminalFacts { - TerminalFacts { - active_session_id: "session-root".to_string(), - session_title: "Terminal session".to_string(), - transcript: SessionTranscriptSnapshot { - records: vec![ - record( - "1.1", - AgentEvent::UserMessage { - turn_id: "turn-1".to_string(), - agent: sample_agent_context(), - content: "实现 terminal v1".to_string(), - }, - ), - record( - "1.2", - AgentEvent::ThinkingDelta { - turn_id: "turn-1".to_string(), - agent: sample_agent_context(), - delta: "先整理协议".to_string(), - }, - ), - record( - "1.3", - AgentEvent::ModelDelta { - turn_id: "turn-1".to_string(), - agent: sample_agent_context(), - delta: "正在实现".to_string(), - }, - ), - record( - "1.4", - AgentEvent::AssistantMessage { - turn_id: "turn-1".to_string(), - agent: sample_agent_context(), - content: "正在实现 terminal v1。".to_string(), - reasoning_content: Some("先整理协议".to_string()), - }, - ), - record( - "1.5", - AgentEvent::ToolCallStart { - turn_id: "turn-1".to_string(), - agent: sample_agent_context(), - tool_call_id: "call-1".to_string(), - tool_name: "shell_command".to_string(), - input: json!({ "command": "rg terminal" }), - }, - ), - record( - "1.6", - AgentEvent::ToolCallDelta { - turn_id: "turn-1".to_string(), - agent: sample_agent_context(), - tool_call_id: "call-1".to_string(), - tool_name: "shell_command".to_string(), - stream: ToolOutputStream::Stdout, - delta: "read files".to_string(), - }, - ), - record( - "1.7", - AgentEvent::ToolCallResult { - turn_id: "turn-1".to_string(), - agent: sample_agent_context(), - result: ToolExecutionResult { - tool_call_id: "call-1".to_string(), - tool_name: "shell_command".to_string(), - ok: true, - output: "read files".to_string(), - error: None, - metadata: None, - duration_ms: 12, - truncated: false, - }, - }, - ), - record( - "1.8", - AgentEvent::CompactApplied { - turn_id: Some("turn-1".to_string()), - agent: sample_agent_context(), - trigger: CompactTrigger::Manual, - summary: "已压缩上下文".to_string(), - preserved_recent_turns: 4, - }, - ), - record( - "1.9", - AgentEvent::ChildSessionNotification { - turn_id: Some("turn-1".to_string()), - agent: sample_agent_context(), - notification: sample_child_notification( - "child-note-1", - ChildSessionNotificationKind::Delivered, - AgentLifecycleStatus::Idle, - "子任务已完成", - ), - }, - ), - record( - "1.10", - AgentEvent::Error { - turn_id: Some("turn-1".to_string()), - agent: sample_agent_context(), - code: "provider_error".to_string(), - message: "provider rate limit on this turn".to_string(), - }, - ), - record( - "1.11", - AgentEvent::TurnDone { - turn_id: "turn-1".to_string(), - agent: sample_agent_context(), - }, - ), - ], - cursor: Some("1.11".to_string()), - phase: Phase::Streaming, - }, - control: TerminalControlFacts { - phase: Phase::Streaming, - active_turn_id: Some("turn-1".to_string()), - manual_compact_pending: false, - }, - child_summaries: vec![sample_child_summary_facts("子任务已完成")], - slash_candidates: vec![ - TerminalSlashCandidateFacts { - kind: ComposerOptionKind::Command, - id: "slash-compact".to_string(), - title: "/compact".to_string(), - description: "压缩当前会话".to_string(), - keywords: vec!["compact".to_string(), "summary".to_string()], - badges: Vec::new(), - action: TerminalSlashAction::RequestCompact, - }, - TerminalSlashCandidateFacts { - kind: ComposerOptionKind::Skill, - id: "slash-skill-review".to_string(), - title: "Review skill".to_string(), - description: "插入 review skill".to_string(), - keywords: vec!["skill".to_string(), "review".to_string()], - badges: Vec::new(), - action: TerminalSlashAction::InsertText { - text: "/skill review".to_string(), - }, - }, - ], - } - } - - fn sample_stream_replay_facts() -> TerminalStreamReplayFacts { - let (_, receiver) = broadcast::channel(8); - let (_, live_receiver) = broadcast::channel(8); - - TerminalStreamReplayFacts { - active_session_id: "session-root".to_string(), - seed_records: vec![ - record( - "1.1", - AgentEvent::UserMessage { - turn_id: "turn-1".to_string(), - agent: sample_agent_context(), - content: "实现 terminal v1".to_string(), - }, - ), - record( - "1.2", - AgentEvent::ThinkingDelta { - turn_id: "turn-1".to_string(), - agent: sample_agent_context(), - delta: "先".to_string(), - }, - ), - ], - replay: SessionReplay { - history: vec![ - record( - "1.3", - AgentEvent::ThinkingDelta { - turn_id: "turn-1".to_string(), - agent: sample_agent_context(), - delta: "整理".to_string(), - }, - ), - record( - "1.4", - AgentEvent::ModelDelta { - turn_id: "turn-1".to_string(), - agent: sample_agent_context(), - delta: "执行中".to_string(), - }, - ), - record( - "1.5", - AgentEvent::ToolCallStart { - turn_id: "turn-1".to_string(), - agent: sample_agent_context(), - tool_call_id: "call-1".to_string(), - tool_name: "shell_command".to_string(), - input: json!({ "command": "pwd" }), - }, - ), - record( - "1.6", - AgentEvent::ToolCallDelta { - turn_id: "turn-1".to_string(), - agent: sample_agent_context(), - tool_call_id: "call-1".to_string(), - tool_name: "shell_command".to_string(), - stream: ToolOutputStream::Stdout, - delta: "line 1\n".to_string(), - }, - ), - record( - "1.7", - AgentEvent::ToolCallResult { - turn_id: "turn-1".to_string(), - agent: sample_agent_context(), - result: ToolExecutionResult { - tool_call_id: "call-1".to_string(), - tool_name: "shell_command".to_string(), - ok: true, - output: "line 1\n".to_string(), - error: None, - metadata: None, - duration_ms: 8, - truncated: false, - }, - }, - ), - record( - "1.8", - AgentEvent::ChildSessionNotification { - turn_id: Some("turn-1".to_string()), - agent: sample_agent_context(), - notification: sample_child_notification( - "child-note-2", - ChildSessionNotificationKind::Waiting, - AgentLifecycleStatus::Running, - "子任务进行中", - ), - }, - ), - record( - "1.9", - AgentEvent::Error { - turn_id: Some("turn-1".to_string()), - agent: sample_agent_context(), - code: "tool_error".to_string(), - message: "tool fatal: shell exited".to_string(), - }, - ), - record( - "1.10", - AgentEvent::TurnDone { - turn_id: "turn-1".to_string(), - agent: sample_agent_context(), - }, - ), - ], - receiver, - live_receiver, - }, - control: TerminalControlFacts { - phase: Phase::Streaming, - active_turn_id: Some("turn-1".to_string()), - manual_compact_pending: false, - }, - child_summaries: vec![sample_child_summary_facts("子任务进行中")], - slash_candidates: vec![ - TerminalSlashCandidateFacts { - kind: ComposerOptionKind::Command, - id: "slash-compact".to_string(), - title: "/compact".to_string(), - description: "压缩当前会话".to_string(), - keywords: vec!["compact".to_string(), "summary".to_string()], - badges: Vec::new(), - action: TerminalSlashAction::RequestCompact, - }, - TerminalSlashCandidateFacts { - kind: ComposerOptionKind::Skill, - id: "slash-skill-review".to_string(), - title: "Review skill".to_string(), - description: "插入 review skill".to_string(), - keywords: vec!["skill".to_string(), "review".to_string()], - badges: Vec::new(), - action: TerminalSlashAction::InsertText { - text: "/skill review".to_string(), - }, - }, - ], - } - } - - fn sample_child_summary_facts(recent_output: &str) -> TerminalChildSummaryFacts { - TerminalChildSummaryFacts { - node: sample_child_node(), - phase: Phase::Streaming, - title: Some("Repo inspector".to_string()), - display_name: Some("repo-inspector".to_string()), - recent_output: Some(recent_output.to_string()), - } - } - - fn sample_child_node() -> ChildSessionNode { - ChildSessionNode { - agent_id: "agent-child".to_string(), - session_id: "session-root".to_string(), - child_session_id: "session-child".to_string(), - sub_run_id: "subrun-child".to_string(), - parent_session_id: "session-root".to_string(), - parent_agent_id: Some("agent-root".to_string()), - parent_sub_run_id: Some("subrun-root".to_string()), - parent_turn_id: "turn-1".to_string(), - lineage_kind: ChildSessionLineageKind::Spawn, - status: AgentLifecycleStatus::Running, - status_source: ChildSessionStatusSource::Durable, - created_by_tool_call_id: Some("call-1".to_string()), - lineage_snapshot: None, - } - } - - fn sample_child_notification( - notification_id: &str, - kind: ChildSessionNotificationKind, - status: AgentLifecycleStatus, - message: &str, - ) -> ChildSessionNotification { - ChildSessionNotification { - notification_id: notification_id.to_string(), - child_ref: sample_child_node().child_ref(), - kind, - status, - source_tool_call_id: Some("call-1".to_string()), - delivery: Some(ParentDelivery { - idempotency_key: notification_id.to_string(), - origin: ParentDeliveryOrigin::Explicit, - terminal_semantics: match kind { - ChildSessionNotificationKind::Started - | ChildSessionNotificationKind::ProgressSummary - | ChildSessionNotificationKind::Waiting - | ChildSessionNotificationKind::Resumed => { - ParentDeliveryTerminalSemantics::NonTerminal - }, - ChildSessionNotificationKind::Delivered - | ChildSessionNotificationKind::Closed - | ChildSessionNotificationKind::Failed => { - ParentDeliveryTerminalSemantics::Terminal - }, - }, - source_turn_id: Some("turn-1".to_string()), - payload: ParentDeliveryPayload::Progress(ProgressParentDeliveryPayload { - message: message.to_string(), - }), - }), - } - } - - fn sample_agent_context() -> AgentEventContext { - AgentEventContext::root_execution("agent-root", "default") - } - - fn record(event_id: &str, event: AgentEvent) -> SessionEventRecord { - SessionEventRecord { - event_id: event_id.to_string(), - event, - } - } - - fn child_summary_json(latest_output_summary: &str) -> serde_json::Value { - json!({ - "childSessionId": "session-child", - "childAgentId": "agent-child", - "title": "Repo inspector", - "lifecycle": "running", - "latestOutputSummary": latest_output_summary, - "childRef": { - "agentId": "agent-child", - "sessionId": "session-root", - "subRunId": "subrun-child", - "parentAgentId": "agent-root", - "parentSubRunId": "subrun-root", - "lineageKind": "spawn", - "status": "running", - "openSessionId": "session-child" - } - }) +fn to_error_code_dto(code: ConversationTranscriptErrorKind) -> ConversationTranscriptErrorCodeDto { + match code { + ConversationTranscriptErrorKind::ProviderError => { + ConversationTranscriptErrorCodeDto::ProviderError + }, + ConversationTranscriptErrorKind::ContextWindowExceeded => { + ConversationTranscriptErrorCodeDto::ContextWindowExceeded + }, + ConversationTranscriptErrorKind::ToolFatal => ConversationTranscriptErrorCodeDto::ToolFatal, + ConversationTranscriptErrorKind::RateLimit => ConversationTranscriptErrorCodeDto::RateLimit, } } diff --git a/crates/session-runtime/src/lib.rs b/crates/session-runtime/src/lib.rs index 95eaead3..16a54aa1 100644 --- a/crates/session-runtime/src/lib.rs +++ b/crates/session-runtime/src/lib.rs @@ -37,10 +37,17 @@ pub use observe::{ SessionEventFilterSpec, SubRunEventScope, SubRunStatusSnapshot, SubRunStatusSource, }; pub use query::{ - AgentObserveSnapshot, ProjectedTurnOutcome, SessionControlStateSnapshot, SessionReplay, - SessionTranscriptSnapshot, TurnTerminalSnapshot, build_agent_observe_snapshot, - current_turn_messages, has_terminal_turn_signal, project_turn_outcome, - recoverable_parent_deliveries, + AgentObserveSnapshot, ConversationAssistantBlockFacts, ConversationBlockFacts, + ConversationBlockPatchFacts, ConversationBlockStatus, ConversationChildHandoffBlockFacts, + ConversationChildHandoffKind, ConversationDeltaFacts, ConversationDeltaFrameFacts, + ConversationDeltaProjector, ConversationErrorBlockFacts, ConversationSnapshotFacts, + ConversationStreamProjector, ConversationStreamReplayFacts, ConversationSystemNoteBlockFacts, + ConversationSystemNoteKind, ConversationThinkingBlockFacts, ConversationTranscriptErrorKind, + ConversationUserBlockFacts, ProjectedTurnOutcome, SessionControlStateSnapshot, SessionReplay, + SessionTranscriptSnapshot, ToolCallBlockFacts, ToolCallStreamsFacts, TurnTerminalSnapshot, + build_agent_observe_snapshot, build_conversation_replay_frames, current_turn_messages, + fallback_live_cursor, has_terminal_turn_signal, project_conversation_snapshot, + project_turn_outcome, recoverable_parent_deliveries, }; pub use state::{ MailboxEventAppend, SessionSnapshot, SessionState, SessionStateEventSink, SessionWriter, @@ -269,6 +276,23 @@ impl SessionRuntime { self.query().session_control_state(session_id).await } + pub async fn conversation_snapshot( + &self, + session_id: &str, + ) -> Result { + self.query().conversation_snapshot(session_id).await + } + + pub async fn conversation_stream_replay( + &self, + session_id: &str, + last_event_id: Option<&str>, + ) -> Result { + self.query() + .conversation_stream_replay(session_id, last_event_id) + .await + } + /// 返回当前 session durable 可见的 direct child lineage 节点。 pub async fn session_child_nodes(&self, session_id: &str) -> Result> { self.query().session_child_nodes(session_id).await diff --git a/crates/session-runtime/src/query/conversation.rs b/crates/session-runtime/src/query/conversation.rs new file mode 100644 index 00000000..0e9e03f0 --- /dev/null +++ b/crates/session-runtime/src/query/conversation.rs @@ -0,0 +1,1768 @@ +//! authoritative conversation / tool display 读模型。 +//! +//! Why: 工具展示的聚合语义属于单 session query 真相,不应该继续滞留在 +//! `server` route/projector 或前端 regroup 逻辑里。 + +use std::collections::HashMap; + +use astrcode_core::{ + AgentEvent, ChildAgentRef, ChildSessionNotification, ChildSessionNotificationKind, Phase, + SessionEventRecord, ToolExecutionResult, ToolOutputStream, +}; +use serde_json::Value; + +use crate::SessionReplay; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ConversationBlockStatus { + Streaming, + Complete, + Failed, + Cancelled, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ConversationSystemNoteKind { + Compact, + SystemNote, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ConversationChildHandoffKind { + Delegated, + Progress, + Returned, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ConversationTranscriptErrorKind { + ProviderError, + ContextWindowExceeded, + ToolFatal, + RateLimit, +} + +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct ToolCallStreamsFacts { + pub stdout: String, + pub stderr: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ConversationUserBlockFacts { + pub id: String, + pub turn_id: Option, + pub markdown: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ConversationAssistantBlockFacts { + pub id: String, + pub turn_id: Option, + pub status: ConversationBlockStatus, + pub markdown: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ConversationThinkingBlockFacts { + pub id: String, + pub turn_id: Option, + pub status: ConversationBlockStatus, + pub markdown: String, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct ToolCallBlockFacts { + pub id: String, + pub turn_id: Option, + pub tool_call_id: String, + pub tool_name: String, + pub status: ConversationBlockStatus, + pub input: Option, + pub summary: Option, + pub error: Option, + pub duration_ms: Option, + pub truncated: bool, + pub metadata: Option, + pub child_ref: Option, + pub streams: ToolCallStreamsFacts, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ConversationErrorBlockFacts { + pub id: String, + pub turn_id: Option, + pub code: ConversationTranscriptErrorKind, + pub message: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ConversationSystemNoteBlockFacts { + pub id: String, + pub note_kind: ConversationSystemNoteKind, + pub markdown: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ConversationChildHandoffBlockFacts { + pub id: String, + pub handoff_kind: ConversationChildHandoffKind, + pub child_ref: ChildAgentRef, + pub message: Option, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum ConversationBlockFacts { + User(ConversationUserBlockFacts), + Assistant(ConversationAssistantBlockFacts), + Thinking(ConversationThinkingBlockFacts), + ToolCall(ToolCallBlockFacts), + Error(ConversationErrorBlockFacts), + SystemNote(ConversationSystemNoteBlockFacts), + ChildHandoff(ConversationChildHandoffBlockFacts), +} + +#[derive(Debug, Clone, PartialEq)] +pub enum ConversationBlockPatchFacts { + AppendMarkdown { + markdown: String, + }, + ReplaceMarkdown { + markdown: String, + }, + AppendToolStream { + stream: ToolOutputStream, + chunk: String, + }, + ReplaceSummary { + summary: String, + }, + ReplaceMetadata { + metadata: Value, + }, + ReplaceError { + error: Option, + }, + ReplaceDuration { + duration_ms: u64, + }, + ReplaceChildRef { + child_ref: ChildAgentRef, + }, + SetTruncated { + truncated: bool, + }, + SetStatus { + status: ConversationBlockStatus, + }, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum ConversationDeltaFacts { + AppendBlock { + block: ConversationBlockFacts, + }, + PatchBlock { + block_id: String, + patch: ConversationBlockPatchFacts, + }, + CompleteBlock { + block_id: String, + status: ConversationBlockStatus, + }, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct ConversationDeltaFrameFacts { + pub cursor: String, + pub delta: ConversationDeltaFacts, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct ConversationSnapshotFacts { + pub cursor: Option, + pub phase: Phase, + pub blocks: Vec, +} + +#[derive(Debug)] +pub struct ConversationStreamReplayFacts { + pub cursor: Option, + pub phase: Phase, + pub seed_records: Vec, + pub replay_frames: Vec, + pub replay: SessionReplay, +} + +#[derive(Default)] +pub struct ConversationDeltaProjector { + blocks: Vec, + block_index: HashMap, + turn_blocks: HashMap, + tool_blocks: HashMap, +} + +#[derive(Default)] +pub struct ConversationStreamProjector { + projector: ConversationDeltaProjector, + last_sent_cursor: Option, + fallback_live_cursor: Option, +} + +#[derive(Default, Clone)] +struct TurnBlockRefs { + current_thinking: Option, + current_assistant: Option, + historical_thinking: Vec, + historical_assistant: Vec, + pending_thinking: Vec, + pending_assistant: Vec, + thinking_count: usize, + assistant_count: usize, +} + +#[derive(Default, Clone)] +struct ToolBlockRefs { + turn_id: Option, + call: Option, + pending_live_stdout_bytes: usize, + pending_live_stderr_bytes: usize, +} + +#[derive(Clone, Copy)] +enum BlockKind { + Thinking, + Assistant, +} + +#[derive(Clone, Copy)] +enum ProjectionSource { + Durable, + Live, +} + +impl ProjectionSource { + fn is_durable(self) -> bool { + matches!(self, Self::Durable) + } + + fn is_live(self) -> bool { + matches!(self, Self::Live) + } +} + +impl ToolBlockRefs { + fn reconcile_tool_chunk( + &mut self, + stream: ToolOutputStream, + delta: &str, + source: ProjectionSource, + ) -> String { + let pending_live_bytes = match stream { + ToolOutputStream::Stdout => &mut self.pending_live_stdout_bytes, + ToolOutputStream::Stderr => &mut self.pending_live_stderr_bytes, + }; + + if matches!(source, ProjectionSource::Live) { + *pending_live_bytes += delta.len(); + return delta.to_string(); + } + + if *pending_live_bytes == 0 { + return delta.to_string(); + } + + let consumed = (*pending_live_bytes).min(delta.len()); + *pending_live_bytes -= consumed; + delta[consumed..].to_string() + } +} + +impl TurnBlockRefs { + fn current_or_next_block_id(&mut self, turn_id: &str, kind: BlockKind) -> String { + match kind { + BlockKind::Thinking => { + if let Some(block_id) = &self.current_thinking { + return block_id.clone(); + } + self.thinking_count += 1; + let block_id = turn_scoped_block_id(turn_id, "thinking", self.thinking_count); + self.current_thinking = Some(block_id.clone()); + block_id + }, + BlockKind::Assistant => { + if let Some(block_id) = &self.current_assistant { + return block_id.clone(); + } + self.assistant_count += 1; + let block_id = turn_scoped_block_id(turn_id, "assistant", self.assistant_count); + self.current_assistant = Some(block_id.clone()); + block_id + }, + } + } + + fn block_id_for_finalize(&mut self, turn_id: &str, kind: BlockKind) -> String { + match kind { + BlockKind::Thinking => { + if let Some(block_id) = self.pending_thinking.first().cloned() { + self.pending_thinking.remove(0); + return block_id; + } + self.current_or_next_block_id(turn_id, kind) + }, + BlockKind::Assistant => { + if let Some(block_id) = self.pending_assistant.first().cloned() { + self.pending_assistant.remove(0); + return block_id; + } + self.current_or_next_block_id(turn_id, kind) + }, + } + } + + fn split_after_live_tool_boundary(&mut self) { + if let Some(block_id) = self.current_thinking.take() { + self.pending_thinking.push(block_id); + } + if let Some(block_id) = self.current_assistant.take() { + self.pending_assistant.push(block_id); + } + } + + fn split_after_durable_tool_boundary(&mut self) { + if let Some(block_id) = self.current_thinking.take() { + self.historical_thinking.push(block_id); + } + if let Some(block_id) = self.current_assistant.take() { + self.historical_assistant.push(block_id); + } + } + + fn all_block_ids(&self) -> Vec { + let mut ids = Vec::new(); + ids.extend(self.historical_thinking.iter().cloned()); + ids.extend(self.historical_assistant.iter().cloned()); + ids.extend(self.pending_thinking.iter().cloned()); + ids.extend(self.pending_assistant.iter().cloned()); + if let Some(block_id) = &self.current_thinking { + ids.push(block_id.clone()); + } + if let Some(block_id) = &self.current_assistant { + ids.push(block_id.clone()); + } + ids + } +} + +fn turn_scoped_block_id(turn_id: &str, role: &str, ordinal: usize) -> String { + if ordinal <= 1 { + format!("turn:{turn_id}:{role}") + } else { + format!("turn:{turn_id}:{role}:{ordinal}") + } +} + +impl ConversationDeltaProjector { + pub fn new() -> Self { + Self::default() + } + + pub fn seed(&mut self, history: &[SessionEventRecord]) { + for record in history { + let _ = self.project_record(record); + } + } + + pub fn blocks(&self) -> &[ConversationBlockFacts] { + &self.blocks + } + + pub fn into_blocks(self) -> Vec { + self.blocks + } + + pub fn project_record(&mut self, record: &SessionEventRecord) -> Vec { + self.project_event( + &record.event, + ProjectionSource::Durable, + Some(record.event_id.as_str()), + ) + } + + pub fn project_live_event(&mut self, event: &AgentEvent) -> Vec { + self.project_event(event, ProjectionSource::Live, None) + } + + fn project_event( + &mut self, + event: &AgentEvent, + source: ProjectionSource, + durable_event_id: Option<&str>, + ) -> Vec { + match event { + AgentEvent::UserMessage { + turn_id, content, .. + } if source.is_durable() => { + self.append_user_block(&format!("turn:{turn_id}:user"), turn_id, content) + }, + AgentEvent::ThinkingDelta { turn_id, delta, .. } => { + self.append_markdown_streaming_block(turn_id, delta, BlockKind::Thinking) + }, + AgentEvent::ModelDelta { turn_id, delta, .. } => { + self.append_markdown_streaming_block(turn_id, delta, BlockKind::Assistant) + }, + AgentEvent::AssistantMessage { + turn_id, + content, + reasoning_content, + .. + } if source.is_durable() => { + self.finalize_assistant_block(turn_id, content, reasoning_content.as_deref()) + }, + AgentEvent::ToolCallStart { + turn_id, + tool_call_id, + tool_name, + input, + .. + } => self.start_tool_call(turn_id, tool_call_id, tool_name, Some(input), source), + AgentEvent::ToolCallDelta { + turn_id, + tool_call_id, + tool_name, + stream, + delta, + .. + } => self.append_tool_stream(turn_id, tool_call_id, tool_name, *stream, delta, source), + AgentEvent::ToolCallResult { + turn_id, result, .. + } => self.complete_tool_call(turn_id, result, source), + AgentEvent::CompactApplied { + turn_id, summary, .. + } if source.is_durable() => { + let block_id = format!( + "system:compact:{}", + turn_id + .clone() + .or_else(|| durable_event_id.map(ToString::to_string)) + .unwrap_or_else(|| "session".to_string()) + ); + self.append_system_note(&block_id, ConversationSystemNoteKind::Compact, summary) + }, + AgentEvent::ChildSessionNotification { notification, .. } => { + self.apply_child_notification(notification, source) + }, + AgentEvent::Error { + turn_id, + code, + message, + .. + } if source.is_durable() => self.append_error(turn_id.as_deref(), code, message), + AgentEvent::TurnDone { turn_id, .. } if source.is_durable() => { + self.complete_turn(turn_id) + }, + AgentEvent::PhaseChanged { .. } + | AgentEvent::SessionStarted { .. } + | AgentEvent::PromptMetrics { .. } + | AgentEvent::SubRunStarted { .. } + | AgentEvent::SubRunFinished { .. } + | AgentEvent::AgentMailboxQueued { .. } + | AgentEvent::AgentMailboxBatchStarted { .. } + | AgentEvent::AgentMailboxBatchAcked { .. } + | AgentEvent::AgentMailboxDiscarded { .. } + | AgentEvent::UserMessage { .. } + | AgentEvent::AssistantMessage { .. } + | AgentEvent::CompactApplied { .. } + | AgentEvent::Error { .. } + | AgentEvent::TurnDone { .. } => Vec::new(), + } + } + + fn append_user_block( + &mut self, + block_id: &str, + turn_id: &str, + content: &str, + ) -> Vec { + if self.block_index.contains_key(block_id) { + return Vec::new(); + } + self.push_block(ConversationBlockFacts::User(ConversationUserBlockFacts { + id: block_id.to_string(), + turn_id: Some(turn_id.to_string()), + markdown: content.to_string(), + })) + } + + fn append_markdown_streaming_block( + &mut self, + turn_id: &str, + delta: &str, + kind: BlockKind, + ) -> Vec { + let block_id = self + .turn_blocks + .entry(turn_id.to_string()) + .or_default() + .current_or_next_block_id(turn_id, kind); + if let Some(index) = self.block_index.get(&block_id).copied() { + self.append_markdown(index, delta); + return vec![ConversationDeltaFacts::PatchBlock { + block_id, + patch: ConversationBlockPatchFacts::AppendMarkdown { + markdown: delta.to_string(), + }, + }]; + } + + let block = match kind { + BlockKind::Thinking => { + ConversationBlockFacts::Thinking(ConversationThinkingBlockFacts { + id: block_id.clone(), + turn_id: Some(turn_id.to_string()), + status: ConversationBlockStatus::Streaming, + markdown: delta.to_string(), + }) + }, + BlockKind::Assistant => { + ConversationBlockFacts::Assistant(ConversationAssistantBlockFacts { + id: block_id, + turn_id: Some(turn_id.to_string()), + status: ConversationBlockStatus::Streaming, + markdown: delta.to_string(), + }) + }, + }; + self.push_block(block) + } + + fn finalize_assistant_block( + &mut self, + turn_id: &str, + content: &str, + reasoning_content: Option<&str>, + ) -> Vec { + let (assistant_id, thinking_id) = { + let turn_refs = self.turn_blocks.entry(turn_id.to_string()).or_default(); + ( + turn_refs.block_id_for_finalize(turn_id, BlockKind::Assistant), + reasoning_content + .filter(|value| !value.trim().is_empty()) + .map(|_| turn_refs.block_id_for_finalize(turn_id, BlockKind::Thinking)), + ) + }; + let mut deltas = Vec::new(); + + if let (Some(reasoning), Some(thinking_id)) = ( + reasoning_content.filter(|value| !value.trim().is_empty()), + thinking_id, + ) { + deltas.extend(self.ensure_full_markdown_block( + &thinking_id, + turn_id, + reasoning, + BlockKind::Thinking, + )); + if let Some(delta) = + self.complete_block(&thinking_id, ConversationBlockStatus::Complete) + { + deltas.push(delta); + } + } + + deltas.extend(self.ensure_full_markdown_block( + &assistant_id, + turn_id, + content, + BlockKind::Assistant, + )); + if let Some(delta) = self.complete_block(&assistant_id, ConversationBlockStatus::Complete) { + deltas.push(delta); + } + deltas + } + + fn ensure_full_markdown_block( + &mut self, + block_id: &str, + turn_id: &str, + content: &str, + kind: BlockKind, + ) -> Vec { + if let Some(index) = self.block_index.get(block_id).copied() { + let existing = self.block_markdown(index); + self.replace_markdown(index, content); + if content.starts_with(&existing) { + let suffix = &content[existing.len()..]; + if suffix.is_empty() { + return Vec::new(); + } + return vec![ConversationDeltaFacts::PatchBlock { + block_id: block_id.to_string(), + patch: ConversationBlockPatchFacts::AppendMarkdown { + markdown: suffix.to_string(), + }, + }]; + } + return vec![ConversationDeltaFacts::PatchBlock { + block_id: block_id.to_string(), + patch: ConversationBlockPatchFacts::ReplaceMarkdown { + markdown: content.to_string(), + }, + }]; + } + + let block = match kind { + BlockKind::Thinking => { + ConversationBlockFacts::Thinking(ConversationThinkingBlockFacts { + id: block_id.to_string(), + turn_id: Some(turn_id.to_string()), + status: ConversationBlockStatus::Streaming, + markdown: content.to_string(), + }) + }, + BlockKind::Assistant => { + ConversationBlockFacts::Assistant(ConversationAssistantBlockFacts { + id: block_id.to_string(), + turn_id: Some(turn_id.to_string()), + status: ConversationBlockStatus::Streaming, + markdown: content.to_string(), + }) + }, + }; + self.push_block(block) + } + + fn start_tool_call( + &mut self, + turn_id: &str, + tool_call_id: &str, + tool_name: &str, + input: Option<&Value>, + source: ProjectionSource, + ) -> Vec { + let block_id = format!("tool:{tool_call_id}:call"); + let refs = self + .tool_blocks + .entry(tool_call_id.to_string()) + .or_default(); + refs.turn_id = Some(turn_id.to_string()); + refs.call = Some(block_id.clone()); + if self.block_index.contains_key(&block_id) { + return Vec::new(); + } + + let turn_refs = self.turn_blocks.entry(turn_id.to_string()).or_default(); + if source.is_live() { + turn_refs.split_after_live_tool_boundary(); + } else { + turn_refs.split_after_durable_tool_boundary(); + } + + self.push_block(ConversationBlockFacts::ToolCall(ToolCallBlockFacts { + id: block_id, + turn_id: Some(turn_id.to_string()), + tool_call_id: tool_call_id.to_string(), + tool_name: tool_name.to_string(), + status: ConversationBlockStatus::Streaming, + input: input.cloned(), + summary: None, + error: None, + duration_ms: None, + truncated: false, + metadata: None, + child_ref: None, + streams: ToolCallStreamsFacts::default(), + })) + } + + fn append_tool_stream( + &mut self, + turn_id: &str, + tool_call_id: &str, + tool_name: &str, + stream: ToolOutputStream, + delta: &str, + source: ProjectionSource, + ) -> Vec { + let mut deltas = self.start_tool_call(turn_id, tool_call_id, tool_name, None, source); + let refs = self + .tool_blocks + .entry(tool_call_id.to_string()) + .or_default(); + let chunk = refs.reconcile_tool_chunk(stream, delta, source); + if chunk.is_empty() { + return deltas; + } + + let Some(call_block_id) = refs.call.clone() else { + return deltas; + }; + let Some(index) = self.block_index.get(&call_block_id).copied() else { + return deltas; + }; + self.append_tool_stream_content(index, stream, &chunk); + deltas.push(ConversationDeltaFacts::PatchBlock { + block_id: call_block_id, + patch: ConversationBlockPatchFacts::AppendToolStream { stream, chunk }, + }); + deltas + } + + fn complete_tool_call( + &mut self, + turn_id: &str, + result: &ToolExecutionResult, + source: ProjectionSource, + ) -> Vec { + let mut deltas = self.start_tool_call( + turn_id, + &result.tool_call_id, + &result.tool_name, + None, + source, + ); + let status = if result.ok { + ConversationBlockStatus::Complete + } else { + ConversationBlockStatus::Failed + }; + let summary = tool_result_summary(result); + let refs = self + .tool_blocks + .entry(result.tool_call_id.clone()) + .or_default(); + if source.is_durable() { + refs.pending_live_stdout_bytes = 0; + refs.pending_live_stderr_bytes = 0; + } + let Some(call_block_id) = refs.call.clone() else { + return deltas; + }; + + if let Some(index) = self.block_index.get(&call_block_id).copied() { + if self.replace_tool_summary(index, &summary) { + deltas.push(ConversationDeltaFacts::PatchBlock { + block_id: call_block_id.clone(), + patch: ConversationBlockPatchFacts::ReplaceSummary { + summary: summary.clone(), + }, + }); + } + if self.replace_tool_error(index, result.error.as_deref()) { + deltas.push(ConversationDeltaFacts::PatchBlock { + block_id: call_block_id.clone(), + patch: ConversationBlockPatchFacts::ReplaceError { + error: result.error.clone(), + }, + }); + } + if self.replace_tool_duration(index, result.duration_ms) { + deltas.push(ConversationDeltaFacts::PatchBlock { + block_id: call_block_id.clone(), + patch: ConversationBlockPatchFacts::ReplaceDuration { + duration_ms: result.duration_ms, + }, + }); + } + if self.replace_tool_truncated(index, result.truncated) { + deltas.push(ConversationDeltaFacts::PatchBlock { + block_id: call_block_id.clone(), + patch: ConversationBlockPatchFacts::SetTruncated { + truncated: result.truncated, + }, + }); + } + if let Some(metadata) = &result.metadata { + if self.replace_tool_metadata(index, metadata) { + deltas.push(ConversationDeltaFacts::PatchBlock { + block_id: call_block_id.clone(), + patch: ConversationBlockPatchFacts::ReplaceMetadata { + metadata: metadata.clone(), + }, + }); + } + } + if let Some(delta) = self.complete_block(&call_block_id, status) { + deltas.push(delta); + } + } + + deltas + } + + fn append_system_note( + &mut self, + block_id: &str, + note_kind: ConversationSystemNoteKind, + markdown: &str, + ) -> Vec { + if self.block_index.contains_key(block_id) { + return Vec::new(); + } + self.push_block(ConversationBlockFacts::SystemNote( + ConversationSystemNoteBlockFacts { + id: block_id.to_string(), + note_kind, + markdown: markdown.to_string(), + }, + )) + } + + fn apply_child_notification( + &mut self, + notification: &ChildSessionNotification, + source: ProjectionSource, + ) -> Vec { + let mut deltas = Vec::new(); + if let Some(source_tool_call_id) = notification.source_tool_call_id.as_deref() { + if let Some(call_block_id) = self + .tool_blocks + .get(source_tool_call_id) + .and_then(|refs| refs.call.clone()) + { + if let Some(index) = self.block_index.get(&call_block_id).copied() { + if self.replace_tool_child_ref(index, ¬ification.child_ref) { + deltas.push(ConversationDeltaFacts::PatchBlock { + block_id: call_block_id, + patch: ConversationBlockPatchFacts::ReplaceChildRef { + child_ref: notification.child_ref.clone(), + }, + }); + } + } + } + } + + if source.is_durable() { + deltas.extend(self.append_child_handoff(notification)); + } + deltas + } + + fn append_child_handoff( + &mut self, + notification: &ChildSessionNotification, + ) -> Vec { + let block_id = format!("child:{}", notification.notification_id); + if self.block_index.contains_key(&block_id) { + return Vec::new(); + } + self.push_block(ConversationBlockFacts::ChildHandoff( + ConversationChildHandoffBlockFacts { + id: block_id, + handoff_kind: match notification.kind { + ChildSessionNotificationKind::Started + | ChildSessionNotificationKind::Resumed => { + ConversationChildHandoffKind::Delegated + }, + ChildSessionNotificationKind::ProgressSummary + | ChildSessionNotificationKind::Waiting => { + ConversationChildHandoffKind::Progress + }, + ChildSessionNotificationKind::Delivered + | ChildSessionNotificationKind::Closed + | ChildSessionNotificationKind::Failed => { + ConversationChildHandoffKind::Returned + }, + }, + child_ref: notification.child_ref.clone(), + message: notification + .delivery + .as_ref() + .map(|delivery| delivery.payload.message().to_string()), + }, + )) + } + + fn append_error( + &mut self, + turn_id: Option<&str>, + code: &str, + message: &str, + ) -> Vec { + if code == "interrupted" { + return Vec::new(); + } + let block_id = format!("turn:{}:error", turn_id.unwrap_or("session")); + if self.block_index.contains_key(&block_id) { + return Vec::new(); + } + self.push_block(ConversationBlockFacts::Error(ConversationErrorBlockFacts { + id: block_id, + turn_id: turn_id.map(ToString::to_string), + code: classify_transcript_error(message), + message: message.to_string(), + })) + } + + fn complete_turn(&mut self, turn_id: &str) -> Vec { + let Some(refs) = self.turn_blocks.get(turn_id).cloned() else { + return Vec::new(); + }; + let mut deltas = Vec::new(); + for block_id in refs.all_block_ids() { + if let Some(delta) = + self.complete_streaming_block(&block_id, ConversationBlockStatus::Complete) + { + deltas.push(delta); + } + } + let tool_blocks = self + .tool_blocks + .values() + .filter(|tool| tool.turn_id.as_deref() == Some(turn_id)) + .cloned() + .collect::>(); + for tool in tool_blocks { + if let Some(call) = &tool.call { + if let Some(delta) = + self.complete_streaming_block(call, ConversationBlockStatus::Complete) + { + deltas.push(delta); + } + } + } + deltas + } + + fn push_block(&mut self, block: ConversationBlockFacts) -> Vec { + let id = block_id(&block).to_string(); + self.block_index.insert(id, self.blocks.len()); + self.blocks.push(block.clone()); + vec![ConversationDeltaFacts::AppendBlock { block }] + } + + fn complete_block( + &mut self, + block_id: &str, + status: ConversationBlockStatus, + ) -> Option { + if let Some(index) = self.block_index.get(block_id).copied() { + if self.block_status(index) == Some(status) { + return None; + } + self.set_status(index, status); + return Some(ConversationDeltaFacts::CompleteBlock { + block_id: block_id.to_string(), + status, + }); + } + None + } + + fn complete_streaming_block( + &mut self, + block_id: &str, + status: ConversationBlockStatus, + ) -> Option { + let Some(index) = self.block_index.get(block_id).copied() else { + return None; + }; + if self.block_status(index) != Some(ConversationBlockStatus::Streaming) { + return None; + } + self.complete_block(block_id, status) + } + + fn append_markdown(&mut self, index: usize, markdown: &str) { + match &mut self.blocks[index] { + ConversationBlockFacts::Thinking(block) => block.markdown.push_str(markdown), + ConversationBlockFacts::Assistant(block) => block.markdown.push_str(markdown), + _ => {}, + } + } + + fn replace_markdown(&mut self, index: usize, markdown: &str) { + match &mut self.blocks[index] { + ConversationBlockFacts::Thinking(block) => block.markdown = markdown.to_string(), + ConversationBlockFacts::Assistant(block) => block.markdown = markdown.to_string(), + _ => {}, + } + } + + fn append_tool_stream_content(&mut self, index: usize, stream: ToolOutputStream, chunk: &str) { + if let ConversationBlockFacts::ToolCall(block) = &mut self.blocks[index] { + match stream { + ToolOutputStream::Stdout => block.streams.stdout.push_str(chunk), + ToolOutputStream::Stderr => block.streams.stderr.push_str(chunk), + } + } + } + + fn replace_tool_summary(&mut self, index: usize, summary: &str) -> bool { + if let ConversationBlockFacts::ToolCall(block) = &mut self.blocks[index] { + if block.summary.as_deref() == Some(summary) { + return false; + } + block.summary = Some(summary.to_string()); + return true; + } + false + } + + fn replace_tool_error(&mut self, index: usize, error: Option<&str>) -> bool { + if let ConversationBlockFacts::ToolCall(block) = &mut self.blocks[index] { + let new_error = error.map(ToString::to_string); + if block.error == new_error { + return false; + } + block.error = new_error; + return true; + } + false + } + + fn replace_tool_duration(&mut self, index: usize, duration_ms: u64) -> bool { + if let ConversationBlockFacts::ToolCall(block) = &mut self.blocks[index] { + if block.duration_ms == Some(duration_ms) { + return false; + } + block.duration_ms = Some(duration_ms); + return true; + } + false + } + + fn replace_tool_truncated(&mut self, index: usize, truncated: bool) -> bool { + if let ConversationBlockFacts::ToolCall(block) = &mut self.blocks[index] { + if block.truncated == truncated { + return false; + } + block.truncated = truncated; + return true; + } + false + } + + fn replace_tool_metadata(&mut self, index: usize, metadata: &Value) -> bool { + if let ConversationBlockFacts::ToolCall(block) = &mut self.blocks[index] { + if block.metadata.as_ref() == Some(metadata) { + return false; + } + block.metadata = Some(metadata.clone()); + return true; + } + false + } + + fn replace_tool_child_ref(&mut self, index: usize, child_ref: &ChildAgentRef) -> bool { + if let ConversationBlockFacts::ToolCall(block) = &mut self.blocks[index] { + if block.child_ref.as_ref() == Some(child_ref) { + return false; + } + block.child_ref = Some(child_ref.clone()); + return true; + } + false + } + + fn set_status(&mut self, index: usize, status: ConversationBlockStatus) { + match &mut self.blocks[index] { + ConversationBlockFacts::Thinking(block) => block.status = status, + ConversationBlockFacts::Assistant(block) => block.status = status, + ConversationBlockFacts::ToolCall(block) => block.status = status, + _ => {}, + } + } + + fn block_markdown(&self, index: usize) -> String { + match &self.blocks[index] { + ConversationBlockFacts::Thinking(block) => block.markdown.clone(), + ConversationBlockFacts::Assistant(block) => block.markdown.clone(), + _ => String::new(), + } + } + + fn block_status(&self, index: usize) -> Option { + match &self.blocks[index] { + ConversationBlockFacts::Thinking(block) => Some(block.status), + ConversationBlockFacts::Assistant(block) => Some(block.status), + ConversationBlockFacts::ToolCall(block) => Some(block.status), + _ => None, + } + } +} + +impl ConversationStreamProjector { + pub fn new(last_sent_cursor: Option, facts: &ConversationStreamReplayFacts) -> Self { + let mut projector = ConversationDeltaProjector::new(); + projector.seed(&facts.seed_records); + Self { + projector, + last_sent_cursor, + fallback_live_cursor: fallback_live_cursor(facts), + } + } + + pub fn last_sent_cursor(&self) -> Option<&str> { + self.last_sent_cursor.as_deref() + } + + pub fn seed_initial_replay( + &mut self, + facts: &ConversationStreamReplayFacts, + ) -> Vec { + let frames = facts.replay_frames.clone(); + self.observe_durable_frames(&frames); + frames + } + + pub fn project_durable_record( + &mut self, + record: &SessionEventRecord, + ) -> Vec { + let deltas = self.projector.project_record(record); + self.wrap_durable_deltas(record.event_id.as_str(), deltas) + } + + pub fn project_live_event(&mut self, event: &AgentEvent) -> Vec { + let cursor = self.live_cursor(); + self.projector + .project_live_event(event) + .into_iter() + .map(|delta| ConversationDeltaFrameFacts { + cursor: cursor.clone(), + delta, + }) + .collect() + } + + pub fn recover_from( + &mut self, + recovered: &ConversationStreamReplayFacts, + ) -> Vec { + self.fallback_live_cursor = fallback_live_cursor(recovered); + let mut frames = Vec::new(); + for record in &recovered.replay.history { + frames.extend(self.project_durable_record(record)); + } + frames + } + + fn wrap_durable_deltas( + &mut self, + cursor: &str, + deltas: Vec, + ) -> Vec { + if deltas.is_empty() { + return Vec::new(); + } + let cursor_owned = cursor.to_string(); + self.last_sent_cursor = Some(cursor_owned.clone()); + deltas + .into_iter() + .map(|delta| ConversationDeltaFrameFacts { + cursor: cursor_owned.clone(), + delta, + }) + .collect() + } + + fn observe_durable_frames(&mut self, frames: &[ConversationDeltaFrameFacts]) { + if let Some(cursor) = frames.last().map(|frame| frame.cursor.clone()) { + self.last_sent_cursor = Some(cursor); + } + } + + fn live_cursor(&self) -> String { + self.last_sent_cursor + .clone() + .or_else(|| self.fallback_live_cursor.clone()) + .unwrap_or_else(|| "0.0".to_string()) + } +} + +pub fn project_conversation_snapshot( + records: &[SessionEventRecord], + phase: Phase, +) -> ConversationSnapshotFacts { + let mut projector = ConversationDeltaProjector::new(); + projector.seed(records); + ConversationSnapshotFacts { + cursor: records.last().map(|record| record.event_id.clone()), + phase, + blocks: projector.into_blocks(), + } +} + +pub fn build_conversation_replay_frames( + seed_records: &[SessionEventRecord], + history: &[SessionEventRecord], +) -> Vec { + let mut projector = ConversationDeltaProjector::new(); + projector.seed(seed_records); + let mut frames = Vec::new(); + for record in history { + for delta in projector.project_record(record) { + frames.push(ConversationDeltaFrameFacts { + cursor: record.event_id.clone(), + delta, + }); + } + } + frames +} + +pub fn fallback_live_cursor(facts: &ConversationStreamReplayFacts) -> Option { + facts + .seed_records + .last() + .map(|record| record.event_id.clone()) + .or_else(|| { + facts + .replay + .history + .last() + .map(|record| record.event_id.clone()) + }) +} + +fn block_id(block: &ConversationBlockFacts) -> &str { + match block { + ConversationBlockFacts::User(block) => &block.id, + ConversationBlockFacts::Assistant(block) => &block.id, + ConversationBlockFacts::Thinking(block) => &block.id, + ConversationBlockFacts::ToolCall(block) => &block.id, + ConversationBlockFacts::Error(block) => &block.id, + ConversationBlockFacts::SystemNote(block) => &block.id, + ConversationBlockFacts::ChildHandoff(block) => &block.id, + } +} + +fn tool_result_summary(result: &ToolExecutionResult) -> String { + const MAX_SUMMARY_CHARS: usize = 120; + + let truncate = |content: &str| { + let normalized = content.split_whitespace().collect::>().join(" "); + let mut chars = normalized.chars(); + let truncated = chars.by_ref().take(MAX_SUMMARY_CHARS).collect::(); + if chars.next().is_some() { + format!("{truncated}…") + } else { + truncated + } + }; + + if result.ok { + if !result.output.trim().is_empty() { + truncate(&result.output) + } else { + format!("{} completed", result.tool_name) + } + } else if let Some(error) = &result.error { + truncate(error) + } else if !result.output.trim().is_empty() { + truncate(&result.output) + } else { + format!("{} failed", result.tool_name) + } +} + +fn classify_transcript_error(message: &str) -> ConversationTranscriptErrorKind { + let lower = message.to_lowercase(); + if lower.contains("context window") || lower.contains("token limit") { + ConversationTranscriptErrorKind::ContextWindowExceeded + } else if lower.contains("rate limit") { + ConversationTranscriptErrorKind::RateLimit + } else if lower.contains("tool") { + ConversationTranscriptErrorKind::ToolFatal + } else { + ConversationTranscriptErrorKind::ProviderError + } +} + +#[cfg(test)] +mod tests { + use std::{path::Path, sync::Arc}; + + use astrcode_core::{ + AgentEvent, AgentEventContext, AgentLifecycleStatus, ChildAgentRef, + ChildSessionLineageKind, ChildSessionNotification, ChildSessionNotificationKind, + DeleteProjectResult, EventStore, ParentDelivery, ParentDeliveryOrigin, + ParentDeliveryPayload, ParentDeliveryTerminalSemantics, Phase, SessionEventRecord, + SessionId, SessionMeta, SessionTurnAcquireResult, StorageEvent, StorageEventPayload, + StoredEvent, ToolExecutionResult, ToolOutputStream, UserMessageOrigin, + }; + use async_trait::async_trait; + use chrono::Utc; + use serde_json::json; + use tokio::sync::broadcast; + + use super::{ + ConversationBlockFacts, ConversationBlockPatchFacts, ConversationBlockStatus, + ConversationChildHandoffKind, ConversationDeltaFacts, ConversationDeltaProjector, + ConversationStreamProjector, ConversationStreamReplayFacts, ToolCallBlockFacts, + build_conversation_replay_frames, fallback_live_cursor, project_conversation_snapshot, + }; + use crate::{ + SessionReplay, SessionRuntime, + turn::test_support::{NoopMetrics, NoopPromptFactsProvider, test_kernel}, + }; + + #[test] + fn snapshot_projects_tool_call_block_with_streams_and_terminal_fields() { + let records = vec![ + record( + "1.1", + AgentEvent::ToolCallStart { + turn_id: "turn-1".to_string(), + agent: sample_agent_context(), + tool_call_id: "call-1".to_string(), + tool_name: "shell_command".to_string(), + input: json!({ "command": "pwd" }), + }, + ), + record( + "1.2", + AgentEvent::ToolCallDelta { + turn_id: "turn-1".to_string(), + agent: sample_agent_context(), + tool_call_id: "call-1".to_string(), + tool_name: "shell_command".to_string(), + stream: ToolOutputStream::Stdout, + delta: "line-1\n".to_string(), + }, + ), + record( + "1.3", + AgentEvent::ToolCallDelta { + turn_id: "turn-1".to_string(), + agent: sample_agent_context(), + tool_call_id: "call-1".to_string(), + tool_name: "shell_command".to_string(), + stream: ToolOutputStream::Stderr, + delta: "warn\n".to_string(), + }, + ), + record( + "1.4", + AgentEvent::ToolCallResult { + turn_id: "turn-1".to_string(), + agent: sample_agent_context(), + result: ToolExecutionResult { + tool_call_id: "call-1".to_string(), + tool_name: "shell_command".to_string(), + ok: false, + output: "line-1\n".to_string(), + error: Some("permission denied".to_string()), + metadata: Some(json!({ "path": "/tmp", "truncated": true })), + duration_ms: 42, + truncated: true, + }, + }, + ), + ]; + + let snapshot = project_conversation_snapshot(&records, Phase::CallingTool); + let tool = snapshot + .blocks + .iter() + .find_map(|block| match block { + ConversationBlockFacts::ToolCall(block) => Some(block), + _ => None, + }) + .expect("tool block should exist"); + + assert_eq!(tool.tool_call_id, "call-1"); + assert_eq!(tool.status, ConversationBlockStatus::Failed); + assert_eq!(tool.streams.stdout, "line-1\n"); + assert_eq!(tool.streams.stderr, "warn\n"); + assert_eq!(tool.error.as_deref(), Some("permission denied")); + assert_eq!(tool.duration_ms, Some(42)); + assert!(tool.truncated); + } + + #[test] + fn snapshot_preserves_failed_tool_status_after_turn_done() { + let records = vec![ + record( + "1.1", + AgentEvent::ToolCallStart { + turn_id: "turn-1".to_string(), + agent: sample_agent_context(), + tool_call_id: "call-1".to_string(), + tool_name: "shell_command".to_string(), + input: json!({ "command": "missing-command" }), + }, + ), + record( + "1.2", + AgentEvent::ToolCallResult { + turn_id: "turn-1".to_string(), + agent: sample_agent_context(), + result: ToolExecutionResult { + tool_call_id: "call-1".to_string(), + tool_name: "shell_command".to_string(), + ok: false, + output: String::new(), + error: Some("command not found".to_string()), + metadata: None, + duration_ms: 127, + truncated: false, + }, + }, + ), + record( + "1.3", + AgentEvent::TurnDone { + turn_id: "turn-1".to_string(), + agent: sample_agent_context(), + }, + ), + ]; + + let snapshot = project_conversation_snapshot(&records, Phase::Idle); + let tool = snapshot + .blocks + .iter() + .find_map(|block| match block { + ConversationBlockFacts::ToolCall(block) => Some(block), + _ => None, + }) + .expect("tool block should exist"); + + assert_eq!(tool.status, ConversationBlockStatus::Failed); + assert_eq!(tool.error.as_deref(), Some("command not found")); + assert_eq!(tool.duration_ms, Some(127)); + } + + #[test] + fn live_then_durable_tool_delta_dedupes_chunk_on_same_tool_block() { + let facts = sample_stream_replay_facts( + vec![record( + "1.1", + AgentEvent::ToolCallStart { + turn_id: "turn-1".to_string(), + agent: sample_agent_context(), + tool_call_id: "call-1".to_string(), + tool_name: "shell_command".to_string(), + input: json!({ "command": "pwd" }), + }, + )], + vec![record( + "1.2", + AgentEvent::ToolCallDelta { + turn_id: "turn-1".to_string(), + agent: sample_agent_context(), + tool_call_id: "call-1".to_string(), + tool_name: "shell_command".to_string(), + stream: ToolOutputStream::Stdout, + delta: "line-1\n".to_string(), + }, + )], + ); + let mut stream = ConversationStreamProjector::new(Some("1.1".to_string()), &facts); + + let live_frames = stream.project_live_event(&AgentEvent::ToolCallDelta { + turn_id: "turn-1".to_string(), + agent: sample_agent_context(), + tool_call_id: "call-1".to_string(), + tool_name: "shell_command".to_string(), + stream: ToolOutputStream::Stdout, + delta: "line-1\n".to_string(), + }); + assert_eq!(live_frames.len(), 1); + + let replayed = stream.recover_from(&facts); + assert!( + replayed.is_empty(), + "durable replay should not duplicate the live-emitted chunk" + ); + } + + #[test] + fn child_notification_patches_tool_block_and_appends_handoff_block() { + let mut projector = ConversationDeltaProjector::new(); + projector.seed(&[record( + "1.1", + AgentEvent::ToolCallStart { + turn_id: "turn-1".to_string(), + agent: sample_agent_context(), + tool_call_id: "call-spawn".to_string(), + tool_name: "spawn_agent".to_string(), + input: json!({ "task": "inspect" }), + }, + )]); + + let deltas = projector.project_record(&record( + "1.2", + AgentEvent::ChildSessionNotification { + turn_id: Some("turn-1".to_string()), + agent: sample_agent_context(), + notification: sample_child_notification(), + }, + )); + + assert!(deltas.iter().any(|delta| matches!( + delta, + ConversationDeltaFacts::PatchBlock { + block_id, + patch: ConversationBlockPatchFacts::ReplaceChildRef { .. }, + } if block_id == "tool:call-spawn:call" + ))); + assert!(deltas.iter().any(|delta| matches!( + delta, + ConversationDeltaFacts::AppendBlock { + block: ConversationBlockFacts::ChildHandoff(block), + } if block.handoff_kind == ConversationChildHandoffKind::Returned + ))); + } + + #[tokio::test] + async fn runtime_query_builds_snapshot_and_stream_replay_facts() { + let event_store = Arc::new(ReplayOnlyEventStore::new(vec![ + stored( + 1, + storage_event( + Some("turn-1"), + StorageEventPayload::UserMessage { + content: "inspect repo".to_string(), + origin: UserMessageOrigin::User, + timestamp: Utc::now(), + }, + ), + ), + stored( + 2, + storage_event( + Some("turn-1"), + StorageEventPayload::ToolCall { + tool_call_id: "call-1".to_string(), + tool_name: "shell_command".to_string(), + args: json!({ "command": "pwd" }), + }, + ), + ), + stored( + 3, + storage_event( + Some("turn-1"), + StorageEventPayload::ToolCallDelta { + tool_call_id: "call-1".to_string(), + tool_name: "shell_command".to_string(), + stream: ToolOutputStream::Stdout, + delta: "D:/GitObjectsOwn/Astrcode\n".to_string(), + }, + ), + ), + stored( + 4, + storage_event( + Some("turn-1"), + StorageEventPayload::ToolResult { + tool_call_id: "call-1".to_string(), + tool_name: "shell_command".to_string(), + output: "D:/GitObjectsOwn/Astrcode\n".to_string(), + success: true, + error: None, + metadata: None, + duration_ms: 7, + }, + ), + ), + stored( + 5, + storage_event( + Some("turn-1"), + StorageEventPayload::AssistantFinal { + content: "done".to_string(), + reasoning_content: Some("think".to_string()), + reasoning_signature: None, + timestamp: None, + }, + ), + ), + ])); + let runtime = SessionRuntime::new( + Arc::new(test_kernel(8192)), + Arc::new(NoopPromptFactsProvider), + event_store, + Arc::new(NoopMetrics), + ); + + let snapshot = runtime + .conversation_snapshot("session-1") + .await + .expect("snapshot should build"); + assert!(snapshot.blocks.iter().any(|block| matches!( + block, + ConversationBlockFacts::ToolCall(ToolCallBlockFacts { tool_call_id, .. }) + if tool_call_id == "call-1" + ))); + + let transcript = runtime + .session_transcript_snapshot("session-1") + .await + .expect("transcript snapshot should build"); + assert!(transcript.records.len() > 4); + let cursor = transcript.records[3].event_id.clone(); + + let replay = runtime + .conversation_stream_replay("session-1", Some(cursor.as_str())) + .await + .expect("replay facts should build"); + assert_eq!( + replay + .seed_records + .last() + .map(|record| record.event_id.as_str()), + Some(cursor.as_str()) + ); + assert!(!replay.replay_frames.is_empty()); + assert_eq!( + fallback_live_cursor(&replay).as_deref(), + Some(cursor.as_str()) + ); + } + + fn sample_stream_replay_facts( + seed_records: Vec, + history: Vec, + ) -> ConversationStreamReplayFacts { + let (_, receiver) = broadcast::channel(8); + let (_, live_receiver) = broadcast::channel(8); + ConversationStreamReplayFacts { + cursor: history.last().map(|record| record.event_id.clone()), + phase: Phase::CallingTool, + seed_records: seed_records.clone(), + replay_frames: build_conversation_replay_frames(&seed_records, &history), + replay: SessionReplay { + history, + receiver, + live_receiver, + }, + } + } + + fn sample_agent_context() -> AgentEventContext { + AgentEventContext::root_execution("agent-root", "default") + } + + fn sample_child_notification() -> ChildSessionNotification { + ChildSessionNotification { + notification_id: "child-note-1".to_string(), + child_ref: ChildAgentRef { + agent_id: "agent-child-1".to_string(), + session_id: "session-root".to_string(), + sub_run_id: "subrun-child-1".to_string(), + parent_agent_id: Some("agent-root".to_string()), + parent_sub_run_id: Some("subrun-root".to_string()), + lineage_kind: ChildSessionLineageKind::Spawn, + status: AgentLifecycleStatus::Running, + open_session_id: "session-child-1".to_string(), + }, + kind: ChildSessionNotificationKind::Delivered, + status: AgentLifecycleStatus::Idle, + source_tool_call_id: Some("call-spawn".to_string()), + delivery: Some(ParentDelivery { + idempotency_key: "delivery-1".to_string(), + origin: ParentDeliveryOrigin::Explicit, + terminal_semantics: ParentDeliveryTerminalSemantics::Terminal, + source_turn_id: Some("turn-1".to_string()), + payload: ParentDeliveryPayload::Progress( + astrcode_core::ProgressParentDeliveryPayload { + message: "child finished".to_string(), + }, + ), + }), + } + } + + fn record(event_id: &str, event: AgentEvent) -> SessionEventRecord { + SessionEventRecord { + event_id: event_id.to_string(), + event, + } + } + + fn stored(storage_seq: u64, event: StorageEvent) -> StoredEvent { + StoredEvent { storage_seq, event } + } + + fn storage_event(turn_id: Option<&str>, payload: StorageEventPayload) -> StorageEvent { + StorageEvent { + turn_id: turn_id.map(ToString::to_string), + agent: sample_agent_context(), + payload, + } + } + + struct ReplayOnlyEventStore { + events: Vec, + } + + impl ReplayOnlyEventStore { + fn new(events: Vec) -> Self { + Self { events } + } + } + + struct StubTurnLease; + + impl astrcode_core::SessionTurnLease for StubTurnLease {} + + #[async_trait] + impl EventStore for ReplayOnlyEventStore { + async fn ensure_session( + &self, + _session_id: &SessionId, + _working_dir: &Path, + ) -> astrcode_core::Result<()> { + Ok(()) + } + + async fn append( + &self, + _session_id: &SessionId, + _event: &astrcode_core::StorageEvent, + ) -> astrcode_core::Result { + panic!("append should not be called in replay-only test store"); + } + + async fn replay(&self, _session_id: &SessionId) -> astrcode_core::Result> { + Ok(self.events.clone()) + } + + async fn try_acquire_turn( + &self, + _session_id: &SessionId, + _turn_id: &str, + ) -> astrcode_core::Result { + Ok(SessionTurnAcquireResult::Acquired(Box::new(StubTurnLease))) + } + + async fn list_sessions(&self) -> astrcode_core::Result> { + Ok(vec![SessionId::from("session-1".to_string())]) + } + + async fn list_session_metas(&self) -> astrcode_core::Result> { + Ok(vec![SessionMeta { + session_id: "session-1".to_string(), + working_dir: ".".to_string(), + display_name: "session-1".to_string(), + title: "session-1".to_string(), + created_at: Utc::now(), + updated_at: Utc::now(), + parent_session_id: None, + parent_storage_seq: None, + phase: Phase::Done, + }]) + } + + async fn delete_session(&self, _session_id: &SessionId) -> astrcode_core::Result<()> { + Ok(()) + } + + async fn delete_sessions_by_working_dir( + &self, + _working_dir: &str, + ) -> astrcode_core::Result { + Ok(DeleteProjectResult { + success_count: 0, + failed_session_ids: Vec::new(), + }) + } + } +} diff --git a/crates/session-runtime/src/query/mod.rs b/crates/session-runtime/src/query/mod.rs index b334509d..42b2a433 100644 --- a/crates/session-runtime/src/query/mod.rs +++ b/crates/session-runtime/src/query/mod.rs @@ -4,6 +4,7 @@ //! 让 `application` 只消费稳定视图,不再自己拼装会话真相。 pub mod agent; +pub mod conversation; pub mod mailbox; mod service; pub mod terminal; @@ -11,6 +12,16 @@ pub mod transcript; pub mod turn; pub use agent::{AgentObserveSnapshot, build_agent_observe_snapshot}; +pub use conversation::{ + ConversationAssistantBlockFacts, ConversationBlockFacts, ConversationBlockPatchFacts, + ConversationBlockStatus, ConversationChildHandoffBlockFacts, ConversationChildHandoffKind, + ConversationDeltaFacts, ConversationDeltaFrameFacts, ConversationDeltaProjector, + ConversationErrorBlockFacts, ConversationSnapshotFacts, ConversationStreamProjector, + ConversationStreamReplayFacts, ConversationSystemNoteBlockFacts, ConversationSystemNoteKind, + ConversationThinkingBlockFacts, ConversationTranscriptErrorKind, ConversationUserBlockFacts, + ToolCallBlockFacts, ToolCallStreamsFacts, build_conversation_replay_frames, + fallback_live_cursor, project_conversation_snapshot, +}; pub use mailbox::recoverable_parent_deliveries; pub use service::SessionQueries; pub use terminal::SessionControlStateSnapshot; diff --git a/crates/session-runtime/src/query/service.rs b/crates/session-runtime/src/query/service.rs index a6e0fe09..26685a06 100644 --- a/crates/session-runtime/src/query/service.rs +++ b/crates/session-runtime/src/query/service.rs @@ -6,9 +6,11 @@ use astrcode_core::{ use tokio::time::sleep; use crate::{ - AgentObserveSnapshot, ProjectedTurnOutcome, SessionControlStateSnapshot, SessionRuntime, - SessionState, TurnTerminalSnapshot, build_agent_observe_snapshot, has_terminal_turn_signal, - project_turn_outcome, recoverable_parent_deliveries, + AgentObserveSnapshot, ConversationSnapshotFacts, ConversationStreamReplayFacts, + ProjectedTurnOutcome, SessionControlStateSnapshot, SessionReplay, SessionRuntime, SessionState, + TurnTerminalSnapshot, build_agent_observe_snapshot, build_conversation_replay_frames, + has_terminal_turn_signal, project_conversation_snapshot, project_turn_outcome, + recoverable_parent_deliveries, }; pub struct SessionQueries<'a> { @@ -109,6 +111,44 @@ impl<'a> SessionQueries<'a> { )) } + pub async fn conversation_snapshot( + &self, + session_id: &str, + ) -> Result { + let session_id = SessionId::from(crate::normalize_session_id(session_id)); + let records = self.runtime.replay_history(&session_id, None).await?; + let phase = self.runtime.session_phase(&session_id).await?; + Ok(project_conversation_snapshot(&records, phase)) + } + + pub async fn conversation_stream_replay( + &self, + session_id: &str, + last_event_id: Option<&str>, + ) -> Result { + let session_id = SessionId::from(crate::normalize_session_id(session_id)); + let actor = self.runtime.ensure_loaded_session(&session_id).await?; + let all_records = self.runtime.replay_history(&session_id, None).await?; + let replay_history = self + .runtime + .replay_history(&session_id, last_event_id) + .await?; + let seed_records = records_before_cursor(&all_records, last_event_id); + let phase = self.runtime.session_phase(&session_id).await?; + + Ok(ConversationStreamReplayFacts { + cursor: replay_history.last().map(|record| record.event_id.clone()), + phase, + replay_frames: build_conversation_replay_frames(&seed_records, &replay_history), + seed_records, + replay: SessionReplay { + history: replay_history, + receiver: actor.state().broadcaster.subscribe(), + live_receiver: actor.state().subscribe_live(), + }, + }) + } + pub async fn pending_delivery_ids_for_agent( &self, session_id: &str, @@ -141,3 +181,19 @@ impl<'a> SessionQueries<'a> { Ok(project_turn_outcome(terminal.phase, &terminal.events)) } } + +fn records_before_cursor( + records: &[astrcode_core::SessionEventRecord], + last_event_id: Option<&str>, +) -> Vec { + let Some(last_event_id) = last_event_id else { + return Vec::new(); + }; + let Some(index) = records + .iter() + .position(|record| record.event_id == last_event_id) + else { + return Vec::new(); + }; + records[..=index].to_vec() +} diff --git a/crates/session-runtime/src/turn/tool_cycle.rs b/crates/session-runtime/src/turn/tool_cycle.rs index 2fbafa6a..22045f5f 100644 --- a/crates/session-runtime/src/turn/tool_cycle.rs +++ b/crates/session-runtime/src/turn/tool_cycle.rs @@ -681,6 +681,74 @@ mod tests { } } + #[derive(Debug)] + struct StreamingStderrProbeTool; + + #[async_trait] + impl Tool for StreamingStderrProbeTool { + fn definition(&self) -> ToolDefinition { + ToolDefinition { + name: "streaming_stderr_probe".to_string(), + description: "emits stderr probe events".to_string(), + parameters: json!({"type": "object"}), + } + } + + fn capability_spec( + &self, + ) -> std::result::Result< + astrcode_core::CapabilitySpec, + astrcode_core::CapabilitySpecBuildError, + > { + astrcode_core::CapabilitySpec::builder("streaming_stderr_probe", CapabilityKind::Tool) + .description("emits stderr probe events") + .schema(json!({"type": "object"}), json!({"type": "string"})) + .build() + } + + async fn execute( + &self, + tool_call_id: String, + _input: Value, + ctx: &ToolContext, + ) -> Result { + let turn_id = ctx + .turn_id() + .expect("stderr probe should receive turn id") + .to_string(); + let sink = ctx + .event_sink() + .expect("stderr probe should receive tool event sink"); + sink.emit(crate::turn::events::tool_call_delta_event( + &turn_id, + ctx.agent_context(), + tool_call_id.clone(), + "streaming_stderr_probe".to_string(), + ToolOutputStream::Stderr, + "durable-stderr".to_string(), + )) + .await?; + assert!( + ctx.emit_stderr( + tool_call_id.clone(), + "streaming_stderr_probe", + "live-stderr" + ), + "stderr probe should be able to emit live stderr" + ); + Ok(ToolExecutionResult { + tool_call_id, + tool_name: "streaming_stderr_probe".to_string(), + ok: false, + output: String::new(), + error: Some("stderr failure".to_string()), + metadata: None, + duration_ms: 0, + truncated: false, + }) + } + } + #[tokio::test] async fn invoke_single_tool_preserves_turn_and_agent_context() { let observed = Arc::new(Mutex::new(Vec::new())); @@ -973,4 +1041,107 @@ mod tests { "buffered mode should still broadcast tool result live" ); } + + #[tokio::test] + async fn invoke_single_tool_forwards_stderr_to_durable_and_live_channels() { + let kernel = test_kernel_with_tool(Arc::new(StreamingStderrProbeTool), 8192); + let tool_call = ToolCallRequest { + id: "call-stderr".to_string(), + name: "streaming_stderr_probe".to_string(), + args: json!({}), + }; + let agent = AgentEventContext::root_execution("root-agent:session-1", "default"); + let session_state = test_session_state(); + let mut live_receiver = session_state.subscribe_live(); + + let cancel = CancelToken::new(); + let (result, fallback_events) = invoke_single_tool(SingleToolInvocation { + gateway: kernel.gateway(), + session_state: Arc::clone(&session_state), + tool_call: &tool_call, + session_id: "session-1", + working_dir: ".", + turn_id: "turn-stderr", + agent: &agent, + cancel: &cancel, + tool_result_inline_limit: 32 * 1024, + event_emission_mode: ToolEventEmissionMode::Immediate, + }) + .await; + + assert!( + !result.ok && result.error.as_deref() == Some("stderr failure"), + "stderr probe should surface failure result: {result:?}" + ); + assert!( + fallback_events.is_empty(), + "immediate stderr event emission should avoid fallback buffering: {fallback_events:?}" + ); + + let stored = session_state + .snapshot_recent_stored_events() + .expect("snapshot recent stored events should work"); + assert!( + stored.iter().any(|event| matches!( + &event.event.payload, + StorageEventPayload::ToolCallDelta { + tool_call_id, + tool_name, + stream: ToolOutputStream::Stderr, + delta, + .. + } if tool_call_id == "call-stderr" + && tool_name == "streaming_stderr_probe" + && delta == "durable-stderr" + )), + "stderr durable delta should be recorded immediately" + ); + + let live_start = timeout(Duration::from_secs(1), live_receiver.recv()) + .await + .expect("live receiver should get tool start in time") + .expect("live receiver should stay open"); + let live_delta = timeout(Duration::from_secs(1), live_receiver.recv()) + .await + .expect("live receiver should get stderr delta in time") + .expect("live receiver should stay open"); + let live_result = timeout(Duration::from_secs(1), live_receiver.recv()) + .await + .expect("live receiver should get tool result in time") + .expect("live receiver should stay open"); + + assert!(matches!( + live_start, + astrcode_core::AgentEvent::ToolCallStart { + turn_id, + tool_call_id, + tool_name, + .. + } if turn_id == "turn-stderr" + && tool_call_id == "call-stderr" + && tool_name == "streaming_stderr_probe" + )); + assert!(matches!( + live_delta, + astrcode_core::AgentEvent::ToolCallDelta { + turn_id, + tool_call_id, + tool_name, + stream: ToolOutputStream::Stderr, + delta, + .. + } if turn_id == "turn-stderr" + && tool_call_id == "call-stderr" + && tool_name == "streaming_stderr_probe" + && delta == "live-stderr" + )); + assert!(matches!( + live_result, + astrcode_core::AgentEvent::ToolCallResult { turn_id, result, .. } + if turn_id == "turn-stderr" + && result.tool_call_id == "call-stderr" + && result.tool_name == "streaming_stderr_probe" + && result.error.as_deref() == Some("stderr failure") + )); + } } diff --git a/frontend/src/components/Chat/MessageList.tsx b/frontend/src/components/Chat/MessageList.tsx index fd5f48c0..d84118b7 100644 --- a/frontend/src/components/Chat/MessageList.tsx +++ b/frontend/src/components/Chat/MessageList.tsx @@ -1,12 +1,11 @@ import React, { Component, useCallback, useEffect, useRef } from 'react'; -import type { Message, SubRunViewData, ThreadItem, ToolStreamMessage } from '../../types'; +import type { Message, SubRunViewData, ThreadItem } from '../../types'; import { emptyStateSurface, errorSurface } from '../../lib/styles'; import { cn } from '../../lib/utils'; import AssistantMessage from './AssistantMessage'; import CompactMessage from './CompactMessage'; import SubRunBlock from './SubRunBlock'; import ToolCallBlock from './ToolCallBlock'; -import ToolStreamBlock from './ToolStreamBlock'; import UserMessage from './UserMessage'; import { useChatScreenContext } from './ChatScreenContext'; import { logger } from '../../lib/logger'; @@ -63,19 +62,6 @@ class MessageBoundary extends Component - ) : message.kind === 'toolStream' ? ( -
-              {JSON.stringify(
-                {
-                  toolCallId: message.toolCallId,
-                  stream: message.stream,
-                  status: message.status,
-                  contentLength: message.content.length,
-                },
-                null,
-                2
-              )}
-            
) : message.kind === 'compact' ? (
               {message.summary}
@@ -147,9 +133,7 @@ class MessageBoundary extends Component {
       if (msg.kind === 'user') {
@@ -246,10 +229,7 @@ export default function MessageList({
         );
       }
       if (msg.kind === 'toolCall') {
-        return ;
-      }
-      if (msg.kind === 'toolStream') {
-        return ;
+        return ;
       }
       if (msg.kind === 'promptMetrics') {
         return null;
@@ -273,7 +253,6 @@ export default function MessageList({
       options?: {
         key?: string;
         nested?: boolean;
-        groupedToolStreams?: ToolStreamMessage[];
       },
       metricsOverride?: Message
     ) => {
@@ -325,7 +304,6 @@ export default function MessageList({
           }
 
           let metricsToAttach: Message | undefined;
-          let groupedToolStreams: ToolStreamMessage[] | undefined;
           if (item.message.kind === 'assistant') {
             let hasMoreAssistantInTurn = false;
             const currentTurnId = item.message.turnId;
@@ -371,27 +349,6 @@ export default function MessageList({
             }
           }
 
-          if (item.message.kind === 'toolCall') {
-            groupedToolStreams = [];
-            let cursor = index + 1;
-            while (cursor < items.length) {
-              const candidate = items[cursor];
-              if (candidate.kind !== 'message' || candidate.message.kind !== 'toolStream') {
-                break;
-              }
-              if (candidate.message.toolCallId !== item.message.toolCallId) {
-                break;
-              }
-              groupedToolStreams.push(candidate.message);
-              cursor += 1;
-            }
-            if (groupedToolStreams.length > 0) {
-              index = cursor - 1;
-            } else {
-              groupedToolStreams = undefined;
-            }
-          }
-
           rendered.push(
             renderMessageRow(
               item.message,
@@ -400,7 +357,6 @@ export default function MessageList({
               {
                 key: item.message.id,
                 nested: options?.nested,
-                groupedToolStreams,
               },
               metricsToAttach
             )
diff --git a/frontend/src/components/Chat/SubRunBlock.tsx b/frontend/src/components/Chat/SubRunBlock.tsx
index dad69b79..8b075efc 100644
--- a/frontend/src/components/Chat/SubRunBlock.tsx
+++ b/frontend/src/components/Chat/SubRunBlock.tsx
@@ -91,11 +91,7 @@ function isVisibleActivityItem(item: ThreadItem): boolean {
     return true;
   }
 
-  return (
-    item.message.kind === 'assistant' ||
-    item.message.kind === 'toolCall' ||
-    item.message.kind === 'toolStream'
-  );
+  return item.message.kind === 'assistant' || item.message.kind === 'toolCall';
 }
 
 function getDeliveryMessage(delivery?: ParentDelivery): string | null {
diff --git a/frontend/src/components/Chat/ToolCallBlock.test.tsx b/frontend/src/components/Chat/ToolCallBlock.test.tsx
index 721c5fe2..8ca768bc 100644
--- a/frontend/src/components/Chat/ToolCallBlock.test.tsx
+++ b/frontend/src/components/Chat/ToolCallBlock.test.tsx
@@ -36,7 +36,7 @@ const chatContextValue: ChatScreenContextValue = {
 };
 
 describe('ToolCallBlock', () => {
-  it('renders real tool args in the collapsed summary and grouped result output in the body', () => {
+  it('renders real tool args in the collapsed summary and embedded stdout in the body', () => {
     const html = renderToStaticMarkup(
       
          {
               limit: 220,
             },
             output: '[workspace]',
+            streams: {
+              stdout: '[workspace]\nmembers = [\n  "crates/core"\n]\n',
+              stderr: '',
+            },
             timestamp: Date.now(),
           }}
-          streams={[
-            {
-              id: 'tool-stream-1',
-              kind: 'toolStream',
-              toolCallId: 'call-1',
-              stream: 'stdout',
-              status: 'ok',
-              content: '[workspace]\nmembers = [\n  "crates/core"\n]\n',
-              timestamp: Date.now(),
-            },
-          ]}
         />
       
     );
@@ -76,7 +69,7 @@ describe('ToolCallBlock', () => {
     expect(html).toContain('max-h-[min(58vh,560px)]');
   });
 
-  it('renders fallback result surface when no streamed output exists', () => {
+  it('renders fallback result surface when no embedded stream output exists', () => {
     const html = renderToStaticMarkup(
       
          {
               pattern: '*.rs',
             },
             output: '找到 12 个文件',
+            streams: {
+              stdout: '',
+              stderr: '',
+            },
             timestamp: Date.now(),
           }}
         />
@@ -100,7 +97,7 @@ describe('ToolCallBlock', () => {
     expect(html).toContain('结果');
   });
 
-  it('renders child session navigation action when spawn metadata exposes an open session', () => {
+  it('renders child session navigation action from explicit child ref', () => {
     const html = renderToStaticMarkup(
       
          {
               description: '探索当前项目',
             },
             output: '子 Agent 已启动',
-            metadata: {
+            childRef: {
+              agentId: 'agent-child-1',
+              sessionId: 'session-root',
+              subRunId: 'subrun-child-1',
+              parentAgentId: 'agent-root',
+              parentSubRunId: 'subrun-root',
+              lineageKind: 'spawn',
+              status: 'running',
               openSessionId: 'session-child-1',
-              agentRef: {
-                agentId: 'agent-child-1',
-                subRunId: 'subrun-child-1',
-                openSessionId: 'session-child-1',
-              },
+            },
+            streams: {
+              stdout: '',
+              stderr: '',
             },
             timestamp: Date.now(),
           }}
@@ -130,4 +133,40 @@ describe('ToolCallBlock', () => {
 
     expect(html).toContain('打开子会话');
   });
+
+  it('renders embedded stdout/stderr sections and failure pills for failed tools', () => {
+    const html = renderToStaticMarkup(
+      
+        
+      
+    );
+
+    expect(html).toContain('已运行 shell');
+    expect(html).toContain('工具结果');
+    expect(html).toContain('错误输出');
+    expect(html).toContain('searching workspace');
+    expect(html).toContain('missing-symbol');
+    expect(html).toContain('88 ms');
+    expect(html).toContain('truncated');
+    expect(html).toContain('失败');
+  });
 });
diff --git a/frontend/src/components/Chat/ToolCallBlock.tsx b/frontend/src/components/Chat/ToolCallBlock.tsx
index c3e99e95..d2a67a57 100644
--- a/frontend/src/components/Chat/ToolCallBlock.tsx
+++ b/frontend/src/components/Chat/ToolCallBlock.tsx
@@ -1,8 +1,7 @@
 import { memo, useRef } from 'react';
 
-import type { ToolCallMessage, ToolStreamMessage } from '../../types';
+import type { ToolCallMessage } from '../../types';
 import {
-  extractToolChildSessionTarget,
   extractStructuredArgs,
   extractStructuredJsonOutput,
   extractToolMetadataSummary,
@@ -18,7 +17,6 @@ import { useNestedScrollContainment } from './useNestedScrollContainment';
 
 interface ToolCallBlockProps {
   message: ToolCallMessage;
-  streams?: ToolStreamMessage[];
 }
 
 function statusPill(status: ToolCallMessage['status']): string {
@@ -43,13 +41,13 @@ function statusLabel(status: ToolCallMessage['status']): string {
   }
 }
 
-function streamBadge(stream: ToolStreamMessage['stream']): string {
+function streamBadge(stream: 'stdout' | 'stderr'): string {
   return stream === 'stderr' ? pillDanger : pillNeutral;
 }
 
 function streamTitle(
   toolName: string,
-  stream: ToolStreamMessage['stream'],
+  stream: 'stdout' | 'stderr',
   hasShellCommand: boolean
 ): string {
   if (hasShellCommand && stream === 'stdout') {
@@ -84,18 +82,24 @@ function resultTextSurface(text: string, tone: 'normal' | 'error') {
   );
 }
 
-function ToolCallBlock({ message, streams = [] }: ToolCallBlockProps) {
+function ToolCallBlock({ message }: ToolCallBlockProps) {
   const { onOpenChildSession, onOpenSubRun } = useChatScreenContext();
   const viewportRef = useRef(null);
   useNestedScrollContainment(viewportRef);
   const shellDisplay = extractToolShellDisplay(message.metadata);
-  const childSessionTarget = extractToolChildSessionTarget(message.metadata);
   const summary = formatToolCallSummary(
     message.toolName,
     message.args,
     message.status,
     message.metadata
   );
+  const streamSections = (['stdout', 'stderr'] as const)
+    .map((stream) => ({
+      id: `${message.id}:${stream}`,
+      stream,
+      content: message.streams?.[stream] ?? '',
+    }))
+    .filter((section) => section.content.trim().length > 0);
   const structuredArgs = extractStructuredArgs(message.args);
   const metadataSummary = extractToolMetadataSummary(message.metadata);
   const fallbackResult =
@@ -111,19 +115,19 @@ function ToolCallBlock({ message, streams = [] }: ToolCallBlockProps) {
       
         {message.toolName}
         {summary}
-        {childSessionTarget && (
+        {message.childRef && (
           
-                );
-              })}
-            
- - - - {sessionError ? : null} -
- {agentNodes.length === 0 ? ( - - ) : ( - agentNodes.map((node) => ) - )} -
-
- - -
- - {overviewError ? : null} -
- - - -
- -
-
-
- Recent 5m Trend -
-
- {timeline - ? `${formatTimestamp(timeline.windowStartedAt)} - ${formatTimestamp(timeline.windowEndedAt)}` - : '等待服务端样本'} -
-
-
- - - - - - - - -
-
- - - -
-
-
- - - {sessionLoading && trace === null ? ( -
正在同步当前会话 trace…
- ) : null} - {trace?.parentSessionId ? ( -
- parent session: {trace.parentSessionId.slice(0, 12)} -
- ) : null} -
- {trace?.items.length ? ( - trace.items.map((item) => ) - ) : ( - - )} -
-
-
- -
- ); -} - -function Panel({ - title, - subtitle, - children, -}: { - title: string; - subtitle: string; - children: ReactNode; -}) { - return ( -
-
-
- {title} -
-
{subtitle}
-
-
{children}
-
- ); -} - -function MetricCard({ - label, - value, - detail, - tone, -}: { - label: string; - value: string; - detail: string; - tone: string; -}) { - return ( -
-
-
- {label} -
- {value} -
-
{detail}
-
- ); -} - -function LegendPill({ label, color, value }: { label: string; color: string; value: string }) { - return ( - - - ); -} - -function TrendLine({ points, color }: { points: Array<{ x: number; y: number }>; color: string }) { - if (points.length === 0) { - return null; - } - if (points.length === 1) { - const point = points[0]; - return ; - } - return ( - `${point.x},${point.y}`).join(' ')} - /> - ); -} - -function AgentTreeNode({ node }: { node: SessionDebugAgentNode }) { - return ( -
-
-
-
{node.title}
-
- {node.agentId} - {node.subRunId ? ` · ${node.subRunId}` : ''} -
-
- - {node.lifecycle} - -
-
- {node.kind === 'sessionRoot' ? 'session root' : 'child agent'} - {node.lineageKind ? {node.lineageKind} : null} - {node.statusSource ? {node.statusSource} : null} - {node.lastTurnOutcome ? {node.lastTurnOutcome} : null} -
-
- ); -} - -function TraceItemCard({ item }: { item: SessionDebugTrace['items'][number] }) { - return ( -
-
-
-
{item.title}
-
- {formatTimestamp(item.recordedAt)} - {item.storageSeq ? ` · seq ${item.storageSeq}` : ''} - {item.turnId ? ` · turn ${item.turnId}` : ''} -
-
- - {item.kind} - -
-
{item.summary}
-
- {item.agentId ? agent {item.agentId} : null} - {item.subRunId ? subRun {item.subRunId} : null} - {item.childAgentId ? child {item.childAgentId} : null} - {item.deliveryId ? delivery {item.deliveryId} : null} - {item.toolName ? tool {item.toolName} : null} - {item.lifecycle ? {item.lifecycle} : null} - {item.lastTurnOutcome ? {item.lastTurnOutcome} : null} -
-
- ); -} - -function ErrorBanner({ message }: { message: string }) { - return ( -
- {message} -
- ); -} - -function EmptyState({ label }: { label: string }) { - return ( -
- {label} -
- ); -} diff --git a/frontend/src/components/Chat/SubRunBlock.test.tsx b/frontend/src/components/Chat/SubRunBlock.test.tsx index 3323ccb6..c4d919ce 100644 --- a/frontend/src/components/Chat/SubRunBlock.test.tsx +++ b/frontend/src/components/Chat/SubRunBlock.test.tsx @@ -5,6 +5,8 @@ import { describe, expect, it } from 'vitest'; import type { ThreadItem } from '../../lib/subRunView'; import type { ChildSessionNotificationMessage, + ParentDelivery, + SubRunResult, SubRunFinishMessage, SubRunStartMessage, } from '../../types'; @@ -20,6 +22,71 @@ function renderThreadItems(items: ThreadItem[]): ReactNode[] { ); } +function makeCompletedResult( + handoff: { + findings: string[]; + artifacts: { + kind: string; + id: string; + label: string; + sessionId?: string; + storageSeq?: number; + uri?: string; + }[]; + delivery?: ParentDelivery; + } = { + findings: [], + artifacts: [], + } +): SubRunResult { + return { + status: 'completed', + handoff, + }; +} + +function makeFailedResult( + failure: { + code: 'transport' | 'provider_http' | 'stream_parse' | 'interrupted' | 'internal'; + displayMessage: '子 Agent 调用模型时网络连接中断,未完成任务。'; + technicalMessage: 'HTTP request error: failed to read anthropic response stream'; + retryable: true; + } = { + code: 'transport', + displayMessage: '子 Agent 调用模型时网络连接中断,未完成任务。', + technicalMessage: 'HTTP request error: failed to read anthropic response stream', + retryable: true, + } +): SubRunResult { + return { + status: 'failed', + failure, + }; +} + +function makeTokenExceededResult( + handoff: { + findings: string[]; + artifacts: { + kind: string; + id: string; + label: string; + sessionId?: string; + storageSeq?: number; + uri?: string; + }[]; + delivery?: ParentDelivery; + } = { + findings: [], + artifacts: [], + } +): SubRunResult { + return { + status: 'token_exceeded', + handoff, + }; +} + describe('SubRunBlock result rendering', () => { it('renders background running guidance and cancel entry for live sub-runs', () => { const html = renderToStaticMarkup( @@ -45,15 +112,7 @@ describe('SubRunBlock result rendering', () => { id: 'subrun-finish-1', kind: 'subRunFinish', subRunId: 'subrun-1', - result: { - status: 'failed', - failure: { - code: 'transport', - displayMessage: '子 Agent 调用模型时网络连接中断,未完成任务。', - technicalMessage: 'HTTP request error: failed to read anthropic response stream', - retryable: true, - }, - }, + result: makeFailedResult(), stepCount: 3, estimatedTokens: 120, timestamp: Date.now(), @@ -194,24 +253,21 @@ describe('SubRunBlock result rendering', () => { id: 'subrun-finish-2', kind: 'subRunFinish', subRunId: 'subrun-3', - result: { - status: 'completed', - handoff: { - findings: ['问题一', '问题二'], - artifacts: [], - delivery: { - idempotencyKey: 'delivery-directory-summary', - origin: 'explicit', - terminalSemantics: 'terminal', - kind: 'completed', - payload: { - message: '完成了静态分析并整理出两个风险点。', - findings: ['问题一', '问题二'], - artifacts: [], - }, + result: makeCompletedResult({ + findings: ['问题一', '问题二'], + artifacts: [], + delivery: { + idempotencyKey: 'delivery-directory-summary', + origin: 'explicit', + terminalSemantics: 'terminal', + kind: 'completed', + payload: { + message: '完成了静态分析并整理出两个风险点。', + findings: ['问题一', '问题二'], + artifacts: [], }, }, - }, + }), stepCount: 2, estimatedTokens: 80, timestamp: Date.now(), @@ -244,24 +300,21 @@ describe('SubRunBlock result rendering', () => { id: 'subrun-finish-json', kind: 'subRunFinish', subRunId: 'subrun-json', - result: { - status: 'completed', - handoff: { - findings: ['问题一', '问题二'], - artifacts: [], - delivery: { - idempotencyKey: 'delivery-json-summary', - origin: 'explicit', - terminalSemantics: 'terminal', - kind: 'completed', - payload: { - message: '审查完成,发现两个问题。', - findings: ['问题一', '问题二'], - artifacts: [], - }, + result: makeCompletedResult({ + findings: ['问题一', '问题二'], + artifacts: [], + delivery: { + idempotencyKey: 'delivery-json-summary', + origin: 'explicit', + terminalSemantics: 'terminal', + kind: 'completed', + payload: { + message: '审查完成,发现两个问题。', + findings: ['问题一', '问题二'], + artifacts: [], }, }, - }, + }), stepCount: 1, estimatedTokens: 50, timestamp: Date.now(), @@ -294,24 +347,21 @@ describe('SubRunBlock result rendering', () => { id: 'subrun-finish-handoff', kind: 'subRunFinish', subRunId: 'subrun-handoff', - result: { - status: 'completed', - handoff: { - findings: [], - artifacts: [], - delivery: { - idempotencyKey: 'delivery-readable-summary', - origin: 'explicit', - terminalSemantics: 'terminal', - kind: 'completed', - payload: { - message: '代码审查完成:所有模块通过检查。', - findings: [], - artifacts: [], - }, + result: makeCompletedResult({ + findings: [], + artifacts: [], + delivery: { + idempotencyKey: 'delivery-readable-summary', + origin: 'explicit', + terminalSemantics: 'terminal', + kind: 'completed', + payload: { + message: '代码审查完成:所有模块通过检查。', + findings: [], + artifacts: [], }, }, - }, + }), stepCount: 3, estimatedTokens: 120, timestamp: Date.now(), @@ -345,24 +395,21 @@ describe('SubRunBlock result rendering', () => { kind: 'subRunFinish', subRunId: 'subrun-child-session', childSessionId: 'session-child', - result: { - status: 'completed', - handoff: { - findings: ['finding-1'], - artifacts: [], - delivery: { - idempotencyKey: 'delivery-child-session-summary', - origin: 'explicit', - terminalSemantics: 'terminal', - kind: 'completed', - payload: { - message: '这是完整子会话报告,不应该再内嵌在父会话里。', - findings: ['finding-1'], - artifacts: [], - }, + result: makeCompletedResult({ + findings: ['finding-1'], + artifacts: [], + delivery: { + idempotencyKey: 'delivery-child-session-summary', + origin: 'explicit', + terminalSemantics: 'terminal', + kind: 'completed', + payload: { + message: '这是完整子会话报告,不应该再内嵌在父会话里。', + findings: ['finding-1'], + artifacts: [], }, }, - }, + }), stepCount: 2, estimatedTokens: 90, timestamp: Date.now(), @@ -389,6 +436,50 @@ describe('SubRunBlock result rendering', () => { expect(html).toContain('
  • finding-1
  • '); }); + it('renders token-exceeded delivery summary in the parent card', () => { + const finishMessage: SubRunFinishMessage = { + id: 'subrun-finish-token-exceeded', + kind: 'subRunFinish', + subRunId: 'subrun-token-exceeded', + result: makeTokenExceededResult({ + findings: ['partial-finding'], + artifacts: [], + delivery: { + idempotencyKey: 'delivery-token-exceeded-summary', + origin: 'explicit', + terminalSemantics: 'terminal', + kind: 'completed', + payload: { + message: '达到 token 上限,但已返回阶段性结论。', + findings: ['partial-finding'], + artifacts: [], + }, + }, + }), + stepCount: 4, + estimatedTokens: 4096, + timestamp: Date.now(), + }; + + const html = renderToStaticMarkup( + {}} + /> + ); + + expect(html).toContain('达到 token 上限,但已返回阶段性结论。'); + expect(html).toContain('最终回复'); + expect(html).toContain('
  • partial-finding
  • '); + }); + it('renders latest notification delivery when finish message is absent', () => { const latestNotification: ChildSessionNotificationMessage = { id: 'notification-progress-1', diff --git a/frontend/src/components/Chat/SubRunBlock.tsx b/frontend/src/components/Chat/SubRunBlock.tsx index 8b075efc..e18dd2e8 100644 --- a/frontend/src/components/Chat/SubRunBlock.tsx +++ b/frontend/src/components/Chat/SubRunBlock.tsx @@ -1,6 +1,7 @@ import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import type { ChildSessionNotificationMessage, + SubRunResult, ParentDelivery, SubRunFinishMessage, SubRunStartMessage, @@ -41,7 +42,7 @@ interface SubRunBlockProps { displayMode?: 'thread' | 'directory'; } -type SubRunStatus = 'running' | 'completed' | 'aborted' | 'token_exceeded' | 'failed'; +type SubRunStatus = 'running' | 'completed' | 'cancelled' | 'token_exceeded' | 'failed'; function toSubRunStatus(finishMessage?: SubRunFinishMessage): SubRunStatus { return finishMessage?.result.status ?? 'running'; @@ -51,8 +52,8 @@ function getStatusLabel(status: SubRunStatus): string { switch (status) { case 'completed': return 'completed'; - case 'aborted': - return 'aborted'; + case 'cancelled': + return 'cancelled'; case 'token_exceeded': return 'token exceeded'; case 'failed': @@ -74,7 +75,7 @@ function getStatusVariant(status: SubRunStatus): string { switch (status) { case 'completed': return pillSuccess; - case 'aborted': + case 'cancelled': return pillNeutral; case 'token_exceeded': return pillWarning; @@ -99,6 +100,24 @@ function getDeliveryMessage(delivery?: ParentDelivery): string | null { return message && message.length > 0 ? message : null; } +function getResultHandoff(result?: SubRunResult) { + if (!result) { + return undefined; + } + return result.status === 'failed' || result.status === 'cancelled' ? undefined : result.handoff; +} + +function getResultFailure(result?: SubRunResult) { + if (!result) { + return undefined; + } + return result.status === 'failed' || result.status === 'cancelled' ? result.failure : undefined; +} + +function isSuccessfulTerminalStatus(status: SubRunStatus): boolean { + return status === 'completed' || status === 'token_exceeded'; +} + function SubRunBlock({ subRunId, sessionId, @@ -132,8 +151,8 @@ function SubRunBlock({ finishMessage !== undefined ? `${finishMessage.stepCount} steps` : getStorageModeLabel(startMessage, childSessionId); - const resultHandoff = finishMessage?.result.handoff; - const resultFailure = finishMessage?.result.failure; + const resultHandoff = getResultHandoff(finishMessage?.result); + const resultFailure = getResultFailure(finishMessage?.result); const latestDelivery = latestNotification?.delivery ?? resultHandoff?.delivery; const latestDeliveryMessage = getDeliveryMessage(latestDelivery); const isBackgroundRunning = status === 'running'; @@ -337,7 +356,7 @@ function SubRunBlock({ ? completedDelivery.payload.findings : (resultHandoff?.findings ?? []); - if (!completedSummary || status !== 'completed') { + if (!completedSummary || !isSuccessfulTerminalStatus(status)) { return null; } return ( diff --git a/frontend/src/components/Sidebar/index.tsx b/frontend/src/components/Sidebar/index.tsx index 132258fc..fc7ae51b 100644 --- a/frontend/src/components/Sidebar/index.tsx +++ b/frontend/src/components/Sidebar/index.tsx @@ -29,8 +29,6 @@ interface SidebarProps { onDeleteSession: (projectId: string, sessionId: string) => void; onOpenSettings: () => void; onNewSession: () => void; - showDebugWorkbenchEntry?: boolean; - onOpenDebugWorkbench?: () => void; } export default function Sidebar({ @@ -48,8 +46,6 @@ export default function Sidebar({ onDeleteSession, onOpenSettings, onNewSession, - showDebugWorkbenchEntry = false, - onOpenDebugWorkbench, }: SidebarProps) { const [showModal, setShowModal] = useState(false); @@ -111,15 +107,6 @@ export default function Sidebar({
    - {showDebugWorkbenchEntry && onOpenDebugWorkbench ? ( - - ) : null}
    + {conversationControl && ( +
    + {conversationControl.compacting ? ( + + 正在 compact + + ) : conversationControl.compactPending ? ( + + compact 已排队 + + ) : null} +
    + )}
    ); } diff --git a/frontend/src/hooks/app/useComposerActions.ts b/frontend/src/hooks/app/useComposerActions.ts index cd09c0a0..3b12f82d 100644 --- a/frontend/src/hooks/app/useComposerActions.ts +++ b/frontend/src/hooks/app/useComposerActions.ts @@ -45,7 +45,8 @@ interface UseComposerActionsOptions { interrupt: (sessionId: string) => Promise; compactSession: ( sessionId: string, - control?: ExecutionControl + control?: ExecutionControl, + instructions?: string ) => Promise<{ accepted: boolean; deferred: boolean; message: string }>; deleteSession: (sessionId: string) => Promise; deleteProject: (workingDir: string) => Promise; @@ -195,17 +196,12 @@ export function useComposerActions({ } if (slashCommand) { - if (slashCommand.kind === 'compactInvalidArgs') { - appendLocalError( - dispatch, + try { + const acceptance = await compactSession( sessionId, - '`/compact` 当前不接受参数,请直接输入 `/compact`。' + { manualCompact: true }, + slashCommand.kind === 'compact' ? slashCommand.instructions : undefined ); - return; - } - - try { - const acceptance = await compactSession(sessionId, { manualCompact: true }); if (acceptance.deferred) { appendLocalNotice(dispatch, sessionId, acceptance.message); } diff --git a/frontend/src/hooks/app/useSessionCoordinator.ts b/frontend/src/hooks/app/useSessionCoordinator.ts index 989e0bae..20d98fc1 100644 --- a/frontend/src/hooks/app/useSessionCoordinator.ts +++ b/frontend/src/hooks/app/useSessionCoordinator.ts @@ -3,7 +3,13 @@ import { ensureKnownProjects } from '../../lib/knownProjects'; import { groupSessionsByProject, replaceSessionMessages } from '../../store/utils'; import { findMatchingSessionId, normalizeSessionIdForCompare } from '../../lib/sessionId'; import { buildFocusedSubRunFilter, type SessionEventFilterQuery } from '../../lib/sessionView'; -import type { Action, Phase, SessionMeta, SubRunViewData } from '../../types'; +import type { + Action, + ConversationControlState, + Phase, + SessionMeta, + SubRunViewData, +} from '../../types'; import type { ConversationViewProjection } from '../../lib/api/conversation'; interface ActiveSubRunChildren { @@ -53,6 +59,8 @@ export function useSessionCoordinator({ subRuns: [], contentFingerprint: '', }); + const [activeConversationControl, setActiveConversationControl] = + useState(null); const loadSessionBundle = useCallback( async (sessionId: string, subRunPath: string[]) => { @@ -62,6 +70,7 @@ export function useSessionCoordinator({ filter, cursor: projection.cursor, phase: projection.phase, + control: projection.control, messages: projection.messages, messageTree: projection.messageTree, messageFingerprint: projection.messageFingerprint, @@ -92,6 +101,7 @@ export function useSessionCoordinator({ subRuns: loaded.childSubRuns, contentFingerprint: loaded.childContentFingerprint, }); + setActiveConversationControl(loaded.control); // 先写入快照,再切换 active,避免会话切换瞬间渲染空白列表。 activeSessionIdRef.current = sessionId; dispatch({ type: 'SET_ACTIVE', projectId, sessionId }); @@ -119,6 +129,7 @@ export function useSessionCoordinator({ phaseRef.current = projection.phase; dispatch({ type: 'SET_PHASE', phase: projection.phase }); } + setActiveConversationControl(projection.control); }); if (activationGeneration !== sessionActivationGenerationRef.current) { return; @@ -200,6 +211,7 @@ export function useSessionCoordinator({ subRuns: loaded.childSubRuns, contentFingerprint: loaded.childContentFingerprint, }); + setActiveConversationControl(loaded.control); dispatch({ type: 'INITIALIZE', projects: hydratedProjects, @@ -229,6 +241,7 @@ export function useSessionCoordinator({ phaseRef.current = projection.phase; dispatch({ type: 'SET_PHASE', phase: projection.phase }); } + setActiveConversationControl(projection.control); }); if (activationGeneration !== sessionActivationGenerationRef.current) { return; @@ -245,6 +258,7 @@ export function useSessionCoordinator({ subRuns: [], contentFingerprint: '', }); + setActiveConversationControl(null); dispatch({ type: 'INITIALIZE', projects, @@ -271,6 +285,7 @@ export function useSessionCoordinator({ return { activeSubRunChildren, + activeConversationControl, loadAndActivateSession, refreshSessions, }; diff --git a/frontend/src/hooks/useAgent.ts b/frontend/src/hooks/useAgent.ts index 702be774..b0974086 100644 --- a/frontend/src/hooks/useAgent.ts +++ b/frontend/src/hooks/useAgent.ts @@ -410,9 +410,10 @@ export function useAgent() { const handleCompactSession = useCallback( async ( sessionId: string, - control?: ExecutionControl + control?: ExecutionControl, + instructions?: string ): Promise<{ accepted: boolean; deferred: boolean; message: string }> => { - return compactSession(sessionId, control); + return compactSession(sessionId, control, instructions); }, [] ); diff --git a/frontend/src/lib/api/conversation.test.ts b/frontend/src/lib/api/conversation.test.ts index 20e32739..7b9e11cb 100644 --- a/frontend/src/lib/api/conversation.test.ts +++ b/frontend/src/lib/api/conversation.test.ts @@ -3,6 +3,14 @@ import { describe, expect, it } from 'vitest'; import type { ConversationSnapshotState } from './conversation'; import { applyConversationEnvelope, projectConversationState } from './conversation'; +const baseControl = { + phase: 'idle' as const, + canSubmitPrompt: true, + canRequestCompact: true, + compactPending: false, + compacting: false, +}; + describe('projectConversationState', () => { it('keeps thinking blocks visible even when the same turn also has assistant output', () => { const state: ConversationSnapshotState = { @@ -24,6 +32,7 @@ describe('projectConversationState', () => { status: 'streaming', }, ], + control: baseControl, childSummaries: [], }; @@ -68,6 +77,7 @@ describe('projectConversationState', () => { }, }, ], + control: { ...baseControl, phase: 'callingTool' as const }, childSummaries: [], }; @@ -135,6 +145,7 @@ describe('projectConversationState', () => { }, }, ], + control: { ...baseControl, phase: 'callingTool' as const }, childSummaries: [], }; @@ -188,6 +199,7 @@ describe('projectConversationState', () => { }, }, ], + control: { ...baseControl, phase: 'callingTool' as const }, childSummaries: [], }; @@ -246,6 +258,7 @@ describe('projectConversationState', () => { }, }, ], + control: { ...baseControl, phase: 'callingTool' as const }, childSummaries: [], }; @@ -364,6 +377,7 @@ describe('projectConversationState', () => { }, }, ], + control: { ...baseControl, phase: 'callingTool' as const }, childSummaries: [], }; @@ -428,6 +442,7 @@ describe('projectConversationState', () => { }, }, ], + control: { ...baseControl, phase: 'callingTool' as const }, childSummaries: [], }; diff --git a/frontend/src/lib/api/conversation.ts b/frontend/src/lib/api/conversation.ts index 5d15a63e..aee0172c 100644 --- a/frontend/src/lib/api/conversation.ts +++ b/frontend/src/lib/api/conversation.ts @@ -2,6 +2,9 @@ import type { AgentLifecycle, ChildSessionNotificationKind, ChildSessionNotificationMessage, + CompactMeta, + ConversationControlState, + LastCompactMeta, Message, ParentDelivery, Phase, @@ -23,6 +26,7 @@ type ConversationRecord = Record; export interface ConversationSnapshotState { cursor: string | null; phase: Phase; + control: ConversationControlState; blocks: ConversationRecord[]; childSummaries: ConversationRecord[]; } @@ -30,6 +34,7 @@ export interface ConversationSnapshotState { export interface ConversationViewProjection { cursor: string | null; phase: Phase; + control: ConversationControlState; messages: Message[]; messageTree: SubRunThreadTree; messageFingerprint: string; @@ -135,6 +140,70 @@ function childSummaryNotificationKind(lifecycle: AgentLifecycle): ChildSessionNo return lifecycle === 'idle' || lifecycle === 'terminated' ? 'delivered' : 'progress_summary'; } +function parseCompactTrigger(value: unknown): LastCompactMeta['trigger'] { + switch (value) { + case 'auto': + case 'manual': + case 'deferred': + return value; + default: + return 'manual'; + } +} + +function parseCompactMode(value: unknown): CompactMeta['mode'] { + switch (value) { + case 'full': + case 'incremental': + case 'retry_salvage': + return value; + default: + return 'full'; + } +} + +function parseCompactMeta(value: unknown): CompactMeta | undefined { + const record = asRecord(value); + if (!record) { + return undefined; + } + return { + mode: parseCompactMode(record.mode), + instructionsPresent: record.instructionsPresent === true, + fallbackUsed: record.fallbackUsed === true, + retryCount: typeof record.retryCount === 'number' ? record.retryCount : 0, + inputUnits: typeof record.inputUnits === 'number' ? record.inputUnits : 0, + outputSummaryChars: + typeof record.outputSummaryChars === 'number' ? record.outputSummaryChars : 0, + }; +} + +function parseLastCompactMeta(value: unknown): LastCompactMeta | undefined { + const record = asRecord(value); + const meta = parseCompactMeta(record?.meta ?? record); + if (!record || !meta) { + return undefined; + } + return { + trigger: parseCompactTrigger(record.trigger), + meta, + }; +} + +function parseConversationControlState(record: ConversationRecord): ConversationControlState { + const controlRecord = asRecord(record.control); + const phase = parsePhase(controlRecord?.phase ?? record.phase); + return { + phase, + canSubmitPrompt: controlRecord?.canSubmitPrompt !== false, + canRequestCompact: controlRecord?.canRequestCompact !== false, + compactPending: controlRecord?.compactPending === true, + compacting: controlRecord?.compacting === true, + activeTurnId: pickOptionalString(controlRecord ?? {}, 'activeTurnId') ?? undefined, + lastCompactMeta: parseLastCompactMeta(controlRecord?.lastCompactMeta), + }; +} + function childSummaryToMessage( summary: ConversationRecord, options?: { @@ -211,9 +280,11 @@ function normalizeSnapshotState(payload: unknown): ConversationSnapshotState { if (!record) { throw new Error('invalid conversation snapshot response'); } + const control = parseConversationControlState(record); return { cursor: pickOptionalString(record, 'cursor') ?? null, - phase: parsePhase(record.phase), + phase: control.phase, + control, blocks: Array.isArray(record.blocks) ? (record.blocks.filter(asRecord) as ConversationRecord[]) : [], @@ -323,7 +394,17 @@ function projectConversationMessages( id: `conversation-compact:${id}`, kind: 'compact', turnId, - trigger: 'manual', + trigger: parseCompactTrigger( + block.compactMeta ? asRecord(block.compactMeta)?.trigger : undefined + ), + meta: parseCompactMeta(block.compactMeta) ?? { + mode: 'full', + instructionsPresent: false, + fallbackUsed: false, + retryCount: 0, + inputUnits: 0, + outputSummaryChars: (pickString(block, 'markdown') ?? '').length, + }, summary: pickString(block, 'markdown') ?? '', preservedRecentTurns: 0, timestamp: index, @@ -428,7 +509,8 @@ export function projectConversationState( return { cursor: state.cursor, - phase: state.phase, + phase: state.control.phase, + control: state.control, messages, messageTree, messageFingerprint: messageTree.rootStreamFingerprint, @@ -610,7 +692,16 @@ export function applyConversationEnvelope( case 'update_control_state': { const control = asRecord(envelope.control); if (control) { - state.phase = parsePhase(control.phase); + state.control = { + phase: parsePhase(control.phase), + canSubmitPrompt: control.canSubmitPrompt !== false, + canRequestCompact: control.canRequestCompact !== false, + compactPending: control.compactPending === true, + compacting: control.compacting === true, + activeTurnId: pickOptionalString(control, 'activeTurnId') ?? undefined, + lastCompactMeta: parseLastCompactMeta(control.lastCompactMeta), + }; + state.phase = state.control.phase; } return; } diff --git a/frontend/src/lib/api/sessions.ts b/frontend/src/lib/api/sessions.ts index e3a26cbe..2322f0aa 100644 --- a/frontend/src/lib/api/sessions.ts +++ b/frontend/src/lib/api/sessions.ts @@ -104,14 +104,15 @@ export async function closeChildAgent( export async function compactSession( sessionId: string, - control?: ExecutionControl + control?: ExecutionControl, + instructions?: string ): Promise { return requestJson( `/api/sessions/${encodeURIComponent(sessionId)}/compact`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ control }), + body: JSON.stringify({ control, instructions }), } ); } diff --git a/frontend/src/lib/slashCommands.test.ts b/frontend/src/lib/slashCommands.test.ts index b2b5a5d2..94a30d12 100644 --- a/frontend/src/lib/slashCommands.test.ts +++ b/frontend/src/lib/slashCommands.test.ts @@ -8,8 +8,15 @@ describe('parseRuntimeSlashCommand', () => { expect(parseRuntimeSlashCommand(' /compact ')).toEqual({ kind: 'compact' }); }); - it('rejects compact with unexpected arguments', () => { - expect(parseRuntimeSlashCommand('/compact now')).toEqual({ kind: 'compactInvalidArgs' }); + it('captures compact instructions when arguments are present', () => { + expect(parseRuntimeSlashCommand('/compact now')).toEqual({ + kind: 'compact', + instructions: 'now', + }); + expect(parseRuntimeSlashCommand('/compact keep paths and errors ')).toEqual({ + kind: 'compact', + instructions: 'keep paths and errors', + }); }); it('does not hijack similarly named prompt text', () => { diff --git a/frontend/src/lib/slashCommands.ts b/frontend/src/lib/slashCommands.ts index a2b90394..57ee56ad 100644 --- a/frontend/src/lib/slashCommands.ts +++ b/frontend/src/lib/slashCommands.ts @@ -2,7 +2,9 @@ //! //! 这里专门收敛前端自执行的 slash command 解析规则,避免把 UI 文本判断散落在组件里。 -export type RuntimeSlashCommand = { kind: 'compact' } | { kind: 'compactInvalidArgs' }; +export type RuntimeSlashCommand = + | { kind: 'compact'; instructions?: string } + | { kind: 'compactInvalidArgs' }; export function parseRuntimeSlashCommand(input: string): RuntimeSlashCommand | null { const trimmed = input.trim(); @@ -10,9 +12,13 @@ export function parseRuntimeSlashCommand(input: string): RuntimeSlashCommand | n return { kind: 'compact' }; } - // v1 只支持独立命令。这里显式拦截附带参数的写法,避免前端悄悄把未知语义发给后端。 - if (/^\/compact\s+.+$/u.test(trimmed)) { - return { kind: 'compactInvalidArgs' }; + const compactWithArgs = trimmed.match(/^\/compact(?:\s+(.*))$/u); + if (compactWithArgs) { + const instructions = compactWithArgs[1]?.trim(); + if (!instructions) { + return { kind: 'compact' }; + } + return { kind: 'compact', instructions }; } return null; diff --git a/frontend/src/types.ts b/frontend/src/types.ts index be885cff..9918ab51 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -4,7 +4,8 @@ export type Phase = 'idle' | 'thinking' | 'callingTool' | 'streaming' | 'interrupted' | 'done'; export type ToolOutputStream = 'stdout' | 'stderr'; -export type CompactTrigger = 'auto' | 'manual'; +export type CompactTrigger = 'auto' | 'manual' | 'deferred'; +export type CompactMode = 'full' | 'incremental' | 'retry_salvage'; export type InvocationKind = 'subRun' | 'rootExecution'; // Why: 当前写路径只允许 `independentSession`,前端读侧保持同样约束, // 避免把已经移除的历史模式继续编码成正式类型。 @@ -133,6 +134,30 @@ export interface ExecutionControl { manualCompact?: boolean; } +export interface CompactMeta { + mode: CompactMode; + instructionsPresent: boolean; + fallbackUsed: boolean; + retryCount: number; + inputUnits: number; + outputSummaryChars: number; +} + +export interface LastCompactMeta { + trigger: CompactTrigger; + meta: CompactMeta; +} + +export interface ConversationControlState { + phase: Phase; + canSubmitPrompt: boolean; + canRequestCompact: boolean; + compactPending: boolean; + compacting: boolean; + activeTurnId?: string; + lastCompactMeta?: LastCompactMeta; +} + export type SubRunResult = | { status: 'running' | 'completed' | 'token_exceeded'; @@ -283,6 +308,7 @@ export interface CompactMessage { storageMode?: SubRunStorageMode; childSessionId?: string; trigger: CompactTrigger; + meta: CompactMeta; summary: string; preservedRecentTurns: number; timestamp: number; From fcfacd108d6a4032c62cb7aeace45ac6bdeafcf2 Mon Sep 17 00:00:00 2001 From: whatevertogo Date: Thu, 16 Apr 2026 22:03:42 +0800 Subject: [PATCH 12/53] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor(protocol):?= =?UTF-8?q?=20=E6=94=B6=E5=8F=A3=E5=85=B1=E4=BA=AB=E6=91=98=E8=A6=81=20own?= =?UTF-8?q?er=20=E5=B9=B6=E7=B2=BE=E7=AE=80=20session-runtime=20=E6=9F=A5?= =?UTF-8?q?=E8=AF=A2=E8=BE=B9=E7=95=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit crates/core/src/{execution_control.rs,composer.rs,observability.rs,session_catalog.rs,config.rs,lib.rs,plugin/registry.rs} - Why: ExecutionControl、composer/model/config 测试结果、runtime observability、session catalog 事件已经成为跨 core/application/protocol/server 共享的稳定语义,继续在多层重复定义会让 owner 漂移并放大维护成本。 - How: 新增 core canonical types 与 serde owner,把 observability/session catalog/composer/execution control/test connection 等共享结构统一收回 core,并补顶层导出与必要的序列化支持。 crates/application/src/{lib.rs,execution/control.rs,composer/mod.rs,config/mod.rs,observability/mod.rs,observability/metrics_snapshot.rs,mcp/mod.rs,session_use_cases.rs,agent_use_cases.rs,terminal/mod.rs,terminal_use_cases.rs} - Why: server 不该继续承担 accepted response、runtime/config/status、conversation terminal UI 派生等业务投影;application 需要成为协议 DTO 之前的共享 summary owner。 - How: 将 application 侧重复类型改为复用 core,并新增 Config/Runtime/Prompt/Compact/SessionList/AgentExecute/SubRunStatus/ConversationAuthoritative 等 summary 输入与解析函数,统一承接 API key 脱敏、active selection、运行时摘要、conversation control/child/slash 派生规则。 crates/protocol/src/http/{agent.rs,composer.rs,config.rs,event.rs,model.rs,runtime.rs,session.rs,session_event.rs} - Why: protocol 只应保留真正的协议壳和读模型,不应继续维护与 core/application 同形同义的镜像 DTO。 - How: 将 model/composer/test result/runtime metrics/plugin state/session catalog/agent 部分共享语义改为直接 alias core canonical type,仅保留协议外层 response/envelope/read-model。 crates/server/src/http/{auth.rs,mapper.rs,terminal_projection.rs,routes/mod.rs,routes/mcp.rs,routes/model.rs,routes/config.rs,routes/agents.rs,routes/conversation.rs,routes/sessions/mutation.rs,routes/sessions/query.rs} - Why: HTTP 层此前同时承担协议包装和业务整形,导致 mapper/route 里充满字段直拷贝、fallback、UI capability 派生和 message 决策。 - How: auth/mcp/config/model/agent/session/conversation 路由改为消费 application summary 或 core alias,删除大批纯搬运映射;terminal_projection 只保留 summary -> DTO 重命名,conversation stream authoritative refresh 也改为围绕 application summary 运行而不再并行维护 raw terminal facts。 crates/session-runtime/src/{lib.rs,actor/mod.rs,catalog/mod.rs,command/mod.rs,query/mod.rs,query/service.rs,query/agent.rs,query/conversation.rs,query/text.rs,turn/mod.rs,turn/runner.rs,turn/tool_cycle.rs,context_window/mod.rs,state/mailbox.rs,context/mod.rs,factory/mod.rs} - Why: session-runtime 对外 surface 过宽、查询辅助逻辑重复且等待 turn 终态依赖轮询,既增加耦合也拉低查询路径的可维护性。 - How: 收紧模块可见性与 re-export,删除无用 factory/context/mailbox 辅助,新增 query::text 统一摘要截断逻辑,并把 turn terminal snapshot 等查询改成基于 broadcaster 唤醒与单次 durable fallback 的实现,同时在 conversation replay 路径上拆分 seed/replay 历史避免重复加载。 --- crates/application/src/agent_use_cases.rs | 501 ++++++++++++++- crates/application/src/composer/mod.rs | 27 +- crates/application/src/config/mod.rs | 176 +++++- crates/application/src/execution/control.rs | 20 +- crates/application/src/lib.rs | 115 +++- crates/application/src/mcp/mod.rs | 56 ++ .../src/observability/metrics_snapshot.rs | 133 +--- crates/application/src/observability/mod.rs | 267 ++++++++ crates/application/src/session_use_cases.rs | 88 ++- crates/application/src/terminal/mod.rs | 146 ++++- crates/application/src/terminal_use_cases.rs | 22 +- crates/core/src/composer.rs | 35 ++ crates/core/src/config.rs | 13 +- crates/core/src/execution_control.rs | 28 + crates/core/src/lib.rs | 15 +- crates/core/src/observability.rs | 255 ++++++++ crates/core/src/plugin/registry.rs | 6 +- crates/core/src/session_catalog.rs | 20 + crates/protocol/src/http/agent.rs | 86 +-- crates/protocol/src/http/composer.rs | 49 +- crates/protocol/src/http/config.rs | 15 +- crates/protocol/src/http/event.rs | 51 +- crates/protocol/src/http/model.rs | 33 +- crates/protocol/src/http/runtime.rs | 157 +---- crates/protocol/src/http/session.rs | 14 +- crates/protocol/src/http/session_event.rs | 32 +- crates/server/src/http/auth.rs | 17 + crates/server/src/http/mapper.rs | 563 +++++------------ crates/server/src/http/routes/agents.rs | 568 +----------------- crates/server/src/http/routes/config.rs | 12 +- crates/server/src/http/routes/conversation.rs | 138 ++--- crates/server/src/http/routes/mcp.rs | 17 +- crates/server/src/http/routes/mod.rs | 8 +- crates/server/src/http/routes/model.rs | 7 +- .../src/http/routes/sessions/mutation.rs | 78 +-- .../server/src/http/routes/sessions/query.rs | 1 + crates/server/src/http/terminal_projection.rs | 263 ++++---- crates/session-runtime/src/actor/mod.rs | 40 +- crates/session-runtime/src/catalog/mod.rs | 26 +- crates/session-runtime/src/command/mod.rs | 4 +- crates/session-runtime/src/context/mod.rs | 26 - .../src/context_window/compaction.rs | 10 +- .../src/context_window/file_access.rs | 4 +- .../src/context_window/micro_compact.rs | 37 +- .../session-runtime/src/context_window/mod.rs | 14 +- .../src/context_window/prune_pass.rs | 4 +- .../src/context_window/token_usage.rs | 4 +- crates/session-runtime/src/factory/mod.rs | 11 - crates/session-runtime/src/lib.rs | 42 +- crates/session-runtime/src/query/agent.rs | 35 +- .../session-runtime/src/query/conversation.rs | 26 +- crates/session-runtime/src/query/mod.rs | 27 +- crates/session-runtime/src/query/service.rs | 535 ++++++++++++++++- crates/session-runtime/src/query/text.rs | 45 ++ .../session-runtime/src/query/transcript.rs | 2 +- crates/session-runtime/src/query/turn.rs | 4 +- crates/session-runtime/src/state/mailbox.rs | 72 --- crates/session-runtime/src/state/mod.rs | 15 +- .../src/turn/compaction_cycle.rs | 4 +- .../src/turn/continuation_cycle.rs | 2 +- crates/session-runtime/src/turn/llm_cycle.rs | 12 +- .../session-runtime/src/turn/loop_control.rs | 2 +- crates/session-runtime/src/turn/mod.rs | 18 +- crates/session-runtime/src/turn/runner.rs | 11 +- crates/session-runtime/src/turn/submit.rs | 2 - .../session-runtime/src/turn/test_support.rs | 4 +- crates/session-runtime/src/turn/tool_cycle.rs | 14 +- 67 files changed, 2799 insertions(+), 2285 deletions(-) create mode 100644 crates/core/src/composer.rs create mode 100644 crates/core/src/execution_control.rs create mode 100644 crates/core/src/session_catalog.rs delete mode 100644 crates/session-runtime/src/context/mod.rs delete mode 100644 crates/session-runtime/src/factory/mod.rs create mode 100644 crates/session-runtime/src/query/text.rs diff --git a/crates/application/src/agent_use_cases.rs b/crates/application/src/agent_use_cases.rs index 622d25f5..449114f6 100644 --- a/crates/application/src/agent_use_cases.rs +++ b/crates/application/src/agent_use_cases.rs @@ -1,5 +1,15 @@ +use astrcode_core::{ + AgentEventContext, AgentLifecycleStatus, AgentTurnOutcome, InvocationKind, + ResolvedExecutionLimitsSnapshot, ResolvedSubagentContextOverrides, StorageEventPayload, + StoredEvent, SubRunResult, SubRunStorageMode, +}; +use astrcode_kernel::SubRunStatusView; + /// ! 这是 App 的用例实现,不是 ports -use crate::{App, ApplicationError}; +use crate::{ + AgentExecuteSummary, App, ApplicationError, RootExecutionRequest, SubRunStatusSourceSummary, + SubRunStatusSummary, summarize_session_meta, +}; impl App { // ── Agent 控制用例(通过 kernel 稳定控制合同) ────────── @@ -8,7 +18,7 @@ impl App { pub async fn get_subrun_status( &self, agent_id: &str, - ) -> Result, ApplicationError> { + ) -> Result, ApplicationError> { self.validate_non_empty("agentId", agent_id)?; Ok(self.kernel.query_subrun_status(agent_id).await) } @@ -17,16 +27,103 @@ impl App { pub async fn get_root_agent_status( &self, session_id: &str, - ) -> Result, ApplicationError> { + ) -> Result, ApplicationError> { self.validate_non_empty("sessionId", session_id)?; Ok(self.kernel.query_root_status(session_id).await) } /// 列出所有 agent 状态。 - pub async fn list_agent_statuses(&self) -> Vec { + pub async fn list_agent_statuses(&self) -> Vec { self.kernel.list_statuses().await } + /// 执行 root agent 并返回共享摘要输入。 + pub async fn execute_root_agent_summary( + &self, + request: RootExecutionRequest, + ) -> Result { + let accepted = self.execute_root_agent(request).await?; + let session_id = accepted.session_id.to_string(); + Ok(AgentExecuteSummary { + accepted: true, + message: format!( + "agent '{}' execution accepted; subscribe to \ + /api/v1/conversation/sessions/{}/stream for progress", + accepted.agent_id.as_deref().unwrap_or("unknown-agent"), + session_id + ), + session_id: Some(session_id), + turn_id: Some(accepted.turn_id.to_string()), + agent_id: accepted.agent_id.map(|value| value.to_string()), + }) + } + + /// 查询指定 session/sub-run 的共享状态摘要。 + pub async fn get_subrun_status_summary( + &self, + session_id: &str, + requested_subrun_id: &str, + ) -> Result { + self.validate_non_empty("sessionId", session_id)?; + self.validate_non_empty("subRunId", requested_subrun_id)?; + + if let Some(view) = self.get_subrun_status(requested_subrun_id).await? { + return Ok(summarize_live_subrun_status(view, session_id.to_string())); + } + + if let Some(view) = self.get_root_agent_status(session_id).await? { + if view.sub_run_id == requested_subrun_id { + return Ok(summarize_live_subrun_status(view, session_id.to_string())); + } + return Err(ApplicationError::NotFound(format!( + "subrun '{}' not found in session '{}'", + requested_subrun_id, session_id + ))); + } + + if let Some(summary) = self + .durable_subrun_status_summary(session_id, requested_subrun_id) + .await? + { + return Ok(summary); + } + + Ok(default_subrun_status_summary( + session_id.to_string(), + requested_subrun_id.to_string(), + )) + } + + async fn durable_subrun_status_summary( + &self, + parent_session_id: &str, + requested_subrun_id: &str, + ) -> Result, ApplicationError> { + let child_sessions = self + .list_sessions() + .await? + .into_iter() + .map(summarize_session_meta) + .filter(|summary| summary.parent_session_id.as_deref() == Some(parent_session_id)) + .collect::>(); + + for child_session in child_sessions { + let stored_events = self + .session_stored_events(&child_session.session_id) + .await?; + if let Some(summary) = project_durable_subrun_status_summary( + parent_session_id, + &child_session.session_id, + requested_subrun_id, + &stored_events, + ) { + return Ok(Some(summary)); + } + } + + Ok(None) + } + /// 关闭 agent 及其子树。 pub async fn close_agent( &self, @@ -42,7 +139,6 @@ impl App { ))); }; if handle.session_id.as_str() != session_id { - // 显式校验归属,避免仅凭 agent_id 跨 session 关闭不相关子树。 return Err(ApplicationError::NotFound(format!( "agent '{}' not found in session '{}'", agent_id, session_id @@ -54,3 +150,398 @@ impl App { .map_err(|error| ApplicationError::Internal(error.to_string())) } } + +fn summarize_live_subrun_status(view: SubRunStatusView, session_id: String) -> SubRunStatusSummary { + SubRunStatusSummary { + sub_run_id: view.sub_run_id, + tool_call_id: None, + source: SubRunStatusSourceSummary::Live, + agent_id: view.agent_id, + agent_profile: view.agent_profile, + session_id, + child_session_id: view.child_session_id, + depth: view.depth, + parent_agent_id: view.parent_agent_id, + parent_sub_run_id: None, + storage_mode: SubRunStorageMode::IndependentSession, + lifecycle: view.lifecycle, + last_turn_outcome: view.last_turn_outcome, + result: None, + step_count: None, + estimated_tokens: None, + resolved_overrides: None, + resolved_limits: Some(view.resolved_limits), + } +} + +fn default_subrun_status_summary(session_id: String, sub_run_id: String) -> SubRunStatusSummary { + SubRunStatusSummary { + sub_run_id, + tool_call_id: None, + source: SubRunStatusSourceSummary::Live, + agent_id: "root-agent".to_string(), + agent_profile: "default".to_string(), + session_id, + child_session_id: None, + depth: 0, + parent_agent_id: None, + parent_sub_run_id: None, + storage_mode: SubRunStorageMode::IndependentSession, + lifecycle: AgentLifecycleStatus::Idle, + last_turn_outcome: None, + result: None, + step_count: None, + estimated_tokens: None, + resolved_overrides: None, + resolved_limits: Some(ResolvedExecutionLimitsSnapshot { + allowed_tools: Vec::new(), + max_steps: None, + }), + } +} + +#[derive(Debug, Clone)] +struct DurableSubRunStatusProjection { + sub_run_id: String, + tool_call_id: Option, + agent_id: String, + agent_profile: String, + child_session_id: String, + depth: usize, + parent_agent_id: Option, + parent_sub_run_id: Option, + lifecycle: AgentLifecycleStatus, + last_turn_outcome: Option, + result: Option, + step_count: Option, + estimated_tokens: Option, + resolved_overrides: Option, + resolved_limits: ResolvedExecutionLimitsSnapshot, +} + +fn project_durable_subrun_status_summary( + parent_session_id: &str, + child_session_id: &str, + requested_subrun_id: &str, + stored_events: &[StoredEvent], +) -> Option { + let mut projection: Option = None; + + for stored in stored_events { + let agent = &stored.event.agent; + if !matches_requested_subrun(agent, requested_subrun_id) { + continue; + } + + match &stored.event.payload { + StorageEventPayload::SubRunStarted { + tool_call_id, + resolved_overrides, + resolved_limits, + .. + } => { + projection = Some(DurableSubRunStatusProjection { + sub_run_id: agent + .sub_run_id + .clone() + .unwrap_or_else(|| requested_subrun_id.to_string().into()) + .to_string(), + tool_call_id: tool_call_id.clone(), + agent_id: agent + .agent_id + .clone() + .unwrap_or_else(|| requested_subrun_id.to_string().into()) + .to_string(), + agent_profile: agent + .agent_profile + .clone() + .unwrap_or_else(|| "unknown".to_string()), + child_session_id: child_session_id.to_string(), + depth: 1, + parent_agent_id: None, + parent_sub_run_id: agent.parent_sub_run_id.clone().map(|id| id.to_string()), + lifecycle: AgentLifecycleStatus::Running, + last_turn_outcome: None, + result: None, + step_count: None, + estimated_tokens: None, + resolved_overrides: Some(resolved_overrides.clone()), + resolved_limits: resolved_limits.clone(), + }); + }, + StorageEventPayload::SubRunFinished { + tool_call_id, + result, + step_count, + estimated_tokens, + .. + } => { + let entry = projection.get_or_insert_with(|| DurableSubRunStatusProjection { + sub_run_id: agent + .sub_run_id + .clone() + .unwrap_or_else(|| requested_subrun_id.to_string().into()) + .to_string(), + tool_call_id: None, + agent_id: agent + .agent_id + .clone() + .unwrap_or_else(|| requested_subrun_id.to_string().into()) + .to_string(), + agent_profile: agent + .agent_profile + .clone() + .unwrap_or_else(|| "unknown".to_string()), + child_session_id: child_session_id.to_string(), + depth: 1, + parent_agent_id: None, + parent_sub_run_id: agent.parent_sub_run_id.clone().map(|id| id.to_string()), + lifecycle: result.status().lifecycle(), + last_turn_outcome: result.status().last_turn_outcome(), + result: None, + step_count: None, + estimated_tokens: None, + resolved_overrides: None, + resolved_limits: ResolvedExecutionLimitsSnapshot::default(), + }); + entry.tool_call_id = tool_call_id.clone().or_else(|| entry.tool_call_id.clone()); + entry.lifecycle = result.status().lifecycle(); + entry.last_turn_outcome = result.status().last_turn_outcome(); + entry.result = Some(result.clone()); + entry.step_count = Some(*step_count); + entry.estimated_tokens = Some(*estimated_tokens); + }, + _ => {}, + } + } + + projection.map(|projection| SubRunStatusSummary { + sub_run_id: projection.sub_run_id, + tool_call_id: projection.tool_call_id, + source: SubRunStatusSourceSummary::Durable, + agent_id: projection.agent_id, + agent_profile: projection.agent_profile, + session_id: parent_session_id.to_string(), + child_session_id: Some(projection.child_session_id), + depth: projection.depth, + parent_agent_id: projection.parent_agent_id, + parent_sub_run_id: projection.parent_sub_run_id, + storage_mode: SubRunStorageMode::IndependentSession, + lifecycle: projection.lifecycle, + last_turn_outcome: projection.last_turn_outcome, + result: projection.result, + step_count: projection.step_count, + estimated_tokens: projection.estimated_tokens, + resolved_overrides: projection.resolved_overrides, + resolved_limits: Some(projection.resolved_limits), + }) +} + +fn matches_requested_subrun(agent: &AgentEventContext, requested_subrun_id: &str) -> bool { + if agent.invocation_kind != Some(InvocationKind::SubRun) { + return false; + } + + agent.sub_run_id.as_deref() == Some(requested_subrun_id) + || agent.agent_id.as_deref() == Some(requested_subrun_id) +} + +#[cfg(test)] +mod tests { + use astrcode_core::{ + AgentTurnOutcome, ArtifactRef, CompletedParentDeliveryPayload, CompletedSubRunOutcome, + ForkMode, ParentDelivery, ParentDeliveryOrigin, ParentDeliveryPayload, + ParentDeliveryTerminalSemantics, ResolvedExecutionLimitsSnapshot, + ResolvedSubagentContextOverrides, StorageEvent, StorageEventPayload, SubRunResult, + SubRunStorageMode, + }; + + use super::project_durable_subrun_status_summary; + use crate::{AgentEventContext, StoredEvent, SubRunHandoff}; + + #[test] + fn durable_subrun_projection_preserves_typed_handoff_delivery() { + let child_agent = AgentEventContext::sub_run( + "agent-child", + "turn-parent", + "reviewer", + "subrun-child", + Some("subrun-parent".into()), + SubRunStorageMode::IndependentSession, + Some("session-child".into()), + ); + let explicit_delivery = ParentDelivery { + idempotency_key: "delivery-explicit".to_string(), + origin: ParentDeliveryOrigin::Explicit, + terminal_semantics: ParentDeliveryTerminalSemantics::Terminal, + source_turn_id: Some("turn-child".to_string()), + payload: ParentDeliveryPayload::Completed(CompletedParentDeliveryPayload { + message: "显式交付".to_string(), + findings: vec!["finding-1".to_string()], + artifacts: vec![ArtifactRef { + kind: "session".to_string(), + id: "session-child".to_string(), + label: "Child Session".to_string(), + session_id: Some("session-child".to_string()), + storage_seq: None, + uri: None, + }], + }), + }; + let stored_events = vec![StoredEvent { + storage_seq: 1, + event: StorageEvent { + turn_id: Some("turn-child".to_string()), + agent: child_agent.clone(), + payload: StorageEventPayload::SubRunFinished { + tool_call_id: Some("call-1".to_string()), + result: SubRunResult::Completed { + outcome: CompletedSubRunOutcome::Completed, + handoff: SubRunHandoff { + findings: vec!["finding-1".to_string()], + artifacts: vec![ArtifactRef { + kind: "session".to_string(), + id: "session-child".to_string(), + label: "Child Session".to_string(), + session_id: Some("session-child".to_string()), + storage_seq: None, + uri: None, + }], + delivery: Some(explicit_delivery.clone()), + }, + }, + timestamp: Some(chrono::Utc::now()), + step_count: 3, + estimated_tokens: 120, + }, + }, + }]; + + let projection = project_durable_subrun_status_summary( + "session-parent", + "session-child", + "subrun-child", + &stored_events, + ) + .expect("projection should exist"); + + let result = projection.result.expect("durable result should exist"); + let handoff = match result { + SubRunResult::Running { handoff } | SubRunResult::Completed { handoff, .. } => handoff, + SubRunResult::Failed { .. } => panic!("expected successful durable handoff"), + }; + let delivery = handoff + .delivery + .expect("typed delivery should survive durable projection"); + assert_eq!(delivery.idempotency_key, "delivery-explicit"); + assert_eq!(delivery.origin, ParentDeliveryOrigin::Explicit); + assert_eq!( + delivery.terminal_semantics, + ParentDeliveryTerminalSemantics::Terminal + ); + match delivery.payload { + ParentDeliveryPayload::Completed(payload) => { + assert_eq!(payload.message, "显式交付"); + assert_eq!(payload.findings, vec!["finding-1".to_string()]); + }, + payload => panic!("unexpected delivery payload: {payload:?}"), + } + } + + #[test] + fn resolved_overrides_projection_preserves_fork_mode() { + let projection = project_durable_subrun_status_summary( + "session-parent", + "session-child", + "subrun-child", + &[StoredEvent { + storage_seq: 1, + event: StorageEvent { + turn_id: Some("turn-child".to_string()), + agent: AgentEventContext::sub_run( + "agent-child", + "turn-parent", + "reviewer", + "subrun-child", + Some("subrun-parent".into()), + SubRunStorageMode::IndependentSession, + Some("session-child".into()), + ), + payload: StorageEventPayload::SubRunStarted { + tool_call_id: Some("call-1".to_string()), + resolved_overrides: ResolvedSubagentContextOverrides { + fork_mode: Some(ForkMode::LastNTurns(7)), + ..ResolvedSubagentContextOverrides::default() + }, + resolved_limits: ResolvedExecutionLimitsSnapshot::default(), + timestamp: Some(chrono::Utc::now()), + }, + }, + }], + ) + .expect("projection should exist"); + + assert_eq!( + projection + .resolved_overrides + .expect("resolved overrides should exist") + .fork_mode, + Some(ForkMode::LastNTurns(7)) + ); + } + + #[test] + fn durable_subrun_projection_maps_token_exceeded_to_successful_handoff_result() { + let child_agent = AgentEventContext::sub_run( + "agent-child", + "turn-parent", + "reviewer", + "subrun-child", + Some("subrun-parent".into()), + SubRunStorageMode::IndependentSession, + Some("session-child".into()), + ); + let stored_events = vec![StoredEvent { + storage_seq: 1, + event: StorageEvent { + turn_id: Some("turn-child".to_string()), + agent: child_agent, + payload: StorageEventPayload::SubRunFinished { + tool_call_id: Some("call-1".to_string()), + result: SubRunResult::Completed { + outcome: CompletedSubRunOutcome::TokenExceeded, + handoff: SubRunHandoff { + findings: vec!["partial-finding".to_string()], + artifacts: Vec::new(), + delivery: None, + }, + }, + timestamp: Some(chrono::Utc::now()), + step_count: 5, + estimated_tokens: 2048, + }, + }, + }]; + + let projection = project_durable_subrun_status_summary( + "session-parent", + "session-child", + "subrun-child", + &stored_events, + ) + .expect("projection should exist"); + + let result = projection.result.expect("durable result should exist"); + match result { + SubRunResult::Completed { outcome, handoff } => { + assert_eq!(outcome, CompletedSubRunOutcome::TokenExceeded); + assert_eq!(handoff.findings, vec!["partial-finding".to_string()]); + }, + other => panic!("expected token exceeded handoff result, got {other:?}"), + } + assert_eq!( + projection.last_turn_outcome, + Some(AgentTurnOutcome::TokenExceeded) + ); + } +} diff --git a/crates/application/src/composer/mod.rs b/crates/application/src/composer/mod.rs index d1f8fe27..78256141 100644 --- a/crates/application/src/composer/mod.rs +++ b/crates/application/src/composer/mod.rs @@ -3,25 +3,13 @@ //! 提供 composer 输入候选列表的查询和过滤用例。 //! 候选来源包括:命令、技能、能力(通过 `KernelGateway` 查询)。 +pub use astrcode_core::{ComposerOption, ComposerOptionActionKind, ComposerOptionKind}; use astrcode_kernel::KernelGateway; // ============================================================ // 业务模型 // ============================================================ -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub enum ComposerOptionKind { - Command, - Skill, - Capability, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub enum ComposerOptionActionKind { - InsertText, - ExecuteCommand, -} - #[derive(Debug, Clone, PartialEq, Eq)] pub struct ComposerOptionsRequest { pub query: Option, @@ -39,19 +27,6 @@ impl Default for ComposerOptionsRequest { } } -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ComposerOption { - pub kind: ComposerOptionKind, - pub id: String, - pub title: String, - pub description: String, - pub insert_text: String, - pub action_kind: ComposerOptionActionKind, - pub action_value: String, - pub badges: Vec, - pub keywords: Vec, -} - #[derive(Debug, Clone, PartialEq, Eq)] pub struct ComposerSkillSummary { pub id: String, diff --git a/crates/application/src/config/mod.rs b/crates/application/src/config/mod.rs index 2e1d6c31..86ff1766 100644 --- a/crates/application/src/config/mod.rs +++ b/crates/application/src/config/mod.rs @@ -24,7 +24,7 @@ use std::{ sync::Arc, }; -use astrcode_core::{Config, ConfigOverlay}; +use astrcode_core::{Config, ConfigOverlay, TestConnectionResult}; pub use astrcode_core::{ config::{ DEFAULT_API_SESSION_TTL_HOURS, DEFAULT_AUTO_COMPACT_ENABLED, @@ -62,21 +62,33 @@ use tokio::sync::RwLock; use crate::ApplicationError; -/// 模型连通性测试结果。 -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct TestConnectionResult { - pub success: bool, - pub provider: String, - pub model: String, - pub error: Option, -} - /// 配置用例入口:负责配置的读取、写入、校验和装配。 pub struct ConfigService { pub(super) store: Arc, pub(super) config: Arc>, } +/// 单个 profile 的摘要输入。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ConfigProfileSummary { + pub name: String, + pub base_url: String, + pub api_key_preview: String, + pub models: Vec, +} + +/// 已解析的配置摘要输入。 +/// +/// 这是 protocol `ConfigView` 的共享 projection input, +/// server 只需要补上 `config_path` 和协议外层壳。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ResolvedConfigSummary { + pub active_profile: String, + pub active_model: String, + pub profiles: Vec, + pub warning: Option, +} + impl ConfigService { /// 从存储创建配置服务,加载并验证配置。 pub fn new(store: Arc) -> Self { @@ -197,6 +209,88 @@ impl ConfigService { } } +/// 生成配置摘要输入,供协议层投影复用。 +pub fn resolve_config_summary(config: &Config) -> Result { + if config.profiles.is_empty() { + return Ok(ResolvedConfigSummary { + active_profile: String::new(), + active_model: String::new(), + profiles: Vec::new(), + warning: Some("no profiles configured".to_string()), + }); + } + + let profiles = config + .profiles + .iter() + .map(|profile| ConfigProfileSummary { + name: profile.name.clone(), + base_url: profile.base_url.clone(), + api_key_preview: api_key_preview(profile.api_key.as_deref()), + models: profile + .models + .iter() + .map(|model| model.id.clone()) + .collect(), + }) + .collect(); + + let selection = selection::resolve_active_selection( + &config.active_profile, + &config.active_model, + &config.profiles, + )?; + + Ok(ResolvedConfigSummary { + active_profile: selection.active_profile, + active_model: selection.active_model, + profiles, + warning: selection.warning, + }) +} + +/// 生成 API key 的安全预览字符串。 +/// +/// 规则: +/// - `None` 或空字符串 → "未配置" +/// - `env:VAR_NAME` 前缀 → "环境变量: VAR_NAME"(不读取实际值) +/// - `literal:KEY` 前缀 → 显示 **** + 最后 4 个字符 +/// - 纯大写+下划线且是有效环境变量名 → "环境变量: NAME" +/// - 长度 > 4 → 显示 "****" + 最后 4 个字符 +/// - 其他 → "****" +pub fn api_key_preview(api_key: Option<&str>) -> String { + match api_key.map(str::trim) { + None | Some("") => "未配置".to_string(), + Some(value) if value.starts_with("env:") => { + let env_name = value.trim_start_matches("env:").trim(); + if env_name.is_empty() { + "未配置".to_string() + } else { + format!("环境变量: {}", env_name) + } + }, + Some(value) if value.starts_with("literal:") => { + let key = value.trim_start_matches("literal:").trim(); + masked_key_preview(key) + }, + Some(value) if is_env_var_name(value) && std::env::var_os(value).is_some() => { + format!("环境变量: {}", value) + }, + Some(value) => masked_key_preview(value), + } +} + +fn masked_key_preview(value: &str) -> String { + let char_starts: Vec = value.char_indices().map(|(index, _)| index).collect(); + + if char_starts.len() <= 4 { + "****".to_string() + } else { + let suffix_start = char_starts[char_starts.len() - 4]; + format!("****{}", &value[suffix_start..]) + } +} + /// 应用项目 overlay 到基础配置(仅覆盖显式设置的字段)。 fn apply_overlay(mut base: Config, overlay: ConfigOverlay) -> Config { if let Some(active_profile) = overlay.active_profile { @@ -220,9 +314,15 @@ pub fn is_env_var_name(value: &str) -> bool { #[cfg(test)] mod tests { + use astrcode_core::{ModelConfig, Profile}; + use super::*; use crate::config::test_support::TestConfigStore; + fn model(id: &str) -> ModelConfig { + ModelConfig::new(id) + } + #[test] fn load_resolved_runtime_config_materializes_defaults() { let service = ConfigService::new(Arc::new(TestConfigStore::default())); @@ -264,4 +364,60 @@ mod tests { assert_eq!(runtime.agent.max_subrun_depth, 5); assert_eq!(runtime.agent.max_spawn_per_turn, 2); } + + #[test] + fn api_key_preview_masks_utf8_literal_without_panicking() { + assert_eq!( + api_key_preview(Some("literal:令牌甲乙丙丁")), + "****甲乙丙丁" + ); + } + + #[test] + fn api_key_preview_masks_utf8_plain_value_without_panicking() { + assert_eq!(api_key_preview(Some("令牌甲乙丙丁戊")), "****乙丙丁戊"); + } + + #[test] + fn resolve_config_summary_builds_preview_and_selection() { + let config = Config { + active_profile: "missing".to_string(), + active_model: "missing-model".to_string(), + profiles: vec![Profile { + name: "deepseek".to_string(), + base_url: "https://example.com".to_string(), + api_key: Some("literal:abc12345".to_string()), + models: vec![model("deepseek-chat"), model("deepseek-reasoner")], + ..Profile::default() + }], + ..Config::default() + }; + + let summary = resolve_config_summary(&config).expect("summary should resolve"); + + assert_eq!(summary.active_profile, "deepseek"); + assert_eq!(summary.active_model, "deepseek-chat"); + assert!(summary.warning.is_some()); + assert_eq!(summary.profiles.len(), 1); + assert_eq!(summary.profiles[0].api_key_preview, "****2345"); + assert_eq!( + summary.profiles[0].models, + vec!["deepseek-chat".to_string(), "deepseek-reasoner".to_string()] + ); + } + + #[test] + fn resolve_config_summary_returns_empty_state_for_missing_profiles() { + let config = Config { + profiles: Vec::new(), + ..Config::default() + }; + + let summary = resolve_config_summary(&config).expect("summary should resolve"); + + assert_eq!(summary.active_profile, ""); + assert_eq!(summary.active_model, ""); + assert!(summary.profiles.is_empty()); + assert_eq!(summary.warning.as_deref(), Some("no profiles configured")); + } } diff --git a/crates/application/src/execution/control.rs b/crates/application/src/execution/control.rs index af0b6c11..ee915797 100644 --- a/crates/application/src/execution/control.rs +++ b/crates/application/src/execution/control.rs @@ -1,19 +1 @@ -use crate::ApplicationError; - -/// 执行控制输入。 -#[derive(Debug, Clone, Default, PartialEq, Eq)] -pub struct ExecutionControl { - pub max_steps: Option, - pub manual_compact: Option, -} - -impl ExecutionControl { - pub fn validate(&self) -> Result<(), ApplicationError> { - if matches!(self.max_steps, Some(0)) { - return Err(ApplicationError::InvalidArgument( - "field 'maxSteps' must be greater than 0".to_string(), - )); - } - Ok(()) - } -} +pub use astrcode_core::ExecutionControl; diff --git a/crates/application/src/lib.rs b/crates/application/src/lib.rs index cba60b9d..d35908d5 100644 --- a/crates/application/src/lib.rs +++ b/crates/application/src/lib.rs @@ -23,22 +23,20 @@ pub use agent::AgentOrchestrationService; pub use astrcode_core::{ AgentEvent, AgentEventContext, AgentLifecycleStatus, AgentMode, AgentTurnOutcome, ArtifactRef, AstrError, CapabilitySpec, ChildAgentRef, ChildSessionLineageKind, - ChildSessionNotificationKind, CompactTrigger, Config, ExecutionAccepted, ForkMode, - InvocationKind, InvocationMode, LocalServerInfo, Phase, PluginHealth, PluginState, - ResolvedExecutionLimitsSnapshot, ResolvedSubagentContextOverrides, SessionEventRecord, - SessionMeta, StorageEventPayload, StoredEvent, SubRunFailure, SubRunFailureCode, SubRunHandoff, - SubRunResult, SubRunStorageMode, SubagentContextOverrides, ToolOutputStream, - format_local_rfc3339, plugin::PluginEntry, + ChildSessionNotificationKind, CompactTrigger, ComposerOption, ComposerOptionActionKind, + ComposerOptionKind, Config, ExecutionAccepted, ForkMode, InvocationKind, InvocationMode, + LocalServerInfo, Phase, PluginHealth, PluginState, ResolvedExecutionLimitsSnapshot, + ResolvedSubagentContextOverrides, SessionEventRecord, SessionMeta, StorageEventPayload, + StoredEvent, SubRunFailure, SubRunFailureCode, SubRunHandoff, SubRunResult, SubRunStorageMode, + SubagentContextOverrides, TestConnectionResult, ToolOutputStream, format_local_rfc3339, + plugin::PluginEntry, }; pub use astrcode_kernel::SubRunStatusView; pub use astrcode_session_runtime::{ SessionCatalogEvent, SessionControlStateSnapshot, SessionEventFilterSpec, SessionReplay, SessionTranscriptSnapshot, SubRunEventScope, TurnCollaborationSummary, TurnSummary, }; -pub use composer::{ - ComposerOption, ComposerOptionActionKind, ComposerOptionKind, ComposerOptionsRequest, - ComposerSkillSummary, -}; +pub use composer::{ComposerOptionsRequest, ComposerSkillSummary}; pub use config::{ // 常量与解析函数 ALL_ASTRCODE_ENV_VARS, @@ -99,9 +97,10 @@ pub use config::{ PROVIDER_KIND_OPENAI, RUNTIME_ENV_VARS, ResolvedAgentConfig, + ResolvedConfigSummary, ResolvedRuntimeConfig, TAURI_ENV_TARGET_TRIPLE_ENV, - TestConnectionResult, + api_key_preview, is_env_var_name, list_model_options, max_tool_concurrency, @@ -109,6 +108,7 @@ pub use config::{ resolve_agent_config, resolve_anthropic_messages_api_url, resolve_anthropic_models_api_url, + resolve_config_summary, resolve_current_model, resolve_openai_chat_completions_api_url, resolve_runtime_config, @@ -119,23 +119,33 @@ pub use lifecycle::governance::{ AppGovernance, ObservabilitySnapshotProvider, RuntimeGovernancePort, RuntimeGovernanceSnapshot, RuntimeReloader, SessionInfoProvider, }; -pub use mcp::{McpConfigScope, McpPort, McpServerStatusView, McpService, RegisterMcpServerInput}; +pub use mcp::{ + McpActionSummary, McpConfigScope, McpPort, McpServerStatusSummary, McpServerStatusView, + McpService, RegisterMcpServerInput, +}; pub use observability::{ AgentCollaborationScorecardSnapshot, ExecutionDiagnosticsSnapshot, GovernanceSnapshot, OperationMetricsSnapshot, ReloadResult, ReplayMetricsSnapshot, ReplayPath, - RuntimeObservabilityCollector, RuntimeObservabilitySnapshot, SubRunExecutionMetricsSnapshot, + ResolvedRuntimeStatusSummary, RuntimeCapabilitySummary, RuntimeObservabilityCollector, + RuntimeObservabilitySnapshot, RuntimePluginSummary, SubRunExecutionMetricsSnapshot, + resolve_runtime_status_summary, }; pub use ports::{ AgentKernelPort, AgentSessionPort, AppKernelPort, AppSessionPort, ComposerSkillPort, }; +pub use session_use_cases::summarize_session_meta; pub use terminal::{ - ConversationChildSummaryFacts, ConversationControlFacts, ConversationFacts, ConversationFocus, - ConversationRehydrateFacts, ConversationRehydrateReason, ConversationResumeCandidateFacts, - ConversationSlashAction, ConversationSlashCandidateFacts, ConversationStreamFacts, + ConversationAuthoritativeSummary, ConversationChildSummaryFacts, + ConversationChildSummarySummary, ConversationControlFacts, ConversationControlSummary, + ConversationFacts, ConversationFocus, ConversationRehydrateFacts, ConversationRehydrateReason, + ConversationResumeCandidateFacts, ConversationSlashAction, ConversationSlashActionSummary, + ConversationSlashCandidateFacts, ConversationSlashCandidateSummary, ConversationStreamFacts, ConversationStreamReplayFacts, TerminalChildSummaryFacts, TerminalControlFacts, TerminalFacts, TerminalRehydrateFacts, TerminalRehydrateReason, TerminalResumeCandidateFacts, TerminalSlashAction, TerminalSlashCandidateFacts, TerminalStreamFacts, - TerminalStreamReplayFacts, + TerminalStreamReplayFacts, summarize_conversation_authoritative, + summarize_conversation_child_ref, summarize_conversation_child_summary, + summarize_conversation_control, summarize_conversation_slash_candidate, }; pub use watch::{WatchEvent, WatchPort, WatchService, WatchSource}; @@ -158,6 +168,77 @@ pub struct CompactSessionAccepted { pub deferred: bool, } +/// prompt 提交成功后的共享摘要输入。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PromptAcceptedSummary { + pub turn_id: String, + pub session_id: String, + pub branched_from_session_id: Option, + pub accepted_control: Option, +} + +/// 手动 compact 的共享摘要输入。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CompactSessionSummary { + pub accepted: bool, + pub deferred: bool, + pub message: String, +} + +/// session 列表项的共享摘要输入。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SessionListSummary { + pub session_id: String, + pub working_dir: String, + pub display_name: String, + pub title: String, + pub created_at: String, + pub updated_at: String, + pub parent_session_id: Option, + pub parent_storage_seq: Option, + pub phase: Phase, +} + +/// root agent 执行接受后的共享摘要输入。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AgentExecuteSummary { + pub accepted: bool, + pub message: String, + pub session_id: Option, + pub turn_id: Option, + pub agent_id: Option, +} + +/// sub-run 状态来源。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SubRunStatusSourceSummary { + Live, + Durable, +} + +/// sub-run 状态的共享摘要输入。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SubRunStatusSummary { + pub sub_run_id: String, + pub tool_call_id: Option, + pub source: SubRunStatusSourceSummary, + pub agent_id: String, + pub agent_profile: String, + pub session_id: String, + pub child_session_id: Option, + pub depth: usize, + pub parent_agent_id: Option, + pub parent_sub_run_id: Option, + pub storage_mode: SubRunStorageMode, + pub lifecycle: AgentLifecycleStatus, + pub last_turn_outcome: Option, + pub result: Option, + pub step_count: Option, + pub estimated_tokens: Option, + pub resolved_overrides: Option, + pub resolved_limits: Option, +} + impl App { pub fn new( kernel: Arc, diff --git a/crates/application/src/mcp/mod.rs b/crates/application/src/mcp/mod.rs index 52e05923..c7db29d2 100644 --- a/crates/application/src/mcp/mod.rs +++ b/crates/application/src/mcp/mod.rs @@ -44,6 +44,28 @@ pub struct McpServerStatusView { pub server_signature: String, } +/// MCP 服务器状态的共享摘要输入。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct McpServerStatusSummary { + pub name: String, + pub scope: String, + pub enabled: bool, + pub status: String, + pub error: Option, + pub tool_count: usize, + pub prompt_count: usize, + pub resource_count: usize, + pub pending_approval: bool, + pub server_signature: String, +} + +/// MCP 动作返回的共享摘要输入。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct McpActionSummary { + pub ok: bool, + pub message: Option, +} + /// 注册 MCP 服务器的业务输入。 /// /// 包含业务层面的配置信息(名称、超时等), @@ -119,6 +141,14 @@ impl McpService { self.port.list_server_status().await } + pub async fn list_status_summary(&self) -> Vec { + self.list_status() + .await + .into_iter() + .map(McpServerStatusSummary::from) + .collect() + } + /// 用例:审批 MCP 服务器。 pub async fn approve_server(&self, server_signature: &str) -> Result<(), ApplicationError> { self.port.approve_server(server_signature).await @@ -167,6 +197,32 @@ impl McpService { } } +impl McpActionSummary { + pub fn ok() -> Self { + Self { + ok: true, + message: None, + } + } +} + +impl From for McpServerStatusSummary { + fn from(value: McpServerStatusView) -> Self { + Self { + name: value.name, + scope: value.scope, + enabled: value.enabled, + status: value.state, + error: value.error, + tool_count: value.tool_count, + prompt_count: value.prompt_count, + resource_count: value.resource_count, + pending_approval: value.pending_approval, + server_signature: value.server_signature, + } + } +} + impl std::fmt::Debug for McpService { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("McpService").finish_non_exhaustive() diff --git a/crates/application/src/observability/metrics_snapshot.rs b/crates/application/src/observability/metrics_snapshot.rs index 7f47bfea..0c3be3ee 100644 --- a/crates/application/src/observability/metrics_snapshot.rs +++ b/crates/application/src/observability/metrics_snapshot.rs @@ -1,127 +1,10 @@ //! # 可观测性指标快照类型 //! -//! 纯数据 DTO,从 `runtime::service::observability::metrics` 迁出的快照定义。 -//! 这些类型不含任何 trait object 或运行时依赖,仅用于跨层传递指标数据。 -//! 实际的指标收集器仍在旧 runtime 中,Phase 10 组合根接线时桥接。 - -/// 回放路径:优先缓存,不足时回退到磁盘。 -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum ReplayPath { - /// 从内存缓存读取(快速路径) - Cache, - /// 从磁盘 JSONL 文件加载(慢速回退路径) - DiskFallback, -} - -/// 单一操作的指标快照。 -#[derive(Debug, Clone, Default, PartialEq, Eq)] -pub struct OperationMetricsSnapshot { - /// 总操作次数 - pub total: u64, - /// 失败次数 - pub failures: u64, - /// 累计耗时(毫秒) - pub total_duration_ms: u64, - /// 最近一次操作的耗时(毫秒) - pub last_duration_ms: u64, - /// 历史最大单次操作耗时(毫秒) - pub max_duration_ms: u64, -} - -/// SSE 回放操作的指标快照。 -#[derive(Debug, Clone, Default, PartialEq, Eq)] -pub struct ReplayMetricsSnapshot { - /// 基础操作指标(总次数、失败率、耗时等) - pub totals: OperationMetricsSnapshot, - /// 缓存命中次数 - pub cache_hits: u64, - /// 磁盘回退次数(说明缓存不足的情况) - pub disk_fallbacks: u64, - /// 成功恢复的事件总数 - pub recovered_events: u64, -} - -/// 子执行域共享观测指标快照。 -#[derive(Debug, Clone, Default, PartialEq, Eq)] -pub struct SubRunExecutionMetricsSnapshot { - pub total: u64, - pub failures: u64, - pub completed: u64, - pub cancelled: u64, - pub token_exceeded: u64, - pub independent_session_total: u64, - pub total_duration_ms: u64, - pub last_duration_ms: u64, - pub total_steps: u64, - pub last_step_count: u64, - pub total_estimated_tokens: u64, - pub last_estimated_tokens: u64, -} - -/// 子会话与缓存切换相关的结构化观测指标快照。 -#[derive(Debug, Clone, Default, PartialEq, Eq)] -pub struct ExecutionDiagnosticsSnapshot { - pub child_spawned: u64, - pub child_started_persisted: u64, - pub child_terminal_persisted: u64, - pub parent_reactivation_requested: u64, - pub parent_reactivation_succeeded: u64, - pub parent_reactivation_failed: u64, - pub lineage_mismatch_parent_agent: u64, - pub lineage_mismatch_parent_session: u64, - pub lineage_mismatch_child_session: u64, - pub lineage_mismatch_descriptor_missing: u64, - pub cache_reuse_hits: u64, - pub cache_reuse_misses: u64, - pub delivery_buffer_queued: u64, - pub delivery_buffer_dequeued: u64, - pub delivery_buffer_wake_requested: u64, - pub delivery_buffer_wake_succeeded: u64, - pub delivery_buffer_wake_failed: u64, -} - -/// Agent collaboration 评估读模型。 -/// -/// 这些字段全部由 raw collaboration facts 派生, -/// 用于判断 agent-tool 是否真的创造了协作价值。 -#[derive(Debug, Clone, Default, PartialEq, Eq)] -pub struct AgentCollaborationScorecardSnapshot { - pub total_facts: u64, - pub spawn_accepted: u64, - pub spawn_rejected: u64, - pub send_reused: u64, - pub send_queued: u64, - pub send_rejected: u64, - pub observe_calls: u64, - pub observe_rejected: u64, - pub observe_followed_by_action: u64, - pub close_calls: u64, - pub close_rejected: u64, - pub delivery_delivered: u64, - pub delivery_consumed: u64, - pub delivery_replayed: u64, - pub orphan_child_count: u64, - pub child_reuse_ratio_bps: Option, - pub observe_to_action_ratio_bps: Option, - pub spawn_to_delivery_ratio_bps: Option, - pub orphan_child_ratio_bps: Option, - pub avg_delivery_latency_ms: Option, - pub max_delivery_latency_ms: Option, -} - -/// 运行时可观测性快照,包含各类操作的指标。 -#[derive(Debug, Clone, Default, PartialEq, Eq)] -pub struct RuntimeObservabilitySnapshot { - /// 会话重水合(从磁盘加载已有会话)的指标 - pub session_rehydrate: OperationMetricsSnapshot, - /// SSE 追赶(客户端重连时回放历史)的指标 - pub sse_catch_up: ReplayMetricsSnapshot, - /// Turn 执行的指标 - pub turn_execution: OperationMetricsSnapshot, - /// 子执行域共享观测指标 - pub subrun_execution: SubRunExecutionMetricsSnapshot, - /// 子会话与缓存切换相关的结构化观测指标 - pub execution_diagnostics: ExecutionDiagnosticsSnapshot, - /// agent-tool 协作效果评估读模型 - pub agent_collaboration: AgentCollaborationScorecardSnapshot, -} +//! owner 已下沉到 `astrcode_core::observability`。 +//! application 这里只保留 re-export,避免继续维护第二套共享语义定义。 + +pub use astrcode_core::{ + AgentCollaborationScorecardSnapshot, ExecutionDiagnosticsSnapshot, OperationMetricsSnapshot, + ReplayMetricsSnapshot, ReplayPath, RuntimeObservabilitySnapshot, + SubRunExecutionMetricsSnapshot, +}; diff --git a/crates/application/src/observability/mod.rs b/crates/application/src/observability/mod.rs index 17de663e..840436af 100644 --- a/crates/application/src/observability/mod.rs +++ b/crates/application/src/observability/mod.rs @@ -32,6 +32,104 @@ pub struct GovernanceSnapshot { pub plugins: Vec, } +/// runtime capability 的共享摘要输入。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RuntimeCapabilitySummary { + pub name: String, + pub kind: String, + pub description: String, + pub profiles: Vec, + pub streaming: bool, +} + +/// runtime plugin 的共享摘要输入。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RuntimePluginSummary { + pub name: String, + pub version: String, + pub description: String, + pub state: astrcode_core::PluginState, + pub health: astrcode_core::PluginHealth, + pub failure_count: u32, + pub failure: Option, + pub warnings: Vec, + pub last_checked_at: Option, + pub capabilities: Vec, +} + +/// 已解析的 runtime 状态摘要输入。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ResolvedRuntimeStatusSummary { + pub runtime_name: String, + pub runtime_kind: String, + pub loaded_session_count: usize, + pub running_session_ids: Vec, + pub plugin_search_paths: Vec, + pub metrics: RuntimeObservabilitySnapshot, + pub capabilities: Vec, + pub plugins: Vec, +} + +/// 将治理快照解析为协议层可复用的摘要输入。 +pub fn resolve_runtime_status_summary( + snapshot: GovernanceSnapshot, +) -> ResolvedRuntimeStatusSummary { + ResolvedRuntimeStatusSummary { + runtime_name: snapshot.runtime_name, + runtime_kind: snapshot.runtime_kind, + loaded_session_count: snapshot.loaded_session_count, + running_session_ids: snapshot.running_session_ids, + plugin_search_paths: snapshot + .plugin_search_paths + .into_iter() + .map(|path| path.display().to_string()) + .collect(), + metrics: snapshot.metrics, + capabilities: snapshot + .capabilities + .into_iter() + .map(resolve_runtime_capability_summary) + .collect(), + plugins: snapshot + .plugins + .into_iter() + .map(resolve_runtime_plugin_summary) + .collect(), + } +} + +fn resolve_runtime_capability_summary(spec: CapabilitySpec) -> RuntimeCapabilitySummary { + RuntimeCapabilitySummary { + name: spec.name.to_string(), + kind: spec.kind.as_str().to_string(), + description: spec.description, + profiles: spec.profiles, + streaming: matches!( + spec.invocation_mode, + astrcode_core::InvocationMode::Streaming + ), + } +} + +fn resolve_runtime_plugin_summary(entry: PluginEntry) -> RuntimePluginSummary { + RuntimePluginSummary { + name: entry.manifest.name, + version: entry.manifest.version, + description: entry.manifest.description, + state: entry.state, + health: entry.health, + failure_count: entry.failure_count, + failure: entry.failure, + warnings: entry.warnings, + last_checked_at: entry.last_checked_at, + capabilities: entry + .capabilities + .into_iter() + .map(resolve_runtime_capability_summary) + .collect(), + } +} + /// 运行时重载操作的结果。 #[derive(Debug, Clone)] pub struct ReloadResult { @@ -40,3 +138,172 @@ pub struct ReloadResult { /// 重载完成的时间 pub reloaded_at: chrono::DateTime, } + +#[cfg(test)] +mod tests { + use astrcode_core::{ + AgentCollaborationScorecardSnapshot, CapabilitySpec, ExecutionDiagnosticsSnapshot, + OperationMetricsSnapshot, PluginHealth, PluginManifest, PluginState, PluginType, + ReplayMetricsSnapshot, SideEffect, Stability, SubRunExecutionMetricsSnapshot, + plugin::PluginEntry, + }; + use serde_json::json; + + use super::{GovernanceSnapshot, RuntimeObservabilitySnapshot, resolve_runtime_status_summary}; + + fn capability(name: &str, streaming: bool) -> CapabilitySpec { + let mut builder = CapabilitySpec::builder(name, "tool") + .description(format!("{name} description")) + .schema(json!({ "type": "object" }), json!({ "type": "object" })) + .profiles(["coding"]); + if streaming { + builder = builder.invocation_mode(astrcode_core::InvocationMode::Streaming); + } + builder + .side_effect(SideEffect::None) + .stability(Stability::Stable) + .build() + .expect("capability should build") + } + + fn manifest(name: &str) -> PluginManifest { + PluginManifest { + name: name.to_string(), + version: "0.1.0".to_string(), + description: format!("{name} manifest"), + plugin_type: vec![PluginType::Tool], + capabilities: Vec::new(), + executable: Some("plugin.exe".to_string()), + args: Vec::new(), + working_dir: None, + repository: None, + } + } + + fn metrics() -> RuntimeObservabilitySnapshot { + RuntimeObservabilitySnapshot { + session_rehydrate: OperationMetricsSnapshot { + total: 3, + failures: 1, + total_duration_ms: 12, + last_duration_ms: 4, + max_duration_ms: 7, + }, + sse_catch_up: ReplayMetricsSnapshot { + totals: OperationMetricsSnapshot { + total: 4, + failures: 0, + total_duration_ms: 8, + last_duration_ms: 2, + max_duration_ms: 5, + }, + cache_hits: 6, + disk_fallbacks: 1, + recovered_events: 20, + }, + turn_execution: OperationMetricsSnapshot { + total: 5, + failures: 1, + total_duration_ms: 15, + last_duration_ms: 5, + max_duration_ms: 9, + }, + subrun_execution: SubRunExecutionMetricsSnapshot { + total: 6, + failures: 1, + completed: 5, + cancelled: 0, + token_exceeded: 0, + independent_session_total: 2, + total_duration_ms: 18, + last_duration_ms: 3, + total_steps: 11, + last_step_count: 2, + total_estimated_tokens: 200, + last_estimated_tokens: 40, + }, + execution_diagnostics: ExecutionDiagnosticsSnapshot { + child_spawned: 1, + child_started_persisted: 1, + child_terminal_persisted: 1, + parent_reactivation_requested: 1, + parent_reactivation_succeeded: 1, + parent_reactivation_failed: 0, + lineage_mismatch_parent_agent: 0, + lineage_mismatch_parent_session: 0, + lineage_mismatch_child_session: 0, + lineage_mismatch_descriptor_missing: 0, + cache_reuse_hits: 2, + cache_reuse_misses: 1, + delivery_buffer_queued: 3, + delivery_buffer_dequeued: 3, + delivery_buffer_wake_requested: 1, + delivery_buffer_wake_succeeded: 1, + delivery_buffer_wake_failed: 0, + }, + agent_collaboration: AgentCollaborationScorecardSnapshot { + total_facts: 2, + spawn_accepted: 1, + spawn_rejected: 1, + send_reused: 0, + send_queued: 1, + send_rejected: 0, + observe_calls: 1, + observe_rejected: 0, + observe_followed_by_action: 1, + close_calls: 0, + close_rejected: 0, + delivery_delivered: 1, + delivery_consumed: 1, + delivery_replayed: 0, + orphan_child_count: 0, + child_reuse_ratio_bps: Some(5000), + observe_to_action_ratio_bps: Some(10000), + spawn_to_delivery_ratio_bps: Some(10000), + orphan_child_ratio_bps: Some(0), + avg_delivery_latency_ms: Some(12), + max_delivery_latency_ms: Some(12), + }, + } + } + + #[test] + fn resolve_runtime_status_summary_projects_snapshot_inputs() { + let snapshot = GovernanceSnapshot { + runtime_name: "astrcode".to_string(), + runtime_kind: "desktop".to_string(), + loaded_session_count: 2, + running_session_ids: vec!["session-a".to_string()], + plugin_search_paths: vec!["C:/plugins".into()], + metrics: metrics(), + capabilities: vec![capability("tool.repo.inspect", true)], + plugins: vec![PluginEntry { + manifest: manifest("repo-plugin"), + state: PluginState::Initialized, + health: PluginHealth::Healthy, + failure_count: 0, + capabilities: vec![capability("tool.repo.inspect", false)], + failure: None, + warnings: vec!["skill warning".to_string()], + last_checked_at: Some("2026-04-16T12:00:00+08:00".to_string()), + }], + }; + + let summary = resolve_runtime_status_summary(snapshot); + + assert_eq!(summary.runtime_name, "astrcode"); + assert_eq!(summary.plugin_search_paths, vec!["C:/plugins".to_string()]); + assert_eq!(summary.capabilities.len(), 1); + assert!(summary.capabilities[0].streaming); + assert_eq!(summary.capabilities[0].kind, "tool"); + assert_eq!(summary.plugins.len(), 1); + assert_eq!(summary.plugins[0].name, "repo-plugin"); + assert_eq!(summary.plugins[0].capabilities.len(), 1); + assert!(!summary.plugins[0].capabilities[0].streaming); + assert_eq!( + summary.plugins[0].last_checked_at.as_deref(), + Some("2026-04-16T12:00:00+08:00") + ); + assert_eq!(summary.metrics.session_rehydrate.total, 3); + } +} diff --git a/crates/application/src/session_use_cases.rs b/crates/application/src/session_use_cases.rs index 6e9c8a90..4fe47d27 100644 --- a/crates/application/src/session_use_cases.rs +++ b/crates/application/src/session_use_cases.rs @@ -7,11 +7,13 @@ use astrcode_core::{ }; use crate::{ - App, ApplicationError, CompactSessionAccepted, ExecutionControl, SessionControlStateSnapshot, - SessionReplay, SessionTranscriptSnapshot, + App, ApplicationError, CompactSessionAccepted, CompactSessionSummary, ExecutionControl, + PromptAcceptedSummary, SessionControlStateSnapshot, SessionListSummary, SessionReplay, + SessionTranscriptSnapshot, agent::{ IMPLICIT_ROOT_PROFILE_ID, implicit_session_root_agent_id, root_execution_event_context, }, + format_local_rfc3339, }; impl App { @@ -102,6 +104,24 @@ impl App { .map_err(ApplicationError::from) } + pub async fn submit_prompt_summary( + &self, + session_id: &str, + text: String, + control: Option, + ) -> Result { + let accepted_control = normalize_prompt_control(control)?; + let accepted = self + .submit_prompt_with_control(session_id, text, accepted_control.clone()) + .await?; + Ok(PromptAcceptedSummary { + turn_id: accepted.turn_id.to_string(), + session_id: accepted.session_id.to_string(), + branched_from_session_id: accepted.branched_from_session_id, + accepted_control, + }) + } + pub async fn interrupt_session(&self, session_id: &str) -> Result<(), ApplicationError> { self.session_runtime .interrupt_session(session_id) @@ -160,6 +180,30 @@ impl App { Ok(CompactSessionAccepted { deferred }) } + pub async fn compact_session_summary( + &self, + session_id: &str, + control: Option, + instructions: Option, + ) -> Result { + let accepted = self + .compact_session_with_options( + session_id, + normalize_compact_control(control), + normalize_compact_instructions(instructions), + ) + .await?; + Ok(CompactSessionSummary { + accepted: true, + deferred: accepted.deferred, + message: if accepted.deferred { + "手动 compact 已登记,会在当前 turn 完成后执行。".to_string() + } else { + "手动 compact 已执行。".to_string() + }, + }) + } + pub async fn session_transcript_snapshot( &self, session_id: &str, @@ -258,3 +302,43 @@ impl App { )) } } + +pub fn summarize_session_meta(meta: SessionMeta) -> SessionListSummary { + SessionListSummary { + session_id: meta.session_id, + working_dir: meta.working_dir, + display_name: meta.display_name, + title: meta.title, + created_at: format_local_rfc3339(meta.created_at), + updated_at: format_local_rfc3339(meta.updated_at), + parent_session_id: meta.parent_session_id, + parent_storage_seq: meta.parent_storage_seq, + phase: meta.phase, + } +} + +fn normalize_prompt_control( + control: Option, +) -> Result, ApplicationError> { + if let Some(control) = &control { + control.validate()?; + } + Ok(control) +} + +fn normalize_compact_control(control: Option) -> Option { + let mut control = control.unwrap_or(ExecutionControl { + max_steps: None, + manual_compact: None, + }); + if control.manual_compact.is_none() { + control.manual_compact = Some(true); + } + Some(control) +} + +fn normalize_compact_instructions(instructions: Option) -> Option { + instructions + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) +} diff --git a/crates/application/src/terminal/mod.rs b/crates/application/src/terminal/mod.rs index f1d8bd64..b32710a6 100644 --- a/crates/application/src/terminal/mod.rs +++ b/crates/application/src/terminal/mod.rs @@ -1,4 +1,4 @@ -use astrcode_core::{ChildSessionNode, CompactAppliedMeta, CompactTrigger, Phase}; +use astrcode_core::{ChildAgentRef, ChildSessionNode, CompactAppliedMeta, CompactTrigger, Phase}; use astrcode_session_runtime::{ ConversationSnapshotFacts as RuntimeConversationSnapshotFacts, ConversationStreamReplayFacts as RuntimeConversationStreamReplayFacts, @@ -22,6 +22,17 @@ pub struct TerminalLastCompactMetaFacts { pub meta: CompactAppliedMeta, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ConversationControlSummary { + pub phase: Phase, + pub can_submit_prompt: bool, + pub can_request_compact: bool, + pub compact_pending: bool, + pub compacting: bool, + pub active_turn_id: Option, + pub last_compact_meta: Option, +} + #[derive(Debug, Clone)] pub struct TerminalControlFacts { pub phase: Phase, @@ -44,6 +55,16 @@ pub struct TerminalChildSummaryFacts { pub type ConversationChildSummaryFacts = TerminalChildSummaryFacts; +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ConversationChildSummarySummary { + pub child_session_id: String, + pub child_agent_id: String, + pub title: String, + pub lifecycle: astrcode_core::AgentLifecycleStatus, + pub latest_output_summary: Option, + pub child_ref: Option, +} + #[derive(Debug, Clone, PartialEq, Eq)] pub enum TerminalSlashAction { CreateSession, @@ -55,6 +76,12 @@ pub enum TerminalSlashAction { pub type ConversationSlashAction = TerminalSlashAction; +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ConversationSlashActionSummary { + InsertText, + ExecuteCommand, +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct TerminalSlashCandidateFacts { pub kind: ComposerOptionKind, @@ -68,6 +95,23 @@ pub struct TerminalSlashCandidateFacts { pub type ConversationSlashCandidateFacts = TerminalSlashCandidateFacts; +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ConversationSlashCandidateSummary { + pub id: String, + pub title: String, + pub description: String, + pub keywords: Vec, + pub action_kind: ConversationSlashActionSummary, + pub action_value: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ConversationAuthoritativeSummary { + pub control: ConversationControlSummary, + pub child_summaries: Vec, + pub slash_candidates: Vec, +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct TerminalResumeCandidateFacts { pub session_id: String, @@ -147,3 +191,103 @@ pub fn truncate_terminal_summary(content: &str) -> String { truncated } } + +pub fn summarize_conversation_control( + control: &TerminalControlFacts, +) -> ConversationControlSummary { + ConversationControlSummary { + phase: control.phase, + can_submit_prompt: matches!( + control.phase, + Phase::Idle | Phase::Done | Phase::Interrupted + ), + can_request_compact: !control.manual_compact_pending && !control.compacting, + compact_pending: control.manual_compact_pending, + compacting: control.compacting, + active_turn_id: control.active_turn_id.clone(), + last_compact_meta: control.last_compact_meta.clone(), + } +} + +pub fn summarize_conversation_child_summary( + summary: &TerminalChildSummaryFacts, +) -> ConversationChildSummarySummary { + ConversationChildSummarySummary { + child_session_id: summary.node.child_session_id.to_string(), + child_agent_id: summary.node.agent_id().to_string(), + title: summary + .title + .clone() + .or_else(|| summary.display_name.clone()) + .unwrap_or_else(|| summary.node.child_session_id.to_string()), + lifecycle: summary.node.status, + latest_output_summary: summary.recent_output.clone(), + child_ref: Some(summary.node.child_ref()), + } +} + +pub fn summarize_conversation_child_ref( + child_ref: &ChildAgentRef, +) -> ConversationChildSummarySummary { + ConversationChildSummarySummary { + child_session_id: child_ref.open_session_id.to_string(), + child_agent_id: child_ref.agent_id().to_string(), + title: child_ref.agent_id().to_string(), + lifecycle: child_ref.status, + latest_output_summary: None, + child_ref: Some(child_ref.clone()), + } +} + +pub fn summarize_conversation_slash_candidate( + candidate: &TerminalSlashCandidateFacts, +) -> ConversationSlashCandidateSummary { + let (action_kind, action_value) = match &candidate.action { + TerminalSlashAction::CreateSession => ( + ConversationSlashActionSummary::ExecuteCommand, + "/new".to_string(), + ), + TerminalSlashAction::OpenResume => ( + ConversationSlashActionSummary::ExecuteCommand, + "/resume".to_string(), + ), + TerminalSlashAction::RequestCompact => ( + ConversationSlashActionSummary::ExecuteCommand, + "/compact".to_string(), + ), + TerminalSlashAction::OpenSkillPalette => ( + ConversationSlashActionSummary::ExecuteCommand, + "/skill".to_string(), + ), + TerminalSlashAction::InsertText { text } => { + (ConversationSlashActionSummary::InsertText, text.clone()) + }, + }; + + ConversationSlashCandidateSummary { + id: candidate.id.clone(), + title: candidate.title.clone(), + description: candidate.description.clone(), + keywords: candidate.keywords.clone(), + action_kind, + action_value, + } +} + +pub fn summarize_conversation_authoritative( + control: &TerminalControlFacts, + child_summaries: &[TerminalChildSummaryFacts], + slash_candidates: &[TerminalSlashCandidateFacts], +) -> ConversationAuthoritativeSummary { + ConversationAuthoritativeSummary { + control: summarize_conversation_control(control), + child_summaries: child_summaries + .iter() + .map(summarize_conversation_child_summary) + .collect(), + slash_candidates: slash_candidates + .iter() + .map(summarize_conversation_slash_candidate) + .collect(), + } +} diff --git a/crates/application/src/terminal_use_cases.rs b/crates/application/src/terminal_use_cases.rs index 710bb555..79fbc152 100644 --- a/crates/application/src/terminal_use_cases.rs +++ b/crates/application/src/terminal_use_cases.rs @@ -9,11 +9,11 @@ use astrcode_session_runtime::{ use crate::{ App, ApplicationError, ComposerOptionKind, ComposerOptionsRequest, SessionMeta, terminal::{ - ConversationFocus, TerminalChildSummaryFacts, TerminalControlFacts, TerminalFacts, - TerminalLastCompactMetaFacts, TerminalRehydrateFacts, TerminalRehydrateReason, - TerminalResumeCandidateFacts, TerminalSlashAction, TerminalSlashCandidateFacts, - TerminalStreamFacts, TerminalStreamReplayFacts, latest_transcript_cursor, - truncate_terminal_summary, + ConversationAuthoritativeSummary, ConversationFocus, TerminalChildSummaryFacts, + TerminalControlFacts, TerminalFacts, TerminalLastCompactMetaFacts, TerminalRehydrateFacts, + TerminalRehydrateReason, TerminalResumeCandidateFacts, TerminalSlashAction, + TerminalSlashCandidateFacts, TerminalStreamFacts, TerminalStreamReplayFacts, + latest_transcript_cursor, summarize_conversation_authoritative, truncate_terminal_summary, }, }; @@ -304,6 +304,18 @@ impl App { .await?; Ok(map_control_facts(control)) } + + pub async fn conversation_authoritative_summary( + &self, + session_id: &str, + focus: &ConversationFocus, + ) -> Result { + Ok(summarize_conversation_authoritative( + &self.terminal_control_facts(session_id).await?, + &self.conversation_child_summaries(session_id, focus).await?, + &self.terminal_slash_candidates(session_id, None).await?, + )) + } } fn map_control_facts(control: SessionControlStateSnapshot) -> TerminalControlFacts { diff --git a/crates/core/src/composer.rs b/crates/core/src/composer.rs new file mode 100644 index 00000000..a2bba407 --- /dev/null +++ b/crates/core/src/composer.rs @@ -0,0 +1,35 @@ +use serde::{Deserialize, Serialize}; + +/// 输入候选项的来源类别。 +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)] +#[serde(rename_all = "snake_case")] +pub enum ComposerOptionKind { + Command, + Skill, + Capability, +} + +/// 输入候选项被选择后的动作类型。 +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)] +#[serde(rename_all = "snake_case")] +pub enum ComposerOptionActionKind { + InsertText, + ExecuteCommand, +} + +/// 单个输入候选项。 +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct ComposerOption { + pub kind: ComposerOptionKind, + pub id: String, + pub title: String, + pub description: String, + pub insert_text: String, + pub action_kind: ComposerOptionActionKind, + pub action_value: String, + #[serde(default)] + pub badges: Vec, + #[serde(default)] + pub keywords: Vec, +} diff --git a/crates/core/src/config.rs b/crates/core/src/config.rs index 15f2ee94..5a321c39 100644 --- a/crates/core/src/config.rs +++ b/crates/core/src/config.rs @@ -377,7 +377,8 @@ pub struct ActiveSelection { } /// 运行时当前将使用的有效模型信息。 -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] pub struct ModelSelection { pub profile_name: String, pub model: String, @@ -403,6 +404,16 @@ pub type CurrentModelSelection = ModelSelection; /// 扁平化的模型选项。 pub type ModelOption = ModelSelection; +/// 模型连通性测试结果。 +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct TestConnectionResult { + pub success: bool, + pub provider: String, + pub model: String, + pub error: Option, +} + impl fmt::Debug for Config { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.debug_struct("Config") diff --git a/crates/core/src/execution_control.rs b/crates/core/src/execution_control.rs new file mode 100644 index 00000000..7d367341 --- /dev/null +++ b/crates/core/src/execution_control.rs @@ -0,0 +1,28 @@ +//! 共享执行控制输入。 +//! +//! 为什么放在 core: +//! `ExecutionControl` 已经被 application、server、client 和 protocol 共同消费, +//! 它描述的是稳定执行语义,而不是某个具体 HTTP route 的 request 壳。 + +use crate::error::AstrError; + +/// 执行控制输入。 +#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ExecutionControl { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub max_steps: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub manual_compact: Option, +} + +impl ExecutionControl { + pub fn validate(&self) -> std::result::Result<(), AstrError> { + if matches!(self.max_steps, Some(0)) { + return Err(AstrError::Validation( + "field 'maxSteps' must be greater than 0".to_string(), + )); + } + Ok(()) + } +} diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index b92188be..c5035486 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -19,10 +19,12 @@ pub mod agent; mod cancel; pub mod capability; mod compact_summary; +mod composer; pub mod config; pub mod env; mod error; pub mod event; +mod execution_control; mod execution_result; pub mod home; pub mod hook; @@ -37,6 +39,7 @@ pub mod projection; pub mod registry; pub mod runtime; pub mod session; +mod session_catalog; mod shell; pub mod store; mod time; @@ -87,10 +90,12 @@ pub use compact_summary::{ COMPACT_SUMMARY_CONTINUATION, COMPACT_SUMMARY_PREFIX, CompactSummaryEnvelope, format_compact_summary, parse_compact_summary_message, }; +pub use composer::{ComposerOption, ComposerOptionActionKind, ComposerOptionKind}; pub use config::{ ActiveSelection, AgentConfig, Config, ConfigOverlay, CurrentModelSelection, ModelConfig, ModelOption, ModelSelection, Profile, ResolvedAgentConfig, ResolvedRuntimeConfig, - RuntimeConfig, max_tool_concurrency, resolve_agent_config, resolve_runtime_config, + RuntimeConfig, TestConnectionResult, max_tool_concurrency, resolve_agent_config, + resolve_runtime_config, }; pub use error::{AstrError, Result, ResultExt}; pub use event::{ @@ -98,6 +103,7 @@ pub use event::{ PromptMetricsPayload, StorageEvent, StorageEventPayload, StoredEvent, generate_session_id, normalize_recovered_phase, phase_of_storage_event, replay_records, }; +pub use execution_control::ExecutionControl; pub use execution_result::ExecutionResultCommon; pub use hook::{ CompactionHookContext, CompactionHookResultContext, HookEvent, HookHandler, HookInput, @@ -105,7 +111,11 @@ pub use hook::{ }; pub use ids::{AgentId, CapabilityName, SessionId, SubRunId, TurnId}; pub use local_server::{LOCAL_SERVER_READY_PREFIX, LocalServerInfo}; -pub use observability::RuntimeMetricsRecorder; +pub use observability::{ + AgentCollaborationScorecardSnapshot, ExecutionDiagnosticsSnapshot, OperationMetricsSnapshot, + ReplayMetricsSnapshot, ReplayPath, RuntimeMetricsRecorder, RuntimeObservabilitySnapshot, + SubRunExecutionMetricsSnapshot, +}; pub use plugin::{PluginHealth, PluginManifest, PluginRegistry, PluginState, PluginType}; pub use policy::{ AllowAllPolicyEngine, ApprovalDefault, ApprovalPending, ApprovalRequest, ApprovalResolution, @@ -128,6 +138,7 @@ pub use runtime::{ SessionTruthBoundary, }; pub use session::{DeleteProjectResult, SessionEventRecord, SessionMeta}; +pub use session_catalog::SessionCatalogEvent; pub use shell::{ ResolvedShell, ShellFamily, default_shell_label, detect_shell_family, resolve_shell, }; diff --git a/crates/core/src/observability.rs b/crates/core/src/observability.rs index aadbf372..bf5a4a9a 100644 --- a/crates/core/src/observability.rs +++ b/crates/core/src/observability.rs @@ -1,5 +1,134 @@ use crate::{AgentCollaborationFact, AgentTurnOutcome, SubRunStorageMode}; +/// 回放路径:优先缓存,不足时回退到磁盘。 +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum ReplayPath { + /// 从内存缓存读取(快速路径) + Cache, + /// 从磁盘 JSONL 文件加载(慢速回退路径) + DiskFallback, +} + +/// 单一操作的指标快照。 +#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct OperationMetricsSnapshot { + /// 总操作次数 + pub total: u64, + /// 失败次数 + pub failures: u64, + /// 累计耗时(毫秒) + pub total_duration_ms: u64, + /// 最近一次操作的耗时(毫秒) + pub last_duration_ms: u64, + /// 历史最大单次操作耗时(毫秒) + pub max_duration_ms: u64, +} + +/// SSE 回放操作的指标快照。 +#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ReplayMetricsSnapshot { + /// 基础操作指标(总次数、失败率、耗时等) + pub totals: OperationMetricsSnapshot, + /// 缓存命中次数 + pub cache_hits: u64, + /// 磁盘回退次数(说明缓存不足的情况) + pub disk_fallbacks: u64, + /// 成功恢复的事件总数 + pub recovered_events: u64, +} + +/// 子执行域共享观测指标快照。 +#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SubRunExecutionMetricsSnapshot { + pub total: u64, + pub failures: u64, + pub completed: u64, + pub cancelled: u64, + pub token_exceeded: u64, + pub independent_session_total: u64, + pub total_duration_ms: u64, + pub last_duration_ms: u64, + pub total_steps: u64, + pub last_step_count: u64, + pub total_estimated_tokens: u64, + pub last_estimated_tokens: u64, +} + +/// 子会话与缓存切换相关的结构化观测指标快照。 +#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ExecutionDiagnosticsSnapshot { + pub child_spawned: u64, + pub child_started_persisted: u64, + pub child_terminal_persisted: u64, + pub parent_reactivation_requested: u64, + pub parent_reactivation_succeeded: u64, + pub parent_reactivation_failed: u64, + pub lineage_mismatch_parent_agent: u64, + pub lineage_mismatch_parent_session: u64, + pub lineage_mismatch_child_session: u64, + pub lineage_mismatch_descriptor_missing: u64, + pub cache_reuse_hits: u64, + pub cache_reuse_misses: u64, + pub delivery_buffer_queued: u64, + pub delivery_buffer_dequeued: u64, + pub delivery_buffer_wake_requested: u64, + pub delivery_buffer_wake_succeeded: u64, + pub delivery_buffer_wake_failed: u64, +} + +/// Agent collaboration 评估读模型。 +/// +/// 这些字段全部由 raw collaboration facts 派生, +/// 用于判断 agent-tool 是否真的创造了协作价值。 +#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentCollaborationScorecardSnapshot { + pub total_facts: u64, + pub spawn_accepted: u64, + pub spawn_rejected: u64, + pub send_reused: u64, + pub send_queued: u64, + pub send_rejected: u64, + pub observe_calls: u64, + pub observe_rejected: u64, + pub observe_followed_by_action: u64, + pub close_calls: u64, + pub close_rejected: u64, + pub delivery_delivered: u64, + pub delivery_consumed: u64, + pub delivery_replayed: u64, + pub orphan_child_count: u64, + pub child_reuse_ratio_bps: Option, + pub observe_to_action_ratio_bps: Option, + pub spawn_to_delivery_ratio_bps: Option, + pub orphan_child_ratio_bps: Option, + pub avg_delivery_latency_ms: Option, + pub max_delivery_latency_ms: Option, +} + +/// 运行时可观测性快照,包含各类操作的指标。 +#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RuntimeObservabilitySnapshot { + /// 会话重水合(从磁盘加载已有会话)的指标 + pub session_rehydrate: OperationMetricsSnapshot, + /// SSE 追赶(客户端重连时回放历史)的指标 + pub sse_catch_up: ReplayMetricsSnapshot, + /// Turn 执行的指标 + pub turn_execution: OperationMetricsSnapshot, + /// 子执行域共享观测指标 + pub subrun_execution: SubRunExecutionMetricsSnapshot, + /// 子会话与缓存切换相关的结构化观测指标 + pub execution_diagnostics: ExecutionDiagnosticsSnapshot, + /// agent-tool 协作效果评估读模型 + pub agent_collaboration: AgentCollaborationScorecardSnapshot, +} + /// 统一的运行时观测记录接口。 /// /// 只暴露窄写入方法,避免业务层反向依赖具体快照实现。 @@ -38,3 +167,129 @@ pub trait RuntimeMetricsRecorder: Send + Sync { fn record_cache_reuse_miss(&self); fn record_agent_collaboration_fact(&self, fact: &AgentCollaborationFact); } + +#[cfg(test)] +mod tests { + use super::{ + AgentCollaborationScorecardSnapshot, ExecutionDiagnosticsSnapshot, + OperationMetricsSnapshot, ReplayMetricsSnapshot, RuntimeObservabilitySnapshot, + SubRunExecutionMetricsSnapshot, + }; + + #[test] + fn operation_metrics_snapshot_uses_camel_case_wire_shape() { + let value = serde_json::to_value(OperationMetricsSnapshot { + total: 1, + failures: 2, + total_duration_ms: 3, + last_duration_ms: 4, + max_duration_ms: 5, + }) + .expect("operation metrics should serialize"); + + assert_eq!( + value, + serde_json::json!({ + "total": 1, + "failures": 2, + "totalDurationMs": 3, + "lastDurationMs": 4, + "maxDurationMs": 5, + }) + ); + } + + #[test] + fn runtime_observability_snapshot_round_trips_with_nested_metrics() { + let snapshot = RuntimeObservabilitySnapshot { + session_rehydrate: OperationMetricsSnapshot { + total: 1, + failures: 0, + total_duration_ms: 2, + last_duration_ms: 3, + max_duration_ms: 4, + }, + sse_catch_up: ReplayMetricsSnapshot { + totals: OperationMetricsSnapshot { + total: 5, + failures: 1, + total_duration_ms: 6, + last_duration_ms: 7, + max_duration_ms: 8, + }, + cache_hits: 9, + disk_fallbacks: 10, + recovered_events: 11, + }, + turn_execution: OperationMetricsSnapshot { + total: 12, + failures: 2, + total_duration_ms: 13, + last_duration_ms: 14, + max_duration_ms: 15, + }, + subrun_execution: SubRunExecutionMetricsSnapshot { + total: 16, + failures: 3, + completed: 17, + cancelled: 18, + token_exceeded: 19, + independent_session_total: 20, + total_duration_ms: 21, + last_duration_ms: 22, + total_steps: 23, + last_step_count: 24, + total_estimated_tokens: 25, + last_estimated_tokens: 26, + }, + execution_diagnostics: ExecutionDiagnosticsSnapshot { + child_spawned: 27, + child_started_persisted: 28, + child_terminal_persisted: 29, + parent_reactivation_requested: 30, + parent_reactivation_succeeded: 31, + parent_reactivation_failed: 32, + lineage_mismatch_parent_agent: 33, + lineage_mismatch_parent_session: 34, + lineage_mismatch_child_session: 35, + lineage_mismatch_descriptor_missing: 36, + cache_reuse_hits: 37, + cache_reuse_misses: 38, + delivery_buffer_queued: 39, + delivery_buffer_dequeued: 40, + delivery_buffer_wake_requested: 41, + delivery_buffer_wake_succeeded: 42, + delivery_buffer_wake_failed: 43, + }, + agent_collaboration: AgentCollaborationScorecardSnapshot { + total_facts: 44, + spawn_accepted: 45, + spawn_rejected: 46, + send_reused: 47, + send_queued: 48, + send_rejected: 49, + observe_calls: 50, + observe_rejected: 51, + observe_followed_by_action: 52, + close_calls: 53, + close_rejected: 54, + delivery_delivered: 55, + delivery_consumed: 56, + delivery_replayed: 57, + orphan_child_count: 58, + child_reuse_ratio_bps: Some(59), + observe_to_action_ratio_bps: Some(60), + spawn_to_delivery_ratio_bps: Some(61), + orphan_child_ratio_bps: Some(62), + avg_delivery_latency_ms: Some(63), + max_delivery_latency_ms: Some(64), + }, + }; + + let json = serde_json::to_string(&snapshot).expect("snapshot should serialize"); + let decoded: RuntimeObservabilitySnapshot = + serde_json::from_str(&json).expect("snapshot should deserialize"); + + assert_eq!(decoded, snapshot); + } +} diff --git a/crates/core/src/plugin/registry.rs b/crates/core/src/plugin/registry.rs index 6392023a..4d8be38b 100644 --- a/crates/core/src/plugin/registry.rs +++ b/crates/core/src/plugin/registry.rs @@ -14,7 +14,8 @@ use std::{collections::BTreeMap, sync::RwLock}; use crate::{CapabilitySpec, PluginManifest}; /// 插件生命周期状态。 -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "camelCase")] pub enum PluginState { /// 已发现(清单已加载,但尚未初始化) Discovered, @@ -28,7 +29,8 @@ pub enum PluginState { /// /// 与 `PluginState` 不同,健康状态反映运行时状况, /// 一个已初始化的插件可能因网络问题变为 `Degraded`。 -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "camelCase")] pub enum PluginHealth { /// 尚未检查 Unknown, diff --git a/crates/core/src/session_catalog.rs b/crates/core/src/session_catalog.rs new file mode 100644 index 00000000..4c299a7c --- /dev/null +++ b/crates/core/src/session_catalog.rs @@ -0,0 +1,20 @@ +use serde::{Deserialize, Serialize}; + +/// Session catalog 变更事件,用于通知外部订阅者 session 列表变化。 +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", tag = "event", content = "data")] +pub enum SessionCatalogEvent { + SessionCreated { + session_id: String, + }, + SessionDeleted { + session_id: String, + }, + ProjectDeleted { + working_dir: String, + }, + SessionBranched { + session_id: String, + source_session_id: String, + }, +} diff --git a/crates/protocol/src/http/agent.rs b/crates/protocol/src/http/agent.rs index 3963dfa5..e969b19b 100644 --- a/crates/protocol/src/http/agent.rs +++ b/crates/protocol/src/http/agent.rs @@ -3,27 +3,18 @@ //! 定义 Agent profile 查询、执行、子执行域(sub-run)状态查询等接口的请求/响应结构。 //! 这些 DTO 用于前端展示和管理 Agent 配置、触发 Agent 执行任务以及监控 sub-run 状态。 +pub use astrcode_core::{ + AgentLifecycleStatus as AgentLifecycleDto, AgentProfile as AgentProfileDto, + AgentTurnOutcome as AgentTurnOutcomeDto, ChildSessionLineageKind as ChildSessionLineageKindDto, + ChildSessionNotificationKind as ChildSessionNotificationKindDto, + SubagentContextOverrides as SubagentContextOverridesDto, +}; use serde::{Deserialize, Serialize}; use crate::http::{ ExecutionControlDto, ResolvedSubagentContextOverridesDto, SubRunResultDto, SubRunStorageModeDto, }; -/// 对外暴露的 Agent Profile 摘要。 -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct AgentProfileDto { - pub id: String, - pub name: String, - pub description: String, - pub mode: String, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub allowed_tools: Vec, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub disallowed_tools: Vec, - // TODO: 未来可能需要添加更多 agent 级执行限制摘要 -} - /// `POST /api/v1/agents/{id}/execute` 请求体。 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] @@ -53,51 +44,6 @@ pub struct AgentExecuteResponseDto { pub agent_id: Option, } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct SubagentContextOverridesDto { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub storage_mode: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub inherit_system_instructions: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub inherit_project_instructions: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub inherit_working_dir: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub inherit_policy_upper_bound: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub inherit_cancel_token: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub include_compact_summary: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub include_recent_tail: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub include_recovery_refs: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub include_parent_findings: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub fork_mode: Option, -} - -#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -pub enum AgentLifecycleDto { - Pending, - Running, - Idle, - Terminated, -} - -#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -pub enum AgentTurnOutcomeDto { - Completed, - Failed, - Cancelled, - TokenExceeded, -} - #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub enum SubRunStatusSourceDto { @@ -142,14 +88,6 @@ pub struct SubRunStatusDto { pub resolved_limits: Option, } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -pub enum ChildSessionLineageKindDto { - Spawn, - Fork, - Resume, -} - /// 谱系来源快照 DTO,fork/resume 时记录来源上下文。 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] @@ -174,15 +112,3 @@ pub struct ChildAgentRefDto { pub status: AgentLifecycleDto, pub open_session_id: String, } - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -pub enum ChildSessionNotificationKindDto { - Started, - ProgressSummary, - Delivered, - Waiting, - Resumed, - Closed, - Failed, -} diff --git a/crates/protocol/src/http/composer.rs b/crates/protocol/src/http/composer.rs index 8645876e..e52c3da7 100644 --- a/crates/protocol/src/http/composer.rs +++ b/crates/protocol/src/http/composer.rs @@ -1,51 +1,14 @@ //! 输入候选(composer options)相关 DTO。 //! -//! 这些 DTO 服务于前端输入框的候选面板,而不是运行时内部的 prompt 组装。 -//! 单独建模的原因是:UI 需要一个稳定、轻量的“可选项投影视图”, -//! 不能直接复用 `SkillSpec` / `CapabilityWireDescriptor` 这类内部结构。 +//! 单个候选项已经是跨层共享的 canonical 读模型,协议层直接复用 `core`; +//! 外层响应壳仍由 protocol 拥有。 +pub use astrcode_core::{ + ComposerOption as ComposerOptionDto, ComposerOptionActionKind as ComposerOptionActionKindDto, + ComposerOptionKind as ComposerOptionKindDto, +}; use serde::{Deserialize, Serialize}; -/// 输入候选项的来源类别。 -/// -/// `skill` 表示具体的 skill 条目,而不是 `Skill` 加载器 capability 本身。 -/// 保留独立枚举可以明确区分“prompt 资源”与“可调用 capability”两个层次。 -#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)] -#[serde(rename_all = "snake_case")] -pub enum ComposerOptionKindDto { - Command, - Skill, - Capability, -} - -#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)] -#[serde(rename_all = "snake_case")] -pub enum ComposerOptionActionKindDto { - InsertText, - ExecuteCommand, -} - -/// 单个输入候选项。 -/// -/// `insert_text` 是前端选择该项后建议写回输入框的文本。 -/// `badges` / `keywords` 让 UI 和本地搜索可以使用统一载荷, -/// 避免前端再去推断来源、标签或 profile。 -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct ComposerOptionDto { - pub kind: ComposerOptionKindDto, - pub id: String, - pub title: String, - pub description: String, - pub insert_text: String, - pub action_kind: ComposerOptionActionKindDto, - pub action_value: String, - #[serde(default)] - pub badges: Vec, - #[serde(default)] - pub keywords: Vec, -} - /// 输入候选列表响应。 /// /// 预留响应外层对象而非直接返回数组,是为了后续在不破坏协议的前提下 diff --git a/crates/protocol/src/http/config.rs b/crates/protocol/src/http/config.rs index 1680db6a..50ca294c 100644 --- a/crates/protocol/src/http/config.rs +++ b/crates/protocol/src/http/config.rs @@ -3,6 +3,7 @@ //! 定义配置查看、保存、连接测试的请求/响应结构。 //! 配置数据包括 profile(提供商配置)、活跃模型选择等。 +pub use astrcode_core::TestConnectionResult as TestResultDto; use serde::{Deserialize, Serialize}; use crate::http::RuntimeStatusDto; @@ -74,17 +75,3 @@ pub struct TestConnectionRequest { /// 要测试的模型 ID pub model: String, } - -/// `POST /api/config/test-connection` 响应体——连接测试结果。 -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct TestResultDto { - /// 连接测试是否成功 - pub success: bool, - /// 实际测试的提供商类型 - pub provider: String, - /// 实际测试的模型 ID - pub model: String, - /// 失败时的错误信息 - pub error: Option, -} diff --git a/crates/protocol/src/http/event.rs b/crates/protocol/src/http/event.rs index dbb51c7e..79f9b0e6 100644 --- a/crates/protocol/src/http/event.rs +++ b/crates/protocol/src/http/event.rs @@ -18,13 +18,16 @@ pub use astrcode_core::{ ArtifactRef as ArtifactRefDto, CloseRequestParentDeliveryPayload as CloseRequestParentDeliveryPayloadDto, CompletedParentDeliveryPayload as CompletedParentDeliveryPayloadDto, + ExecutionControl as ExecutionControlDto, FailedParentDeliveryPayload as FailedParentDeliveryPayloadDto, ForkMode as ForkModeDto, ParentDelivery as ParentDeliveryDto, ParentDeliveryOrigin as ParentDeliveryOriginDto, ParentDeliveryPayload as ParentDeliveryPayloadDto, ParentDeliveryTerminalSemantics as ParentDeliveryTerminalSemanticsDto, Phase as PhaseDto, ProgressParentDeliveryPayload as ProgressParentDeliveryPayloadDto, - SubRunFailureCode as SubRunFailureCodeDto, SubRunHandoff as SubRunHandoffDto, - ToolOutputStream as ToolOutputStreamDto, + ResolvedExecutionLimitsSnapshot as ResolvedExecutionLimitsDto, + ResolvedSubagentContextOverrides as ResolvedSubagentContextOverridesDto, + SubRunFailure as SubRunFailureDto, SubRunFailureCode as SubRunFailureCodeDto, + SubRunHandoff as SubRunHandoffDto, ToolOutputStream as ToolOutputStreamDto, }; #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] @@ -37,15 +40,6 @@ pub enum SubRunOutcomeDto { TokenExceeded, } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct SubRunFailureDto { - pub code: SubRunFailureCodeDto, - pub display_message: String, - pub technical_message: String, - pub retryable: bool, -} - #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(tag = "status", rename_all = "snake_case")] pub enum SubRunResultDto { @@ -55,38 +49,3 @@ pub enum SubRunResultDto { Failed { failure: SubRunFailureDto }, Cancelled { failure: SubRunFailureDto }, } - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct ExecutionControlDto { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub max_steps: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub manual_compact: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct ResolvedSubagentContextOverridesDto { - pub storage_mode: SubRunStorageModeDto, - pub inherit_system_instructions: bool, - pub inherit_project_instructions: bool, - pub inherit_working_dir: bool, - pub inherit_policy_upper_bound: bool, - pub inherit_cancel_token: bool, - pub include_compact_summary: bool, - pub include_recent_tail: bool, - pub include_recovery_refs: bool, - pub include_parent_findings: bool, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub fork_mode: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct ResolvedExecutionLimitsDto { - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub allowed_tools: Vec, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub max_steps: Option, -} diff --git a/crates/protocol/src/http/model.rs b/crates/protocol/src/http/model.rs index fc480ae2..c99b0c21 100644 --- a/crates/protocol/src/http/model.rs +++ b/crates/protocol/src/http/model.rs @@ -1,29 +1,8 @@ -//! 模型信息相关 DTO +//! 模型信息相关 DTO。 //! -//! 定义模型查询接口的请求/响应结构,包括当前活跃模型信息和可用模型列表。 +//! 当前模型信息和模型选项已经是 `core::config::ModelSelection` 的共享语义, +//! 协议层直接复用 canonical owner。 -use serde::{Deserialize, Serialize}; - -/// GET /api/models/current 响应体——当前活跃的模型信息。 -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct CurrentModelInfoDto { - /// 配置文件中的 profile 名称 - pub profile_name: String, - /// 当前使用的模型 ID(如 "claude-3-5-sonnet") - pub model: String, - /// 提供商类型("anthropic" 或 "openai") - pub provider_kind: String, -} - -/// GET /api/models 响应体中的单个模型选项。 -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct ModelOptionDto { - /// 此模型所属的 profile 名称 - pub profile_name: String, - /// 模型 ID - pub model: String, - /// 提供商类型 - pub provider_kind: String, -} +pub use astrcode_core::{ + CurrentModelSelection as CurrentModelInfoDto, ModelOption as ModelOptionDto, +}; diff --git a/crates/protocol/src/http/runtime.rs b/crates/protocol/src/http/runtime.rs index 1bbca189..8de733f0 100644 --- a/crates/protocol/src/http/runtime.rs +++ b/crates/protocol/src/http/runtime.rs @@ -3,6 +3,14 @@ //! 定义运行时健康检查、指标查询、插件状态等接口的响应结构。 //! 这些数据用于前端展示系统运行状态、性能指标和插件健康度。 +pub use astrcode_core::{ + AgentCollaborationScorecardSnapshot as AgentCollaborationScorecardDto, + ExecutionDiagnosticsSnapshot as ExecutionDiagnosticsDto, + OperationMetricsSnapshot as OperationMetricsDto, PluginHealth as PluginHealthDto, + PluginState as PluginRuntimeStateDto, ReplayMetricsSnapshot as ReplayMetricsDto, + RuntimeObservabilitySnapshot as RuntimeMetricsDto, + SubRunExecutionMetricsSnapshot as SubRunExecutionMetricsDto, +}; use serde::{Deserialize, Serialize}; /// 运行时能力的摘要信息。 @@ -24,156 +32,7 @@ pub struct RuntimeCapabilityDto { pub streaming: bool, } -/// 操作级别的指标统计。 -/// -/// 记录某类操作的总次数、失败次数、耗时等,用于性能监控。 -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct OperationMetricsDto { - /// 总操作次数 - pub total: u64, - /// 失败次数 - pub failures: u64, - /// 累计耗时(毫秒) - pub total_duration_ms: u64, - /// 最近一次操作耗时(毫秒) - pub last_duration_ms: u64, - /// 最大单次操作耗时(毫秒) - pub max_duration_ms: u64, -} - -/// 事件回放相关的指标。 -/// -/// 记录 SSE 断线重连时从磁盘/缓存恢复事件的统计信息。 -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct ReplayMetricsDto { - /// 回放操作的总体指标 - pub totals: OperationMetricsDto, - /// 缓存命中次数 - pub cache_hits: u64, - /// 回退到磁盘读取的次数 - pub disk_fallbacks: u64, - /// 成功恢复的事件数量 - pub recovered_events: u64, -} - -/// 运行时整体指标。 -/// -/// 包含会话重连、SSE 追赶回放、turn 执行三个维度的指标。 -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct RuntimeMetricsDto { - /// 会话重连(rehydrate)指标 - pub session_rehydrate: OperationMetricsDto, - /// SSE 断线重连后的回放指标 - pub sse_catch_up: ReplayMetricsDto, - /// turn 执行指标 - pub turn_execution: OperationMetricsDto, - /// 子执行域共享观测指标 - pub subrun_execution: SubRunExecutionMetricsDto, - /// delivery / lineage / cache 诊断指标 - pub execution_diagnostics: ExecutionDiagnosticsDto, - /// agent-tool 协作效果评估读模型 - pub agent_collaboration: AgentCollaborationScorecardDto, -} - -/// 子执行域共享观测指标。 -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct SubRunExecutionMetricsDto { - pub total: u64, - pub failures: u64, - pub completed: u64, - pub cancelled: u64, - pub token_exceeded: u64, - pub independent_session_total: u64, - pub total_duration_ms: u64, - pub last_duration_ms: u64, - pub total_steps: u64, - pub last_step_count: u64, - pub total_estimated_tokens: u64, - pub last_estimated_tokens: u64, -} - -/// 结构化执行诊断指标。 -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct ExecutionDiagnosticsDto { - pub child_spawned: u64, - pub child_started_persisted: u64, - pub child_terminal_persisted: u64, - pub parent_reactivation_requested: u64, - pub parent_reactivation_succeeded: u64, - pub parent_reactivation_failed: u64, - pub lineage_mismatch_parent_agent: u64, - pub lineage_mismatch_parent_session: u64, - pub lineage_mismatch_child_session: u64, - pub lineage_mismatch_descriptor_missing: u64, - pub cache_reuse_hits: u64, - pub cache_reuse_misses: u64, - pub delivery_buffer_queued: u64, - pub delivery_buffer_dequeued: u64, - pub delivery_buffer_wake_requested: u64, - pub delivery_buffer_wake_succeeded: u64, - pub delivery_buffer_wake_failed: u64, -} - -/// agent-tool 协作效果评估 DTO。 -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct AgentCollaborationScorecardDto { - pub total_facts: u64, - pub spawn_accepted: u64, - pub spawn_rejected: u64, - pub send_reused: u64, - pub send_queued: u64, - pub send_rejected: u64, - pub observe_calls: u64, - pub observe_rejected: u64, - pub observe_followed_by_action: u64, - pub close_calls: u64, - pub close_rejected: u64, - pub delivery_delivered: u64, - pub delivery_consumed: u64, - pub delivery_replayed: u64, - pub orphan_child_count: u64, - pub child_reuse_ratio_bps: Option, - pub observe_to_action_ratio_bps: Option, - pub spawn_to_delivery_ratio_bps: Option, - pub orphan_child_ratio_bps: Option, - pub avg_delivery_latency_ms: Option, - pub max_delivery_latency_ms: Option, -} - /// 插件运行时状态。 -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub enum PluginRuntimeStateDto { - /// 已发现但尚未初始化 - Discovered, - /// 已初始化并可用 - Initialized, - /// 初始化或运行期间失败 - Failed, -} - -/// 插件健康度。 -/// -/// 用于前端展示插件的可用性状态。 -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub enum PluginHealthDto { - /// 尚未进行健康检查 - Unknown, - /// 正常运行 - Healthy, - /// 部分功能降级 - Degraded, - /// 不可用 - Unavailable, -} - /// 运行时中单个插件的状态摘要。 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] diff --git a/crates/protocol/src/http/session.rs b/crates/protocol/src/http/session.rs index 1502062d..32253f86 100644 --- a/crates/protocol/src/http/session.rs +++ b/crates/protocol/src/http/session.rs @@ -3,6 +3,7 @@ //! 定义会话创建、列表、提示词提交、消息历史等接口的请求/响应结构。 //! 会话是 Astrcode 的核心概念,代表一次独立的 AI 辅助编程交互。 +pub use astrcode_core::DeleteProjectResult as DeleteProjectResultDto; use serde::{Deserialize, Serialize}; use super::{ExecutionControlDto, PhaseDto}; @@ -92,16 +93,3 @@ pub struct CompactSessionResponse { pub deferred: bool, pub message: String, } - -/// `DELETE /api/projects/:working_dir` 响应体——项目删除结果。 -/// -/// 由于项目下可能有多个会话,删除是批量操作。 -/// `failed_session_ids` 列出删除失败的会话 ID(如文件被锁定)。 -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct DeleteProjectResultDto { - /// 成功删除的会话数量 - pub success_count: usize, - /// 删除失败的会话 ID 列表 - pub failed_session_ids: Vec, -} diff --git a/crates/protocol/src/http/session_event.rs b/crates/protocol/src/http/session_event.rs index 7db6d464..1fe04370 100644 --- a/crates/protocol/src/http/session_event.rs +++ b/crates/protocol/src/http/session_event.rs @@ -1,37 +1,13 @@ -//! 会话目录事件 DTO +//! 会话目录事件 DTO。 //! -//! 定义会话生命周期中的目录级事件(创建、删除、分支), -//! 通过 SSE 广播通知前端更新会话列表视图。 -//! 与 `event.rs` 中的 Agent 事件不同,这些事件关注会话管理而非 Agent 执行。 +//! 目录事件载荷已经是共享语义,协议层直接复用 `core`; +//! 外层信封仍由 protocol 拥有。 +pub use astrcode_core::SessionCatalogEvent as SessionCatalogEventPayload; use serde::{Deserialize, Serialize}; use crate::http::PROTOCOL_VERSION; -/// 会话目录事件载荷的 tagged enum。 -/// -/// 采用 `#[serde(tag = "event", content = "data")]` 序列化策略。 -/// 这些事件由 server 在会话生命周期变更时广播,前端据此更新会话列表。 -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(tag = "event", content = "data", rename_all = "camelCase")] -pub enum SessionCatalogEventPayload { - /// 新会话创建事件。 - SessionCreated { session_id: String }, - /// 会话删除事件。 - SessionDeleted { session_id: String }, - /// 整个项目(工作目录)删除事件。 - /// - /// 删除项目会级联删除其下所有会话。 - ProjectDeleted { working_dir: String }, - /// 会话分支事件。 - /// - /// 当从一个现有会话分支出新会话时触发。 - SessionBranched { - session_id: String, - source_session_id: String, - }, -} - /// 会话目录事件信封。 /// /// 为事件载荷添加协议版本号,确保前端可以验证兼容性。 diff --git a/crates/server/src/http/auth.rs b/crates/server/src/http/auth.rs index b85f74f2..c0fa63c4 100644 --- a/crates/server/src/http/auth.rs +++ b/crates/server/src/http/auth.rs @@ -73,6 +73,14 @@ pub(crate) struct IssuedAuthToken { pub expires_at_ms: i64, } +/// auth exchange 的共享摘要输入。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct AuthExchangeSummary { + pub ok: bool, + pub token: String, + pub expires_at_ms: i64, +} + /// API 会话 token 管理器。 /// /// 维护一个线程安全的 token 映射,支持签发和验证。 @@ -90,6 +98,15 @@ impl AuthSessionManager { self.issue_named_token(random_hex_token(), API_SESSION_TTL_HOURS) } + pub(crate) fn issue_exchange_summary(&self) -> AuthExchangeSummary { + let issued = self.issue_token(); + AuthExchangeSummary { + ok: true, + token: issued.token, + expires_at_ms: issued.expires_at_ms, + } + } + /// 验证 token 是否有效且未过期。 /// /// 验证前会先清理所有过期 token。 diff --git a/crates/server/src/http/mapper.rs b/crates/server/src/http/mapper.rs index d69716b1..d2c24fff 100644 --- a/crates/server/src/http/mapper.rs +++ b/crates/server/src/http/mapper.rs @@ -20,52 +20,93 @@ //! - **SSE 工具**:事件 ID 解析/格式化(`{storage_seq}.{subindex}` 格式) use astrcode_application::{ - AgentCollaborationScorecardSnapshot, AgentMode, ApplicationError, CapabilitySpec, - ComposerOption, ComposerOptionKind, Config, ExecutionDiagnosticsSnapshot, GovernanceSnapshot, - InvocationMode, OperationMetricsSnapshot, PluginEntry, PluginHealth, PluginState, - ReplayMetricsSnapshot, RuntimeObservabilitySnapshot, SessionCatalogEvent, SessionMeta, - SubRunExecutionMetricsSnapshot, SubagentContextOverrides, format_local_rfc3339, - is_env_var_name, list_model_options as resolve_model_options, resolve_active_selection, + AgentExecuteSummary, ApplicationError, ComposerOption, Config, ResolvedConfigSummary, + ResolvedRuntimeStatusSummary, SessionCatalogEvent, SessionListSummary, + SubRunStatusSourceSummary, SubRunStatusSummary, SubagentContextOverrides, + list_model_options as resolve_model_options, resolve_current_model as resolve_runtime_current_model, }; use astrcode_protocol::http::{ - AgentCollaborationScorecardDto, AgentProfileDto, ComposerOptionActionKindDto, - ComposerOptionDto, ComposerOptionKindDto, ComposerOptionsResponseDto, ConfigView, - CurrentModelInfoDto, ExecutionDiagnosticsDto, ModelOptionDto, OperationMetricsDto, - PROTOCOL_VERSION, PluginHealthDto, PluginRuntimeStateDto, ProfileView, ReplayMetricsDto, - RuntimeCapabilityDto, RuntimeMetricsDto, RuntimePluginDto, RuntimeStatusDto, - SessionCatalogEventEnvelope, SessionCatalogEventPayload, SessionListItem, - SubRunExecutionMetricsDto, SubagentContextOverridesDto, + AgentExecuteResponseDto, ComposerOptionsResponseDto, ConfigView, CurrentModelInfoDto, + ModelOptionDto, PROTOCOL_VERSION, ProfileView, ResolvedExecutionLimitsDto, + RuntimeCapabilityDto, RuntimePluginDto, RuntimeStatusDto, SessionCatalogEventEnvelope, + SessionListItem, SubRunResultDto, SubRunStatusDto, SubRunStatusSourceDto, + SubagentContextOverridesDto, }; use axum::{http::StatusCode, response::sse::Event}; use crate::ApiError; -#[derive(Debug, Clone)] -pub(crate) struct AgentProfileSummary { - pub id: String, - pub name: String, - pub description: String, - pub mode: AgentMode, - pub allowed_tools: Vec, - pub disallowed_tools: Vec, +fn to_runtime_capability_dto( + capability: astrcode_application::RuntimeCapabilitySummary, +) -> RuntimeCapabilityDto { + RuntimeCapabilityDto { + name: capability.name, + kind: capability.kind, + description: capability.description, + profiles: capability.profiles, + streaming: capability.streaming, + } } -/// 将会话元数据映射为列表项 DTO。 +/// 将会话摘要输入映射为列表项 DTO。 /// /// 用于 `GET /api/sessions` 和 `POST /api/sessions` 的响应, -/// 将时间戳转换为 RFC3339 字符串格式。 -pub(crate) fn to_session_list_item(meta: SessionMeta) -> SessionListItem { +/// server 只负责协议包装,不再自行格式化时间字段。 +pub(crate) fn to_session_list_item(summary: SessionListSummary) -> SessionListItem { SessionListItem { - session_id: meta.session_id, - working_dir: meta.working_dir, - display_name: meta.display_name, - title: meta.title, - created_at: format_local_rfc3339(meta.created_at), - updated_at: format_local_rfc3339(meta.updated_at), - parent_session_id: meta.parent_session_id, - parent_storage_seq: meta.parent_storage_seq, - phase: meta.phase, + session_id: summary.session_id, + working_dir: summary.working_dir, + display_name: summary.display_name, + title: summary.title, + created_at: summary.created_at, + updated_at: summary.updated_at, + parent_session_id: summary.parent_session_id, + parent_storage_seq: summary.parent_storage_seq, + phase: summary.phase, + } +} + +pub(crate) fn to_agent_execute_response_dto( + summary: AgentExecuteSummary, +) -> AgentExecuteResponseDto { + AgentExecuteResponseDto { + accepted: summary.accepted, + message: summary.message, + session_id: summary.session_id, + turn_id: summary.turn_id, + agent_id: summary.agent_id, + } +} + +pub(crate) fn to_subrun_status_dto(summary: SubRunStatusSummary) -> SubRunStatusDto { + SubRunStatusDto { + sub_run_id: summary.sub_run_id, + tool_call_id: summary.tool_call_id, + source: match summary.source { + SubRunStatusSourceSummary::Live => SubRunStatusSourceDto::Live, + SubRunStatusSourceSummary::Durable => SubRunStatusSourceDto::Durable, + }, + agent_id: summary.agent_id, + agent_profile: summary.agent_profile, + session_id: summary.session_id, + child_session_id: summary.child_session_id, + depth: summary.depth, + parent_agent_id: summary.parent_agent_id, + parent_sub_run_id: summary.parent_sub_run_id, + storage_mode: summary.storage_mode, + lifecycle: summary.lifecycle, + last_turn_outcome: summary.last_turn_outcome, + result: summary.result.map(to_subrun_result_dto), + step_count: summary.step_count, + estimated_tokens: summary.estimated_tokens, + resolved_overrides: summary.resolved_overrides, + resolved_limits: summary + .resolved_limits + .map(|limits| ResolvedExecutionLimitsDto { + allowed_tools: limits.allowed_tools, + max_steps: limits.max_steps, + }), } } @@ -73,63 +114,46 @@ pub(crate) fn to_session_list_item(meta: SessionMeta) -> SessionListItem { /// /// 包含运行时名称、类型、已加载会话数、运行中的会话 ID、 /// 插件搜索路径、运行时指标、能力描述和插件状态。 -pub(crate) fn to_runtime_status_dto(snapshot: GovernanceSnapshot) -> RuntimeStatusDto { +pub(crate) fn to_runtime_status_dto(summary: ResolvedRuntimeStatusSummary) -> RuntimeStatusDto { RuntimeStatusDto { - runtime_name: snapshot.runtime_name, - runtime_kind: snapshot.runtime_kind, - loaded_session_count: snapshot.loaded_session_count, - running_session_ids: snapshot.running_session_ids, - plugin_search_paths: snapshot - .plugin_search_paths - .into_iter() - .map(|path| path.display().to_string()) - .collect(), - metrics: to_runtime_metrics_dto(snapshot.metrics), - capabilities: snapshot + runtime_name: summary.runtime_name, + runtime_kind: summary.runtime_kind, + loaded_session_count: summary.loaded_session_count, + running_session_ids: summary.running_session_ids, + plugin_search_paths: summary.plugin_search_paths, + metrics: summary.metrics, + capabilities: summary .capabilities .into_iter() .map(to_runtime_capability_dto) .collect(), - plugins: snapshot + plugins: summary .plugins .into_iter() - .map(to_runtime_plugin_dto) + .map(|plugin| RuntimePluginDto { + name: plugin.name, + version: plugin.version, + description: plugin.description, + state: plugin.state, + health: plugin.health, + failure_count: plugin.failure_count, + failure: plugin.failure, + warnings: plugin.warnings, + last_checked_at: plugin.last_checked_at, + capabilities: plugin + .capabilities + .into_iter() + .map(to_runtime_capability_dto) + .collect(), + }) .collect(), } } -pub(crate) fn to_agent_profile_dto(profile: AgentProfileSummary) -> AgentProfileDto { - AgentProfileDto { - id: profile.id, - name: profile.name, - description: profile.description, - mode: match profile.mode { - AgentMode::Primary => "primary".to_string(), - AgentMode::SubAgent => "subAgent".to_string(), - AgentMode::All => "all".to_string(), - }, - allowed_tools: profile.allowed_tools, - disallowed_tools: profile.disallowed_tools, - // TODO: 未来可能需要添加更多 agent 级执行限制摘要 - } -} - pub(crate) fn from_subagent_context_overrides_dto( dto: Option, ) -> Option { - dto.map(|dto| SubagentContextOverrides { - storage_mode: dto.storage_mode, - inherit_system_instructions: dto.inherit_system_instructions, - inherit_project_instructions: dto.inherit_project_instructions, - inherit_working_dir: dto.inherit_working_dir, - inherit_policy_upper_bound: dto.inherit_policy_upper_bound, - inherit_cancel_token: dto.inherit_cancel_token, - include_compact_summary: dto.include_compact_summary, - include_recent_tail: dto.include_recent_tail, - include_recovery_refs: dto.include_recovery_refs, - include_parent_findings: dto.include_parent_findings, - fork_mode: dto.fork_mode, - }) + dto } /// 将会话目录事件转换为 SSE 事件。 @@ -138,272 +162,49 @@ pub(crate) fn from_subagent_context_overrides_dto( /// 序列化失败时返回 `projectDeleted` 事件并携带错误信息, /// 保证 SSE 流不会中断。 pub(crate) fn to_session_catalog_sse_event(event: SessionCatalogEvent) -> Event { - let payload = serde_json::to_string(&SessionCatalogEventEnvelope::new( - to_session_catalog_event_dto(event), - )) - .unwrap_or_else(|error| { - serde_json::json!({ - "protocolVersion": PROTOCOL_VERSION, - "event": "projectDeleted", - "data": { - "workingDir": format!("serialization-error: {error}") - } - }) - .to_string() - }); + let payload = + serde_json::to_string(&SessionCatalogEventEnvelope::new(event)).unwrap_or_else(|error| { + serde_json::json!({ + "protocolVersion": PROTOCOL_VERSION, + "event": "projectDeleted", + "data": { + "workingDir": format!("serialization-error: {error}") + } + }) + .to_string() + }); Event::default().data(payload) } -/// 将能力描述符映射为 DTO。 -/// -/// `kind` 字段通过 serde_json 序列化后取字符串表示, -/// 反序列化失败时降级为 "unknown",避免协议层崩溃。 -fn to_runtime_capability_dto(spec: CapabilitySpec) -> RuntimeCapabilityDto { - RuntimeCapabilityDto { - name: spec.name.to_string(), - kind: spec.kind.as_str().to_string(), - description: spec.description, - profiles: spec.profiles, - streaming: matches!(spec.invocation_mode, InvocationMode::Streaming), - } -} - -/// 将插件条目映射为 DTO。 +/// 构建配置视图 DTO。 /// -/// 包含插件清单信息(名称、版本、描述)、运行时状态、健康度、 -/// 失败计数和最后检查时间,以及插件暴露的所有能力。 -fn to_runtime_plugin_dto(entry: PluginEntry) -> RuntimePluginDto { - RuntimePluginDto { - name: entry.manifest.name, - version: entry.manifest.version, - description: entry.manifest.description, - state: match entry.state { - PluginState::Discovered => PluginRuntimeStateDto::Discovered, - PluginState::Initialized => PluginRuntimeStateDto::Initialized, - PluginState::Failed => PluginRuntimeStateDto::Failed, - }, - health: match entry.health { - PluginHealth::Unknown => PluginHealthDto::Unknown, - PluginHealth::Healthy => PluginHealthDto::Healthy, - PluginHealth::Degraded => PluginHealthDto::Degraded, - PluginHealth::Unavailable => PluginHealthDto::Unavailable, - }, - failure_count: entry.failure_count, - failure: entry.failure, - warnings: entry.warnings, - last_checked_at: entry.last_checked_at, - capabilities: entry - .capabilities +/// server 只负责补充 `config_path` 和协议外层壳, +/// 已解析选择、profile 摘要与 API key 预览均由 application 统一提供。 +pub(crate) fn build_config_view(summary: ResolvedConfigSummary, config_path: String) -> ConfigView { + ConfigView { + config_path, + active_profile: summary.active_profile, + active_model: summary.active_model, + profiles: summary + .profiles .into_iter() - .map(to_runtime_capability_dto) + .map(|profile| ProfileView { + name: profile.name, + base_url: profile.base_url, + api_key_preview: profile.api_key_preview, + models: profile.models, + }) .collect(), + warning: summary.warning, } } -/// 将运行时观测指标快照映射为 DTO。 -/// -/// 包含三个维度的指标:会话重连(session_rehydrate)、 -/// SSE 追赶(sse_catch_up)、轮次执行(turn_execution)和子执行域观测(subrun_execution)。 -pub(crate) fn to_runtime_metrics_dto(snapshot: RuntimeObservabilitySnapshot) -> RuntimeMetricsDto { - RuntimeMetricsDto { - session_rehydrate: to_operation_metrics_dto(snapshot.session_rehydrate), - sse_catch_up: to_replay_metrics_dto(snapshot.sse_catch_up), - turn_execution: to_operation_metrics_dto(snapshot.turn_execution), - subrun_execution: to_subrun_execution_metrics_dto(snapshot.subrun_execution), - execution_diagnostics: to_execution_diagnostics_dto(snapshot.execution_diagnostics), - agent_collaboration: to_agent_collaboration_scorecard_dto(snapshot.agent_collaboration), - } -} - -/// 将操作指标快照映射为 DTO。 -/// -/// 记录总执行次数、失败次数、总耗时、最近一次耗时和最大耗时, -/// 用于前端展示运行时性能面板。 -fn to_operation_metrics_dto(snapshot: OperationMetricsSnapshot) -> OperationMetricsDto { - OperationMetricsDto { - total: snapshot.total, - failures: snapshot.failures, - total_duration_ms: snapshot.total_duration_ms, - last_duration_ms: snapshot.last_duration_ms, - max_duration_ms: snapshot.max_duration_ms, - } -} - -/// 将回放指标快照映射为 DTO。 -/// -/// 在操作指标基础上增加缓存命中数、磁盘回退数和已恢复事件数, -/// 用于衡量 SSE 断线重连后的事件恢复效率。 -fn to_replay_metrics_dto(snapshot: ReplayMetricsSnapshot) -> ReplayMetricsDto { - ReplayMetricsDto { - totals: to_operation_metrics_dto(snapshot.totals), - cache_hits: snapshot.cache_hits, - disk_fallbacks: snapshot.disk_fallbacks, - recovered_events: snapshot.recovered_events, - } -} - -fn to_subrun_execution_metrics_dto( - snapshot: SubRunExecutionMetricsSnapshot, -) -> SubRunExecutionMetricsDto { - SubRunExecutionMetricsDto { - total: snapshot.total, - failures: snapshot.failures, - completed: snapshot.completed, - cancelled: snapshot.cancelled, - token_exceeded: snapshot.token_exceeded, - independent_session_total: snapshot.independent_session_total, - total_duration_ms: snapshot.total_duration_ms, - last_duration_ms: snapshot.last_duration_ms, - total_steps: snapshot.total_steps, - last_step_count: snapshot.last_step_count, - total_estimated_tokens: snapshot.total_estimated_tokens, - last_estimated_tokens: snapshot.last_estimated_tokens, - } -} - -fn to_execution_diagnostics_dto(snapshot: ExecutionDiagnosticsSnapshot) -> ExecutionDiagnosticsDto { - ExecutionDiagnosticsDto { - child_spawned: snapshot.child_spawned, - child_started_persisted: snapshot.child_started_persisted, - child_terminal_persisted: snapshot.child_terminal_persisted, - parent_reactivation_requested: snapshot.parent_reactivation_requested, - parent_reactivation_succeeded: snapshot.parent_reactivation_succeeded, - parent_reactivation_failed: snapshot.parent_reactivation_failed, - lineage_mismatch_parent_agent: snapshot.lineage_mismatch_parent_agent, - lineage_mismatch_parent_session: snapshot.lineage_mismatch_parent_session, - lineage_mismatch_child_session: snapshot.lineage_mismatch_child_session, - lineage_mismatch_descriptor_missing: snapshot.lineage_mismatch_descriptor_missing, - cache_reuse_hits: snapshot.cache_reuse_hits, - cache_reuse_misses: snapshot.cache_reuse_misses, - delivery_buffer_queued: snapshot.delivery_buffer_queued, - delivery_buffer_dequeued: snapshot.delivery_buffer_dequeued, - delivery_buffer_wake_requested: snapshot.delivery_buffer_wake_requested, - delivery_buffer_wake_succeeded: snapshot.delivery_buffer_wake_succeeded, - delivery_buffer_wake_failed: snapshot.delivery_buffer_wake_failed, - } -} - -fn to_agent_collaboration_scorecard_dto( - snapshot: AgentCollaborationScorecardSnapshot, -) -> AgentCollaborationScorecardDto { - AgentCollaborationScorecardDto { - total_facts: snapshot.total_facts, - spawn_accepted: snapshot.spawn_accepted, - spawn_rejected: snapshot.spawn_rejected, - send_reused: snapshot.send_reused, - send_queued: snapshot.send_queued, - send_rejected: snapshot.send_rejected, - observe_calls: snapshot.observe_calls, - observe_rejected: snapshot.observe_rejected, - observe_followed_by_action: snapshot.observe_followed_by_action, - close_calls: snapshot.close_calls, - close_rejected: snapshot.close_rejected, - delivery_delivered: snapshot.delivery_delivered, - delivery_consumed: snapshot.delivery_consumed, - delivery_replayed: snapshot.delivery_replayed, - orphan_child_count: snapshot.orphan_child_count, - child_reuse_ratio_bps: snapshot.child_reuse_ratio_bps, - observe_to_action_ratio_bps: snapshot.observe_to_action_ratio_bps, - spawn_to_delivery_ratio_bps: snapshot.spawn_to_delivery_ratio_bps, - orphan_child_ratio_bps: snapshot.orphan_child_ratio_bps, - avg_delivery_latency_ms: snapshot.avg_delivery_latency_ms, - max_delivery_latency_ms: snapshot.max_delivery_latency_ms, - } -} - -/// 将会话目录事件映射为协议层载荷。 -/// -/// 目录事件用于前端同步会话列表变更,包括会话创建/删除、 -/// 项目删除(级联删除该工作目录下所有会话)、会话分支。 -pub(crate) fn to_session_catalog_event_dto( - event: SessionCatalogEvent, -) -> SessionCatalogEventPayload { - match event { - SessionCatalogEvent::SessionCreated { session_id } => { - SessionCatalogEventPayload::SessionCreated { session_id } - }, - SessionCatalogEvent::SessionDeleted { session_id } => { - SessionCatalogEventPayload::SessionDeleted { session_id } - }, - SessionCatalogEvent::ProjectDeleted { working_dir } => { - SessionCatalogEventPayload::ProjectDeleted { working_dir } - }, - SessionCatalogEvent::SessionBranched { - session_id, - source_session_id, - } => SessionCatalogEventPayload::SessionBranched { - session_id, - source_session_id, - }, - } -} - -/// 构建配置视图 DTO。 -/// -/// 将内部 `Config` 转换为前端可展示的配置视图,包括: -/// - 配置文件路径 -/// - 当前激活的 profile 和 model -/// - 所有 profile 列表(API key 做脱敏预览) -/// - 配置警告(如无 profile 时提示) -/// -/// Profile 为空时直接返回带警告的视图,不走活跃选择解析。 -pub(crate) fn build_config_view( - config: &Config, - config_path: String, -) -> Result { - if config.profiles.is_empty() { - return Ok(ConfigView { - config_path, - active_profile: String::new(), - active_model: String::new(), - profiles: Vec::new(), - warning: Some("no profiles configured".to_string()), - }); - } - - let profiles = config - .profiles - .iter() - .map(|profile| ProfileView { - name: profile.name.clone(), - base_url: profile.base_url.clone(), - api_key_preview: api_key_preview(profile.api_key.as_deref()), - models: profile - .models - .iter() - .map(|model| model.id.clone()) - .collect(), - }) - .collect::>(); - - let selection = resolve_active_selection( - &config.active_profile, - &config.active_model, - &config.profiles, - ) - .map_err(config_selection_error)?; - - Ok(ConfigView { - config_path, - active_profile: selection.active_profile, - active_model: selection.active_model, - profiles, - warning: selection.warning, - }) -} - /// 解析当前激活的模型信息。 /// /// 从配置中提取当前使用的 profile 名称、模型名称和提供者类型, /// 用于 `GET /api/models/current` 响应。 pub(crate) fn resolve_current_model(config: &Config) -> Result { - let selection = resolve_runtime_current_model(config).map_err(config_selection_error)?; - - Ok(CurrentModelInfoDto { - profile_name: selection.profile_name, - model: selection.model, - provider_kind: selection.provider_kind, - }) + resolve_runtime_current_model(config).map_err(config_selection_error) } /// 列出所有可用的模型选项。 @@ -412,13 +213,6 @@ pub(crate) fn resolve_current_model(config: &Config) -> Result Vec { resolve_model_options(config) - .into_iter() - .map(|option| ModelOptionDto { - profile_name: option.profile_name, - model: option.model, - provider_kind: option.provider_kind, - }) - .collect() } /// 将 runtime 输入候选项映射为协议 DTO。 @@ -427,34 +221,7 @@ pub(crate) fn list_model_options(config: &Config) -> Vec { pub(crate) fn to_composer_options_response( items: Vec, ) -> ComposerOptionsResponseDto { - ComposerOptionsResponseDto { - items: items.into_iter().map(to_composer_option_dto).collect(), - } -} - -fn to_composer_option_dto(item: ComposerOption) -> ComposerOptionDto { - ComposerOptionDto { - kind: match item.kind { - ComposerOptionKind::Command => ComposerOptionKindDto::Command, - ComposerOptionKind::Skill => ComposerOptionKindDto::Skill, - ComposerOptionKind::Capability => ComposerOptionKindDto::Capability, - }, - id: item.id, - title: item.title, - description: item.description, - insert_text: item.insert_text, - action_kind: match item.action_kind { - astrcode_application::ComposerOptionActionKind::InsertText => { - ComposerOptionActionKindDto::InsertText - }, - astrcode_application::ComposerOptionActionKind::ExecuteCommand => { - ComposerOptionActionKindDto::ExecuteCommand - }, - }, - action_value: item.action_value, - badges: item.badges, - keywords: item.keywords, - } + ComposerOptionsResponseDto { items } } fn config_selection_error(error: ApplicationError) -> ApiError { @@ -464,64 +231,22 @@ fn config_selection_error(error: ApplicationError) -> ApiError { } } -/// 生成 API key 的安全预览字符串。 -/// -/// 规则: -/// - `None` 或空字符串 → "未配置" -/// - `env:VAR_NAME` 前缀 → "环境变量: VAR_NAME"(不读取实际值) -/// - `literal:KEY` 前缀 → 显示 **** + 最后 4 个字符 -/// - 纯大写+下划线且是有效环境变量名 → "环境变量: NAME" -/// - 长度 > 4 → 显示 "****" + 最后 4 个字符 -/// - 其他 → "****" -pub(crate) fn api_key_preview(api_key: Option<&str>) -> String { - match api_key.map(str::trim) { - None | Some("") => "未配置".to_string(), - Some(value) if value.starts_with("env:") => { - let env_name = value.trim_start_matches("env:").trim(); - if env_name.is_empty() { - "未配置".to_string() - } else { - format!("环境变量: {}", env_name) - } +fn to_subrun_result_dto(result: astrcode_application::SubRunResult) -> SubRunResultDto { + match result { + astrcode_application::SubRunResult::Running { handoff } => { + SubRunResultDto::Running { handoff } }, - Some(value) if value.starts_with("literal:") => { - let key = value.trim_start_matches("literal:").trim(); - masked_key_preview(key) + astrcode_application::SubRunResult::Completed { outcome, handoff } => match outcome { + astrcode_core::CompletedSubRunOutcome::Completed => { + SubRunResultDto::Completed { handoff } + }, + astrcode_core::CompletedSubRunOutcome::TokenExceeded => { + SubRunResultDto::TokenExceeded { handoff } + }, }, - Some(value) if is_env_var_name(value) && std::env::var_os(value).is_some() => { - format!("环境变量: {}", value) + astrcode_application::SubRunResult::Failed { outcome, failure } => match outcome { + astrcode_core::FailedSubRunOutcome::Failed => SubRunResultDto::Failed { failure }, + astrcode_core::FailedSubRunOutcome::Cancelled => SubRunResultDto::Cancelled { failure }, }, - Some(value) => masked_key_preview(value), - } -} - -fn masked_key_preview(value: &str) -> String { - let char_starts: Vec = value.char_indices().map(|(index, _)| index).collect(); - - if char_starts.len() <= 4 { - "****".to_string() - } else { - // 预览语义是“最后 4 个字符”而不是“最后 4 个字节”, - // 用字符起始位置切片可以避免多字节 UTF-8 密钥在预览时 panic。 - let suffix_start = char_starts[char_starts.len() - 4]; - format!("****{}", &value[suffix_start..]) - } -} - -#[cfg(test)] -mod tests { - use super::api_key_preview; - - #[test] - fn api_key_preview_masks_utf8_literal_without_panicking() { - assert_eq!( - api_key_preview(Some("literal:令牌甲乙丙丁")), - "****甲乙丙丁" - ); - } - - #[test] - fn api_key_preview_masks_utf8_plain_value_without_panicking() { - assert_eq!(api_key_preview(Some("令牌甲乙丙丁戊")), "****乙丙丁戊"); } } diff --git a/crates/server/src/http/routes/agents.rs b/crates/server/src/http/routes/agents.rs index 33a37ea3..66370d45 100644 --- a/crates/server/src/http/routes/agents.rs +++ b/crates/server/src/http/routes/agents.rs @@ -5,17 +5,10 @@ use std::path::PathBuf; -use astrcode_application::{ - AgentEventContext, AgentLifecycleStatus, AgentTurnOutcome, InvocationKind, - ResolvedExecutionLimitsSnapshot, ResolvedSubagentContextOverrides, StorageEventPayload, - StoredEvent, SubRunFailure, SubRunResult, SubRunStatusView, -}; -use astrcode_core::{CompletedSubRunOutcome, FailedSubRunOutcome}; +use astrcode_application::{AgentExecuteSummary, RootExecutionRequest}; use astrcode_protocol::http::{ - AgentExecuteRequestDto, AgentExecuteResponseDto, AgentLifecycleDto, AgentProfileDto, - AgentTurnOutcomeDto, ExecutionControlDto, ResolvedExecutionLimitsDto, - ResolvedSubagentContextOverridesDto, SubRunFailureDto, SubRunResultDto, SubRunStatusDto, - SubRunStatusSourceDto, SubRunStorageModeDto, + AgentExecuteRequestDto, AgentExecuteResponseDto, AgentProfileDto, ExecutionControlDto, + SubRunStatusDto, }; use axum::{ Json, @@ -24,15 +17,19 @@ use axum::{ }; use serde::Serialize; -use crate::{ApiError, AppState, auth::require_auth, routes::sessions}; +use crate::{ + ApiError, AppState, + auth::require_auth, + mapper::{ + from_subagent_context_overrides_dto, to_agent_execute_response_dto, to_subrun_status_dto, + }, + routes::sessions, +}; fn to_execution_control( control: Option, ) -> Option { - control.map(|control| astrcode_application::ExecutionControl { - max_steps: control.max_steps, - manual_compact: control.manual_compact, - }) + control } pub(crate) async fn list_agents( @@ -45,16 +42,6 @@ pub(crate) async fn list_agents( .list_global_agent_profiles() .map_err(ApiError::from)? .into_iter() - .map(|profile| { - crate::mapper::to_agent_profile_dto(crate::mapper::AgentProfileSummary { - id: profile.id, - name: profile.name, - description: profile.description, - mode: profile.mode, - allowed_tools: profile.allowed_tools, - disallowed_tools: profile.disallowed_tools, - }) - }) .collect(); Ok(Json(profiles)) } @@ -70,33 +57,21 @@ pub(crate) async fn execute_agent( .working_dir .map(PathBuf::from) .ok_or_else(|| ApiError::bad_request("workingDir is required".to_string()))?; - let accepted = state + let summary: AgentExecuteSummary = state .app - .execute_root_agent(astrcode_application::RootExecutionRequest { + .execute_root_agent_summary(RootExecutionRequest { agent_id: agent_id.clone(), working_dir: working_dir.to_string_lossy().to_string(), task: request.task, context: request.context, control: to_execution_control(request.control), - context_overrides: crate::mapper::from_subagent_context_overrides_dto( - request.context_overrides, - ), + context_overrides: from_subagent_context_overrides_dto(request.context_overrides), }) .await .map_err(ApiError::from)?; Ok(( StatusCode::ACCEPTED, - Json(AgentExecuteResponseDto { - accepted: true, - message: format!( - "agent '{}' execution accepted; subscribe to \ - /api/v1/conversation/sessions/{}/stream for progress", - agent_id, accepted.session_id - ), - session_id: Some(accepted.session_id.to_string()), - turn_id: Some(accepted.turn_id.to_string()), - agent_id: accepted.agent_id.map(|value| value.to_string()), - }), + Json(to_agent_execute_response_dto(summary)), )) } @@ -107,97 +82,12 @@ pub(crate) async fn get_subrun_status( ) -> Result, ApiError> { require_auth(&state, &headers, None)?; let session_id = sessions::validate_session_path_id(&session_id)?; - - // 先尝试通过 agent_id 查询稳定视图 - if let Some(view) = state - .app - .get_subrun_status(&sub_run_id) - .await - .map_err(ApiError::from)? - { - return Ok(Json(to_subrun_status_dto(view, session_id))); - } - - // 再尝试通过 session 查找根 agent - if let Some(view) = state + let summary = state .app - .get_root_agent_status(&session_id) + .get_subrun_status_summary(&session_id, &sub_run_id) .await - .map_err(ApiError::from)? - { - if view.sub_run_id == sub_run_id { - return Ok(Json(to_subrun_status_dto(view, session_id))); - } - return Err(ApiError { - status: StatusCode::NOT_FOUND, - message: format!( - "subrun '{}' not found in session '{}'", - sub_run_id, session_id - ), - }); - } - - if let Some(view) = durable_subrun_status(&state, &session_id, &sub_run_id).await? { - return Ok(Json(view)); - } - - // 兜底:返回默认值(兼容无 agent 的 session) - Ok(Json(SubRunStatusDto { - sub_run_id, - tool_call_id: None, - source: SubRunStatusSourceDto::Live, - agent_id: "root-agent".to_string(), - agent_profile: "default".to_string(), - session_id, - child_session_id: None, - depth: 0, - parent_agent_id: None, - parent_sub_run_id: None, - storage_mode: SubRunStorageModeDto::IndependentSession, - lifecycle: AgentLifecycleDto::Idle, - last_turn_outcome: None, - result: None, - step_count: None, - estimated_tokens: None, - resolved_overrides: None, - resolved_limits: Some(ResolvedExecutionLimitsDto { - allowed_tools: Vec::new(), - max_steps: None, - }), - })) -} - -async fn durable_subrun_status( - state: &AppState, - parent_session_id: &str, - requested_subrun_id: &str, -) -> Result, ApiError> { - let child_sessions = state - .app - .list_sessions() - .await - .map_err(ApiError::from)? - .into_iter() - .filter(|meta| meta.parent_session_id.as_deref() == Some(parent_session_id)) - .collect::>(); - - for child_session in child_sessions { - let stored_events = state - .app - .session_stored_events(&child_session.session_id) - .await - .map_err(ApiError::from)?; - if let Some(snapshot) = project_durable_subrun_status( - parent_session_id, - &child_session.session_id, - requested_subrun_id, - &stored_events, - ) { - return Ok(Some(snapshot)); - } - } - - Ok(None) + .map_err(ApiError::from)?; + Ok(Json(to_subrun_status_dto(summary))) } /// 关闭指定 agent 及其子树。 @@ -220,426 +110,8 @@ pub(crate) async fn close_agent( })) } -/// 将稳定视图转换为协议 DTO。 -fn to_subrun_status_dto(view: SubRunStatusView, session_id: String) -> SubRunStatusDto { - SubRunStatusDto { - sub_run_id: view.sub_run_id, - tool_call_id: None, - source: SubRunStatusSourceDto::Live, - agent_id: view.agent_id, - agent_profile: view.agent_profile, - session_id, - child_session_id: view.child_session_id, - depth: view.depth, - parent_agent_id: view.parent_agent_id, - parent_sub_run_id: None, - storage_mode: SubRunStorageModeDto::IndependentSession, - lifecycle: to_lifecycle_dto(view.lifecycle), - last_turn_outcome: view.last_turn_outcome.map(to_turn_outcome_dto), - result: None, - step_count: None, - estimated_tokens: None, - resolved_overrides: None, - resolved_limits: Some(ResolvedExecutionLimitsDto { - allowed_tools: view.resolved_limits.allowed_tools, - max_steps: view.resolved_limits.max_steps, - }), - } -} - -#[derive(Debug, Clone)] -struct DurableSubRunStatusProjection { - sub_run_id: String, - tool_call_id: Option, - agent_id: String, - agent_profile: String, - child_session_id: String, - depth: usize, - parent_agent_id: Option, - parent_sub_run_id: Option, - lifecycle: AgentLifecycleStatus, - last_turn_outcome: Option, - result: Option, - step_count: Option, - estimated_tokens: Option, - resolved_overrides: Option, - resolved_limits: ResolvedExecutionLimitsSnapshot, -} - -fn project_durable_subrun_status( - parent_session_id: &str, - child_session_id: &str, - requested_subrun_id: &str, - stored_events: &[StoredEvent], -) -> Option { - let mut projection: Option = None; - - for stored in stored_events { - let agent = &stored.event.agent; - if !matches_requested_subrun(agent, requested_subrun_id) { - continue; - } - - match &stored.event.payload { - StorageEventPayload::SubRunStarted { - tool_call_id, - resolved_overrides, - resolved_limits, - .. - } => { - projection = Some(DurableSubRunStatusProjection { - sub_run_id: agent - .sub_run_id - .clone() - .unwrap_or_else(|| requested_subrun_id.to_string().into()) - .to_string(), - tool_call_id: tool_call_id.clone(), - agent_id: agent - .agent_id - .clone() - .unwrap_or_else(|| requested_subrun_id.to_string().into()) - .to_string(), - agent_profile: agent - .agent_profile - .clone() - .unwrap_or_else(|| "unknown".to_string()), - child_session_id: child_session_id.to_string(), - depth: 1, - parent_agent_id: None, - parent_sub_run_id: agent.parent_sub_run_id.clone().map(|id| id.to_string()), - lifecycle: AgentLifecycleStatus::Running, - last_turn_outcome: None, - result: None, - step_count: None, - estimated_tokens: None, - resolved_overrides: Some(resolved_overrides.clone()), - resolved_limits: resolved_limits.clone(), - }); - }, - StorageEventPayload::SubRunFinished { - tool_call_id, - result, - step_count, - estimated_tokens, - .. - } => { - let entry = projection.get_or_insert_with(|| DurableSubRunStatusProjection { - sub_run_id: agent - .sub_run_id - .clone() - .unwrap_or_else(|| requested_subrun_id.to_string().into()) - .to_string(), - tool_call_id: None, - agent_id: agent - .agent_id - .clone() - .unwrap_or_else(|| requested_subrun_id.to_string().into()) - .to_string(), - agent_profile: agent - .agent_profile - .clone() - .unwrap_or_else(|| "unknown".to_string()), - child_session_id: child_session_id.to_string(), - depth: 1, - parent_agent_id: None, - parent_sub_run_id: agent.parent_sub_run_id.clone().map(|id| id.to_string()), - lifecycle: result.status().lifecycle(), - last_turn_outcome: result.status().last_turn_outcome(), - result: None, - step_count: None, - estimated_tokens: None, - resolved_overrides: None, - resolved_limits: ResolvedExecutionLimitsSnapshot::default(), - }); - entry.tool_call_id = tool_call_id.clone().or_else(|| entry.tool_call_id.clone()); - entry.lifecycle = result.status().lifecycle(); - entry.last_turn_outcome = result.status().last_turn_outcome(); - entry.result = Some(result.clone()); - entry.step_count = Some(*step_count); - entry.estimated_tokens = Some(*estimated_tokens); - }, - _ => {}, - } - } - - projection.map(|projection| SubRunStatusDto { - sub_run_id: projection.sub_run_id, - tool_call_id: projection.tool_call_id, - source: SubRunStatusSourceDto::Durable, - agent_id: projection.agent_id, - agent_profile: projection.agent_profile, - session_id: parent_session_id.to_string(), - child_session_id: Some(projection.child_session_id), - depth: projection.depth, - parent_agent_id: projection.parent_agent_id, - parent_sub_run_id: projection.parent_sub_run_id, - storage_mode: SubRunStorageModeDto::IndependentSession, - lifecycle: to_lifecycle_dto(projection.lifecycle), - last_turn_outcome: projection.last_turn_outcome.map(to_turn_outcome_dto), - result: projection.result.map(to_subrun_result_dto), - step_count: projection.step_count, - estimated_tokens: projection.estimated_tokens, - resolved_overrides: projection.resolved_overrides.map(to_resolved_overrides_dto), - resolved_limits: Some(ResolvedExecutionLimitsDto { - allowed_tools: projection.resolved_limits.allowed_tools, - max_steps: projection.resolved_limits.max_steps, - }), - }) -} - -fn matches_requested_subrun(agent: &AgentEventContext, requested_subrun_id: &str) -> bool { - if agent.invocation_kind != Some(InvocationKind::SubRun) { - return false; - } - - agent.sub_run_id.as_deref() == Some(requested_subrun_id) - || agent.agent_id.as_deref() == Some(requested_subrun_id) -} - -fn to_resolved_overrides_dto( - overrides: ResolvedSubagentContextOverrides, -) -> ResolvedSubagentContextOverridesDto { - ResolvedSubagentContextOverridesDto { - storage_mode: overrides.storage_mode, - inherit_system_instructions: overrides.inherit_system_instructions, - inherit_project_instructions: overrides.inherit_project_instructions, - inherit_working_dir: overrides.inherit_working_dir, - inherit_policy_upper_bound: overrides.inherit_policy_upper_bound, - inherit_cancel_token: overrides.inherit_cancel_token, - include_compact_summary: overrides.include_compact_summary, - include_recent_tail: overrides.include_recent_tail, - include_recovery_refs: overrides.include_recovery_refs, - include_parent_findings: overrides.include_parent_findings, - fork_mode: overrides.fork_mode, - } -} - -fn to_subrun_result_dto(result: SubRunResult) -> SubRunResultDto { - match result { - SubRunResult::Running { handoff } => SubRunResultDto::Running { handoff }, - SubRunResult::Completed { outcome, handoff } => match outcome { - CompletedSubRunOutcome::Completed => SubRunResultDto::Completed { handoff }, - CompletedSubRunOutcome::TokenExceeded => SubRunResultDto::TokenExceeded { handoff }, - }, - SubRunResult::Failed { outcome, failure } => match outcome { - FailedSubRunOutcome::Failed => SubRunResultDto::Failed { - failure: to_subrun_failure_dto(failure), - }, - FailedSubRunOutcome::Cancelled => SubRunResultDto::Cancelled { - failure: to_subrun_failure_dto(failure), - }, - }, - } -} - -fn to_subrun_failure_dto(failure: SubRunFailure) -> SubRunFailureDto { - SubRunFailureDto { - code: failure.code, - display_message: failure.display_message, - technical_message: failure.technical_message, - retryable: failure.retryable, - } -} - -fn to_lifecycle_dto(status: AgentLifecycleStatus) -> AgentLifecycleDto { - match status { - AgentLifecycleStatus::Pending => AgentLifecycleDto::Pending, - AgentLifecycleStatus::Running => AgentLifecycleDto::Running, - AgentLifecycleStatus::Idle => AgentLifecycleDto::Idle, - AgentLifecycleStatus::Terminated => AgentLifecycleDto::Terminated, - } -} - -fn to_turn_outcome_dto(outcome: AgentTurnOutcome) -> AgentTurnOutcomeDto { - match outcome { - AgentTurnOutcome::Completed => AgentTurnOutcomeDto::Completed, - AgentTurnOutcome::Cancelled => AgentTurnOutcomeDto::Cancelled, - AgentTurnOutcome::TokenExceeded => AgentTurnOutcomeDto::TokenExceeded, - AgentTurnOutcome::Failed => AgentTurnOutcomeDto::Failed, - } -} - #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub(crate) struct CloseAgentResponse { closed_agent_ids: Vec, } - -#[cfg(test)] -mod tests { - use astrcode_application::{ - AgentEventContext, StoredEvent, SubRunHandoff, SubRunResult, SubRunStorageMode, - }; - use astrcode_core::{ - ArtifactRef, CompletedParentDeliveryPayload, CompletedSubRunOutcome, ForkMode, - ParentDelivery, ParentDeliveryOrigin, ParentDeliveryPayload, - ParentDeliveryTerminalSemantics, ResolvedSubagentContextOverrides, StorageEvent, - StorageEventPayload, - }; - - use super::project_durable_subrun_status; - - #[test] - fn durable_subrun_projection_preserves_typed_handoff_delivery() { - let child_agent = AgentEventContext::sub_run( - "agent-child", - "turn-parent", - "reviewer", - "subrun-child", - Some("subrun-parent".into()), - SubRunStorageMode::IndependentSession, - Some("session-child".into()), - ); - let explicit_delivery = ParentDelivery { - idempotency_key: "delivery-explicit".to_string(), - origin: ParentDeliveryOrigin::Explicit, - terminal_semantics: ParentDeliveryTerminalSemantics::Terminal, - source_turn_id: Some("turn-child".to_string()), - payload: ParentDeliveryPayload::Completed(CompletedParentDeliveryPayload { - message: "显式交付".to_string(), - findings: vec!["finding-1".to_string()], - artifacts: vec![ArtifactRef { - kind: "session".to_string(), - id: "session-child".to_string(), - label: "Child Session".to_string(), - session_id: Some("session-child".to_string()), - storage_seq: None, - uri: None, - }], - }), - }; - let stored_events = vec![StoredEvent { - storage_seq: 1, - event: StorageEvent { - turn_id: Some("turn-child".to_string()), - agent: child_agent.clone(), - payload: StorageEventPayload::SubRunFinished { - tool_call_id: Some("call-1".to_string()), - result: SubRunResult::Completed { - outcome: CompletedSubRunOutcome::Completed, - handoff: SubRunHandoff { - findings: vec!["finding-1".to_string()], - artifacts: vec![ArtifactRef { - kind: "session".to_string(), - id: "session-child".to_string(), - label: "Child Session".to_string(), - session_id: Some("session-child".to_string()), - storage_seq: None, - uri: None, - }], - delivery: Some(explicit_delivery.clone()), - }, - }, - timestamp: Some(chrono::Utc::now()), - step_count: 3, - estimated_tokens: 120, - }, - }, - }]; - - let projection = project_durable_subrun_status( - "session-parent", - "session-child", - "subrun-child", - &stored_events, - ) - .expect("projection should exist"); - - let result = projection.result.expect("durable result should exist"); - let handoff = match result { - astrcode_protocol::http::SubRunResultDto::Running { handoff } - | astrcode_protocol::http::SubRunResultDto::Completed { handoff } - | astrcode_protocol::http::SubRunResultDto::TokenExceeded { handoff } => handoff, - astrcode_protocol::http::SubRunResultDto::Failed { .. } - | astrcode_protocol::http::SubRunResultDto::Cancelled { .. } => { - panic!("expected successful durable handoff") - }, - }; - let delivery = handoff - .delivery - .expect("typed delivery should survive durable projection"); - assert_eq!(delivery.idempotency_key, "delivery-explicit"); - assert_eq!( - delivery.origin, - astrcode_protocol::http::ParentDeliveryOriginDto::Explicit - ); - assert_eq!( - delivery.terminal_semantics, - astrcode_protocol::http::ParentDeliveryTerminalSemanticsDto::Terminal - ); - match delivery.payload { - astrcode_protocol::http::ParentDeliveryPayloadDto::Completed(payload) => { - assert_eq!(payload.message, "显式交付"); - assert_eq!(payload.findings, vec!["finding-1".to_string()]); - }, - payload => panic!("unexpected delivery payload: {payload:?}"), - } - } - - #[test] - fn resolved_overrides_projection_preserves_fork_mode() { - let dto = super::to_resolved_overrides_dto(ResolvedSubagentContextOverrides { - fork_mode: Some(ForkMode::LastNTurns(7)), - ..ResolvedSubagentContextOverrides::default() - }); - - assert_eq!( - dto.fork_mode, - Some(astrcode_protocol::http::ForkModeDto::LastNTurns(7)) - ); - } - - #[test] - fn durable_subrun_projection_maps_token_exceeded_to_successful_handoff_result() { - let child_agent = AgentEventContext::sub_run( - "agent-child", - "turn-parent", - "reviewer", - "subrun-child", - Some("subrun-parent".into()), - SubRunStorageMode::IndependentSession, - Some("session-child".into()), - ); - let stored_events = vec![StoredEvent { - storage_seq: 1, - event: StorageEvent { - turn_id: Some("turn-child".to_string()), - agent: child_agent, - payload: StorageEventPayload::SubRunFinished { - tool_call_id: Some("call-1".to_string()), - result: SubRunResult::Completed { - outcome: CompletedSubRunOutcome::TokenExceeded, - handoff: SubRunHandoff { - findings: vec!["partial-finding".to_string()], - artifacts: Vec::new(), - delivery: None, - }, - }, - timestamp: Some(chrono::Utc::now()), - step_count: 5, - estimated_tokens: 2048, - }, - }, - }]; - - let projection = project_durable_subrun_status( - "session-parent", - "session-child", - "subrun-child", - &stored_events, - ) - .expect("projection should exist"); - - let result = projection.result.expect("durable result should exist"); - match result { - astrcode_protocol::http::SubRunResultDto::TokenExceeded { handoff } => { - assert_eq!(handoff.findings, vec!["partial-finding".to_string()]); - }, - other => panic!("expected token exceeded handoff result, got {other:?}"), - } - assert_eq!( - projection.last_turn_outcome, - Some(astrcode_protocol::http::AgentTurnOutcomeDto::TokenExceeded) - ); - } -} diff --git a/crates/server/src/http/routes/config.rs b/crates/server/src/http/routes/config.rs index fadd7170..e2021927 100644 --- a/crates/server/src/http/routes/config.rs +++ b/crates/server/src/http/routes/config.rs @@ -4,7 +4,9 @@ //! - `GET /api/config` — 获取配置视图(含 profile 列表和当前选择) //! - `POST /api/config/active-selection` — 保存活跃的 profile/model 选择 -use astrcode_application::format_local_rfc3339; +use astrcode_application::{ + format_local_rfc3339, resolve_config_summary, resolve_runtime_status_summary, +}; use astrcode_protocol::http::{ConfigReloadResponse, ConfigView, SaveActiveSelectionRequest}; use axum::{ Json, @@ -34,7 +36,8 @@ pub(crate) async fn get_config( .config_path() .to_string_lossy() .to_string(); - Ok(Json(build_config_view(&config, config_path)?)) + let summary = resolve_config_summary(&config).map_err(ApiError::from)?; + Ok(Json(build_config_view(summary, config_path))) } /// 保存活跃的 profile 和 model 选择。 @@ -68,20 +71,21 @@ pub(crate) async fn reload_config( require_auth(&state, &headers, None)?; let reloaded = state.governance.reload().await.map_err(ApiError::from)?; let config = state.app.config().get_config().await; + let summary = resolve_config_summary(&config).map_err(ApiError::from)?; let config_path = state .app .config() .config_path() .to_string_lossy() .to_string(); - let config_view = build_config_view(&config, config_path)?; + let config_view = build_config_view(summary, config_path); Ok(( StatusCode::ACCEPTED, Json(ConfigReloadResponse { reloaded_at: format_local_rfc3339(reloaded.reloaded_at), config: config_view, - status: to_runtime_status_dto(reloaded.snapshot), + status: to_runtime_status_dto(resolve_runtime_status_summary(reloaded.snapshot)), }), )) } diff --git a/crates/server/src/http/routes/conversation.rs b/crates/server/src/http/routes/conversation.rs index 94a202e1..8af68219 100644 --- a/crates/server/src/http/routes/conversation.rs +++ b/crates/server/src/http/routes/conversation.rs @@ -1,13 +1,14 @@ -use std::{collections::HashMap, convert::Infallible, pin::Pin, time::Duration}; +use std::{convert::Infallible, pin::Pin, time::Duration}; use astrcode_application::{ - ApplicationError, ConversationFocus, TerminalChildSummaryFacts, TerminalControlFacts, - TerminalSlashCandidateFacts, TerminalStreamFacts, TerminalStreamReplayFacts, + ApplicationError, ConversationAuthoritativeSummary, ConversationChildSummarySummary, + ConversationControlSummary, ConversationFocus, ConversationSlashCandidateSummary, + TerminalStreamFacts, TerminalStreamReplayFacts, }; use astrcode_core::AgentEvent; use astrcode_protocol::http::conversation::v1::{ - ConversationChildSummaryDto, ConversationDeltaDto, ConversationSlashCandidatesResponseDto, - ConversationSnapshotResponseDto, ConversationStreamEnvelopeDto, + ConversationDeltaDto, ConversationSlashCandidatesResponseDto, ConversationSnapshotResponseDto, + ConversationStreamEnvelopeDto, }; use astrcode_session_runtime::ConversationStreamProjector as RuntimeConversationStreamProjector; use async_stream::stream; @@ -28,10 +29,10 @@ use crate::{ auth::is_authorized, routes::sessions::validate_session_path_id, terminal_projection::{ - project_child_summary, project_conversation_child_summary_deltas, - project_conversation_control_delta, project_conversation_frame, - project_conversation_rehydrate_envelope, project_conversation_slash_candidates, - project_conversation_snapshot, + child_summary_summary_lookup, project_conversation_child_summary_summary_deltas, + project_conversation_control_summary_delta, project_conversation_frame, + project_conversation_rehydrate_envelope, project_conversation_slash_candidate_summaries, + project_conversation_slash_candidates, project_conversation_snapshot, }, }; @@ -338,32 +339,27 @@ fn build_conversation_stream( ) } -fn project_conversation_control_deltas( - previous: &TerminalControlFacts, - current: &TerminalControlFacts, -) -> Vec { - let previous = control_state_delta(previous); - let current = control_state_delta(current); - if previous == current { - Vec::new() - } else { - vec![current] - } -} - #[derive(Debug, Clone)] struct ConversationAuthoritativeFacts { - control: TerminalControlFacts, - child_summaries: Vec, - slash_candidates: Vec, + control: ConversationControlSummary, + child_summaries: Vec, + slash_candidates: Vec, } impl ConversationAuthoritativeFacts { fn from_replay(facts: &TerminalStreamReplayFacts) -> Self { + Self::from_summary(astrcode_application::summarize_conversation_authoritative( + &facts.control, + &facts.child_summaries, + &facts.slash_candidates, + )) + } + + fn from_summary(summary: ConversationAuthoritativeSummary) -> Self { Self { - control: facts.control.clone(), - child_summaries: facts.child_summaries.clone(), - slash_candidates: facts.slash_candidates.clone(), + control: summary.control, + child_summaries: summary.child_summaries, + slash_candidates: summary.slash_candidates, } } } @@ -371,9 +367,7 @@ impl ConversationAuthoritativeFacts { struct ConversationStreamProjectorState { session_id: String, projector: RuntimeConversationStreamProjector, - control: TerminalControlFacts, - child_summaries: Vec, - slash_candidates: Vec, + authoritative: ConversationAuthoritativeFacts, } impl ConversationStreamProjectorState { @@ -385,9 +379,7 @@ impl ConversationStreamProjectorState { Self { session_id, projector: RuntimeConversationStreamProjector::new(last_sent_cursor, &facts.replay), - control: facts.control.clone(), - child_summaries: facts.child_summaries.clone(), - slash_candidates: facts.slash_candidates.clone(), + authoritative: ConversationAuthoritativeFacts::from_replay(facts), } } @@ -399,7 +391,7 @@ impl ConversationStreamProjectorState { &mut self, facts: &TerminalStreamReplayFacts, ) -> Vec { - let child_lookup = child_summary_lookup(&facts.child_summaries); + let child_lookup = child_summary_summary_lookup(&self.authoritative.child_summaries); let envelopes = self .projector .seed_initial_replay(&facts.replay) @@ -414,7 +406,7 @@ impl ConversationStreamProjectorState { &mut self, record: &astrcode_core::SessionEventRecord, ) -> Vec { - let child_lookup = child_summary_lookup(&self.child_summaries); + let child_lookup = child_summary_summary_lookup(&self.authoritative.child_summaries); self.projector .project_durable_record(record) .into_iter() @@ -430,7 +422,7 @@ impl ConversationStreamProjectorState { project_conversation_frame( self.session_id.as_str(), frame, - &child_summary_lookup(&self.child_summaries), + &child_summary_summary_lookup(&self.authoritative.child_summaries), ) }) .collect() @@ -441,21 +433,27 @@ impl ConversationStreamProjectorState { cursor: &str, refreshed: ConversationAuthoritativeFacts, ) -> Vec { - let mut deltas = project_conversation_control_deltas(&self.control, &refreshed.control); - deltas.extend(project_conversation_child_summary_deltas( - &self.child_summaries, + let mut deltas = if self.authoritative.control == refreshed.control { + Vec::new() + } else { + vec![project_conversation_control_summary_delta( + &refreshed.control, + )] + }; + deltas.extend(project_conversation_child_summary_summary_deltas( + &self.authoritative.child_summaries, &refreshed.child_summaries, )); - if self.slash_candidates != refreshed.slash_candidates { + if self.authoritative.slash_candidates != refreshed.slash_candidates { deltas.push(ConversationDeltaDto::ReplaceSlashCandidates { - candidates: project_conversation_slash_candidates(&refreshed.slash_candidates) - .items, + candidates: project_conversation_slash_candidate_summaries( + &refreshed.slash_candidates, + ) + .items, }); } - self.control = refreshed.control; - self.child_summaries = refreshed.child_summaries; - self.slash_candidates = refreshed.slash_candidates; + self.authoritative = refreshed; self.wrap_durable_deltas(cursor, deltas) } @@ -463,7 +461,8 @@ impl ConversationStreamProjectorState { &mut self, recovered: &TerminalStreamReplayFacts, ) -> Vec { - let child_lookup = child_summary_lookup(&recovered.child_summaries); + let refreshed = ConversationAuthoritativeFacts::from_replay(recovered); + let child_lookup = child_summary_summary_lookup(&refreshed.child_summaries); let mut envelopes = self .projector .recover_from(&recovered.replay) @@ -476,10 +475,7 @@ impl ConversationStreamProjectorState { .last_sent_cursor() .unwrap_or("0.0") .to_string(); - envelopes.extend(self.apply_authoritative_refresh( - recovery_cursor.as_str(), - ConversationAuthoritativeFacts::from_replay(recovered), - )); + envelopes.extend(self.apply_authoritative_refresh(recovery_cursor.as_str(), refreshed)); envelopes } @@ -506,30 +502,10 @@ async fn refresh_conversation_authoritative_facts( session_id: &str, focus: &ConversationFocus, ) -> Result { - Ok(ConversationAuthoritativeFacts { - control: app.terminal_control_facts(session_id).await?, - child_summaries: app.conversation_child_summaries(session_id, focus).await?, - slash_candidates: app.terminal_slash_candidates(session_id, None).await?, - }) -} - -fn child_summary_lookup( - summaries: &[TerminalChildSummaryFacts], -) -> HashMap { - let mut lookup = HashMap::new(); - for summary in summaries { - let dto = project_child_summary(summary); - lookup.insert(summary.node.child_session_id.to_string(), dto.clone()); - if let Some(child_ref) = &dto.child_ref { - lookup.insert(child_ref.open_session_id.clone(), dto.clone()); - lookup.insert(child_ref.session_id.clone(), dto.clone()); - } - } - lookup -} - -fn control_state_delta(control: &TerminalControlFacts) -> ConversationDeltaDto { - project_conversation_control_delta(control) + Ok(ConversationAuthoritativeFacts::from_summary( + app.conversation_authoritative_summary(session_id, focus) + .await?, + )) } fn single_envelope_stream(envelope: ConversationStreamEnvelopeDto) -> ConversationSse { @@ -607,6 +583,7 @@ type ConversationSse = Sse (StatusCode, Json) { + let summary = McpActionSummary::ok(); ( status, Json(McpActionResponse { - ok: true, - message: None, + ok: summary.ok, + message: summary.message, }), ) } @@ -235,13 +238,13 @@ fn parse_scope(scope: &str) -> Result { } } -impl From for McpServerStatus { - fn from(value: McpServerStatusView) -> Self { +impl From for McpServerStatus { + fn from(value: McpServerStatusSummary) -> Self { Self { name: value.name, scope: value.scope, enabled: value.enabled, - status: value.state, + status: value.status, error: value.error, tool_count: value.tool_count, prompt_count: value.prompt_count, diff --git a/crates/server/src/http/routes/mod.rs b/crates/server/src/http/routes/mod.rs index 335e8e22..c8dfaf72 100644 --- a/crates/server/src/http/routes/mod.rs +++ b/crates/server/src/http/routes/mod.rs @@ -158,10 +158,10 @@ async fn exchange_auth( return Err(ApiError::unauthorized()); } - let issued = state.auth_sessions.issue_token(); + let summary = state.auth_sessions.issue_exchange_summary(); Ok(Json(AuthExchangeResponse { - ok: true, - token: issued.token, - expires_at_ms: issued.expires_at_ms, + ok: summary.ok, + token: summary.token, + expires_at_ms: summary.expires_at_ms, })) } diff --git a/crates/server/src/http/routes/model.rs b/crates/server/src/http/routes/model.rs index 385c6c14..969da3fa 100644 --- a/crates/server/src/http/routes/model.rs +++ b/crates/server/src/http/routes/model.rs @@ -57,10 +57,5 @@ pub(crate) async fn test_model_connection( .test_connection(&request.profile_name, &request.model) .await .map_err(ApiError::from)?; - Ok(Json(TestResultDto { - success: result.success, - provider: result.provider, - model: result.model, - error: result.error, - })) + Ok(Json(result)) } diff --git a/crates/server/src/http/routes/sessions/mutation.rs b/crates/server/src/http/routes/sessions/mutation.rs index 4f0b5680..d1a73786 100644 --- a/crates/server/src/http/routes/sessions/mutation.rs +++ b/crates/server/src/http/routes/sessions/mutation.rs @@ -1,7 +1,6 @@ -use astrcode_application::ExecutionAccepted; use astrcode_protocol::http::{ CompactSessionRequest, CompactSessionResponse, CreateSessionRequest, DeleteProjectResultDto, - ExecutionControlDto, PromptAcceptedResponse, PromptRequest, SessionListItem, + PromptAcceptedResponse, PromptRequest, SessionListItem, }; use axum::{ Json, @@ -15,32 +14,6 @@ use crate::{ routes::sessions::validate_session_path_id, }; -fn to_execution_control( - control: Option, -) -> Option { - control.map(|control| astrcode_application::ExecutionControl { - max_steps: control.max_steps, - manual_compact: control.manual_compact, - }) -} - -fn normalize_compact_control(control: Option) -> ExecutionControlDto { - let mut control = control.unwrap_or(ExecutionControlDto { - max_steps: None, - manual_compact: None, - }); - if control.manual_compact.is_none() { - control.manual_compact = Some(true); - } - control -} - -fn normalize_compact_instructions(instructions: Option) -> Option { - instructions - .map(|value| value.trim().to_string()) - .filter(|value| !value.is_empty()) -} - #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub(crate) struct DeleteProjectQuery { @@ -58,7 +31,9 @@ pub(crate) async fn create_session( .create_session(request.working_dir) .await .map_err(ApiError::from)?; - Ok(Json(to_session_list_item(meta))) + Ok(Json(to_session_list_item( + astrcode_application::summarize_session_meta(meta), + ))) } pub(crate) async fn submit_prompt( @@ -69,22 +44,18 @@ pub(crate) async fn submit_prompt( ) -> Result<(StatusCode, Json), ApiError> { require_auth(&state, &headers, None)?; let session_id = validate_session_path_id(&session_id)?; - let accepted: ExecutionAccepted = state + let summary = state .app - .submit_prompt_with_control( - &session_id, - request.text, - to_execution_control(request.control.clone()), - ) + .submit_prompt_summary(&session_id, request.text, request.control) .await .map_err(ApiError::from)?; Ok(( StatusCode::ACCEPTED, Json(PromptAcceptedResponse { - turn_id: accepted.turn_id.to_string(), - session_id: accepted.session_id.to_string(), - branched_from_session_id: accepted.branched_from_session_id, - accepted_control: request.control, + turn_id: summary.turn_id, + session_id: summary.session_id, + branched_from_session_id: summary.branched_from_session_id, + accepted_control: summary.accepted_control, }), )) } @@ -113,31 +84,21 @@ pub(crate) async fn compact_session( require_auth(&state, &headers, None)?; let session_id = validate_session_path_id(&session_id)?; let request = request.map(|request| request.0); - let instructions = normalize_compact_instructions( - request - .as_ref() - .and_then(|request| request.instructions.clone()), - ); - let control = normalize_compact_control(request.and_then(|request| request.control)); - let accepted = state + let summary = state .app - .compact_session_with_options( + .compact_session_summary( &session_id, - to_execution_control(Some(control)), - instructions, + request.as_ref().and_then(|request| request.control.clone()), + request.and_then(|request| request.instructions), ) .await .map_err(ApiError::from)?; Ok(( StatusCode::ACCEPTED, Json(CompactSessionResponse { - accepted: true, - deferred: accepted.deferred, - message: if accepted.deferred { - "手动 compact 已登记,会在当前 turn 完成后执行。".to_string() - } else { - "手动 compact 已执行。".to_string() - }, + accepted: summary.accepted, + deferred: summary.deferred, + message: summary.message, }), )) } @@ -168,8 +129,5 @@ pub(crate) async fn delete_project( .delete_project(&query.working_dir) .await .map_err(ApiError::from)?; - Ok(Json(DeleteProjectResultDto { - success_count: result.success_count, - failed_session_ids: result.failed_session_ids, - })) + Ok(Json(result)) } diff --git a/crates/server/src/http/routes/sessions/query.rs b/crates/server/src/http/routes/sessions/query.rs index 9c0785ce..4080c090 100644 --- a/crates/server/src/http/routes/sessions/query.rs +++ b/crates/server/src/http/routes/sessions/query.rs @@ -14,6 +14,7 @@ pub(crate) async fn list_sessions( .await .map_err(ApiError::from)? .into_iter() + .map(astrcode_application::summarize_session_meta) .map(to_session_list_item) .collect(); Ok(Json(sessions)) diff --git a/crates/server/src/http/terminal_projection.rs b/crates/server/src/http/terminal_projection.rs index 96f1cabf..33af6973 100644 --- a/crates/server/src/http/terminal_projection.rs +++ b/crates/server/src/http/terminal_projection.rs @@ -1,24 +1,23 @@ use std::collections::HashMap; use astrcode_application::{ - TerminalChildSummaryFacts, TerminalControlFacts, TerminalFacts, TerminalRehydrateFacts, - TerminalSlashAction, TerminalSlashCandidateFacts, -}; -use astrcode_core::{ - AgentLifecycleStatus, ChildAgentRef, ChildSessionLineageKind, ToolOutputStream, + ConversationChildSummarySummary, ConversationControlSummary, ConversationSlashActionSummary, + ConversationSlashCandidateSummary, TerminalChildSummaryFacts, TerminalFacts, + TerminalRehydrateFacts, summarize_conversation_child_ref, summarize_conversation_child_summary, + summarize_conversation_control, summarize_conversation_slash_candidate, }; +use astrcode_core::ChildAgentRef; use astrcode_protocol::http::{ - AgentLifecycleDto, ChildAgentRefDto, ChildSessionLineageKindDto, ConversationAssistantBlockDto, - ConversationBannerDto, ConversationBannerErrorCodeDto, ConversationBlockDto, - ConversationBlockPatchDto, ConversationBlockStatusDto, ConversationChildHandoffBlockDto, - ConversationChildHandoffKindDto, ConversationChildSummaryDto, ConversationControlStateDto, - ConversationCursorDto, ConversationDeltaDto, ConversationErrorBlockDto, - ConversationErrorEnvelopeDto, ConversationLastCompactMetaDto, ConversationSlashActionKindDto, - ConversationSlashCandidateDto, ConversationSlashCandidatesResponseDto, - ConversationSnapshotResponseDto, ConversationStreamEnvelopeDto, ConversationSystemNoteBlockDto, - ConversationSystemNoteKindDto, ConversationThinkingBlockDto, ConversationToolCallBlockDto, - ConversationToolStreamsDto, ConversationTranscriptErrorCodeDto, ConversationUserBlockDto, - PhaseDto, ToolOutputStreamDto, + ChildAgentRefDto, ConversationAssistantBlockDto, ConversationBannerDto, + ConversationBannerErrorCodeDto, ConversationBlockDto, ConversationBlockPatchDto, + ConversationBlockStatusDto, ConversationChildHandoffBlockDto, ConversationChildHandoffKindDto, + ConversationChildSummaryDto, ConversationControlStateDto, ConversationCursorDto, + ConversationDeltaDto, ConversationErrorBlockDto, ConversationErrorEnvelopeDto, + ConversationLastCompactMetaDto, ConversationSlashActionKindDto, ConversationSlashCandidateDto, + ConversationSlashCandidatesResponseDto, ConversationSnapshotResponseDto, + ConversationStreamEnvelopeDto, ConversationSystemNoteBlockDto, ConversationSystemNoteKindDto, + ConversationThinkingBlockDto, ConversationToolCallBlockDto, ConversationToolStreamsDto, + ConversationTranscriptErrorCodeDto, ConversationUserBlockDto, }; use astrcode_session_runtime::{ ConversationBlockFacts, ConversationBlockPatchFacts, ConversationBlockStatus, @@ -42,8 +41,8 @@ pub(crate) fn project_conversation_snapshot( .clone() .unwrap_or_else(|| "0.0".to_string()), ), - phase: to_phase_dto(facts.control.phase), - control: project_control_state(&facts.control), + phase: facts.control.phase, + control: to_conversation_control_state_dto(summarize_conversation_control(&facts.control)), blocks: facts .transcript .blocks @@ -53,12 +52,14 @@ pub(crate) fn project_conversation_snapshot( child_summaries: facts .child_summaries .iter() - .map(project_child_summary) + .map(summarize_conversation_child_summary) + .map(to_conversation_child_summary_dto) .collect(), slash_candidates: facts .slash_candidates .iter() - .map(project_slash_candidate) + .map(summarize_conversation_slash_candidate) + .map(to_conversation_slash_candidate_dto) .collect(), banner: None, } @@ -76,14 +77,6 @@ pub(crate) fn project_conversation_frame( } } -pub(crate) fn project_conversation_control_delta( - control: &TerminalControlFacts, -) -> ConversationDeltaDto { - ConversationDeltaDto::UpdateControlState { - control: project_control_state(control), - } -} - pub(crate) fn project_conversation_rehydrate_banner( rehydrate: &TerminalRehydrateFacts, ) -> ConversationBannerDto { @@ -122,23 +115,27 @@ pub(crate) fn project_conversation_rehydrate_envelope( } pub(crate) fn project_conversation_slash_candidates( - candidates: &[TerminalSlashCandidateFacts], + candidates: &[astrcode_application::TerminalSlashCandidateFacts], ) -> ConversationSlashCandidatesResponseDto { ConversationSlashCandidatesResponseDto { - items: candidates.iter().map(project_slash_candidate).collect(), + items: candidates + .iter() + .map(summarize_conversation_slash_candidate) + .map(to_conversation_slash_candidate_dto) + .collect(), } } -pub(crate) fn project_conversation_child_summary_deltas( - previous: &[TerminalChildSummaryFacts], - current: &[TerminalChildSummaryFacts], +pub(crate) fn project_conversation_child_summary_summary_deltas( + previous: &[ConversationChildSummarySummary], + current: &[ConversationChildSummarySummary], ) -> Vec { let previous_by_id = previous .iter() .map(|summary| { ( - summary.node.child_session_id.clone(), - project_child_summary(summary), + summary.child_session_id.clone(), + to_conversation_child_summary_dto(summary.clone()), ) }) .collect::>(); @@ -146,8 +143,8 @@ pub(crate) fn project_conversation_child_summary_deltas( .iter() .map(|summary| { ( - summary.node.child_session_id.clone(), - project_child_summary(summary), + summary.child_session_id.clone(), + to_conversation_child_summary_dto(summary.clone()), ) }) .collect::>(); @@ -181,6 +178,26 @@ pub(crate) fn project_conversation_child_summary_deltas( deltas } +pub(crate) fn project_conversation_control_summary_delta( + summary: &ConversationControlSummary, +) -> ConversationDeltaDto { + ConversationDeltaDto::UpdateControlState { + control: to_conversation_control_state_dto(summary.clone()), + } +} + +pub(crate) fn project_conversation_slash_candidate_summaries( + candidates: &[ConversationSlashCandidateSummary], +) -> ConversationSlashCandidatesResponseDto { + ConversationSlashCandidatesResponseDto { + items: candidates + .iter() + .cloned() + .map(to_conversation_slash_candidate_dto) + .collect(), + } +} + fn project_delta( delta: ConversationDeltaFacts, child_lookup: &HashMap, @@ -213,10 +230,7 @@ fn project_patch(patch: ConversationBlockPatchFacts) -> ConversationBlockPatchDt ConversationBlockPatchDto::ReplaceMarkdown { markdown } }, ConversationBlockPatchFacts::AppendToolStream { stream, chunk } => { - ConversationBlockPatchDto::AppendToolStream { - stream: to_stream_dto(stream), - chunk, - } + ConversationBlockPatchDto::AppendToolStream { stream, chunk } }, ConversationBlockPatchFacts::ReplaceSummary { summary } => { ConversationBlockPatchDto::ReplaceSummary { summary } @@ -342,7 +356,9 @@ fn project_child_handoff_block( .get(block.child_ref.session_id().as_str()) .cloned() }) - .unwrap_or_else(|| fallback_child_summary(&block.child_ref)); + .unwrap_or_else(|| { + to_conversation_child_summary_dto(summarize_conversation_child_ref(&block.child_ref)) + }); ConversationChildHandoffBlockDto { id: block.id.clone(), @@ -356,24 +372,24 @@ fn project_child_handoff_block( } } -fn fallback_child_summary(child_ref: &ChildAgentRef) -> ConversationChildSummaryDto { - ConversationChildSummaryDto { - child_session_id: child_ref.open_session_id.to_string(), - child_agent_id: child_ref.agent_id().to_string(), - title: child_ref.agent_id().to_string(), - lifecycle: to_lifecycle_dto(child_ref.status), - latest_output_summary: None, - child_ref: Some(to_child_ref_dto(child_ref.clone())), - } -} - fn child_summary_lookup( summaries: &[TerminalChildSummaryFacts], +) -> HashMap { + child_summary_summary_lookup( + &summaries + .iter() + .map(summarize_conversation_child_summary) + .collect::>(), + ) +} + +pub(crate) fn child_summary_summary_lookup( + summaries: &[ConversationChildSummarySummary], ) -> HashMap { let mut lookup = HashMap::new(); for summary in summaries { - let dto = project_child_summary(summary); - lookup.insert(summary.node.child_session_id.to_string(), dto.clone()); + let dto = to_conversation_child_summary_dto(summary.clone()); + lookup.insert(summary.child_session_id.clone(), dto.clone()); if let Some(child_ref) = &dto.child_ref { lookup.insert(child_ref.open_session_id.clone(), dto.clone()); lookup.insert(child_ref.session_id.clone(), dto.clone()); @@ -382,110 +398,55 @@ fn child_summary_lookup( lookup } -pub(crate) fn project_child_summary( - summary: &TerminalChildSummaryFacts, +fn to_conversation_child_summary_dto( + summary: ConversationChildSummarySummary, ) -> ConversationChildSummaryDto { ConversationChildSummaryDto { - child_session_id: summary.node.child_session_id.to_string(), - child_agent_id: summary.node.agent_id().to_string(), - title: summary - .title - .clone() - .or_else(|| summary.display_name.clone()) - .unwrap_or_else(|| summary.node.child_session_id.to_string()), - lifecycle: to_lifecycle_dto(summary.node.status), - latest_output_summary: summary.recent_output.clone(), - child_ref: Some(to_child_ref_dto(summary.node.child_ref())), + child_session_id: summary.child_session_id, + child_agent_id: summary.child_agent_id, + title: summary.title, + lifecycle: summary.lifecycle, + latest_output_summary: summary.latest_output_summary, + child_ref: summary.child_ref.map(to_child_ref_dto), } } -fn project_control_state(control: &TerminalControlFacts) -> ConversationControlStateDto { - let can_submit_prompt = matches!( - control.phase, - astrcode_core::Phase::Idle | astrcode_core::Phase::Done | astrcode_core::Phase::Interrupted - ); +fn to_conversation_control_state_dto( + summary: ConversationControlSummary, +) -> ConversationControlStateDto { ConversationControlStateDto { - phase: to_phase_dto(control.phase), - can_submit_prompt, - can_request_compact: !control.manual_compact_pending && !control.compacting, - compact_pending: control.manual_compact_pending, - compacting: control.compacting, - active_turn_id: control.active_turn_id.clone(), - last_compact_meta: control + phase: summary.phase, + can_submit_prompt: summary.can_submit_prompt, + can_request_compact: summary.can_request_compact, + compact_pending: summary.compact_pending, + compacting: summary.compacting, + active_turn_id: summary.active_turn_id, + last_compact_meta: summary .last_compact_meta - .as_ref() - .map(project_last_compact_meta), - } -} - -fn project_last_compact_meta( - facts: &astrcode_application::terminal::TerminalLastCompactMetaFacts, -) -> ConversationLastCompactMetaDto { - ConversationLastCompactMetaDto { - trigger: facts.trigger, - meta: facts.meta.clone(), + .map(|meta| ConversationLastCompactMetaDto { + trigger: meta.trigger, + meta: meta.meta, + }), } } -fn project_slash_candidate( - candidate: &TerminalSlashCandidateFacts, +fn to_conversation_slash_candidate_dto( + summary: ConversationSlashCandidateSummary, ) -> ConversationSlashCandidateDto { - let (action_kind, action_value) = match &candidate.action { - TerminalSlashAction::CreateSession => ( - ConversationSlashActionKindDto::ExecuteCommand, - "/new".to_string(), - ), - TerminalSlashAction::OpenResume => ( - ConversationSlashActionKindDto::ExecuteCommand, - "/resume".to_string(), - ), - TerminalSlashAction::RequestCompact => ( - ConversationSlashActionKindDto::ExecuteCommand, - "/compact".to_string(), - ), - TerminalSlashAction::OpenSkillPalette => ( - ConversationSlashActionKindDto::ExecuteCommand, - "/skill".to_string(), - ), - TerminalSlashAction::InsertText { text } => { - (ConversationSlashActionKindDto::InsertText, text.clone()) - }, - }; - ConversationSlashCandidateDto { - id: candidate.id.clone(), - title: candidate.title.clone(), - description: candidate.description.clone(), - keywords: candidate.keywords.clone(), - action_kind, - action_value, - } -} - -fn to_phase_dto(phase: astrcode_core::Phase) -> PhaseDto { - match phase { - astrcode_core::Phase::Idle => PhaseDto::Idle, - astrcode_core::Phase::Thinking => PhaseDto::Thinking, - astrcode_core::Phase::CallingTool => PhaseDto::CallingTool, - astrcode_core::Phase::Streaming => PhaseDto::Streaming, - astrcode_core::Phase::Interrupted => PhaseDto::Interrupted, - astrcode_core::Phase::Done => PhaseDto::Done, - } -} - -fn to_lifecycle_dto(status: AgentLifecycleStatus) -> AgentLifecycleDto { - match status { - AgentLifecycleStatus::Pending => AgentLifecycleDto::Pending, - AgentLifecycleStatus::Running => AgentLifecycleDto::Running, - AgentLifecycleStatus::Idle => AgentLifecycleDto::Idle, - AgentLifecycleStatus::Terminated => AgentLifecycleDto::Terminated, - } -} - -fn to_stream_dto(stream: ToolOutputStream) -> ToolOutputStreamDto { - match stream { - ToolOutputStream::Stdout => ToolOutputStreamDto::Stdout, - ToolOutputStream::Stderr => ToolOutputStreamDto::Stderr, + id: summary.id, + title: summary.title, + description: summary.description, + keywords: summary.keywords, + action_kind: match summary.action_kind { + ConversationSlashActionSummary::InsertText => { + ConversationSlashActionKindDto::InsertText + }, + ConversationSlashActionSummary::ExecuteCommand => { + ConversationSlashActionKindDto::ExecuteCommand + }, + }, + action_value: summary.action_value, } } @@ -496,12 +457,8 @@ fn to_child_ref_dto(child_ref: ChildAgentRef) -> ChildAgentRefDto { sub_run_id: child_ref.sub_run_id().to_string(), parent_agent_id: child_ref.parent_agent_id().map(|id| id.to_string()), parent_sub_run_id: child_ref.parent_sub_run_id().map(|id| id.to_string()), - lineage_kind: match child_ref.lineage_kind { - ChildSessionLineageKind::Spawn => ChildSessionLineageKindDto::Spawn, - ChildSessionLineageKind::Fork => ChildSessionLineageKindDto::Fork, - ChildSessionLineageKind::Resume => ChildSessionLineageKindDto::Resume, - }, - status: to_lifecycle_dto(child_ref.status), + lineage_kind: child_ref.lineage_kind, + status: child_ref.status, open_session_id: child_ref.open_session_id.to_string(), } } diff --git a/crates/session-runtime/src/actor/mod.rs b/crates/session-runtime/src/actor/mod.rs index 13794b63..255381fe 100644 --- a/crates/session-runtime/src/actor/mod.rs +++ b/crates/session-runtime/src/actor/mod.rs @@ -37,26 +37,11 @@ pub struct SessionActor { state: Arc, session_id: SessionId, working_dir: String, - root_agent_id: AgentId, } impl SessionActor { - /// 创建 actor,包装一个已有的 live session state。 - pub fn new( - session_id: SessionId, - working_dir: impl Into, - root_agent_id: AgentId, - state: Arc, - ) -> Self { - Self { - state, - session_id, - working_dir: working_dir.into(), - root_agent_id, - } - } - /// 创建一个带 durable writer 的 actor。 + #[cfg(test)] pub async fn new_persistent( session_id: SessionId, working_dir: impl Into, @@ -78,7 +63,7 @@ impl SessionActor { pub async fn new_persistent_with_lineage( session_id: SessionId, working_dir: impl Into, - root_agent_id: AgentId, + _root_agent_id: AgentId, event_store: Arc, parent_session_id: Option, parent_storage_seq: Option, @@ -111,7 +96,6 @@ impl SessionActor { state: Arc::new(state), session_id, working_dir, - root_agent_id, }) } @@ -122,7 +106,7 @@ impl SessionActor { pub fn from_replay( session_id: SessionId, working_dir: impl Into, - root_agent_id: AgentId, + _root_agent_id: AgentId, event_store: Arc, stored_events: Vec, ) -> astrcode_core::Result { @@ -149,7 +133,6 @@ impl SessionActor { state: Arc::new(state), session_id, working_dir, - root_agent_id, }) } @@ -160,7 +143,7 @@ impl SessionActor { pub fn new_idle( session_id: SessionId, working_dir: impl Into, - root_agent_id: AgentId, + _root_agent_id: AgentId, ) -> Self { let writer = Arc::new(SessionWriter::new(Box::new(NopEventLogWriter))); let state = SessionState::new( @@ -174,7 +157,6 @@ impl SessionActor { state: Arc::new(state), session_id, working_dir: working_dir.into(), - root_agent_id, } } @@ -200,24 +182,10 @@ impl SessionActor { } } - /// 标记 turn 完成。 - pub fn mark_turn_completed(&self, _turn_id: TurnId) { - // Turn 完成由 SessionState.complete_execution_state 驱动, - // 此处仅作为外部标记入口保留。 - } - - pub fn root_agent_id(&self) -> &AgentId { - &self.root_agent_id - } - pub fn state(&self) -> &Arc { &self.state } - pub fn session_id(&self) -> &SessionId { - &self.session_id - } - pub fn working_dir(&self) -> &str { &self.working_dir } diff --git a/crates/session-runtime/src/catalog/mod.rs b/crates/session-runtime/src/catalog/mod.rs index f7361831..67bf362a 100644 --- a/crates/session-runtime/src/catalog/mod.rs +++ b/crates/session-runtime/src/catalog/mod.rs @@ -1,26 +1,6 @@ //! Session catalog 事件与生命周期协调。 //! -//! 从 `runtime/service/session/` 迁入核心 catalog 事件定义。 -//! 实际的磁盘 create/load/delete 由 adapter-storage 实现, -//! session-runtime 只编排生命周期和广播。 +//! catalog 事件的 canonical owner 已下沉到 `astrcode_core`; +//! session-runtime 这里只保留 re-export,负责生命周期编排与广播。 -use serde::{Deserialize, Serialize}; - -/// Session catalog 变更事件,用于通知外部订阅者 session 列表变化。 -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase", tag = "type")] -pub enum SessionCatalogEvent { - SessionCreated { - session_id: String, - }, - SessionDeleted { - session_id: String, - }, - ProjectDeleted { - working_dir: String, - }, - SessionBranched { - session_id: String, - source_session_id: String, - }, -} +pub use astrcode_core::SessionCatalogEvent; diff --git a/crates/session-runtime/src/command/mod.rs b/crates/session-runtime/src/command/mod.rs index 198303c3..fd18280d 100644 --- a/crates/session-runtime/src/command/mod.rs +++ b/crates/session-runtime/src/command/mod.rs @@ -9,12 +9,12 @@ use chrono::Utc; use crate::{MailboxEventAppend, SessionRuntime, append_and_broadcast, append_mailbox_event}; -pub struct SessionCommands<'a> { +pub(crate) struct SessionCommands<'a> { runtime: &'a SessionRuntime, } impl<'a> SessionCommands<'a> { - pub fn new(runtime: &'a SessionRuntime) -> Self { + pub(crate) fn new(runtime: &'a SessionRuntime) -> Self { Self { runtime } } diff --git a/crates/session-runtime/src/context/mod.rs b/crates/session-runtime/src/context/mod.rs deleted file mode 100644 index 4a7b1fc4..00000000 --- a/crates/session-runtime/src/context/mod.rs +++ /dev/null @@ -1,26 +0,0 @@ -//! 子 Agent 上下文快照。 -//! -//! 从 `runtime-execution/context.rs` 迁入。 -//! 描述子 Agent 执行时可继承的父上下文。 -//! -//! 边界约束: -//! - 这里只表达“有哪些上下文来源、继承了哪些结果” -//! - 不负责 token 裁剪 -//! - 不负责最终 request / prompt 组装 - -use serde::{Deserialize, Serialize}; - -/// 子 Agent 执行前的结构化上下文快照。 -#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ResolvedContextSnapshot { - /// 任务主体(上层已经解析好的 task payload)。 - #[serde(default)] - pub task_payload: String, - /// 从父会话继承的 compact summary。 - #[serde(default)] - pub inherited_compact_summary: Option, - /// 从父会话继承的最近 N 轮对话 tail。 - #[serde(default)] - pub inherited_recent_tail: Vec, -} diff --git a/crates/session-runtime/src/context_window/compaction.rs b/crates/session-runtime/src/context_window/compaction.rs index 38668e23..9ad023e2 100644 --- a/crates/session-runtime/src/context_window/compaction.rs +++ b/crates/session-runtime/src/context_window/compaction.rs @@ -31,7 +31,7 @@ const INCREMENTAL_COMPACT_PROMPT_TEMPLATE: &str = include_str!("templates/compac /// 压缩配置。 #[derive(Debug, Clone, PartialEq, Eq)] -pub struct CompactConfig { +pub(crate) struct CompactConfig { /// 保留最近的用户 turn 数量。 pub keep_recent_turns: usize, /// 压缩触发方式。 @@ -46,7 +46,7 @@ pub struct CompactConfig { /// 压缩执行结果。 #[derive(Debug, Clone)] -pub struct CompactResult { +pub(crate) struct CompactResult { /// 压缩后的完整消息列表。 pub messages: Vec, /// 压缩生成的摘要文本。 @@ -195,7 +195,8 @@ pub async fn auto_compact( } /// 合并 compact 使用的 prompt 上下文。 -pub fn merge_compact_prompt_context( +#[cfg(test)] +fn merge_compact_prompt_context( runtime_system_prompt: Option<&str>, additional_system_prompt: Option<&str>, ) -> Option { @@ -423,7 +424,8 @@ fn strip_child_agent_reference_hint(content: &str) -> String { } /// 检查消息是否可以被压缩。 -pub fn can_compact(messages: &[LlmMessage], keep_recent_turns: usize) -> bool { +#[cfg(test)] +fn can_compact(messages: &[LlmMessage], keep_recent_turns: usize) -> bool { split_for_compaction(messages, keep_recent_turns).is_some() } diff --git a/crates/session-runtime/src/context_window/file_access.rs b/crates/session-runtime/src/context_window/file_access.rs index 6e28ecea..a23b842f 100644 --- a/crates/session-runtime/src/context_window/file_access.rs +++ b/crates/session-runtime/src/context_window/file_access.rs @@ -15,7 +15,7 @@ use serde::Deserialize; use super::token_usage::estimate_text_tokens; #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct FileRecoveryConfig { +pub(crate) struct FileRecoveryConfig { pub max_tracked_files: usize, pub max_recovered_files: usize, pub recovery_token_budget: usize, @@ -29,7 +29,7 @@ struct TrackedFileAccess { } #[derive(Debug, Clone, Default)] -pub struct FileAccessTracker { +pub(crate) struct FileAccessTracker { accesses: VecDeque, max_tracked_files: usize, } diff --git a/crates/session-runtime/src/context_window/micro_compact.rs b/crates/session-runtime/src/context_window/micro_compact.rs index 930d8ea7..bec86dfe 100644 --- a/crates/session-runtime/src/context_window/micro_compact.rs +++ b/crates/session-runtime/src/context_window/micro_compact.rs @@ -14,20 +14,14 @@ use chrono::{DateTime, Utc}; use super::tool_results::tool_call_name_map; #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct MicroCompactConfig { +pub(crate) struct MicroCompactConfig { pub gap_threshold: Duration, pub keep_recent_results: usize, } -#[derive(Debug, Clone, Default, PartialEq, Eq)] -pub struct MicroCompactStats { - pub cleared_tool_results: usize, -} - #[derive(Debug, Clone)] -pub struct MicroCompactOutcome { +pub(crate) struct MicroCompactOutcome { pub messages: Vec, - pub stats: MicroCompactStats, } #[derive(Debug, Clone)] @@ -37,7 +31,7 @@ struct TrackedToolResult { } #[derive(Debug, Clone, Default)] -pub struct MicroCompactState { +pub(crate) struct MicroCompactState { tracked_results: VecDeque, last_prompt_activity: Option, } @@ -102,14 +96,12 @@ impl MicroCompactState { let Some(last_activity) = self.last_prompt_activity else { return MicroCompactOutcome { messages: messages.to_vec(), - stats: MicroCompactStats::default(), }; }; if now.duration_since(last_activity) < config.gap_threshold { return MicroCompactOutcome { messages: messages.to_vec(), - stats: MicroCompactStats::default(), }; } @@ -117,7 +109,6 @@ impl MicroCompactState { if self.tracked_results.len() <= keep_recent_results { return MicroCompactOutcome { messages: messages.to_vec(), - stats: MicroCompactStats::default(), }; } @@ -146,12 +137,10 @@ impl MicroCompactState { if stale_ids.is_empty() { return MicroCompactOutcome { messages: messages.to_vec(), - stats: MicroCompactStats::default(), }; } let mut compacted = messages.to_vec(); - let mut cleared = 0usize; for message in &mut compacted { let LlmMessage::Tool { tool_call_id, @@ -173,14 +162,10 @@ impl MicroCompactState { "[micro-compacted stale tool result from '{tool_name}' after idle gap; rerun the \ tool if exact output is needed]" ); - cleared += 1; } MicroCompactOutcome { messages: compacted, - stats: MicroCompactStats { - cleared_tool_results: cleared, - }, } } @@ -263,7 +248,6 @@ mod tests { now + Duration::from_secs(31), ); - assert_eq!(outcome.stats.cleared_tool_results, 1); assert!(matches!( &outcome.messages[2], LlmMessage::Tool { content, .. } if content.contains("micro-compacted") @@ -309,7 +293,6 @@ mod tests { now + Duration::from_secs(5), ); - assert_eq!(outcome.stats.cleared_tool_results, 0); assert_eq!(outcome.messages, messages); } @@ -361,7 +344,7 @@ mod tests { config, now + Duration::from_secs(35), ); - assert_eq!(early.stats.cleared_tool_results, 0); + assert_eq!(early.messages, messages); let late = state.apply_if_idle( &messages, @@ -369,7 +352,10 @@ mod tests { config, now + Duration::from_secs(41), ); - assert_eq!(late.stats.cleared_tool_results, 1); + assert!(matches!( + &late.messages[1], + LlmMessage::Tool { content, .. } if content.contains("micro-compacted") + )); } #[test] @@ -419,7 +405,7 @@ mod tests { config, now + Duration::from_secs(15), ); - assert_eq!(early.stats.cleared_tool_results, 0); + assert_eq!(early.messages, messages); let mut late_state = state; let late = late_state.apply_if_idle( @@ -428,6 +414,9 @@ mod tests { config, now + Duration::from_secs(25), ); - assert_eq!(late.stats.cleared_tool_results, 1); + assert!(matches!( + &late.messages[1], + LlmMessage::Tool { content, .. } if content.contains("micro-compacted") + )); } } diff --git a/crates/session-runtime/src/context_window/mod.rs b/crates/session-runtime/src/context_window/mod.rs index 36578e9d..ca3cd72b 100644 --- a/crates/session-runtime/src/context_window/mod.rs +++ b/crates/session-runtime/src/context_window/mod.rs @@ -11,12 +11,12 @@ //! Final request assembly must not be implemented here. //! That flow is implemented in `session-runtime::turn::request`. -pub mod compaction; -pub mod file_access; -pub mod micro_compact; -pub mod prune_pass; -pub mod settings; -pub mod token_usage; +pub(crate) mod compaction; +pub(crate) mod file_access; +pub(crate) mod micro_compact; +pub(crate) mod prune_pass; +pub(crate) mod settings; +pub(crate) mod token_usage; pub(crate) mod tool_results; -pub use settings::ContextWindowSettings; +pub(crate) use settings::ContextWindowSettings; diff --git a/crates/session-runtime/src/context_window/prune_pass.rs b/crates/session-runtime/src/context_window/prune_pass.rs index 35cdc7c1..63add324 100644 --- a/crates/session-runtime/src/context_window/prune_pass.rs +++ b/crates/session-runtime/src/context_window/prune_pass.rs @@ -21,14 +21,14 @@ use super::tool_results::tool_call_name_map; /// Prune pass 执行统计。 #[derive(Debug, Clone, Default, PartialEq, Eq)] -pub struct PruneStats { +pub(crate) struct PruneStats { pub truncated_tool_results: usize, pub cleared_tool_results: usize, } /// Prune pass 执行结果。 #[derive(Debug, Clone)] -pub struct PruneOutcome { +pub(crate) struct PruneOutcome { pub messages: Vec, pub stats: PruneStats, } diff --git a/crates/session-runtime/src/context_window/token_usage.rs b/crates/session-runtime/src/context_window/token_usage.rs index e59586ce..f605b890 100644 --- a/crates/session-runtime/src/context_window/token_usage.rs +++ b/crates/session-runtime/src/context_window/token_usage.rs @@ -26,7 +26,7 @@ const REQUEST_ESTIMATE_PADDING_DENOMINATOR: usize = 3; /// Prompt token 使用快照。 #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct PromptTokenSnapshot { +pub(crate) struct PromptTokenSnapshot { /// 估算的上下文 token 数。 pub context_tokens: usize, /// 已确认的预算 token 数(优先使用 Provider 报告值)。 @@ -48,7 +48,7 @@ pub struct PromptTokenSnapshot { /// 优先使用 Provider 报告的 usage 数据(最接近计费 Token), /// 若 Provider 未报告则回退到估算值。 #[derive(Debug, Default, Clone, Copy)] -pub struct TokenUsageTracker { +pub(crate) struct TokenUsageTracker { anchored_budget_tokens: usize, } diff --git a/crates/session-runtime/src/factory/mod.rs b/crates/session-runtime/src/factory/mod.rs deleted file mode 100644 index 7bbb9b7c..00000000 --- a/crates/session-runtime/src/factory/mod.rs +++ /dev/null @@ -1,11 +0,0 @@ -//! 单 session 执行对象构造辅助。 -//! -//! Why: `factory` 只保留“构造执行对象”这类无状态职责, -//! 状态读取应通过 `query` 完成,避免 factory 膨胀成杂项入口。 - -use astrcode_core::SessionTurnLease; - -#[derive(Debug)] -pub struct NoopSessionTurnLease; - -impl SessionTurnLease for NoopSessionTurnLease {} diff --git a/crates/session-runtime/src/lib.rs b/crates/session-runtime/src/lib.rs index 28f2237d..26b6573c 100644 --- a/crates/session-runtime/src/lib.rs +++ b/crates/session-runtime/src/lib.rs @@ -16,25 +16,21 @@ use dashmap::DashMap; use thiserror::Error; use tokio::sync::broadcast; -pub mod actor; -pub mod catalog; -pub mod command; -pub mod context; -pub mod context_window; -pub mod factory; +mod actor; +mod catalog; +mod command; +mod context_window; mod heuristics; -pub mod observe; -pub mod query; -pub mod state; -pub mod turn; +mod observe; +mod query; +mod state; +mod turn; use actor::SessionActor; pub use catalog::SessionCatalogEvent; -pub use command::SessionCommands; -pub use context::ResolvedContextSnapshot; -use observe::SessionObserveSnapshot; pub use observe::{ - SessionEventFilterSpec, SubRunEventScope, SubRunStatusSnapshot, SubRunStatusSource, + SessionEventFilterSpec, SessionObserveSnapshot, SubRunEventScope, SubRunStatusSnapshot, + SubRunStatusSource, }; pub use query::{ AgentObserveSnapshot, ConversationAssistantBlockFacts, ConversationBlockFacts, @@ -45,22 +41,16 @@ pub use query::{ ConversationSystemNoteKind, ConversationThinkingBlockFacts, ConversationTranscriptErrorKind, ConversationUserBlockFacts, LastCompactMetaSnapshot, ProjectedTurnOutcome, SessionControlStateSnapshot, SessionReplay, SessionTranscriptSnapshot, ToolCallBlockFacts, - ToolCallStreamsFacts, TurnTerminalSnapshot, build_agent_observe_snapshot, - build_conversation_replay_frames, current_turn_messages, fallback_live_cursor, - has_terminal_turn_signal, project_conversation_snapshot, project_turn_outcome, - recoverable_parent_deliveries, + ToolCallStreamsFacts, TurnTerminalSnapshot, recoverable_parent_deliveries, }; +pub(crate) use state::{MailboxEventAppend, SessionStateEventSink, append_mailbox_event}; pub use state::{ - MailboxEventAppend, SessionSnapshot, SessionState, SessionStateEventSink, SessionWriter, - append_and_broadcast, append_batch_acked, append_batch_started, append_mailbox_discarded, - append_mailbox_event, append_mailbox_queued, complete_session_execution, + SessionSnapshot, SessionState, append_and_broadcast, complete_session_execution, display_name_from_working_dir, normalize_session_id, normalize_working_dir, - prepare_session_execution, recent_turn_event_tail, should_record_compaction_tail_event, -}; -pub use turn::{ - AgentPromptSubmission, TurnCollaborationSummary, TurnFinishReason, TurnOutcome, TurnRunRequest, - TurnRunResult, TurnSummary, run_turn, + prepare_session_execution, }; +pub use turn::{AgentPromptSubmission, TurnCollaborationSummary, TurnFinishReason, TurnSummary}; +pub(crate) use turn::{TurnOutcome, TurnRunResult, run_turn}; const ROOT_AGENT_ID: &str = "root-agent"; diff --git a/crates/session-runtime/src/query/agent.rs b/crates/session-runtime/src/query/agent.rs index fd056192..6659bb7c 100644 --- a/crates/session-runtime/src/query/agent.rs +++ b/crates/session-runtime/src/query/agent.rs @@ -10,7 +10,10 @@ use astrcode_core::{ StorageEventPayload, StoredEvent, UserMessageOrigin, }; -use crate::heuristics::{MAX_RECENT_MAILBOX_MESSAGES, MAX_TASK_SUMMARY_CHARS}; +use crate::{ + heuristics::{MAX_RECENT_MAILBOX_MESSAGES, MAX_TASK_SUMMARY_CHARS}, + query::text::{summarize_inline_text, truncate_text}, +}; #[derive(Debug, Clone)] pub struct AgentObserveSnapshot { @@ -23,7 +26,7 @@ pub struct AgentObserveSnapshot { pub last_output: Option, } -pub fn build_agent_observe_snapshot( +pub(crate) fn build_agent_observe_snapshot( lifecycle_status: AgentLifecycleStatus, projected: &AgentState, mailbox_projection: &MailboxProjection, @@ -51,14 +54,7 @@ pub fn build_agent_observe_snapshot( fn extract_last_output(messages: &[LlmMessage]) -> Option { messages.iter().rev().find_map(|msg| match msg { - LlmMessage::Assistant { content, .. } if !content.is_empty() => { - let char_count = content.chars().count(); - if char_count > 200 { - Some(content.chars().take(200).collect::() + "...") - } else { - Some(content.clone()) - } - }, + LlmMessage::Assistant { content, .. } if !content.is_empty() => truncate_text(content, 200), _ => None, }) } @@ -166,24 +162,7 @@ fn recent_mailbox_message_summaries( } fn summarize_task_text(text: &str) -> Option { - let normalized = text.split_whitespace().collect::>().join(" "); - let trimmed = normalized.trim(); - if trimmed.is_empty() { - return None; - } - - let char_count = trimmed.chars().count(); - if char_count <= MAX_TASK_SUMMARY_CHARS { - return Some(trimmed.to_string()); - } - - Some( - trimmed - .chars() - .take(MAX_TASK_SUMMARY_CHARS) - .collect::() - + "...", - ) + summarize_inline_text(text, MAX_TASK_SUMMARY_CHARS) } #[cfg(test)] diff --git a/crates/session-runtime/src/query/conversation.rs b/crates/session-runtime/src/query/conversation.rs index aede995e..fd5ab482 100644 --- a/crates/session-runtime/src/query/conversation.rs +++ b/crates/session-runtime/src/query/conversation.rs @@ -1203,7 +1203,7 @@ impl ConversationStreamProjector { } } -pub fn project_conversation_snapshot( +pub(crate) fn project_conversation_snapshot( records: &[SessionEventRecord], phase: Phase, ) -> ConversationSnapshotFacts { @@ -1216,7 +1216,7 @@ pub fn project_conversation_snapshot( } } -pub fn build_conversation_replay_frames( +pub(crate) fn build_conversation_replay_frames( seed_records: &[SessionEventRecord], history: &[SessionEventRecord], ) -> Vec { @@ -1234,7 +1234,7 @@ pub fn build_conversation_replay_frames( frames } -pub fn fallback_live_cursor(facts: &ConversationStreamReplayFacts) -> Option { +pub(crate) fn fallback_live_cursor(facts: &ConversationStreamReplayFacts) -> Option { facts .seed_records .last() @@ -1263,27 +1263,19 @@ fn block_id(block: &ConversationBlockFacts) -> &str { fn tool_result_summary(result: &ToolExecutionResult) -> String { const MAX_SUMMARY_CHARS: usize = 120; - let truncate = |content: &str| { - let normalized = content.split_whitespace().collect::>().join(" "); - let mut chars = normalized.chars(); - let truncated = chars.by_ref().take(MAX_SUMMARY_CHARS).collect::(); - if chars.next().is_some() { - format!("{truncated}…") - } else { - truncated - } - }; - if result.ok { if !result.output.trim().is_empty() { - truncate(&result.output) + crate::query::text::summarize_inline_text(&result.output, MAX_SUMMARY_CHARS) + .unwrap_or_else(|| format!("{} completed", result.tool_name)) } else { format!("{} completed", result.tool_name) } } else if let Some(error) = &result.error { - truncate(error) + crate::query::text::summarize_inline_text(error, MAX_SUMMARY_CHARS) + .unwrap_or_else(|| format!("{} failed", result.tool_name)) } else if !result.output.trim().is_empty() { - truncate(&result.output) + crate::query::text::summarize_inline_text(&result.output, MAX_SUMMARY_CHARS) + .unwrap_or_else(|| format!("{} failed", result.tool_name)) } else { format!("{} failed", result.tool_name) } diff --git a/crates/session-runtime/src/query/mod.rs b/crates/session-runtime/src/query/mod.rs index 0594014d..efe910dc 100644 --- a/crates/session-runtime/src/query/mod.rs +++ b/crates/session-runtime/src/query/mod.rs @@ -3,15 +3,16 @@ //! 这些类型表达的是 session-runtime 对外提供的只读快照, //! 让 `application` 只消费稳定视图,不再自己拼装会话真相。 -pub mod agent; -pub mod conversation; -pub mod mailbox; +mod agent; +mod conversation; +mod mailbox; mod service; -pub mod terminal; -pub mod transcript; -pub mod turn; +mod terminal; +mod text; +mod transcript; +mod turn; -pub use agent::{AgentObserveSnapshot, build_agent_observe_snapshot}; +pub use agent::AgentObserveSnapshot; pub use conversation::{ ConversationAssistantBlockFacts, ConversationBlockFacts, ConversationBlockPatchFacts, ConversationBlockStatus, ConversationChildHandoffBlockFacts, ConversationChildHandoffKind, @@ -19,13 +20,11 @@ pub use conversation::{ ConversationErrorBlockFacts, ConversationSnapshotFacts, ConversationStreamProjector, ConversationStreamReplayFacts, ConversationSystemNoteBlockFacts, ConversationSystemNoteKind, ConversationThinkingBlockFacts, ConversationTranscriptErrorKind, ConversationUserBlockFacts, - ToolCallBlockFacts, ToolCallStreamsFacts, build_conversation_replay_frames, - fallback_live_cursor, project_conversation_snapshot, + ToolCallBlockFacts, ToolCallStreamsFacts, }; pub use mailbox::recoverable_parent_deliveries; -pub use service::SessionQueries; +pub(crate) use service::SessionQueries; pub use terminal::{LastCompactMetaSnapshot, SessionControlStateSnapshot}; -pub use transcript::{SessionReplay, SessionTranscriptSnapshot, current_turn_messages}; -pub use turn::{ - ProjectedTurnOutcome, TurnTerminalSnapshot, has_terminal_turn_signal, project_turn_outcome, -}; +pub(crate) use transcript::current_turn_messages; +pub use transcript::{SessionReplay, SessionTranscriptSnapshot}; +pub use turn::{ProjectedTurnOutcome, TurnTerminalSnapshot}; diff --git a/crates/session-runtime/src/query/service.rs b/crates/session-runtime/src/query/service.rs index 38884f63..87a97a45 100644 --- a/crates/session-runtime/src/query/service.rs +++ b/crates/session-runtime/src/query/service.rs @@ -1,20 +1,24 @@ -use std::{sync::Arc, time::Duration}; +use std::sync::Arc; use astrcode_core::{ - AgentLifecycleStatus, ChildSessionNode, Phase, Result, SessionId, StorageEventPayload, - StoredEvent, + AgentEvent, AgentLifecycleStatus, ChildSessionNode, Phase, Result, SessionEventRecord, + SessionId, StorageEventPayload, StoredEvent, }; -use tokio::time::sleep; +use tokio::sync::broadcast::error::RecvError; use crate::{ AgentObserveSnapshot, ConversationSnapshotFacts, ConversationStreamReplayFacts, LastCompactMetaSnapshot, ProjectedTurnOutcome, SessionControlStateSnapshot, SessionReplay, - SessionRuntime, SessionState, TurnTerminalSnapshot, build_agent_observe_snapshot, - build_conversation_replay_frames, has_terminal_turn_signal, project_conversation_snapshot, - project_turn_outcome, recoverable_parent_deliveries, + SessionRuntime, SessionState, TurnTerminalSnapshot, + query::{ + agent::build_agent_observe_snapshot, + conversation::{build_conversation_replay_frames, project_conversation_snapshot}, + mailbox::recoverable_parent_deliveries, + turn::{has_terminal_turn_signal, project_turn_outcome}, + }, }; -pub struct SessionQueries<'a> { +pub(crate) struct SessionQueries<'a> { runtime: &'a SessionRuntime, } @@ -87,21 +91,46 @@ impl<'a> SessionQueries<'a> { turn_id: &str, ) -> Result { let session_id = SessionId::from(crate::normalize_session_id(session_id)); + let state = self.session_state(&session_id).await?; + let mut receiver = state.broadcaster.subscribe(); + if let Some(snapshot) = self + .try_turn_terminal_snapshot(&session_id, state.as_ref(), turn_id, true) + .await? + { + return Ok(snapshot); + } loop { - let state = self.session_state(&session_id).await?; - let phase = state.current_phase()?; - if matches!(phase, Phase::Idle | Phase::Interrupted | Phase::Done) { - let events = self - .stored_events(&session_id) - .await? - .into_iter() - .filter(|stored| stored.event.turn_id() == Some(turn_id)) - .collect::>(); - if has_terminal_turn_signal(&events) || matches!(phase, Phase::Interrupted) { - return Ok(TurnTerminalSnapshot { phase, events }); - } + match receiver.recv().await { + Ok(record) => { + if !record_targets_turn(&record, turn_id) + && !matches!(state.current_phase()?, Phase::Interrupted) + { + continue; + } + if let Some(snapshot) = + try_turn_terminal_snapshot_from_recent(state.as_ref(), turn_id)? + { + return Ok(snapshot); + } + }, + Err(RecvError::Lagged(_)) => { + if let Some(snapshot) = self + .try_turn_terminal_snapshot(&session_id, state.as_ref(), turn_id, true) + .await? + { + return Ok(snapshot); + } + }, + Err(RecvError::Closed) => { + if let Some(snapshot) = self + .try_turn_terminal_snapshot(&session_id, state.as_ref(), turn_id, true) + .await? + { + return Ok(snapshot); + } + receiver = state.broadcaster.subscribe(); + }, } - sleep(Duration::from_millis(20)).await; } } @@ -142,12 +171,8 @@ impl<'a> SessionQueries<'a> { ) -> Result { let session_id = SessionId::from(crate::normalize_session_id(session_id)); let actor = self.runtime.ensure_loaded_session(&session_id).await?; - let all_records = self.runtime.replay_history(&session_id, None).await?; - let replay_history = self - .runtime - .replay_history(&session_id, last_event_id) - .await?; - let seed_records = records_before_cursor(&all_records, last_event_id); + let full_history = self.runtime.replay_history(&session_id, None).await?; + let (seed_records, replay_history) = split_records_at_cursor(full_history, last_event_id); let phase = self.runtime.session_phase(&session_id).await?; Ok(ConversationStreamReplayFacts { @@ -197,20 +222,464 @@ impl<'a> SessionQueries<'a> { .await?; Ok(project_turn_outcome(terminal.phase, &terminal.events)) } + + async fn try_turn_terminal_snapshot( + &self, + session_id: &SessionId, + state: &SessionState, + turn_id: &str, + allow_durable_fallback: bool, + ) -> Result> { + if let Some(snapshot) = try_turn_terminal_snapshot_from_recent(state, turn_id)? { + return Ok(Some(snapshot)); + } + + if !allow_durable_fallback { + return Ok(None); + } + + let phase = state.current_phase()?; + let events = turn_events(self.stored_events(session_id).await?, turn_id); + if turn_snapshot_is_terminal(phase, &events) { + return Ok(Some(TurnTerminalSnapshot { phase, events })); + } + + Ok(None) + } } -fn records_before_cursor( - records: &[astrcode_core::SessionEventRecord], +fn split_records_at_cursor( + mut records: Vec, last_event_id: Option<&str>, -) -> Vec { +) -> (Vec, Vec) { let Some(last_event_id) = last_event_id else { - return Vec::new(); + return (Vec::new(), records); }; + let Some(index) = records .iter() .position(|record| record.event_id == last_event_id) else { - return Vec::new(); + return (Vec::new(), records); }; - records[..=index].to_vec() + + let replay_records = records.split_off(index + 1); + (records, replay_records) +} + +fn try_turn_terminal_snapshot_from_recent( + state: &SessionState, + turn_id: &str, +) -> Result> { + let phase = state.current_phase()?; + let events = turn_events(state.snapshot_recent_stored_events()?, turn_id); + if turn_snapshot_is_terminal(phase, &events) { + return Ok(Some(TurnTerminalSnapshot { phase, events })); + } + + Ok(None) +} + +fn turn_events(stored_events: Vec, turn_id: &str) -> Vec { + stored_events + .into_iter() + .filter(|stored| stored.event.turn_id() == Some(turn_id)) + .collect() +} + +fn turn_snapshot_is_terminal(phase: Phase, events: &[StoredEvent]) -> bool { + has_terminal_turn_signal(events) || (!events.is_empty() && matches!(phase, Phase::Interrupted)) +} + +fn record_targets_turn(record: &SessionEventRecord, turn_id: &str) -> bool { + match &record.event { + AgentEvent::UserMessage { turn_id: id, .. } + | AgentEvent::ModelDelta { turn_id: id, .. } + | AgentEvent::ThinkingDelta { turn_id: id, .. } + | AgentEvent::AssistantMessage { turn_id: id, .. } + | AgentEvent::ToolCallStart { turn_id: id, .. } + | AgentEvent::ToolCallDelta { turn_id: id, .. } + | AgentEvent::ToolCallResult { turn_id: id, .. } + | AgentEvent::TurnDone { turn_id: id, .. } => id == turn_id, + AgentEvent::PhaseChanged { + turn_id: Some(id), .. + } + | AgentEvent::PromptMetrics { + turn_id: Some(id), .. + } + | AgentEvent::CompactApplied { + turn_id: Some(id), .. + } + | AgentEvent::SubRunStarted { + turn_id: Some(id), .. + } + | AgentEvent::SubRunFinished { + turn_id: Some(id), .. + } + | AgentEvent::ChildSessionNotification { + turn_id: Some(id), .. + } + | AgentEvent::AgentMailboxQueued { + turn_id: Some(id), .. + } + | AgentEvent::AgentMailboxBatchStarted { + turn_id: Some(id), .. + } + | AgentEvent::AgentMailboxBatchAcked { + turn_id: Some(id), .. + } + | AgentEvent::AgentMailboxDiscarded { + turn_id: Some(id), .. + } + | AgentEvent::Error { + turn_id: Some(id), .. + } => id == turn_id, + _ => false, + } +} + +#[cfg(test)] +mod tests { + use std::{ + path::Path, + sync::{ + Arc, Mutex, + atomic::{AtomicU64, AtomicUsize, Ordering}, + }, + }; + + use astrcode_core::{ + AgentEventContext, DeleteProjectResult, EventStore, EventTranslator, Phase, Result, + SessionEventRecord, SessionId, SessionMeta, SessionTurnAcquireResult, StorageEvent, + StorageEventPayload, StoredEvent, UserMessageOrigin, + }; + use async_trait::async_trait; + use tokio::time::{Duration, timeout}; + + use super::{split_records_at_cursor, turn_snapshot_is_terminal}; + use crate::{ + state::append_and_broadcast, + turn::test_support::{StubEventStore, test_runtime}, + }; + + #[test] + fn split_records_at_cursor_keeps_seed_prefix_and_replay_suffix() { + let records = vec![ + SessionEventRecord { + event_id: "1.0".to_string(), + event: astrcode_core::AgentEvent::SessionStarted { + session_id: "session-1".to_string(), + }, + }, + SessionEventRecord { + event_id: "2.0".to_string(), + event: astrcode_core::AgentEvent::SessionStarted { + session_id: "session-1".to_string(), + }, + }, + SessionEventRecord { + event_id: "3.0".to_string(), + event: astrcode_core::AgentEvent::SessionStarted { + session_id: "session-1".to_string(), + }, + }, + ]; + + let (seed, replay) = split_records_at_cursor(records, Some("2.0")); + + assert_eq!( + seed.iter() + .map(|record| record.event_id.as_str()) + .collect::>(), + vec!["1.0", "2.0"] + ); + assert_eq!( + replay + .iter() + .map(|record| record.event_id.as_str()) + .collect::>(), + vec!["3.0"] + ); + } + + #[tokio::test] + async fn wait_for_turn_terminal_snapshot_wakes_on_broadcast_event() { + let runtime = test_runtime(Arc::new(StubEventStore::default())); + let session = runtime + .create_session(".") + .await + .expect("session should be created"); + let session_id = session.session_id.clone(); + let turn_id = "turn-1".to_string(); + + let waiter = { + let runtime = &runtime; + let session_id = session_id.clone(); + let turn_id = turn_id.clone(); + async move { + runtime + .wait_for_turn_terminal_snapshot(&session_id, &turn_id) + .await + } + }; + + let state = runtime + .get_session_state(&session_id.clone().into()) + .await + .expect("state should load"); + tokio::spawn(async move { + tokio::time::sleep(Duration::from_millis(10)).await; + let mut translator = EventTranslator::new(Phase::Idle); + append_and_broadcast( + state.as_ref(), + &StorageEvent { + turn_id: Some(turn_id), + agent: AgentEventContext::default(), + payload: StorageEventPayload::TurnDone { + timestamp: chrono::Utc::now(), + reason: Some("completed".to_string()), + }, + }, + &mut translator, + ) + .await + .expect("turn done should append"); + }); + + let snapshot = timeout(Duration::from_secs(1), waiter) + .await + .expect("wait should complete") + .expect("snapshot should load"); + + assert!(turn_snapshot_is_terminal(snapshot.phase, &snapshot.events)); + assert_eq!(snapshot.events.len(), 1); + assert_eq!(snapshot.events[0].event.turn_id(), Some("turn-1")); + } + + #[tokio::test] + async fn wait_for_turn_terminal_snapshot_replays_only_once_while_waiting() { + let event_store = Arc::new(CountingEventStore::default()); + let runtime = test_runtime(event_store.clone()); + let session = runtime + .create_session(".") + .await + .expect("session should be created"); + let session_id = session.session_id.clone(); + let turn_id = "turn-1".to_string(); + + let waiter = { + let runtime = &runtime; + let session_id = session_id.clone(); + let turn_id = turn_id.clone(); + async move { + runtime + .wait_for_turn_terminal_snapshot(&session_id, &turn_id) + .await + } + }; + + let state = runtime + .get_session_state(&session_id.clone().into()) + .await + .expect("state should load"); + tokio::spawn(async move { + tokio::time::sleep(Duration::from_millis(75)).await; + let mut translator = EventTranslator::new(Phase::Idle); + append_and_broadcast( + state.as_ref(), + &StorageEvent { + turn_id: Some(turn_id), + agent: AgentEventContext::default(), + payload: StorageEventPayload::TurnDone { + timestamp: chrono::Utc::now(), + reason: Some("completed".to_string()), + }, + }, + &mut translator, + ) + .await + .expect("turn done should append"); + }); + + timeout(Duration::from_secs(1), waiter) + .await + .expect("wait should complete") + .expect("snapshot should load"); + + assert_eq!( + event_store.replay_count(), + 1, + "live wait should not repeatedly rescan durable history" + ); + } + + #[tokio::test] + async fn conversation_stream_replay_reuses_single_history_load_when_cache_is_truncated() { + let event_store = Arc::new(CountingEventStore::with_events(build_large_history())); + let runtime = test_runtime(event_store.clone()); + + runtime + .get_session_state(&SessionId::from("1".to_string())) + .await + .expect("state should load from durable history"); + event_store.reset_replay_count(); + + let replay = runtime + .conversation_stream_replay("session-1", Some("1.0")) + .await + .expect("replay facts should build"); + + assert_eq!( + replay + .seed_records + .last() + .map(|record| record.event_id.as_str()), + Some("1.0") + ); + assert_eq!( + event_store.replay_count(), + 1, + "truncated cache should trigger only one durable replay for stream recovery" + ); + } + + fn build_large_history() -> Vec { + let mut events = Vec::with_capacity(16_386); + events.push(StoredEvent { + storage_seq: 1, + event: StorageEvent { + turn_id: None, + agent: AgentEventContext::default(), + payload: StorageEventPayload::SessionStart { + session_id: "1".to_string(), + timestamp: chrono::Utc::now(), + working_dir: ".".to_string(), + parent_session_id: None, + parent_storage_seq: None, + }, + }, + }); + for storage_seq in 2..=16_386 { + events.push(StoredEvent { + storage_seq, + event: StorageEvent { + turn_id: Some(format!("turn-{storage_seq}")), + agent: AgentEventContext::default(), + payload: StorageEventPayload::UserMessage { + content: format!("message {storage_seq}"), + origin: UserMessageOrigin::User, + timestamp: chrono::Utc::now(), + }, + }, + }); + } + events + } + + #[derive(Debug, Default)] + struct CountingEventStore { + events: Mutex>, + next_seq: AtomicU64, + replay_count: AtomicUsize, + } + + impl CountingEventStore { + fn with_events(events: Vec) -> Self { + let next_seq = events + .last() + .map(|stored| stored.storage_seq) + .unwrap_or_default(); + Self { + events: Mutex::new(events), + next_seq: AtomicU64::new(next_seq), + replay_count: AtomicUsize::new(0), + } + } + + fn replay_count(&self) -> usize { + self.replay_count.load(Ordering::SeqCst) + } + + fn reset_replay_count(&self) { + self.replay_count.store(0, Ordering::SeqCst); + } + } + + struct CountingTurnLease; + + impl astrcode_core::SessionTurnLease for CountingTurnLease {} + + #[async_trait] + impl EventStore for CountingEventStore { + async fn ensure_session(&self, _session_id: &SessionId, _working_dir: &Path) -> Result<()> { + Ok(()) + } + + async fn append( + &self, + _session_id: &SessionId, + event: &StorageEvent, + ) -> Result { + let stored = StoredEvent { + storage_seq: self.next_seq.fetch_add(1, Ordering::SeqCst) + 1, + event: event.clone(), + }; + self.events + .lock() + .expect("counting event store should lock") + .push(stored.clone()); + Ok(stored) + } + + async fn replay(&self, _session_id: &SessionId) -> Result> { + self.replay_count.fetch_add(1, Ordering::SeqCst); + Ok(self + .events + .lock() + .expect("counting event store should lock") + .clone()) + } + + async fn try_acquire_turn( + &self, + _session_id: &SessionId, + _turn_id: &str, + ) -> Result { + Ok(SessionTurnAcquireResult::Acquired(Box::new( + CountingTurnLease, + ))) + } + + async fn list_sessions(&self) -> Result> { + Ok(vec![SessionId::from("1".to_string())]) + } + + async fn list_session_metas(&self) -> Result> { + Ok(vec![SessionMeta { + session_id: "1".to_string(), + working_dir: ".".to_string(), + display_name: "session-1".to_string(), + title: "session-1".to_string(), + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + parent_session_id: None, + parent_storage_seq: None, + phase: Phase::Idle, + }]) + } + + async fn delete_session(&self, _session_id: &SessionId) -> Result<()> { + Ok(()) + } + + async fn delete_sessions_by_working_dir( + &self, + _working_dir: &str, + ) -> Result { + Ok(DeleteProjectResult { + success_count: 0, + failed_session_ids: Vec::new(), + }) + } + } } diff --git a/crates/session-runtime/src/query/text.rs b/crates/session-runtime/src/query/text.rs new file mode 100644 index 00000000..c98bf732 --- /dev/null +++ b/crates/session-runtime/src/query/text.rs @@ -0,0 +1,45 @@ +//! query 层共享的文本规整与截断规则。 +//! +//! Why: 各类只读快照都需要输出短摘要,统一到这里可以避免 +//! 不同查询面各自复制 whitespace normalize / truncate 逻辑后逐渐漂移。 + +const DEFAULT_ELLIPSIS: &str = "..."; + +pub(crate) fn summarize_inline_text(text: &str, max_chars: usize) -> Option { + let normalized = text.split_whitespace().collect::>().join(" "); + truncate_text(&normalized, max_chars) +} + +pub(crate) fn truncate_text(text: &str, max_chars: usize) -> Option { + let trimmed = text.trim(); + if trimmed.is_empty() { + return None; + } + + let char_count = trimmed.chars().count(); + if char_count <= max_chars { + return Some(trimmed.to_string()); + } + + Some(trimmed.chars().take(max_chars).collect::() + DEFAULT_ELLIPSIS) +} + +#[cfg(test)] +mod tests { + use super::{summarize_inline_text, truncate_text}; + + #[test] + fn summarize_inline_text_normalizes_whitespace_before_truncating() { + assert_eq!( + summarize_inline_text(" review mailbox \n state ", 120), + Some("review mailbox state".to_string()) + ); + } + + #[test] + fn truncate_text_trims_and_truncates_with_ascii_ellipsis() { + assert_eq!(truncate_text(" hello ", 10), Some("hello".to_string())); + assert_eq!(truncate_text(" ", 10), None); + assert_eq!(truncate_text(&"a".repeat(5), 3), Some("aaa...".to_string())); + } +} diff --git a/crates/session-runtime/src/query/transcript.rs b/crates/session-runtime/src/query/transcript.rs index d6a03371..e6028e5c 100644 --- a/crates/session-runtime/src/query/transcript.rs +++ b/crates/session-runtime/src/query/transcript.rs @@ -22,7 +22,7 @@ pub struct SessionTranscriptSnapshot { pub phase: Phase, } -pub fn current_turn_messages(session: &SessionState) -> Result> { +pub(crate) fn current_turn_messages(session: &SessionState) -> Result> { Ok(session.snapshot_projected_state()?.messages) } diff --git a/crates/session-runtime/src/query/turn.rs b/crates/session-runtime/src/query/turn.rs index 18dba881..55750a4d 100644 --- a/crates/session-runtime/src/query/turn.rs +++ b/crates/session-runtime/src/query/turn.rs @@ -18,7 +18,7 @@ pub struct ProjectedTurnOutcome { pub technical_message: String, } -pub fn has_terminal_turn_signal(events: &[StoredEvent]) -> bool { +pub(crate) fn has_terminal_turn_signal(events: &[StoredEvent]) -> bool { events.iter().any(|stored| { matches!( stored.event.payload, @@ -27,7 +27,7 @@ pub fn has_terminal_turn_signal(events: &[StoredEvent]) -> bool { }) } -pub fn project_turn_outcome(phase: Phase, events: &[StoredEvent]) -> ProjectedTurnOutcome { +pub(crate) fn project_turn_outcome(phase: Phase, events: &[StoredEvent]) -> ProjectedTurnOutcome { let last_assistant = events .iter() .rev() diff --git a/crates/session-runtime/src/state/mailbox.rs b/crates/session-runtime/src/state/mailbox.rs index d49ff0e4..6d1c6749 100644 --- a/crates/session-runtime/src/state/mailbox.rs +++ b/crates/session-runtime/src/state/mailbox.rs @@ -92,78 +92,6 @@ pub async fn append_mailbox_event( .await } -/// 追加一条 `AgentMailboxQueued` 事件到 session event log。 -pub async fn append_mailbox_queued( - session: &SessionState, - turn_id: &str, - agent: astrcode_core::AgentEventContext, - payload: MailboxQueuedPayload, - translator: &mut EventTranslator, -) -> Result { - append_mailbox_event( - session, - turn_id, - agent, - MailboxEventAppend::Queued(payload), - translator, - ) - .await -} - -/// 追加一条 `AgentMailboxBatchStarted` 事件。 -pub async fn append_batch_started( - session: &SessionState, - turn_id: &str, - agent: astrcode_core::AgentEventContext, - payload: MailboxBatchStartedPayload, - translator: &mut EventTranslator, -) -> Result { - append_mailbox_event( - session, - turn_id, - agent, - MailboxEventAppend::BatchStarted(payload), - translator, - ) - .await -} - -/// 追加一条 `AgentMailboxBatchAcked` 事件。 -pub async fn append_batch_acked( - session: &SessionState, - turn_id: &str, - agent: astrcode_core::AgentEventContext, - payload: MailboxBatchAckedPayload, - translator: &mut EventTranslator, -) -> Result { - append_mailbox_event( - session, - turn_id, - agent, - MailboxEventAppend::BatchAcked(payload), - translator, - ) - .await -} - -/// 追加一条 `AgentMailboxDiscarded` 事件。 -pub async fn append_mailbox_discarded( - session: &SessionState, - turn_id: &str, - agent: astrcode_core::AgentEventContext, - payload: MailboxDiscardedPayload, - translator: &mut EventTranslator, -) -> Result { - append_mailbox_event( - session, - turn_id, - agent, - MailboxEventAppend::Discarded(payload), - translator, - ) - .await -} - #[cfg(test)] mod tests { use astrcode_core::{ diff --git a/crates/session-runtime/src/state/mod.rs b/crates/session-runtime/src/state/mod.rs index 98ee2558..de094b66 100644 --- a/crates/session-runtime/src/state/mod.rs +++ b/crates/session-runtime/src/state/mod.rs @@ -5,6 +5,7 @@ mod cache; mod child_sessions; +#[cfg(test)] mod compaction; mod execution; mod mailbox; @@ -26,18 +27,12 @@ use astrcode_core::{ }; use cache::{RecentSessionEvents, RecentStoredEvents}; use child_sessions::{child_node_from_stored_event, rebuild_child_nodes}; -pub use compaction::{recent_turn_event_tail, should_record_compaction_tail_event}; -pub use execution::{ - SessionStateEventSink, append_and_broadcast, complete_session_execution, - prepare_session_execution, -}; -pub use mailbox::{ - MailboxEventAppend, append_batch_acked, append_batch_started, append_mailbox_discarded, - append_mailbox_event, append_mailbox_queued, -}; +pub(crate) use execution::SessionStateEventSink; +pub use execution::{append_and_broadcast, complete_session_execution, prepare_session_execution}; +pub(crate) use mailbox::{MailboxEventAppend, append_mailbox_event}; pub use paths::{display_name_from_working_dir, normalize_session_id, normalize_working_dir}; use tokio::sync::broadcast; -pub use writer::SessionWriter; +pub(crate) use writer::SessionWriter; const SESSION_BROADCAST_CAPACITY: usize = 2048; const SESSION_LIVE_BROADCAST_CAPACITY: usize = 2048; diff --git a/crates/session-runtime/src/turn/compaction_cycle.rs b/crates/session-runtime/src/turn/compaction_cycle.rs index 5d043d43..c899731c 100644 --- a/crates/session-runtime/src/turn/compaction_cycle.rs +++ b/crates/session-runtime/src/turn/compaction_cycle.rs @@ -30,7 +30,7 @@ use crate::{ }; /// reactive compact 恢复成功后的结果。 -pub struct RecoveryResult { +pub(crate) struct RecoveryResult { /// 压缩后的消息历史(含文件恢复消息)。 pub messages: Vec, /// 压缩期间产生的事件。 @@ -40,7 +40,7 @@ pub struct RecoveryResult { /// reactive compact 调用上下文。 /// /// 将分散的参数聚合为结构体,避免函数签名过长。 -pub struct ReactiveCompactContext<'a> { +pub(crate) struct ReactiveCompactContext<'a> { pub gateway: &'a KernelGateway, pub prompt_facts_provider: &'a dyn PromptFactsProvider, pub messages: &'a [LlmMessage], diff --git a/crates/session-runtime/src/turn/continuation_cycle.rs b/crates/session-runtime/src/turn/continuation_cycle.rs index 676ee824..de81b0c6 100644 --- a/crates/session-runtime/src/turn/continuation_cycle.rs +++ b/crates/session-runtime/src/turn/continuation_cycle.rs @@ -13,7 +13,7 @@ pub const OUTPUT_CONTINUATION_PROMPT: &str = "Continue from the exact point wher apologize."; #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum OutputContinuationDecision { +pub(crate) enum OutputContinuationDecision { Continue, Stop(TurnStopCause), NotNeeded, diff --git a/crates/session-runtime/src/turn/llm_cycle.rs b/crates/session-runtime/src/turn/llm_cycle.rs index d9b56df1..d6e07fd4 100644 --- a/crates/session-runtime/src/turn/llm_cycle.rs +++ b/crates/session-runtime/src/turn/llm_cycle.rs @@ -22,14 +22,14 @@ use tokio::sync::mpsc; use crate::SessionState; #[derive(Debug, Clone, PartialEq, Eq)] -pub struct StreamedToolCallDelta { +pub(crate) struct StreamedToolCallDelta { pub index: usize, pub id: Option, pub name: Option, pub arguments_delta: String, } -pub type ToolCallDeltaSink = Arc; +pub(crate) type ToolCallDeltaSink = Arc; /// 调用 LLM 并收集流式 delta 为 StorageEvent。 /// @@ -84,14 +84,6 @@ pub async fn call_llm_streaming( output.map_err(map_kernel_error) } -/// 检查错误是否为 prompt-too-long 类型。 -/// -/// 不同 provider 使用不同的错误消息描述上下文长度溢出, -/// 此函数覆盖常见的几种表述方式。 -pub fn is_prompt_too_long(error: &astrcode_core::AstrError) -> bool { - error.is_prompt_too_long() -} - /// 将 LLM 流式增量直接发到 live-only 广播通道。 /// /// 为什么不再把 token 级 delta 塞进 turn 结束后的 durable 批量事件: diff --git a/crates/session-runtime/src/turn/loop_control.rs b/crates/session-runtime/src/turn/loop_control.rs index 78b1c20f..1a0c0422 100644 --- a/crates/session-runtime/src/turn/loop_control.rs +++ b/crates/session-runtime/src/turn/loop_control.rs @@ -36,7 +36,7 @@ pub enum TurnStopCause { /// budget 驱动 auto-continue 的判断结果。 #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum BudgetContinuationDecision { +pub(crate) enum BudgetContinuationDecision { Continue, Stop(TurnStopCause), NotNeeded, diff --git a/crates/session-runtime/src/turn/mod.rs b/crates/session-runtime/src/turn/mod.rs index 21233320..77116ea7 100644 --- a/crates/session-runtime/src/turn/mod.rs +++ b/crates/session-runtime/src/turn/mod.rs @@ -8,7 +8,7 @@ mod compaction_cycle; mod continuation_cycle; mod events; mod interrupt; -pub mod llm_cycle; +pub(crate) mod llm_cycle; mod loop_control; pub(crate) mod manual_compact; mod replay; @@ -18,25 +18,17 @@ mod submit; #[cfg(test)] pub(crate) mod test_support; // pub mod subagent; -pub mod summary; -pub mod tool_cycle; +mod summary; +pub(crate) mod tool_cycle; mod tool_result_budget; -use astrcode_core::{SessionId, TurnId}; pub use loop_control::{TurnLoopTransition, TurnStopCause}; pub use submit::AgentPromptSubmission; pub use summary::{TurnCollaborationSummary, TurnFinishReason, TurnSummary}; -/// Turn 运行请求参数。 -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct TurnRunRequest { - pub session_id: SessionId, - pub turn_id: TurnId, -} - /// Turn 结束原因。 #[derive(Debug, Clone, PartialEq, Eq)] -pub enum TurnOutcome { +pub(crate) enum TurnOutcome { /// LLM 返回纯文本(无 tool_calls),自然结束。 Completed, /// 用户取消或 CancelToken 触发。 @@ -45,4 +37,4 @@ pub enum TurnOutcome { Error { message: String }, } -pub use runner::{TurnRunRequest as RunnerRequest, TurnRunResult, run_turn}; +pub(crate) use runner::{TurnRunRequest as RunnerRequest, TurnRunResult, run_turn}; diff --git a/crates/session-runtime/src/turn/runner.rs b/crates/session-runtime/src/turn/runner.rs index c8062d94..4ff26588 100644 --- a/crates/session-runtime/src/turn/runner.rs +++ b/crates/session-runtime/src/turn/runner.rs @@ -28,8 +28,7 @@ use std::{collections::HashSet, path::Path, sync::Arc, time::Instant}; use astrcode_core::{ AgentEventContext, CancelToken, LlmMessage, PromptDeclaration, PromptFactsProvider, - ResolvedExecutionLimitsSnapshot, ResolvedRuntimeConfig, Result, StorageEvent, - StorageEventPayload, ToolDefinition, + ResolvedRuntimeConfig, Result, StorageEvent, StorageEventPayload, ToolDefinition, }; use astrcode_kernel::{CapabilityRouter, Kernel, KernelGateway}; use chrono::{DateTime, Utc}; @@ -55,7 +54,7 @@ use crate::{ const CLEARABLE_TOOLS: &[&str] = &["readFile", "listDir", "grep", "findFiles"]; /// Turn 执行请求。 -pub struct TurnRunRequest { +pub(crate) struct TurnRunRequest { pub session_id: String, pub working_dir: String, pub turn_id: String, @@ -68,12 +67,10 @@ pub struct TurnRunRequest { pub prompt_facts_provider: Arc, pub capability_router: Option, pub prompt_declarations: Vec, - pub resolved_limits: Option, - pub source_tool_call_id: Option, } /// Turn 执行结果。 -pub struct TurnRunResult { +pub(crate) struct TurnRunResult { pub outcome: TurnOutcome, /// Turn 结束时的完整消息历史(含本次 turn 新增的)。 pub messages: Vec, @@ -288,8 +285,6 @@ pub async fn run_turn(kernel: Arc, request: TurnRunRequest) -> Result, @@ -59,13 +59,13 @@ pub struct ToolCycleResult { } #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ToolEventEmissionMode { +pub(crate) enum ToolEventEmissionMode { Immediate, Buffered, } /// 工具执行周期的上下文参数,避免函数参数过多。 -pub struct ToolCycleContext<'a> { +pub(crate) struct ToolCycleContext<'a> { pub gateway: &'a KernelGateway, pub session_state: &'a Arc, pub session_id: &'a str, @@ -92,7 +92,7 @@ struct SingleToolInvocation<'a> { event_emission_mode: ToolEventEmissionMode, } -pub struct BufferedToolExecutionRequest { +pub(crate) struct BufferedToolExecutionRequest { pub gateway: KernelGateway, pub session_state: Arc, pub tool_call: ToolCallRequest, @@ -104,8 +104,7 @@ pub struct BufferedToolExecutionRequest { pub tool_result_inline_limit: usize, } -pub struct BufferedToolExecution { - pub tool_call: ToolCallRequest, +pub(crate) struct BufferedToolExecution { pub result: ToolExecutionResult, pub events: Vec, pub started_at: Instant, @@ -346,7 +345,6 @@ pub async fn execute_buffered_tool_call( .await; let finished_at = Instant::now(); BufferedToolExecution { - tool_call, result, events, started_at, From b3faf20d4ec896eab05ac5e1113963b4f8cdc703 Mon Sep 17 00:00:00 2001 From: whatevertogo Date: Thu, 16 Apr 2026 22:10:02 +0800 Subject: [PATCH 13/53] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor(reconnect):?= =?UTF-8?q?=20=E4=BD=BF=E7=94=A8=E4=BB=BB=E5=8A=A1=E4=BF=9D=E6=8A=A4?= =?UTF-8?q?=E5=99=A8=E7=AE=80=E5=8C=96=E9=87=8D=E8=BF=9E=E4=BB=BB=E5=8A=A1?= =?UTF-8?q?=E7=AE=A1=E7=90=86=20=E2=99=BB=EF=B8=8F=20refactor(skill=5Fcata?= =?UTF-8?q?log):=20=E5=BC=95=E5=85=A5=E8=AF=BB=E5=86=99=E4=BF=9D=E6=8A=A4?= =?UTF-8?q?=E5=99=A8=E4=BC=98=E5=8C=96=E6=8A=80=E8=83=BD=E7=9B=AE=E5=BD=95?= =?UTF-8?q?=E7=AE=A1=E7=90=86=20=E2=99=BB=EF=B8=8F=20refactor(apply=5Fpatc?= =?UTF-8?q?h):=20=E4=BD=BF=E7=94=A8=20Map=20=E4=BC=98=E5=8C=96=E8=A1=A5?= =?UTF-8?q?=E4=B8=81=E8=A7=A3=E6=9E=90=E5=92=8C=E5=85=83=E6=95=B0=E6=8D=AE?= =?UTF-8?q?=E6=9E=84=E5=BB=BA=20=E2=99=BB=EF=B8=8F=20refactor(profiles):?= =?UTF-8?q?=20=E5=BC=95=E5=85=A5=E8=AF=BB=E5=86=99=E4=BF=9D=E6=8A=A4?= =?UTF-8?q?=E5=99=A8=E4=BC=98=E5=8C=96=E5=85=A8=E5=B1=80=E9=85=8D=E7=BD=AE?= =?UTF-8?q?=E6=96=87=E4=BB=B6=E7=BC=93=E5=AD=98=E7=AE=A1=E7=90=86=20?= =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor(mod):=20=E4=BD=BF=E7=94=A8=20Head?= =?UTF-8?q?erValue=20=E4=BC=98=E5=8C=96=20CORS=20=E5=B1=82=E9=85=8D?= =?UTF-8?q?=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/adapter-mcp/src/manager/reconnect.rs | 20 +++++---- crates/adapter-skills/src/skill_catalog.rs | 18 ++++++-- .../src/builtin_tools/apply_patch.rs | 42 ++++++++++--------- crates/application/src/execution/profiles.rs | 20 +++++++-- crates/server/src/bootstrap/mod.rs | 4 +- 5 files changed, 68 insertions(+), 36 deletions(-) diff --git a/crates/adapter-mcp/src/manager/reconnect.rs b/crates/adapter-mcp/src/manager/reconnect.rs index 7a1a0632..7bc71cff 100644 --- a/crates/adapter-mcp/src/manager/reconnect.rs +++ b/crates/adapter-mcp/src/manager/reconnect.rs @@ -6,7 +6,7 @@ use std::{ collections::HashMap, - sync::{Arc, Mutex as StdMutex}, + sync::{Arc, Mutex as StdMutex, MutexGuard as StdMutexGuard}, }; use log::{info, warn}; @@ -51,7 +51,7 @@ impl McpReconnectManager { // 已有活跃重连任务则跳过 { - let tasks = self.tasks.lock().unwrap(); + let tasks = self.tasks_guard(); if let Some(handle) = tasks.get(&server_name) { if !handle.is_finished() { warn!( @@ -74,7 +74,7 @@ impl McpReconnectManager { )); { - let mut tasks = self.tasks.lock().unwrap(); + let mut tasks = self.tasks_guard(); // 清理已完成的旧任务 tasks.retain(|_, h| !h.is_finished()); tasks.insert(name_clone, handle); @@ -83,7 +83,7 @@ impl McpReconnectManager { /// 取消指定服务器的重连任务。 pub fn cancel_reconnect(&self, server_name: &str) { - let mut tasks = self.tasks.lock().unwrap(); + let mut tasks = self.tasks_guard(); if let Some(handle) = tasks.remove(server_name) { handle.abort(); info!("MCP reconnect task cancelled for '{}'", server_name); @@ -92,7 +92,7 @@ impl McpReconnectManager { /// 取消所有重连任务。 pub fn cancel_all(&self) { - let mut tasks = self.tasks.lock().unwrap(); + let mut tasks = self.tasks_guard(); let count = tasks.len(); for (_, handle) in tasks.drain() { handle.abort(); @@ -105,7 +105,7 @@ impl McpReconnectManager { /// 指定服务器是否有活跃的重连任务。 #[allow(dead_code)] pub fn is_reconnecting(&self, server_name: &str) -> bool { - let tasks = self.tasks.lock().unwrap(); + let tasks = self.tasks_guard(); tasks .get(server_name) .map(|h| !h.is_finished()) @@ -115,9 +115,15 @@ impl McpReconnectManager { /// 清理已完成的重连任务。 #[allow(dead_code)] pub fn cleanup_finished(&self) { - let mut tasks = self.tasks.lock().unwrap(); + let mut tasks = self.tasks_guard(); tasks.retain(|_, h| !h.is_finished()); } + + fn tasks_guard(&self) -> StdMutexGuard<'_, HashMap>> { + self.tasks + .lock() + .expect("MCP reconnect task registry lock should not be poisoned") + } } /// 重连循环。 diff --git a/crates/adapter-skills/src/skill_catalog.rs b/crates/adapter-skills/src/skill_catalog.rs index ce9875ee..eeb9b74f 100644 --- a/crates/adapter-skills/src/skill_catalog.rs +++ b/crates/adapter-skills/src/skill_catalog.rs @@ -22,7 +22,7 @@ use std::{ path::{Path, PathBuf}, - sync::{Arc, RwLock}, + sync::{Arc, RwLock, RwLockReadGuard, RwLockWriteGuard}, }; use log::debug; @@ -84,13 +84,13 @@ impl SkillCatalog { /// 用于 runtime reload 场景,新的 base skills 会完全替换旧的。 /// 调用方应确保 `new_base_skills` 已按优先级排序。 pub fn replace_base_skills(&self, new_base_skills: Vec) { - let mut guard = self.base_skills.write().unwrap(); + let mut guard = self.write_base_skills(); *guard = normalize_base_skills(new_base_skills); } /// 获取当前 base skills 的快照。 pub fn base_skills(&self) -> Vec { - let guard = self.base_skills.read().unwrap(); + let guard = self.read_base_skills(); guard.clone() } @@ -105,6 +105,18 @@ impl SkillCatalog { let base = self.base_skills(); resolve_skills(&base, self.user_home_dir.as_deref(), working_dir) } + + fn read_base_skills(&self) -> RwLockReadGuard<'_, Vec> { + self.base_skills + .read() + .expect("skill catalog base_skills lock should not be poisoned") + } + + fn write_base_skills(&self) -> RwLockWriteGuard<'_, Vec> { + self.base_skills + .write() + .expect("skill catalog base_skills lock should not be poisoned") + } } /// 合并 base skills、user skills 和 project skills。 diff --git a/crates/adapter-tools/src/builtin_tools/apply_patch.rs b/crates/adapter-tools/src/builtin_tools/apply_patch.rs index 04bf6c63..e62a6736 100644 --- a/crates/adapter-tools/src/builtin_tools/apply_patch.rs +++ b/crates/adapter-tools/src/builtin_tools/apply_patch.rs @@ -23,7 +23,7 @@ use astrcode_core::{ }; use async_trait::async_trait; use serde::Deserialize; -use serde_json::json; +use serde_json::{Map, json}; use crate::builtin_tools::fs_common::{ build_text_change_report, check_cancel, is_symlink, is_unc_path, read_utf8_file, resolve_path, @@ -123,12 +123,17 @@ fn parse_patch(patch: &str) -> Result> { continue; } - if line.starts_with("--- ") { - let old_path = strip_diff_prefix(line.strip_prefix("--- ").unwrap()); + if let Some(old_path_line) = line.strip_prefix("--- ") { + let old_path = strip_diff_prefix(old_path_line); i += 1; - if i < lines.len() && lines[i].starts_with("+++ ") { - let new_path = strip_diff_prefix(lines[i].strip_prefix("+++ ").unwrap()); + if i < lines.len() { + let Some(new_path_line) = lines[i].strip_prefix("+++ ") else { + return Err(AstrError::Validation( + "patch format error: expected '+++ new_path' after '--- old_path'".into(), + )); + }; + let new_path = strip_diff_prefix(new_path_line); i += 1; let hunks = parse_hunks(&lines, &mut i)?; @@ -145,10 +150,6 @@ fn parse_patch(patch: &str) -> Result> { }, hunks, }); - } else { - return Err(AstrError::Validation( - "patch format error: expected '+++ new_path' after '--- old_path'".into(), - )); } } else { return Err(AstrError::Validation(format!( @@ -203,7 +204,11 @@ fn parse_hunks(lines: &[&str], i: &mut usize) -> Result> { hunk_lines.push(HunkLine::Context(String::new())); *i += 1; } else { - match l.chars().next().unwrap() { + let Some(prefix) = l.chars().next() else { + *i += 1; + continue; + }; + match prefix { ' ' => { hunk_lines.push(HunkLine::Context(l.chars().skip(1).collect())); *i += 1; @@ -848,18 +853,15 @@ fn build_apply_patch_metadata( let file_results: Vec = results .iter() .map(|r| { - let mut obj = json!({ - "path": r.path, - "changeType": r.change_type, - "applied": r.applied, - "summary": r.summary, - }); + let mut obj = Map::new(); + obj.insert("path".to_string(), json!(r.path)); + obj.insert("changeType".to_string(), json!(r.change_type)); + obj.insert("applied".to_string(), json!(r.applied)); + obj.insert("summary".to_string(), json!(r.summary)); if let Some(err) = &r.error { - obj.as_object_mut() - .unwrap() - .insert("error".to_string(), json!(err)); + obj.insert("error".to_string(), json!(err)); } - obj + serde_json::Value::Object(obj) }) .collect(); diff --git a/crates/application/src/execution/profiles.rs b/crates/application/src/execution/profiles.rs index 7a809849..3153369c 100644 --- a/crates/application/src/execution/profiles.rs +++ b/crates/application/src/execution/profiles.rs @@ -5,7 +5,7 @@ use std::{ path::{Path, PathBuf}, - sync::{Arc, RwLock}, + sync::{Arc, RwLock, RwLockReadGuard, RwLockWriteGuard}, }; use astrcode_core::AgentProfile; @@ -75,7 +75,7 @@ impl ProfileResolutionService { /// 全局 profile 列表(不绑定 working-dir)。 pub fn resolve_global(&self) -> Result>, ApplicationError> { { - let guard = self.global_cache.read().unwrap(); + let guard = self.read_global_cache(); if let Some(ref cached) = *guard { return Ok(Arc::clone(cached)); } @@ -84,7 +84,7 @@ impl ProfileResolutionService { let profiles = self.provider.load_global().map(Arc::new)?; { - let mut guard = self.global_cache.write().unwrap(); + let mut guard = self.write_global_cache(); *guard = Some(Arc::clone(&profiles)); } @@ -139,7 +139,7 @@ impl ProfileResolutionService { /// 使全局缓存失效。 pub fn invalidate_global(&self) { self.cache.clear(); - let mut guard = self.global_cache.write().unwrap(); + let mut guard = self.write_global_cache(); *guard = None; } @@ -148,6 +148,18 @@ impl ProfileResolutionService { self.cache.clear(); self.invalidate_global(); } + + fn read_global_cache(&self) -> RwLockReadGuard<'_, Option>>> { + self.global_cache + .read() + .expect("global profile cache lock should not be poisoned") + } + + fn write_global_cache(&self) -> RwLockWriteGuard<'_, Option>>> { + self.global_cache + .write() + .expect("global profile cache lock should not be poisoned") + } } /// 规范化路径用于缓存键。 diff --git a/crates/server/src/bootstrap/mod.rs b/crates/server/src/bootstrap/mod.rs index cdee2ad5..cc82a5c4 100644 --- a/crates/server/src/bootstrap/mod.rs +++ b/crates/server/src/bootstrap/mod.rs @@ -326,8 +326,8 @@ fn browser_index_response(index_html: &str) -> Response { pub(crate) fn build_cors_layer() -> CorsLayer { CorsLayer::new() .allow_origin([ - "http://localhost:5173".parse().unwrap(), - "http://127.0.0.1:5173".parse().unwrap(), + HeaderValue::from_static("http://localhost:5173"), + HeaderValue::from_static("http://127.0.0.1:5173"), ]) .allow_methods([Method::GET, Method::POST, Method::DELETE, Method::OPTIONS]) .allow_headers([ From 32415f5ed438947a976d8d5ac760ffc6cf16e4ce Mon Sep 17 00:00:00 2001 From: whatevertogo Date: Thu, 16 Apr 2026 23:34:51 +0800 Subject: [PATCH 14/53] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor(tests):=20?= =?UTF-8?q?=E4=BD=BF=E7=94=A8=20expect=20=E6=96=B9=E6=B3=95=E5=A2=9E?= =?UTF-8?q?=E5=BC=BA=E9=94=99=E8=AF=AF=E4=BF=A1=E6=81=AF=EF=BC=8C=E6=8F=90?= =?UTF-8?q?=E5=8D=87=E5=8F=AF=E8=AF=BB=E6=80=A7=E5=92=8C=E8=B0=83=E8=AF=95?= =?UTF-8?q?=E4=BD=93=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/adapter-llm/src/anthropic.rs | 8 +- crates/adapter-mcp/src/bridge/tool_bridge.rs | 4 +- crates/adapter-mcp/src/config/approval.rs | 112 ++++++++++++------ crates/adapter-mcp/src/config/loader.rs | 32 +++-- crates/adapter-mcp/src/manager/hot_reload.rs | 6 +- crates/adapter-mcp/src/protocol/client.rs | 18 ++- crates/adapter-mcp/src/transport/http.rs | 8 +- crates/adapter-mcp/src/transport/sse.rs | 6 +- crates/adapter-skills/src/skill_catalog.rs | 5 +- .../adapter-tools/src/builtin_tools/grep.rs | 54 +++++++-- .../src/builtin_tools/read_file.rs | 2 +- crates/application/src/config/api_key.rs | 5 +- crates/application/src/config/env_resolver.rs | 8 +- crates/application/src/config/validation.rs | 2 +- crates/application/src/execution/profiles.rs | 86 +++++++++----- crates/core/src/registry/router.rs | 2 +- crates/core/src/tool_result_persist.rs | 5 +- crates/kernel/src/agent_tree/tests.rs | 2 +- crates/server/src/logging.rs | 51 ++++---- .../src/tests/session_contract_tests.rs | 13 +- 20 files changed, 289 insertions(+), 140 deletions(-) diff --git a/crates/adapter-llm/src/anthropic.rs b/crates/adapter-llm/src/anthropic.rs index fcd04e77..8d10c300 100644 --- a/crates/adapter-llm/src/anthropic.rs +++ b/crates/adapter-llm/src/anthropic.rs @@ -2150,7 +2150,13 @@ mod tests { let body = serde_json::to_value(&request).expect("request should serialize"); assert!(body.get("system").is_some_and(Value::is_array)); - assert_eq!(body["system"].as_array().unwrap().len(), 7); + assert_eq!( + body["system"] + .as_array() + .expect("system should be an array") + .len(), + 7 + ); // Stable 层内的前两个 block 不应该有 cache_control assert!( diff --git a/crates/adapter-mcp/src/bridge/tool_bridge.rs b/crates/adapter-mcp/src/bridge/tool_bridge.rs index c1f8c7c5..871b698f 100644 --- a/crates/adapter-mcp/src/bridge/tool_bridge.rs +++ b/crates/adapter-mcp/src/bridge/tool_bridge.rs @@ -263,7 +263,9 @@ mod tests { #[tokio::test] async fn test_bridge_capability_spec() { let (mock_transport, _) = create_connected_mock().await; - let client = McpClient::connect(mock_transport).await.unwrap(); + let client = McpClient::connect(mock_transport) + .await + .expect("client connect should succeed"); let bridge = McpToolBridge::new( "test-server", diff --git a/crates/adapter-mcp/src/config/approval.rs b/crates/adapter-mcp/src/config/approval.rs index c8b3a649..6bf74d6c 100644 --- a/crates/adapter-mcp/src/config/approval.rs +++ b/crates/adapter-mcp/src/config/approval.rs @@ -165,7 +165,11 @@ mod tests { &self, _project_path: &str, ) -> std::result::Result, String> { - Ok(self.approvals.lock().unwrap().clone()) + Ok(self + .approvals + .lock() + .expect("mock lock should not be poisoned") + .clone()) } fn save_approval( @@ -173,7 +177,10 @@ mod tests { _project_path: &str, data: &McpApprovalData, ) -> std::result::Result<(), String> { - let mut approvals = self.approvals.lock().unwrap(); + let mut approvals = self + .approvals + .lock() + .expect("mock lock should not be poisoned"); // 更新或新增 if let Some(existing) = approvals .iter_mut() @@ -187,7 +194,10 @@ mod tests { } fn clear_approvals(&self, _project_path: &str) -> std::result::Result<(), String> { - self.approvals.lock().unwrap().clear(); + self.approvals + .lock() + .expect("mock lock should not be poisoned") + .clear(); Ok(()) } } @@ -209,7 +219,7 @@ mod tests { manager .approve("/project", "stdio:npx:server", "user") - .unwrap(); + .expect("approve should succeed"); assert!(manager.is_approved("/project", "stdio:npx:server")); assert_eq!( @@ -223,7 +233,9 @@ mod tests { let store = MockSettingsStore::new(); let manager = McpApprovalManager::new(Box::new(store)); - manager.reject("/project", "stdio:npx:server").unwrap(); + manager + .reject("/project", "stdio:npx:server") + .expect("reject should succeed"); assert!(!manager.is_approved("/project", "stdio:npx:server")); assert_eq!( @@ -237,12 +249,16 @@ mod tests { let store = MockSettingsStore::new(); // 预先添加一个 pending 状态的服务器 - store.approvals.lock().unwrap().push(McpApprovalData { - server_signature: "stdio:npx:server-a".to_string(), - status: McpApprovalStatus::Pending, - approved_at: None, - approved_by: None, - }); + store + .approvals + .lock() + .expect("mock lock should not be poisoned") + .push(McpApprovalData { + server_signature: "stdio:npx:server-a".to_string(), + status: McpApprovalStatus::Pending, + approved_at: None, + approved_by: None, + }); let manager = McpApprovalManager::new(Box::new(store)); @@ -256,33 +272,45 @@ mod tests { let store = MockSettingsStore::new(); let manager = McpApprovalManager::new(Box::new(store)); - manager.reject("/project", "stdio:npx:server").unwrap(); + manager + .reject("/project", "stdio:npx:server") + .expect("reject should succeed"); assert!(!manager.is_approved("/project", "stdio:npx:server")); manager .approve("/project", "stdio:npx:server", "admin") - .unwrap(); + .expect("approve should succeed"); assert!(manager.is_approved("/project", "stdio:npx:server")); } #[test] fn test_approve_all() { let store = MockSettingsStore::new(); - store.approvals.lock().unwrap().push(McpApprovalData { - server_signature: "stdio:npx:server-a".to_string(), - status: McpApprovalStatus::Pending, - approved_at: None, - approved_by: None, - }); - store.approvals.lock().unwrap().push(McpApprovalData { - server_signature: "stdio:npx:server-b".to_string(), - status: McpApprovalStatus::Rejected, - approved_at: None, - approved_by: None, - }); + store + .approvals + .lock() + .expect("mock lock should not be poisoned") + .push(McpApprovalData { + server_signature: "stdio:npx:server-a".to_string(), + status: McpApprovalStatus::Pending, + approved_at: None, + approved_by: None, + }); + store + .approvals + .lock() + .expect("mock lock should not be poisoned") + .push(McpApprovalData { + server_signature: "stdio:npx:server-b".to_string(), + status: McpApprovalStatus::Rejected, + approved_at: None, + approved_by: None, + }); let manager = McpApprovalManager::new(Box::new(store)); - let count = manager.approve_all("/project", "admin").unwrap(); + let count = manager + .approve_all("/project", "admin") + .expect("approve_all should succeed"); assert_eq!(count, 2); assert!(manager.is_approved("/project", "stdio:npx:server-a")); @@ -292,18 +320,26 @@ mod tests { #[test] fn test_all_server_statuses() { let store = MockSettingsStore::new(); - store.approvals.lock().unwrap().push(McpApprovalData { - server_signature: "stdio:npx:server-a".to_string(), - status: McpApprovalStatus::Approved, - approved_at: Some("t=123".to_string()), - approved_by: Some("user".to_string()), - }); - store.approvals.lock().unwrap().push(McpApprovalData { - server_signature: "stdio:npx:server-b".to_string(), - status: McpApprovalStatus::Pending, - approved_at: None, - approved_by: None, - }); + store + .approvals + .lock() + .expect("mock lock should not be poisoned") + .push(McpApprovalData { + server_signature: "stdio:npx:server-a".to_string(), + status: McpApprovalStatus::Approved, + approved_at: Some("t=123".to_string()), + approved_by: Some("user".to_string()), + }); + store + .approvals + .lock() + .expect("mock lock should not be poisoned") + .push(McpApprovalData { + server_signature: "stdio:npx:server-b".to_string(), + status: McpApprovalStatus::Pending, + approved_at: None, + approved_by: None, + }); let manager = McpApprovalManager::new(Box::new(store)); let statuses = manager.all_server_statuses("/project"); diff --git a/crates/adapter-mcp/src/config/loader.rs b/crates/adapter-mcp/src/config/loader.rs index fa43318b..b4e3da6d 100644 --- a/crates/adapter-mcp/src/config/loader.rs +++ b/crates/adapter-mcp/src/config/loader.rs @@ -273,7 +273,8 @@ mod tests { } }"#; - let configs = McpConfigManager::load_from_json(json, McpConfigScope::Project).unwrap(); + let configs = McpConfigManager::load_from_json(json, McpConfigScope::Project) + .expect("config should parse"); assert_eq!(configs.len(), 1); assert_eq!(configs[0].name, "filesystem"); assert!(matches!( @@ -296,7 +297,8 @@ mod tests { } }"#; - let configs = McpConfigManager::load_from_json(json, McpConfigScope::Project).unwrap(); + let configs = McpConfigManager::load_from_json(json, McpConfigScope::Project) + .expect("config should parse"); assert_eq!(configs.len(), 1); assert_eq!(configs[0].name, "filesystem"); assert!(matches!( @@ -323,7 +325,7 @@ mod tests { assert!(result.is_err()); assert!( result - .unwrap_err() + .expect_err("explicit stdio without command should fail") .to_string() .contains("declared as stdio but missing command") ); @@ -341,7 +343,8 @@ mod tests { } }"#; - let configs = McpConfigManager::load_from_json(json, McpConfigScope::User).unwrap(); + let configs = McpConfigManager::load_from_json(json, McpConfigScope::User) + .expect("config should parse"); assert_eq!(configs.len(), 1); assert!(matches!( configs[0].transport, @@ -360,7 +363,8 @@ mod tests { } }"#; - let configs = McpConfigManager::load_from_json(json, McpConfigScope::Project).unwrap(); + let configs = McpConfigManager::load_from_json(json, McpConfigScope::Project) + .expect("config should parse"); assert_eq!(configs.len(), 1); assert!(!configs[0].enabled); } @@ -374,7 +378,8 @@ mod tests { } }"#; - let configs = McpConfigManager::load_from_json(json, McpConfigScope::Project).unwrap(); + let configs = McpConfigManager::load_from_json(json, McpConfigScope::Project) + .expect("config should parse"); assert_eq!(configs.len(), 1); // 第二个被去重 // HashMap 迭代顺序不确定,保留的是先遇到的那个 assert!(configs[0].name == "fs1" || configs[0].name == "fs2"); @@ -390,7 +395,12 @@ mod tests { let result = McpConfigManager::load_from_json(json, McpConfigScope::Project); assert!(result.is_err()); - assert!(result.unwrap_err().to_string().contains("neither")); + assert!( + result + .expect_err("no command/url should fail") + .to_string() + .contains("neither") + ); } #[test] @@ -405,7 +415,7 @@ mod tests { assert!(result.is_err()); assert!( result - .unwrap_err() + .expect_err("unknown transport type should fail") .to_string() .contains("unknown transport type") ); @@ -424,21 +434,21 @@ mod tests { let result = McpConfigManager::load_from_json(json, McpConfigScope::Project); assert!(result.is_err()); - let err = result.unwrap_err().to_string(); + let err = result.expect_err("missing env var should fail").to_string(); assert!(err.contains("NONEXISTENT_VAR_12345"), "Error: {}", err); } #[test] fn test_expand_env_vars_with_existing_var() { std::env::set_var("TEST_MCP_HOME", "/test/path"); - let result = expand_env_vars("${TEST_MCP_HOME}/server").unwrap(); + let result = expand_env_vars("${TEST_MCP_HOME}/server").expect("env var should resolve"); assert_eq!(result, "/test/path/server"); std::env::remove_var("TEST_MCP_HOME"); } #[test] fn test_expand_env_vars_no_vars() { - let result = expand_env_vars("plain-string").unwrap(); + let result = expand_env_vars("plain-string").expect("plain string should resolve"); assert_eq!(result, "plain-string"); } diff --git a/crates/adapter-mcp/src/manager/hot_reload.rs b/crates/adapter-mcp/src/manager/hot_reload.rs index b87137dc..c3f2fecb 100644 --- a/crates/adapter-mcp/src/manager/hot_reload.rs +++ b/crates/adapter-mcp/src/manager/hot_reload.rs @@ -158,7 +158,7 @@ mod tests { fn test_resolve_mcp_json_path() { let dir = std::env::temp_dir().join("mcp_hot_reload_test_resolve"); let _ = fs::remove_dir_all(&dir); - fs::create_dir_all(&dir).unwrap(); + fs::create_dir_all(&dir).expect("temp dir should be created"); let path = resolve_mcp_json_path(&dir); assert_eq!(path, dir.join(".mcp.json")); @@ -170,8 +170,8 @@ mod tests { fn test_resolve_existing_mcp_json() { let dir = std::env::temp_dir().join("mcp_hot_reload_test_existing"); let _ = fs::remove_dir_all(&dir); - fs::create_dir_all(&dir).unwrap(); - fs::write(dir.join(".mcp.json"), "{}").unwrap(); + fs::create_dir_all(&dir).expect("temp dir should be created"); + fs::write(dir.join(".mcp.json"), "{}").expect("write .mcp.json"); let path = resolve_mcp_json_path(&dir); assert!(path.exists()); diff --git a/crates/adapter-mcp/src/protocol/client.rs b/crates/adapter-mcp/src/protocol/client.rs index f27d7442..acc4a36c 100644 --- a/crates/adapter-mcp/src/protocol/client.rs +++ b/crates/adapter-mcp/src/protocol/client.rs @@ -408,14 +408,19 @@ mod tests { async fn setup_connected_client() -> McpClient { let (mock_transport, _) = create_connected_mock().await; - McpClient::connect(mock_transport).await.unwrap() + McpClient::connect(mock_transport) + .await + .expect("client connect should succeed") } #[tokio::test] async fn test_handshake_success() { let client = setup_connected_client().await; assert!(client.server_info().is_some()); - assert_eq!(client.server_info().unwrap().name, "test-server"); + assert_eq!( + client.server_info().expect("server_info should exist").name, + "test-server" + ); assert!(client.capabilities().is_some()); assert_eq!(client.instructions(), Some("Test server instructions")); } @@ -447,9 +452,14 @@ mod tests { // 添加 tools/list 响应到 transport // 验证 capabilities 中 tools 存在 - let caps = client.capabilities().unwrap(); + let caps = client.capabilities().expect("capabilities should exist"); assert!(caps.tools.is_some()); - assert!(caps.tools.as_ref().unwrap().list_changed); + assert!( + caps.tools + .as_ref() + .expect("tools capability should exist") + .list_changed + ); } #[tokio::test] diff --git a/crates/adapter-mcp/src/transport/http.rs b/crates/adapter-mcp/src/transport/http.rs index ae4cc679..d2cd1cff 100644 --- a/crates/adapter-mcp/src/transport/http.rs +++ b/crates/adapter-mcp/src/transport/http.rs @@ -267,15 +267,15 @@ mod tests { #[tokio::test] async fn test_start_sets_active() { let mut transport = StreamableHttpTransport::new("http://localhost:8080/mcp", Vec::new()); - transport.start().await.unwrap(); + transport.start().await.expect("start should succeed"); assert!(transport.is_active()); } #[tokio::test] async fn test_close_deactivates() { let mut transport = StreamableHttpTransport::new("http://localhost:8080/mcp", Vec::new()); - transport.start().await.unwrap(); - transport.close().await.unwrap(); + transport.start().await.expect("start should succeed"); + transport.close().await.expect("close should succeed"); assert!(!transport.is_active()); } @@ -300,7 +300,7 @@ mod tests { let event = "id:1\nevent:message\ndata:{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{\"ok\":true}}"; let response = parse_sse_event_jsonrpc(event) - .unwrap() + .expect("parsing should succeed") .expect("json-rpc response"); assert_eq!(response.id, Some(serde_json::json!(1))); } diff --git a/crates/adapter-mcp/src/transport/sse.rs b/crates/adapter-mcp/src/transport/sse.rs index e8eb4b18..44e84b56 100644 --- a/crates/adapter-mcp/src/transport/sse.rs +++ b/crates/adapter-mcp/src/transport/sse.rs @@ -180,15 +180,15 @@ mod tests { #[tokio::test] async fn test_start_sets_active() { let mut transport = SseTransport::new("http://localhost:8080/sse", Vec::new()); - transport.start().await.unwrap(); + transport.start().await.expect("start should succeed"); assert!(transport.is_active()); } #[tokio::test] async fn test_close_deactivates() { let mut transport = SseTransport::new("http://localhost:8080/sse", Vec::new()); - transport.start().await.unwrap(); - transport.close().await.unwrap(); + transport.start().await.expect("start should succeed"); + transport.close().await.expect("close should succeed"); assert!(!transport.is_active()); } diff --git a/crates/adapter-skills/src/skill_catalog.rs b/crates/adapter-skills/src/skill_catalog.rs index eeb9b74f..802cb145 100644 --- a/crates/adapter-skills/src/skill_catalog.rs +++ b/crates/adapter-skills/src/skill_catalog.rs @@ -234,7 +234,10 @@ mod tests { let normalized = catalog.base_skills(); let git_skill = normalized.iter().find(|s| s.id == "git-commit"); assert!(git_skill.is_some()); - assert_eq!(git_skill.unwrap().source, SkillSource::Plugin); + assert_eq!( + git_skill.expect("git-commit skill should exist").source, + SkillSource::Plugin + ); } #[test] diff --git a/crates/adapter-tools/src/builtin_tools/grep.rs b/crates/adapter-tools/src/builtin_tools/grep.rs index 9df74abc..88cf1853 100644 --- a/crates/adapter-tools/src/builtin_tools/grep.rs +++ b/crates/adapter-tools/src/builtin_tools/grep.rs @@ -1309,12 +1309,50 @@ mod tests { let matches: Vec = serde_json::from_str(&result.output).expect("output should be valid json"); assert_eq!(matches.len(), 1); - assert_eq!(matches[0].before.as_ref().unwrap().len(), 2); - assert_eq!(matches[0].before.as_ref().unwrap()[0], "line1"); - assert_eq!(matches[0].before.as_ref().unwrap()[1], "line2"); - assert_eq!(matches[0].after.as_ref().unwrap().len(), 2); - assert_eq!(matches[0].after.as_ref().unwrap()[0], "line4"); - assert_eq!(matches[0].after.as_ref().unwrap()[1], "line5"); + assert_eq!( + matches[0] + .before + .as_ref() + .expect("before context should exist") + .len(), + 2 + ); + assert_eq!( + matches[0] + .before + .as_ref() + .expect("before context should exist")[0], + "line1" + ); + assert_eq!( + matches[0] + .before + .as_ref() + .expect("before context should exist")[1], + "line2" + ); + assert_eq!( + matches[0] + .after + .as_ref() + .expect("after context should exist") + .len(), + 2 + ); + assert_eq!( + matches[0] + .after + .as_ref() + .expect("after context should exist")[0], + "line4" + ); + assert_eq!( + matches[0] + .after + .as_ref() + .expect("after context should exist")[1], + "line5" + ); } #[tokio::test] @@ -1445,14 +1483,14 @@ mod tests { #[test] fn extract_match_text_returns_first_capture_group() { - let re = regex::Regex::new(r"fn\s+(\w+)").unwrap(); + let re = regex::Regex::new(r"fn\s+(\w+)").expect("regex should compile"); let text = extract_match_text(&re, "pub fn greet(name: &str)"); assert_eq!(text, Some("greet".to_string())); } #[test] fn extract_match_text_returns_full_match_when_no_groups() { - let re = regex::Regex::new(r"pub fn").unwrap(); + let re = regex::Regex::new(r"pub fn").expect("regex should compile"); let text = extract_match_text(&re, "pub fn main()"); assert_eq!(text, Some("pub fn".to_string())); } diff --git a/crates/adapter-tools/src/builtin_tools/read_file.rs b/crates/adapter-tools/src/builtin_tools/read_file.rs index da90bae1..232676cb 100644 --- a/crates/adapter-tools/src/builtin_tools/read_file.rs +++ b/crates/adapter-tools/src/builtin_tools/read_file.rs @@ -784,7 +784,7 @@ mod tests { assert!(!result.ok); let error = result.error.unwrap_or_default(); assert!(error.contains("device files")); - assert!(result.metadata.unwrap()["deviceFile"] == json!(true)); + assert!(result.metadata.expect("metadata should exist")["deviceFile"] == json!(true)); } #[tokio::test] diff --git a/crates/application/src/config/api_key.rs b/crates/application/src/config/api_key.rs index f87e2864..abc66f4c 100644 --- a/crates/application/src/config/api_key.rs +++ b/crates/application/src/config/api_key.rs @@ -78,6 +78,9 @@ mod tests { #[test] fn literal_api_key_resolved() { let profile = test_profile(Some("literal:sk-123")); - assert_eq!(resolve_api_key(&profile).unwrap(), "sk-123"); + assert_eq!( + resolve_api_key(&profile).expect("literal key should resolve"), + "sk-123" + ); } } diff --git a/crates/application/src/config/env_resolver.rs b/crates/application/src/config/env_resolver.rs index c2076fd9..1ef7999f 100644 --- a/crates/application/src/config/env_resolver.rs +++ b/crates/application/src/config/env_resolver.rs @@ -94,7 +94,7 @@ mod tests { #[test] fn literal_prefix_bypasses_env() { assert_eq!( - parse_env_value("literal:my-secret-key").unwrap(), + parse_env_value("literal:my-secret-key").expect("literal should parse"), ParsedEnvValue::Literal("my-secret-key") ); } @@ -102,7 +102,7 @@ mod tests { #[test] fn env_prefix_parses() { assert_eq!( - parse_env_value("env:MY_KEY").unwrap(), + parse_env_value("env:MY_KEY").expect("env prefix should parse"), ParsedEnvValue::ExplicitEnv("MY_KEY") ); } @@ -110,7 +110,7 @@ mod tests { #[test] fn bare_uppercase_with_underscore_is_optional_env() { assert_eq!( - parse_env_value("MY_API_KEY").unwrap(), + parse_env_value("MY_API_KEY").expect("uppercase with underscore should parse"), ParsedEnvValue::OptionalEnv("MY_API_KEY") ); } @@ -118,7 +118,7 @@ mod tests { #[test] fn plain_text_is_literal() { assert_eq!( - parse_env_value("hello world").unwrap(), + parse_env_value("hello world").expect("plain text should parse"), ParsedEnvValue::Literal("hello world") ); } diff --git a/crates/application/src/config/validation.rs b/crates/application/src/config/validation.rs index c08f209b..35e94968 100644 --- a/crates/application/src/config/validation.rs +++ b/crates/application/src/config/validation.rs @@ -322,7 +322,7 @@ mod tests { version: String::new(), ..Config::default() }; - let result = normalize_config(config).unwrap(); + let result = normalize_config(config).expect("normalize should succeed"); assert_eq!(result.version, "1"); } diff --git a/crates/application/src/execution/profiles.rs b/crates/application/src/execution/profiles.rs index 3153369c..b792158b 100644 --- a/crates/application/src/execution/profiles.rs +++ b/crates/application/src/execution/profiles.rs @@ -290,9 +290,9 @@ mod tests { test_profile("plan"), ])); let service = ProfileResolutionService::new(provider.clone()); - let dir = std::env::current_dir().unwrap(); + let dir = std::env::current_dir().expect("current_dir should be available"); - let profiles = service.resolve(&dir).unwrap(); + let profiles = service.resolve(&dir).expect("resolve should succeed"); assert_eq!(profiles.len(), 2); assert_eq!(provider.load_count(), 1); } @@ -301,10 +301,10 @@ mod tests { fn resolve_hits_cache_on_second_call() { let provider = Arc::new(StubProfileProvider::new(vec![test_profile("explore")])); let service = ProfileResolutionService::new(provider.clone()); - let dir = std::env::current_dir().unwrap(); + let dir = std::env::current_dir().expect("current_dir should be available"); - let _first = service.resolve(&dir).unwrap(); - let second = service.resolve(&dir).unwrap(); + let _first = service.resolve(&dir).expect("resolve should succeed"); + let second = service.resolve(&dir).expect("resolve should succeed"); assert_eq!(second.len(), 1); assert_eq!(provider.load_count(), 1, "不应重复加载"); } @@ -316,9 +316,11 @@ mod tests { test_profile("plan"), ])); let service = ProfileResolutionService::new(provider); - let dir = std::env::current_dir().unwrap(); + let dir = std::env::current_dir().expect("current_dir should be available"); - let profile = service.find_profile(&dir, "plan").unwrap(); + let profile = service + .find_profile(&dir, "plan") + .expect("find_profile should find 'plan'"); assert_eq!(profile.id, "plan"); } @@ -326,9 +328,11 @@ mod tests { fn find_profile_returns_not_found_when_missing() { let provider = Arc::new(StubProfileProvider::new(vec![test_profile("explore")])); let service = ProfileResolutionService::new(provider); - let dir = std::env::current_dir().unwrap(); + let dir = std::env::current_dir().expect("current_dir should be available"); - let err = service.find_profile(&dir, "nonexistent").unwrap_err(); + let err = service + .find_profile(&dir, "nonexistent") + .expect_err("find_profile should fail for nonexistent"); assert!( matches!(err, ApplicationError::NotFound(ref msg) if msg.contains("nonexistent")), "should be NotFound: {err}" @@ -339,12 +343,14 @@ mod tests { fn find_profile_returns_not_found_even_with_cache_hit() { let provider = Arc::new(StubProfileProvider::new(vec![test_profile("explore")])); let service = ProfileResolutionService::new(provider); - let dir = std::env::current_dir().unwrap(); + let dir = std::env::current_dir().expect("current_dir should be available"); // 首次访问,缓存生效 - let _ = service.resolve(&dir).unwrap(); + let _ = service.resolve(&dir).expect("resolve should succeed"); // 再次查询不存在的 profile:命中缓存但返回 NotFound - let err = service.find_profile(&dir, "missing").unwrap_err(); + let err = service + .find_profile(&dir, "missing") + .expect_err("find_profile should fail for missing"); assert!( matches!(err, ApplicationError::NotFound(_)), "缓存命中不影响业务校验: {err}" @@ -355,13 +361,13 @@ mod tests { fn invalidate_clears_cache_for_path() { let provider = Arc::new(StubProfileProvider::new(vec![test_profile("explore")])); let service = ProfileResolutionService::new(provider.clone()); - let dir = std::env::current_dir().unwrap(); + let dir = std::env::current_dir().expect("current_dir should be available"); - let _ = service.resolve(&dir).unwrap(); + let _ = service.resolve(&dir).expect("resolve should succeed"); assert_eq!(provider.load_count(), 1); service.invalidate(&dir); - let _ = service.resolve(&dir).unwrap(); + let _ = service.resolve(&dir).expect("resolve should succeed"); assert_eq!(provider.load_count(), 2, "失效后应重新加载"); } @@ -369,15 +375,19 @@ mod tests { fn invalidate_all_clears_everything() { let provider = Arc::new(StubProfileProvider::new(vec![test_profile("explore")])); let service = ProfileResolutionService::new(provider.clone()); - let dir = std::env::current_dir().unwrap(); + let dir = std::env::current_dir().expect("current_dir should be available"); - let _ = service.resolve(&dir).unwrap(); - let _ = service.resolve_global().unwrap(); + let _ = service.resolve(&dir).expect("resolve should succeed"); + let _ = service + .resolve_global() + .expect("resolve_global should succeed"); service.invalidate_all(); - let _ = service.resolve(&dir).unwrap(); - let _ = service.resolve_global().unwrap(); + let _ = service.resolve(&dir).expect("resolve should succeed"); + let _ = service + .resolve_global() + .expect("resolve_global should succeed"); assert_eq!(provider.load_count(), 2, "全部失效后应重新加载"); assert_eq!(provider.global_count(), 2, "全部失效后应重新加载全局"); } @@ -387,8 +397,12 @@ mod tests { let provider = Arc::new(StubProfileProvider::new(vec![test_profile("explore")])); let service = ProfileResolutionService::new(provider.clone()); - let _first = service.resolve_global().unwrap(); - let second = service.resolve_global().unwrap(); + let _first = service + .resolve_global() + .expect("resolve_global should succeed"); + let second = service + .resolve_global() + .expect("resolve_global should succeed"); assert_eq!(second.len(), 1); assert_eq!(provider.global_count(), 1, "全局缓存应命中"); } @@ -401,7 +415,9 @@ mod tests { ])); let service = ProfileResolutionService::new(provider); - let profile = service.find_global_profile("plan").unwrap(); + let profile = service + .find_global_profile("plan") + .expect("find_global_profile should find 'plan'"); assert_eq!(profile.id, "plan"); } @@ -410,7 +426,9 @@ mod tests { let provider = Arc::new(StubProfileProvider::new(vec![])); let service = ProfileResolutionService::new(provider); - let err = service.find_global_profile("nobody").unwrap_err(); + let err = service + .find_global_profile("nobody") + .expect_err("find_global_profile should fail for 'nobody'"); assert!( matches!(err, ApplicationError::NotFound(ref msg) if msg.contains("nobody")), "should be NotFound: {err}" @@ -421,16 +439,20 @@ mod tests { fn invalidate_global_also_clears_scoped_cache() { let provider = Arc::new(MutableProfileProvider::new(vec![test_profile("explore")])); let service = ProfileResolutionService::new(provider.clone()); - let dir = std::env::current_dir().unwrap(); + let dir = std::env::current_dir().expect("current_dir should be available"); - let _ = service.resolve(&dir).unwrap(); - let _ = service.resolve_global().unwrap(); + let _ = service.resolve(&dir).expect("resolve should succeed"); + let _ = service + .resolve_global() + .expect("resolve_global should succeed"); assert_eq!(provider.load_count(), 1); assert_eq!(provider.global_count(), 1); service.invalidate_global(); - let _ = service.resolve(&dir).unwrap(); - let _ = service.resolve_global().unwrap(); + let _ = service.resolve(&dir).expect("resolve should succeed"); + let _ = service + .resolve_global() + .expect("resolve_global should succeed"); assert_eq!(provider.load_count(), 2, "全局失效也应清理 scoped cache"); assert_eq!(provider.global_count(), 2, "全局 cache 应重新加载"); } @@ -439,9 +461,9 @@ mod tests { fn invalidate_reloads_future_requests_without_mutating_existing_snapshot() { let provider = Arc::new(MutableProfileProvider::new(vec![test_profile("explore")])); let service = ProfileResolutionService::new(provider.clone()); - let dir = std::env::current_dir().unwrap(); + let dir = std::env::current_dir().expect("current_dir should be available"); - let first = service.resolve(&dir).unwrap(); + let first = service.resolve(&dir).expect("resolve should succeed"); assert_eq!(first[0].description, "test explore"); provider.replace_profiles(vec![AgentProfile { @@ -449,7 +471,7 @@ mod tests { ..test_profile("explore") }]); service.invalidate(&dir); - let second = service.resolve(&dir).unwrap(); + let second = service.resolve(&dir).expect("resolve should succeed"); assert_eq!(first[0].description, "test explore"); assert_eq!(second[0].description, "updated explore"); diff --git a/crates/core/src/registry/router.rs b/crates/core/src/registry/router.rs index 7c6091a1..bcc89215 100644 --- a/crates/core/src/registry/router.rs +++ b/crates/core/src/registry/router.rs @@ -1,6 +1,6 @@ //! # 能力路由契约 //! -//! core 仅保留能力调用相关的契约与 DTO,具体路由实现下沉到 runtime-registry。 +//! core 仅保留能力调用相关的契约与 DTO,具体路由实现下沉到 adapter 层 use std::{fmt, path::PathBuf, sync::Arc}; diff --git a/crates/core/src/tool_result_persist.rs b/crates/core/src/tool_result_persist.rs index e8e9f4eb..54a8c400 100644 --- a/crates/core/src/tool_result_persist.rs +++ b/crates/core/src/tool_result_persist.rs @@ -219,7 +219,10 @@ mod tests { let file_path = dir.path().join("tool-results/call-abc123.txt"); assert!(file_path.exists()); - assert_eq!(fs::read_to_string(&file_path).unwrap(), content); + assert_eq!( + fs::read_to_string(&file_path).expect("persisted file should be readable"), + content + ); } #[test] diff --git a/crates/kernel/src/agent_tree/tests.rs b/crates/kernel/src/agent_tree/tests.rs index 40575532..f5788de2 100644 --- a/crates/kernel/src/agent_tree/tests.rs +++ b/crates/kernel/src/agent_tree/tests.rs @@ -1709,7 +1709,7 @@ async fn wait_for_inbox_resolves_on_terminate_subtree() { result.is_some(), "wait_for_inbox should return Some after terminate" ); - let handle = result.unwrap(); + let handle = result.expect("result should be Some after terminate"); assert!( handle.lifecycle.is_final(), "handle should be in final state after terminate, got {:?}", diff --git a/crates/server/src/logging.rs b/crates/server/src/logging.rs index c6cc42f3..a8ea776f 100644 --- a/crates/server/src/logging.rs +++ b/crates/server/src/logging.rs @@ -385,20 +385,20 @@ mod tests { #[test] fn cleanup_removes_oldest_archives_over_limit() { - let dir = tempfile::tempdir().unwrap(); + let dir = tempfile::tempdir().expect("tempdir should be created"); // 创建超过上限的归档文件 for i in 0..12 { let name = format!("server-2026-04-12-{i:02}0000-000-pid12345.log"); - File::create(dir.path().join(&name)).unwrap(); + File::create(dir.path().join(&name)).expect("archive file should be created"); } - File::create(dir.path().join("server-current.log")).unwrap(); - File::create(dir.path().join("server-latest.txt")).unwrap(); - File::create(dir.path().join("other.txt")).unwrap(); + File::create(dir.path().join("server-current.log")).expect("create server-current.log"); + File::create(dir.path().join("server-latest.txt")).expect("create server-latest.txt"); + File::create(dir.path().join("other.txt")).expect("create other.txt"); cleanup_old_archives(dir.path()); let archive_count = fs::read_dir(dir.path()) - .unwrap() + .expect("read_dir should succeed") .filter(|e| { e.as_ref() .ok() @@ -412,14 +412,14 @@ mod tests { #[test] fn cleanup_ignores_current_latest_and_unrelated_files() { - let dir = tempfile::tempdir().unwrap(); + let dir = tempfile::tempdir().expect("tempdir should be created"); for i in 0..12 { let name = format!("server-2026-04-12-{i:02}0000-000-pid12345.log"); - File::create(dir.path().join(&name)).unwrap(); + File::create(dir.path().join(&name)).expect("archive file should be created"); } - File::create(dir.path().join("server-current.log")).unwrap(); - File::create(dir.path().join("server-latest.txt")).unwrap(); - File::create(dir.path().join("other.txt")).unwrap(); + File::create(dir.path().join("server-current.log")).expect("create server-current.log"); + File::create(dir.path().join("server-latest.txt")).expect("create server-latest.txt"); + File::create(dir.path().join("other.txt")).expect("create other.txt"); cleanup_old_archives(dir.path()); @@ -430,18 +430,23 @@ mod tests { #[test] fn cleanup_does_nothing_when_under_limit() { - let dir = tempfile::tempdir().unwrap(); + let dir = tempfile::tempdir().expect("tempdir should be created"); for i in 0..5 { let name = format!("server-2026-04-12-{i:02}0000-000-pid12345.log"); - File::create(dir.path().join(&name)).unwrap(); + File::create(dir.path().join(&name)).expect("archive file should be created"); } cleanup_old_archives(dir.path()); - assert_eq!(fs::read_dir(dir.path()).unwrap().count(), 5); + assert_eq!( + fs::read_dir(dir.path()) + .expect("read_dir should succeed") + .count(), + 5 + ); } #[test] fn cleanup_handles_missing_dir_gracefully() { - let dir = tempfile::tempdir().unwrap(); + let dir = tempfile::tempdir().expect("tempdir should be created"); let missing = dir.path().join("missing"); // 不应 panic cleanup_old_archives(&missing); @@ -449,7 +454,7 @@ mod tests { #[test] fn archive_file_retries_on_collision() { - let dir = tempfile::tempdir().unwrap(); + let dir = tempfile::tempdir().expect("tempdir should be created"); let pid = std::process::id(); let now = Local::now(); // 覆盖当前秒和接下来 3 秒的所有毫秒候选文件名, @@ -459,11 +464,12 @@ mod tests { let timestamp = t.format("%Y-%m-%d-%H%M%S"); for ms in 0..1000u32 { let name = format!("server-{timestamp}-{ms:03}-pid{pid}.log"); - File::create(dir.path().join(&name)).unwrap(); + File::create(dir.path().join(&name)).expect("archive file should be created"); } } // 所有首选候选都被占,create_archive_file 必须走 -N 后缀重试 - let (_file, retried_name) = create_archive_file(dir.path()).unwrap(); + let (_file, retried_name) = + create_archive_file(dir.path()).expect("archive file should be created after retry"); assert!( retried_name.contains("-1.log") || retried_name.contains("-2.log") @@ -474,13 +480,14 @@ mod tests { #[test] fn current_file_is_truncated_on_restart() { - let dir = tempfile::tempdir().unwrap(); + let dir = tempfile::tempdir().expect("tempdir should be created"); // 先写入旧内容 - fs::write(dir.path().join(CURRENT_LOG_NAME), "old content").unwrap(); + fs::write(dir.path().join(CURRENT_LOG_NAME), "old content").expect("write old content"); // 重新创建应截断 - let file = create_current_file(dir.path()).unwrap(); + let file = create_current_file(dir.path()).expect("create current file"); drop(file); - let content = fs::read_to_string(dir.path().join(CURRENT_LOG_NAME)).unwrap(); + let content = + fs::read_to_string(dir.path().join(CURRENT_LOG_NAME)).expect("read current file"); assert!(content.is_empty()); } } diff --git a/crates/server/src/tests/session_contract_tests.rs b/crates/server/src/tests/session_contract_tests.rs index 1e63ed94..85f300ca 100644 --- a/crates/server/src/tests/session_contract_tests.rs +++ b/crates/server/src/tests/session_contract_tests.rs @@ -145,10 +145,19 @@ async fn subrun_status_contract_returns_default_for_missing_subrun() { assert_eq!(payload["subRunId"], "missing-subrun"); // lifecycle 和 source 序列化为小写枚举值 assert_eq!( - payload["lifecycle"].as_str().unwrap().to_lowercase(), + payload["lifecycle"] + .as_str() + .expect("lifecycle should be a string") + .to_lowercase(), "idle" ); - assert_eq!(payload["source"].as_str().unwrap().to_lowercase(), "live"); + assert_eq!( + payload["source"] + .as_str() + .expect("source should be a string") + .to_lowercase(), + "live" + ); } #[tokio::test] From c88b320349323e502529bcd453e4be6b0a60dcaa Mon Sep 17 00:00:00 2001 From: whatevertogo Date: Fri, 17 Apr 2026 01:27:50 +0800 Subject: [PATCH 15/53] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor(mod):=20?= =?UTF-8?q?=E9=87=8D=E5=BB=BA=20CLI=20palette=20TUI=20=E5=B9=B6=E6=8B=86?= =?UTF-8?q?=E5=88=86=20anthropic=20provider?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AGENTS.md - Why: 补充本仓库 TUI 风格与 ratatui 文本包装约定,避免后续终端界面继续走散乱实现。 - How: 新增 TUI style/code conventions,并引入 `codex-rs/tui/styles.md` 作为约束来源。 CODE_REVIEW_ISSUES.md - Why: 同步本轮 CLI 工作树评审结论,记录仍需关注的滚动逻辑与魔法数问题。 - How: 用新的 review 结果替换旧报告,更新质量、测试与架构章节。 crates/adapter-llm/src/anthropic.rs - Why: 旧 Anthropic provider 单文件过大,协议构建、请求发送、响应解析和 SSE 流式逻辑耦合过深,维护成本过高。 - How: 删除单体实现文件,将 DTO、provider、request、response 与 stream 逻辑迁移到独立子模块。 crates/adapter-llm/src/anthropic/dto.rs - Why: 需要把 Anthropic 协议数据结构从 provider 行为中拆出来,稳定消息与 usage 模型边界。 - How: 抽取 request/response 共享 DTO、usage 合并逻辑和序列化结构。 crates/adapter-llm/src/anthropic/mod.rs - Why: Anthropic 模块拆分后需要显式收口导出面,避免调用方继续依赖内部实现细节。 - How: 新增模块入口,声明并重导出 dto/provider/request/response/stream 子模块。 crates/adapter-llm/src/anthropic/provider.rs - Why: Provider 需要只保留构建请求、发送请求与 orchestrate 流程的职责,降低单函数复杂度。 - How: 抽离 `AnthropicProvider` 主体实现、重试发送逻辑、缓存跟踪和 generate 主流程。 crates/adapter-llm/src/anthropic/request.rs - Why: 请求构建涉及 system prompt、tool、thinking 和 cache breakpoint 规则,适合独立成可测试的构造层。 - How: 提取 request 构建、消息转换、工具定义转换与缓存策略相关辅助函数。 crates/adapter-llm/src/anthropic/response.rs - Why: 非流式响应解析与输出装配不应继续散落在 provider 主流程里。 - How: 抽出 response 到 `LlmOutput` 的转换、finish reason 与 usage 映射逻辑。 crates/adapter-llm/src/anthropic/stream.rs - Why: Anthropic SSE 解析分支最多、最容易演化,单独模块化后更易验证增量事件和错误映射。 - How: 提取 SSE block 解析、stream error 分类、usage 累加与 stream 相关测试。 crates/application/src/terminal/mod.rs - Why: 旧的“插入技能”中间动作会把 slash 流程人为分叉,不符合新的统一 palette 语义。 - How: 删除 `OpenSkillPalette` 动作及其摘要映射,让技能直接作为真实候选项出现。 crates/application/src/terminal_use_cases.rs - Why: slash 候选源需要与新的底部 palette 保持一致,去掉多余的 skill 面板入口。 - How: 移除内建 “插入技能” 候选,并补充测试断言 `skill` 不再作为中间候选出现。 crates/cli/src/app/coordinator.rs - Why: slash、resume 与输入框此前是多套状态源,导致 palette 与 composer 脱节。 - How: 将 overlay 流程改为 palette 流程,同步 composer 输入、resume 查询与 slash 查询刷新逻辑。 crates/cli/src/app/mod.rs - Why: 主事件循环仍围绕旧 overlay/child pane 设计,无法支撑 Claude 风格的 transcript + palette + footer 交互。 - How: 改为 palette 焦点模型,接管键盘事件、Ctrl+O thinking 展开、输入与 palette 联动、以及新的 transcript enter 语义。 crates/cli/src/command/mod.rs - Why: 命令层命名仍然绑定旧 overlay 语义,不利于新的 palette 状态模型。 - How: 将 `OverlayAction`/`overlay_action` 重命名为 `PaletteAction`/`palette_action`,并适配新的选择类型。 crates/cli/src/render/mod.rs - Why: 旧渲染器仍保留多面板/overlay 布局,不符合目标中的单屏 transcript + 底部 dock 架构。 - How: 重写布局为 transcript、贴边 palette 和四行 footer dock,并接入 transcript 选中滚动与 palette 可见性判断。 crates/cli/src/state/conversation.rs - Why: transcript 语义变化后,会话状态需要暴露更适合轻量文本渲染的派生数据。 - How: 调整会话视图与 transcript cell 更新路径,配合新的极简渲染和选中逻辑。 crates/cli/src/state/interaction.rs - Why: 旧交互层包含 overlay/nav/child pane 等过度泛化状态,已经与当前 TUI 目标不匹配。 - How: 收缩为 `Transcript / Composer / Palette` 三焦点模型,并重建 palette 选择、清理、循环和导航逻辑。 crates/cli/src/state/mod.rs - Why: CLI 根状态需要承接新的 palette、thinking 播放和 footer 驱动逻辑,替换旧 overlay 状态容器。 - How: 引入 `PaletteState`、新的 selection 类型、thinking 驱动接线以及配套的状态操作方法。 crates/cli/src/state/thinking.rs - Why: thinking 默认展示需要与后端原始块解耦,支持脚本化播放而不是直接回显原文。 - How: 新增本地片段池、稳定伪随机播放状态与 presentation 逻辑,供 transcript 渲染层调用。 crates/cli/src/state/transcript_cell.rs - Why: transcript cell 需要为轻量文本渲染提供更清晰的消息种类与展开状态支撑。 - How: 调整 cell 结构和 kind 派生字段,适配 thinking/tool/assistant/user 的新渲染协议。 crates/cli/src/ui/bottom_pane.rs - Why: 旧 bottom pane 模块对应的多区块布局已经退出主渲染路径,继续保留只会制造维护噪音。 - How: 删除废弃 bottom pane UI 实现。 crates/cli/src/ui/cells.rs - Why: 旧消息卡片和标签式渲染太厚重,和 Claude Code 风格的纯文本语法相冲突。 - How: 重写 transcript cell 渲染,将用户、助手、thinking、tool 和错误统一压成更轻的单列文本样式。 crates/cli/src/ui/footer.rs - Why: 需要独立的底部 dock 组件承接输入行、分隔线和状态行,而不是沿用旧 composer 面板。 - How: 新增 footer 渲染,输出 placeholder、输入内容与状态提示的四行 dock 文本。 crates/cli/src/ui/mod.rs - Why: UI 模块出口仍然暴露旧 overlay/bottom pane 思路,不利于围绕新语义收口。 - How: 改为导出 transcript/footer/palette/theme/cells 所需接口,并清理旧模块接线。 crates/cli/src/ui/overlay.rs - Why: 通用 overlay 方案已经被底部 palette 替代,继续保留会误导后续维护方向。 - How: 删除废弃 overlay UI 实现。 crates/cli/src/ui/palette.rs - Why: slash 和 resume 需要共享同一种无边框、单行候选的底部列表组件。 - How: 新增 palette 渲染、可见窗口滚动和单行截断逻辑,统一展示 commands、skills 与会话候选。 crates/cli/src/ui/theme.rs - Why: 新界面需要更接近 Claude Code 的极简深色主题和更窄的文本语义样式集。 - How: 重定义主题色板、背景/文本/强调色以及 `WrappedLineStyle` 到 `Style` 的映射,移除旧的垂直分隔语义。 crates/cli/src/ui/transcript.rs - Why: transcript 需要成为真正的主视图,承接空态、banner、thinking 播放与选中行范围计算。 - How: 新增 transcript 渲染输出结构,集中生成空态文案、banner 文本和 selected line range。 --- AGENTS.md | 32 + CODE_REVIEW_ISSUES.md | 50 +- crates/adapter-llm/src/anthropic.rs | 2397 ------------------ crates/adapter-llm/src/anthropic/dto.rs | 310 +++ crates/adapter-llm/src/anthropic/mod.rs | 32 + crates/adapter-llm/src/anthropic/provider.rs | 491 ++++ crates/adapter-llm/src/anthropic/request.rs | 812 ++++++ crates/adapter-llm/src/anthropic/response.rs | 226 ++ crates/adapter-llm/src/anthropic/stream.rs | 608 +++++ crates/application/src/terminal/mod.rs | 5 - crates/application/src/terminal_use_cases.rs | 15 +- crates/cli/src/app/coordinator.rs | 97 +- crates/cli/src/app/mod.rs | 143 +- crates/cli/src/command/mod.rs | 14 +- crates/cli/src/render/mod.rs | 338 ++- crates/cli/src/state/conversation.rs | 55 +- crates/cli/src/state/interaction.rs | 287 +-- crates/cli/src/state/mod.rs | 177 +- crates/cli/src/state/thinking.rs | 182 ++ crates/cli/src/state/transcript_cell.rs | 44 +- crates/cli/src/ui/bottom_pane.rs | 231 -- crates/cli/src/ui/cells.rs | 551 ++-- crates/cli/src/ui/footer.rs | 106 + crates/cli/src/ui/mod.rs | 235 +- crates/cli/src/ui/overlay.rs | 137 - crates/cli/src/ui/palette.rs | 175 ++ crates/cli/src/ui/theme.rs | 145 +- crates/cli/src/ui/transcript.rs | 84 + 28 files changed, 4097 insertions(+), 3882 deletions(-) delete mode 100644 crates/adapter-llm/src/anthropic.rs create mode 100644 crates/adapter-llm/src/anthropic/dto.rs create mode 100644 crates/adapter-llm/src/anthropic/mod.rs create mode 100644 crates/adapter-llm/src/anthropic/provider.rs create mode 100644 crates/adapter-llm/src/anthropic/request.rs create mode 100644 crates/adapter-llm/src/anthropic/response.rs create mode 100644 crates/adapter-llm/src/anthropic/stream.rs create mode 100644 crates/cli/src/state/thinking.rs delete mode 100644 crates/cli/src/ui/bottom_pane.rs create mode 100644 crates/cli/src/ui/footer.rs delete mode 100644 crates/cli/src/ui/overlay.rs create mode 100644 crates/cli/src/ui/palette.rs create mode 100644 crates/cli/src/ui/transcript.rs diff --git a/AGENTS.md b/AGENTS.md index c1deec56..66bf5cb8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -70,3 +70,35 @@ node scripts/check-crate-boundaries.mjs --strict # 严格模式 - 使用 `node scripts/check-crate-boundaries.mjs` 验证 crate 依赖规则没有被违反 - `src-tauri` 是 Tauri 薄壳,不含业务逻辑 - `server` 组合根在 `crates/server/src/bootstrap/runtime.rs` + +## TUI style conventions + +See `codex-rs/tui/styles.md`. + +## TUI code conventions + +- Use concise styling helpers from ratatui’s Stylize trait. + - Basic spans: use "text".into() + - Styled spans: use "text".red(), "text".green(), "text".magenta(), "text".dim(), etc. + - Prefer these over constructing styles with `Span::styled` and `Style` directly. + - Example: patch summary file lines + - Desired: vec![" └ ".into(), "M".red(), " ".dim(), "tui/src/app.rs".dim()] + +### TUI Styling (ratatui) + +- Prefer Stylize helpers: use "text".dim(), .bold(), .cyan(), .italic(), .underlined() instead of manual Style where possible. +- Prefer simple conversions: use "text".into() for spans and vec![…].into() for lines; when inference is ambiguous (e.g., Paragraph::new/Cell::from), use Line::from(spans) or Span::from(text). +- Computed styles: if the Style is computed at runtime, using `Span::styled` is OK (`Span::from(text).set_style(style)` is also acceptable). +- Avoid hardcoded white: do not use `.white()`; prefer the default foreground (no color). +- Chaining: combine helpers by chaining for readability (e.g., url.cyan().underlined()). +- Single items: prefer "text".into(); use Line::from(text) or Span::from(text) only when the target type isn’t obvious from context, or when using .into() would require extra type annotations. +- Building lines: use vec![…].into() to construct a Line when the target type is obvious and no extra type annotations are needed; otherwise use Line::from(vec![…]). +- Avoid churn: don’t refactor between equivalent forms (Span::styled ↔ set_style, Line::from ↔ .into()) without a clear readability or functional gain; follow file‑local conventions and do not introduce type annotations solely to satisfy .into(). +- Compactness: prefer the form that stays on one line after rustfmt; if only one of Line::from(vec![…]) or vec![…].into() avoids wrapping, choose that. If both wrap, pick the one with fewer wrapped lines. + +### Text wrapping + +- Always use textwrap::wrap to wrap plain strings. +- If you have a ratatui Line and you want to wrap it, use the helpers in tui/src/wrapping.rs, e.g. word_wrap_lines / word_wrap_line. +- If you need to indent wrapped lines, use the initial_indent / subsequent_indent options from RtOptions if you can, rather than writing custom logic. +- If you have a list of lines and you need to prefix them all with some prefix (optionally different on the first vs subsequent lines), use the `prefix_lines` helper from line_utils. \ No newline at end of file diff --git a/CODE_REVIEW_ISSUES.md b/CODE_REVIEW_ISSUES.md index 956b5ce4..741c27c1 100644 --- a/CODE_REVIEW_ISSUES.md +++ b/CODE_REVIEW_ISSUES.md @@ -1,40 +1,52 @@ -# Code Review — staged changes +# Code Review — dev (working tree) ## Summary -Files reviewed: 98 | New issues: 0 | Perspectives: 4/4 +Files reviewed: 4 | New issues: 1 (0 critical, 0 high, 1 medium, 0 low) | Perspectives: 4/4 --- -## 🔒 Security -No security issues found. +## Security + +No security issues found. All changes are TUI rendering/layout logic with no external input sinks. --- -## 📝 Code Quality -No code quality issues found. +## Code Quality + +| Sev | Issue | File:Line | Consequence | +|-----|-------|-----------|-------------| +| Medium | `nav_visible_for_width` uses magic number `96` without named constant | state/mod.rs:316 | Threshold meaning is opaque; other layout thresholds in the same file may diverge silently | + +No other quality issues. The scroll-offset logic is correct: `saturating_add`/`saturating_sub` prevent underflow, `.min(max_scroll)` caps the result. The `selected_line_range` 0-based inclusive indexing is consistent between producer (`transcript.rs`) and consumer (`render/mod.rs`). --- -## ✅ Tests -Run results: +## Tests -- `cargo check --workspace` passed -- `cd frontend && npm run typecheck` passed -- `cargo test -p astrcode-core --lib` passed -- `cargo test -p astrcode-application --lib` passed -- `cargo test -p astrcode-session-runtime --lib` passed -- `cargo test -p astrcode-server --tests` passed -- `cargo test -p astrcode-protocol` passed +**Run results**: 19 passed, 0 failed, 0 skipped (all 3 test suites in `astrcode-cli`) -No missing high-confidence test coverage found for the staged diff. +| Sev | Untested scenario | Location | +|-----|-------------------|----------| +| Medium | `transcript_scroll_offset` "scroll down" branch — when selected range is below viewport (`selected_end >= top_offset + viewport_height`) | render/mod.rs:181-184 | +| Medium | `transcript_scroll_offset` with `selection_drives_scroll = false` — should not adjust offset | render/mod.rs:175 | +| Medium | `transcript_scroll_offset` with `selected_line_range = None` — should behave like original | render/mod.rs:176 | + +The two existing scroll-offset tests both exercise only the "scroll up" branch (`selected_start < top_offset`). The "scroll down" branch (`selected_end >= top_offset + viewport_height`) and the no-op paths are untested. --- -## 🏗️ Architecture -No architecture issues found. +## Architecture + +No cross-layer inconsistencies. The `TranscriptRenderOutput` struct cleanly extends the existing return type without breaking callers. The `nav_visible` propagation from `CliState` through `InteractionState` follows the existing dependency direction. --- -## 🚨 Must Fix Before Merge +## Must Fix Before Merge None. + +--- + +## Pre-Existing Issues (not blocking) + +- `CODE_REVIEW_ISSUES.md` file exists at repo root — consider `.gitignore`ing it if it's local-only diff --git a/crates/adapter-llm/src/anthropic.rs b/crates/adapter-llm/src/anthropic.rs deleted file mode 100644 index 8d10c300..00000000 --- a/crates/adapter-llm/src/anthropic.rs +++ /dev/null @@ -1,2397 +0,0 @@ -//! # Anthropic Messages API 提供者 -//! -//! 实现了 [`LlmProvider`] trait,对接 Anthropic Claude 系列模型。 -//! -//! ## 协议特性 -//! -//! - **Extended Thinking**: 自动为 Claude 模型启用深度推理模式(`thinking` 配置), 预算 token 设为 -//! `max_tokens` 的 75%,保留至少 25% 给实际输出 -//! - **Prompt Caching**: 优先对分层 system blocks 放置 `ephemeral` breakpoint,并在消息尾部保留 -//! 一个缓存边界,复用 KV cache -//! - **SSE 流式解析**: Anthropic 使用多行 SSE 块格式(`event: ...\ndata: {...}\n\n`), 与 OpenAI -//! 的单行 `data: {...}` 不同,因此有独立的解析逻辑 -//! - **内容块模型**: Anthropic 响应由多种内容块组成(text / tool_use / thinking), 使用 -//! `Vec` 灵活处理未知或新增的块类型 -//! -//! ## 流式事件分派 -//! -//! Anthropic SSE 事件类型: -//! - `content_block_start`: 新内容块开始(文本或工具调用) -//! - `content_block_delta`: 增量内容(text_delta / thinking_delta / signature_delta / -//! input_json_delta) -//! - `message_stop`: 流结束信号 -//! - `message_start / message_delta`: 提取 usage / stop_reason 等元数据 -//! - `content_block_stop / ping`: 元数据事件,静默忽略 - -use std::{ - fmt, - sync::{Arc, Mutex}, -}; - -use astrcode_core::{ - AstrError, CancelToken, LlmMessage, ReasoningContent, Result, SystemPromptBlock, - SystemPromptLayer, ToolCallRequest, ToolDefinition, -}; -use async_trait::async_trait; -use futures_util::StreamExt; -use log::{debug, warn}; -use serde::Serialize; -use serde_json::{Value, json}; -use tokio::select; - -use crate::{ - EventSink, FinishReason, LlmAccumulator, LlmClientConfig, LlmEvent, LlmOutput, LlmProvider, - LlmRequest, LlmUsage, ModelLimits, Utf8StreamDecoder, build_http_client, - cache_tracker::CacheTracker, classify_http_error, emit_event, is_retryable_status, - wait_retry_delay, -}; -const ANTHROPIC_VERSION: &str = "2023-06-01"; -const ANTHROPIC_CACHE_BREAKPOINT_LIMIT: usize = 4; - -fn summarize_request_for_diagnostics(request: &AnthropicRequest) -> Value { - let messages = request - .messages - .iter() - .map(|message| { - let block_types = message - .content - .iter() - .map(AnthropicContentBlock::block_type) - .collect::>(); - json!({ - "role": message.role, - "blockTypes": block_types, - "blockCount": message.content.len(), - "cacheControlCount": message - .content - .iter() - .filter(|block| block.has_cache_control()) - .count(), - }) - }) - .collect::>(); - let system = match &request.system { - None => Value::Null, - Some(AnthropicSystemPrompt::Text(text)) => json!({ - "kind": "text", - "chars": text.chars().count(), - }), - Some(AnthropicSystemPrompt::Blocks(blocks)) => json!({ - "kind": "blocks", - "count": blocks.len(), - "cacheControlCount": blocks - .iter() - .filter(|block| block.cache_control.is_some()) - .count(), - "chars": blocks.iter().map(|block| block.text.chars().count()).sum::(), - }), - }; - let tools = request.tools.as_ref().map(|tools| { - json!({ - "count": tools.len(), - "names": tools.iter().map(|tool| tool.name.clone()).collect::>(), - "cacheControlCount": tools - .iter() - .filter(|tool| tool.cache_control.is_some()) - .count(), - }) - }); - - json!({ - "model": request.model, - "maxTokens": request.max_tokens, - "topLevelCacheControl": request.cache_control.is_some(), - "hasThinking": request.thinking.is_some(), - "stream": request.stream.unwrap_or(false), - "system": system, - "messages": messages, - "tools": tools, - }) -} - -/// Anthropic Claude API 提供者实现。 -/// -/// 封装了 HTTP 客户端、API 密钥和模型配置,提供统一的 [`LlmProvider`] 接口。 -/// -/// ## 设计要点 -/// -/// - HTTP 客户端在构造时创建,使用共享的超时策略(连接 10s / 读取 90s) -/// - `limits.max_output_tokens` 同时控制请求体的上限和 extended thinking 的预算计算 -/// - Debug 实现会隐藏 API 密钥(显示为 ``) -#[derive(Clone)] -pub struct AnthropicProvider { - client: reqwest::Client, - client_config: LlmClientConfig, - messages_api_url: String, - api_key: String, - model: String, - /// 运行时已解析好的模型 limits。 - /// - /// Anthropic 的上下文窗口来自 Models API,不应该继续在 provider 内写死。 - limits: ModelLimits, - /// 缓存失效检测跟踪器 - cache_tracker: Arc>, -} - -impl fmt::Debug for AnthropicProvider { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_struct("AnthropicProvider") - .field("client", &self.client) - .field("messages_api_url", &self.messages_api_url) - .field("api_key", &"") - .field("model", &self.model) - .field("limits", &self.limits) - .field("client_config", &self.client_config) - .field("cache_tracker", &"") - .finish() - } -} - -impl AnthropicProvider { - /// 创建新的 Anthropic 提供者实例。 - /// - /// `limits.max_output_tokens` 同时用于: - /// 1. 请求体中的 `max_tokens` 字段(输出上限) - /// 2. Extended thinking 预算计算(75% 的 max_tokens) - pub fn new( - messages_api_url: String, - api_key: String, - model: String, - limits: ModelLimits, - client_config: LlmClientConfig, - ) -> Result { - Ok(Self { - client: build_http_client(client_config)?, - client_config, - messages_api_url, - api_key, - model, - limits, - cache_tracker: Arc::new(Mutex::new(CacheTracker::new())), - }) - } - - /// 构建 Anthropic Messages API 请求体。 - /// - /// - 将 `LlmMessage` 转换为 Anthropic 格式的内容块数组 - /// - 对分层 system blocks 和消息尾部启用 prompt caching(KV cache 复用) - /// - 如果启用了工具,附加工具定义 - /// - 根据模型名称和 max_tokens 自动配置 extended thinking - fn build_request( - &self, - messages: &[LlmMessage], - tools: &[ToolDefinition], - system_prompt: Option<&str>, - system_prompt_blocks: &[SystemPromptBlock], - stream: bool, - ) -> AnthropicRequest { - let use_official_endpoint = is_official_anthropic_api_url(&self.messages_api_url); - let use_automatic_cache = use_official_endpoint; - let mut remaining_cache_breakpoints = ANTHROPIC_CACHE_BREAKPOINT_LIMIT; - let request_cache_control = if use_automatic_cache { - remaining_cache_breakpoints = remaining_cache_breakpoints.saturating_sub(1); - Some(AnthropicCacheControl::ephemeral()) - } else { - None - }; - - let mut anthropic_messages = to_anthropic_messages( - messages, - MessageBuildOptions { - include_reasoning_blocks: use_official_endpoint, - }, - ); - let tools = if tools.is_empty() { - None - } else { - Some(to_anthropic_tools(tools, &mut remaining_cache_breakpoints)) - }; - let system = to_anthropic_system( - system_prompt, - system_prompt_blocks, - &mut remaining_cache_breakpoints, - ); - - if !use_automatic_cache { - enable_message_caching(&mut anthropic_messages, remaining_cache_breakpoints); - } - - AnthropicRequest { - model: self.model.clone(), - max_tokens: self.limits.max_output_tokens.min(u32::MAX as usize) as u32, - cache_control: request_cache_control, - messages: anthropic_messages, - system, - tools, - stream: stream.then_some(true), - // Why: 第三方 Anthropic 兼容网关常见只支持基础 messages 子集; - // 在非官方 endpoint 下关闭 `thinking` 字段,避免触发参数校验失败。 - thinking: if use_official_endpoint { - thinking_config_for_model( - &self.model, - self.limits.max_output_tokens.min(u32::MAX as usize) as u32, - ) - } else { - None - }, - } - } - - async fn send_request( - &self, - request: &AnthropicRequest, - cancel: CancelToken, - ) -> Result { - // 调试日志:打印请求信息(不暴露完整 API Key) - let api_key_preview = if self.api_key.len() > 8 { - format!( - "{}...{}", - &self.api_key[..4], - &self.api_key[self.api_key.len() - 4..] - ) - } else { - "****".to_string() - }; - debug!( - "Anthropic request: url={}, api_key_preview={}, model={}", - self.messages_api_url, api_key_preview, self.model - ); - if !is_official_anthropic_api_url(&self.messages_api_url) { - debug!( - "Anthropic-compatible request summary: {}", - summarize_request_for_diagnostics(request) - ); - } - - for attempt in 0..=self.client_config.max_retries { - let send_future = self - .client - .post(&self.messages_api_url) - .header("x-api-key", &self.api_key) - .header("anthropic-version", ANTHROPIC_VERSION) - .header(reqwest::header::CONTENT_TYPE, "application/json") - .json(request) - .send(); - - let response = select! { - _ = crate::cancelled(cancel.clone()) => { - return Err(AstrError::LlmInterrupted); - } - result = send_future => result.map_err(|e| AstrError::http("failed to call anthropic endpoint", e)) - }; - - match response { - Ok(response) => { - let status = response.status(); - if status == reqwest::StatusCode::UNAUTHORIZED { - // 读取响应体以便调试 - let body = response.text().await.unwrap_or_default(); - warn!( - "Anthropic 401 Unauthorized: url={}, api_key_preview={}, response={}", - self.messages_api_url, - if self.api_key.len() > 8 { - format!( - "{}...{}", - &self.api_key[..4], - &self.api_key[self.api_key.len() - 4..] - ) - } else { - "****".to_string() - }, - body - ); - return Err(AstrError::InvalidApiKey("Anthropic".to_string())); - } - if status.is_success() { - return Ok(response); - } - - let body = response.text().await.unwrap_or_default(); - if is_retryable_status(status) && attempt < self.client_config.max_retries { - wait_retry_delay( - attempt, - cancel.clone(), - self.client_config.retry_base_delay, - ) - .await?; - continue; - } - - if status.is_client_error() - && !is_official_anthropic_api_url(&self.messages_api_url) - { - warn!( - "Anthropic-compatible request rejected: url={}, status={}, \ - request_summary={}, response={}", - self.messages_api_url, - status.as_u16(), - summarize_request_for_diagnostics(request), - body - ); - } - - // 使用结构化错误分类 (P4.3) - return Err(classify_http_error(status.as_u16(), &body).into()); - }, - Err(error) => { - if error.is_retryable() && attempt < self.client_config.max_retries { - wait_retry_delay( - attempt, - cancel.clone(), - self.client_config.retry_base_delay, - ) - .await?; - continue; - } - return Err(error); - }, - } - } - - // 所有路径都会通过 return 退出循环;若到达此处说明逻辑有误, - // 返回 Internal 而非 panic 以保证运行时安全 - Err(AstrError::Internal( - "retry loop should have returned on all paths".into(), - )) - } -} - -#[async_trait] -impl LlmProvider for AnthropicProvider { - fn supports_cache_metrics(&self) -> bool { - true - } - - fn prompt_metrics_input_tokens(&self, usage: LlmUsage) -> usize { - usage - .input_tokens - .saturating_add(usage.cache_read_input_tokens) - } - - async fn generate(&self, request: LlmRequest, sink: Option) -> Result { - let cancel = request.cancel; - - // 检测缓存失效并记录原因 - let system_prompt_text = request.system_prompt.as_deref().unwrap_or(""); - let tool_names: Vec = request.tools.iter().map(|t| t.name.clone()).collect(); - - if let Ok(mut tracker) = self.cache_tracker.lock() { - let break_reasons = - tracker.check_and_update(system_prompt_text, &tool_names, &self.model, "anthropic"); - - if !break_reasons.is_empty() { - debug!("[CACHE] Cache break detected: {:?}", break_reasons); - } - } - - let body = self.build_request( - &request.messages, - &request.tools, - request.system_prompt.as_deref(), - &request.system_prompt_blocks, - sink.is_some(), - ); - let response = self.send_request(&body, cancel.clone()).await?; - - match sink { - None => { - let payload: AnthropicResponse = response - .json() - .await - .map_err(|e| AstrError::http("failed to parse anthropic response", e))?; - Ok(response_to_output(payload)) - }, - Some(sink) => { - let mut stream = response.bytes_stream(); - let mut sse_buffer = String::new(); - let mut utf8_decoder = Utf8StreamDecoder::default(); - let mut accumulator = LlmAccumulator::default(); - // 流式路径下从 message_delta 的 stop_reason 提取 (P4.2) - let mut stream_stop_reason: Option = None; - let mut stream_usage = AnthropicUsage::default(); - - loop { - let next_item = select! { - _ = crate::cancelled(cancel.clone()) => { - return Err(AstrError::LlmInterrupted); - } - item = stream.next() => item, - }; - - let Some(item) = next_item else { - break; - }; - - let bytes = item.map_err(|e| { - AstrError::http("failed to read anthropic response stream", e) - })?; - let Some(chunk_text) = utf8_decoder - .push(&bytes, "anthropic response stream was not valid utf-8")? - else { - continue; - }; - - if consume_sse_text_chunk( - &chunk_text, - &mut sse_buffer, - &mut accumulator, - &sink, - &mut stream_stop_reason, - &mut stream_usage, - )? { - let mut output = accumulator.finish(); - // 优先使用 API 返回的 stop_reason,否则使用推断值 - if let Some(reason) = stream_stop_reason.as_deref() { - output.finish_reason = FinishReason::from_api_value(reason); - } - output.usage = stream_usage.into_llm_usage(); - - // 记录流式响应的缓存状态 - if let Some(ref u) = output.usage { - let input = u.input_tokens; - let cache_read = u.cache_read_input_tokens; - let cache_creation = u.cache_creation_input_tokens; - let total_prompt_tokens = input.saturating_add(cache_read); - - if cache_read == 0 && cache_creation > 0 { - debug!( - "Cache miss (streaming): writing {} tokens to cache (total \ - prompt: {}, uncached input: {})", - cache_creation, total_prompt_tokens, input - ); - } else if cache_read > 0 { - let hit_rate = - (cache_read as f32 / total_prompt_tokens as f32) * 100.0; - debug!( - "Cache hit (streaming): {:.1}% ({} / {} prompt tokens, \ - creation: {}, uncached input: {})", - hit_rate, - cache_read, - total_prompt_tokens, - cache_creation, - input - ); - } else { - debug!( - "Cache disabled or unavailable (streaming, total prompt: {} \ - tokens)", - total_prompt_tokens - ); - } - } - - return Ok(output); - } - } - - if let Some(tail_text) = - utf8_decoder.finish("anthropic response stream was not valid utf-8")? - { - let done = consume_sse_text_chunk( - &tail_text, - &mut sse_buffer, - &mut accumulator, - &sink, - &mut stream_stop_reason, - &mut stream_usage, - )?; - if done { - let mut output = accumulator.finish(); - if let Some(reason) = stream_stop_reason.as_deref() { - output.finish_reason = FinishReason::from_api_value(reason); - } - output.usage = stream_usage.into_llm_usage(); - return Ok(output); - } - } - - flush_sse_buffer( - &mut sse_buffer, - &mut accumulator, - &sink, - &mut stream_stop_reason, - &mut stream_usage, - )?; - let mut output = accumulator.finish(); - if let Some(reason) = stream_stop_reason.as_deref() { - output.finish_reason = FinishReason::from_api_value(reason); - } - output.usage = stream_usage.into_llm_usage(); - Ok(output) - }, - } - } - - fn model_limits(&self) -> ModelLimits { - self.limits - } -} - -/// 将 `LlmMessage` 转换为 Anthropic 格式的消息结构。 -/// -/// Anthropic 使用内容块数组(而非纯文本),因此需要按消息类型分派: -/// - User 消息 → 单个 `text` 内容块 -/// - Assistant 消息 → 可能包含 `thinking`、`text`、`tool_use` 多个块 -/// - Tool 消息 → 单个 `tool_result` 内容块 -#[derive(Clone, Copy)] -struct MessageBuildOptions { - include_reasoning_blocks: bool, -} - -fn to_anthropic_messages( - messages: &[LlmMessage], - options: MessageBuildOptions, -) -> Vec { - let mut anthropic_messages = Vec::with_capacity(messages.len()); - let mut pending_user_blocks = Vec::new(); - - let flush_pending_user_blocks = - |anthropic_messages: &mut Vec, - pending_user_blocks: &mut Vec| { - if pending_user_blocks.is_empty() { - return; - } - - anthropic_messages.push(AnthropicMessage { - role: "user".to_string(), - content: std::mem::take(pending_user_blocks), - }); - }; - - for message in messages { - match message { - LlmMessage::User { content, .. } => { - pending_user_blocks.push(AnthropicContentBlock::Text { - text: content.clone(), - cache_control: None, - }); - }, - LlmMessage::Assistant { - content, - tool_calls, - reasoning, - } => { - flush_pending_user_blocks(&mut anthropic_messages, &mut pending_user_blocks); - - let mut blocks = Vec::new(); - if options.include_reasoning_blocks { - if let Some(reasoning) = reasoning { - blocks.push(AnthropicContentBlock::Thinking { - thinking: reasoning.content.clone(), - signature: reasoning.signature.clone(), - cache_control: None, - }); - } - } - // Anthropic assistant 消息可以直接包含 tool_use 块,不要求前置 text 块。 - // 仅在确实有文本时写入 text 块,避免向兼容网关发送空 text 导致参数校验失败。 - if !content.is_empty() { - blocks.push(AnthropicContentBlock::Text { - text: content.clone(), - cache_control: None, - }); - } - blocks.extend( - tool_calls - .iter() - .map(|call| AnthropicContentBlock::ToolUse { - id: call.id.clone(), - name: call.name.clone(), - input: call.args.clone(), - cache_control: None, - }), - ); - if blocks.is_empty() { - blocks.push(AnthropicContentBlock::Text { - text: String::new(), - cache_control: None, - }); - } - - anthropic_messages.push(AnthropicMessage { - role: "assistant".to_string(), - content: blocks, - }); - }, - LlmMessage::Tool { - tool_call_id, - content, - } => { - pending_user_blocks.push(AnthropicContentBlock::ToolResult { - tool_use_id: tool_call_id.clone(), - content: content.clone(), - cache_control: None, - }); - }, - } - } - - flush_pending_user_blocks(&mut anthropic_messages, &mut pending_user_blocks); - anthropic_messages -} - -/// 在最近的消息内容块上启用显式 prompt caching。 -/// -/// 只有在自定义 Anthropic 网关上才需要这条兜底路径。官方 Anthropic endpoint 使用顶层 -/// 自动缓存来追踪不断增长的对话尾部,避免显式断点超过 4 个 slot。 -fn enable_message_caching(messages: &mut [AnthropicMessage], max_breakpoints: usize) -> usize { - if messages.is_empty() || max_breakpoints == 0 { - return 0; - } - - let mut used = 0; - for msg in messages.iter_mut().rev() { - if used >= max_breakpoints { - break; - } - - let Some(block) = msg - .content - .iter_mut() - .rev() - .find(|block| block.can_use_explicit_cache_control()) - else { - continue; - }; - - if block.set_cache_control_if_allowed(true) { - used += 1; - } - } - - used -} - -fn consume_cache_breakpoint(remaining: &mut usize) -> bool { - if *remaining == 0 { - return false; - } - - *remaining -= 1; - true -} - -fn is_official_anthropic_api_url(url: &str) -> bool { - reqwest::Url::parse(url) - .ok() - .and_then(|url| { - url.host_str() - .map(|host| host.eq_ignore_ascii_case("api.anthropic.com")) - }) - .unwrap_or(false) -} - -fn cache_control_if_allowed(remaining: &mut usize) -> Option { - consume_cache_breakpoint(remaining).then(AnthropicCacheControl::ephemeral) -} - -// Dynamic 层不参与缓存,动态内容每轮都变 -fn cacheable_system_layer(layer: SystemPromptLayer) -> bool { - !matches!(layer, SystemPromptLayer::Dynamic) -} - -fn cacheable_text(text: &str) -> bool { - !text.is_empty() -} - -/// 将 `ToolDefinition` 转换为 Anthropic 工具定义格式。 -fn to_anthropic_tools( - tools: &[ToolDefinition], - remaining_cache_breakpoints: &mut usize, -) -> Vec { - if tools.is_empty() { - return Vec::new(); - } - - let last_cacheable_index = tools - .iter() - .rposition(|tool| cacheable_text(&tool.name) || cacheable_text(&tool.description)); - - tools - .iter() - .enumerate() - .map(|(index, tool)| { - let cache_control = if Some(index) == last_cacheable_index { - cache_control_if_allowed(remaining_cache_breakpoints) - } else { - None - }; - - AnthropicTool { - name: tool.name.clone(), - description: tool.description.clone(), - input_schema: tool.parameters.clone(), - cache_control, - } - }) - .collect() -} - -fn to_anthropic_system( - system_prompt: Option<&str>, - system_prompt_blocks: &[SystemPromptBlock], - remaining_cache_breakpoints: &mut usize, -) -> Option { - if !system_prompt_blocks.is_empty() { - return Some(AnthropicSystemPrompt::Blocks( - system_prompt_blocks - .iter() - .map(|block| { - let text = block.render(); - let cache_control = if block.cache_boundary - && cacheable_system_layer(block.layer) - && cacheable_text(&text) - { - cache_control_if_allowed(remaining_cache_breakpoints) - } else { - None - }; - - AnthropicSystemBlock { - type_: "text".to_string(), - text, - cache_control, - } - }) - .collect(), - )); - } - - system_prompt.map(|value| AnthropicSystemPrompt::Text(value.to_string())) -} - -/// 将 Anthropic 非流式响应转换为统一的 `LlmOutput`。 -/// -/// 遍历内容块数组,根据块类型分派: -/// - `text`: 拼接到输出内容 -/// - `tool_use`: 提取 id、name、input 构造工具调用请求 -/// - `thinking`: 提取推理内容和签名 -/// - 未知类型:记录警告并跳过 -/// -/// TODO:更好的办法? -/// `stop_reason` 映射到统一的 `FinishReason` (P4.2): -/// - `end_turn` → Stop -/// - `max_tokens` → MaxTokens -/// - `tool_use` → ToolCalls -/// - `stop_sequence` → Stop -fn response_to_output(response: AnthropicResponse) -> LlmOutput { - let usage = response.usage.and_then(AnthropicUsage::into_llm_usage); - - // 记录缓存状态 - if let Some(ref u) = usage { - let input = u.input_tokens; - let cache_read = u.cache_read_input_tokens; - let cache_creation = u.cache_creation_input_tokens; - let total_prompt_tokens = input.saturating_add(cache_read); - - if cache_read == 0 && cache_creation > 0 { - debug!( - "Cache miss: writing {} tokens to cache (total prompt: {}, uncached input: {})", - cache_creation, total_prompt_tokens, input - ); - } else if cache_read > 0 { - let hit_rate = (cache_read as f32 / total_prompt_tokens as f32) * 100.0; - debug!( - "Cache hit: {:.1}% ({} / {} prompt tokens, creation: {}, uncached input: {})", - hit_rate, cache_read, total_prompt_tokens, cache_creation, input - ); - } else { - debug!( - "Cache disabled or unavailable (total prompt: {} tokens)", - total_prompt_tokens - ); - } - } - - let mut content = String::new(); - let mut tool_calls = Vec::new(); - let mut reasoning = None; - - for block in response.content { - match block_type(&block) { - Some("text") => { - if let Some(text) = block.get("text").and_then(Value::as_str) { - content.push_str(text); - } - }, - Some("tool_use") => { - let id = match block.get("id").and_then(Value::as_str) { - Some(id) if !id.is_empty() => id.to_string(), - _ => { - warn!("anthropic: tool_use block missing non-empty id, skipping"); - continue; - }, - }; - let name = match block.get("name").and_then(Value::as_str) { - Some(name) if !name.is_empty() => name.to_string(), - _ => { - warn!("anthropic: tool_use block missing non-empty name, skipping"); - continue; - }, - }; - let args = block.get("input").cloned().unwrap_or(Value::Null); - tool_calls.push(ToolCallRequest { id, name, args }); - }, - Some("thinking") => { - if let Some(thinking) = block.get("thinking").and_then(Value::as_str) { - reasoning = Some(ReasoningContent { - content: thinking.to_string(), - signature: block - .get("signature") - .and_then(Value::as_str) - .map(str::to_string), - }); - } - }, - Some(other) => { - warn!("anthropic: unknown content block type: {}", other); - }, - None => { - warn!("anthropic: content block missing type"); - }, - } - } - - // Anthropic stop_reason 映射到统一 FinishReason - let finish_reason = response - .stop_reason - .as_deref() - .map(|reason| match reason { - "end_turn" | "stop_sequence" => FinishReason::Stop, - "max_tokens" => FinishReason::MaxTokens, - "tool_use" => FinishReason::ToolCalls, - other => FinishReason::Other(other.to_string()), - }) - .unwrap_or_else(|| { - if !tool_calls.is_empty() { - FinishReason::ToolCalls - } else { - FinishReason::Stop - } - }); - - LlmOutput { - content, - tool_calls, - reasoning, - usage, - finish_reason, - } -} - -/// 从 JSON Value 中提取内容块的类型字段。 -fn block_type(value: &Value) -> Option<&str> { - value.get("type").and_then(Value::as_str) -} - -/// 解析单个 Anthropic SSE 块。 -/// -/// Anthropic SSE 块由多行组成(`event: ...\ndata: {...}\n\n`), -/// 本函数提取事件类型和 JSON payload,支持事件类型回退到 payload 中的 `type` 字段。 -fn parse_sse_block(block: &str) -> Result> { - let trimmed = block.trim(); - if trimmed.is_empty() { - return Ok(None); - } - - let mut event_type = None; - let mut data_lines = Vec::new(); - - for line in trimmed.lines() { - if let Some(value) = sse_field_value(line, "event") { - event_type = Some(value.trim().to_string()); - } else if let Some(value) = sse_field_value(line, "data") { - data_lines.push(value); - } - } - - if data_lines.is_empty() { - return Ok(None); - } - - let data = data_lines.join("\n"); - let data = data.trim(); - if data.is_empty() { - return Ok(None); - } - - // 兼容部分 Anthropic 网关沿用 OpenAI 风格的流结束哨兵。 - // 如果这里严格要求 JSON,会在流尾直接误报 parse error。 - if data == "[DONE]" { - return Ok(Some(( - "message_stop".to_string(), - json!({ "type": "message_stop" }), - ))); - } - - let payload = serde_json::from_str::(data) - .map_err(|error| AstrError::parse("failed to parse anthropic sse payload", error))?; - let event_type = event_type - .or_else(|| { - payload - .get("type") - .and_then(Value::as_str) - .map(str::to_string) - }) - .unwrap_or_default(); - - Ok(Some((event_type, payload))) -} - -fn sse_field_value<'a>(line: &'a str, field: &str) -> Option<&'a str> { - let value = line.strip_prefix(field)?.strip_prefix(':')?; - - // SSE 规范只忽略冒号后的一个可选空格;这里兼容 `data:...` 和 `data: ...`, - // 同时保留业务数据中其余前导空白,避免悄悄改写 payload。 - Some(value.strip_prefix(' ').unwrap_or(value)) -} - -/// 从 `content_block_start` 事件 payload 中提取内容块。 -/// -/// Anthropic 在 `content_block_start` 事件中将块数据放在 `content_block` 字段, -/// 但某些事件可能直接放在根级别,因此有回退逻辑。 -fn extract_start_block(payload: &Value) -> &Value { - payload.get("content_block").unwrap_or(payload) -} - -/// 从 `content_block_delta` 事件 payload 中提取增量数据。 -/// -/// Anthropic 在 `content_block_delta` 事件中将增量数据放在 `delta` 字段。 -fn extract_delta_block(payload: &Value) -> &Value { - payload.get("delta").unwrap_or(payload) -} - -fn anthropic_stream_error(payload: &Value) -> AstrError { - let error = payload.get("error").unwrap_or(payload); - let message = error - .get("message") - .or_else(|| error.get("msg")) - .or_else(|| payload.get("message")) - .and_then(Value::as_str) - .unwrap_or("anthropic stream returned an error event"); - - let mut error_type = error - .get("type") - .or_else(|| error.get("code")) - .or_else(|| payload.get("error_type")) - .or_else(|| payload.get("code")) - .and_then(Value::as_str) - .unwrap_or("unknown_error"); - - // Why: 部分兼容网关不回传结构化 error.type,只给中文文案。 - // 这类错误本质仍是请求参数错误,不应退化成 internal stream error。 - let message_lower = message.to_lowercase(); - if matches!(error_type, "unknown_error" | "error") - && (message_lower.contains("参数非法") - || message_lower.contains("invalid request") - || message_lower.contains("invalid parameter") - || message_lower.contains("invalid arguments") - || (message_lower.contains("messages") && message_lower.contains("illegal"))) - { - error_type = "invalid_request_error"; - } - - let detail = format!("{error_type}: {message}"); - - match error_type { - "invalid_request_error" => classify_http_error(400, &detail).into(), - "authentication_error" => classify_http_error(401, &detail).into(), - "permission_error" => classify_http_error(403, &detail).into(), - "not_found_error" => classify_http_error(404, &detail).into(), - "rate_limit_error" => classify_http_error(429, &detail).into(), - "overloaded_error" => classify_http_error(529, &detail).into(), - "api_error" => classify_http_error(500, &detail).into(), - _ => classify_http_error(400, &detail).into(), - } -} - -/// 处理单个 Anthropic SSE 块,返回 `(is_done, stop_reason)`。 -/// -/// Anthropic SSE 事件类型分派: -/// - `content_block_start`: 新内容块开始(可能是文本或工具调用) -/// - `content_block_delta`: 增量内容(文本/思考/签名/工具参数) -/// - `message_stop`: 流结束信号,返回 is_done=true -/// - `message_delta`: 包含 `stop_reason`,用于检测 max_tokens 截断 (P4.2) -/// - `message_start/content_block_stop/ping`: 元数据事件,静默忽略 -fn process_sse_block( - block: &str, - accumulator: &mut LlmAccumulator, - sink: &EventSink, -) -> Result { - let Some((event_type, payload)) = parse_sse_block(block)? else { - return Ok(SseProcessResult::default()); - }; - - match event_type.as_str() { - "content_block_start" => { - let index = payload - .get("index") - .and_then(Value::as_u64) - .unwrap_or_default() as usize; - let block = extract_start_block(&payload); - - // 工具调用块开始时,发射 ToolCallDelta(id + name,参数为空) - if block_type(block) == Some("tool_use") { - emit_event( - LlmEvent::ToolCallDelta { - index, - id: block.get("id").and_then(Value::as_str).map(str::to_string), - name: block - .get("name") - .and_then(Value::as_str) - .map(str::to_string), - arguments_delta: String::new(), - }, - accumulator, - sink, - ); - } - Ok(SseProcessResult::default()) - }, - "content_block_delta" => { - let index = payload - .get("index") - .and_then(Value::as_u64) - .unwrap_or_default() as usize; - let delta = extract_delta_block(&payload); - - // 根据增量类型分派到对应的事件 - match block_type(delta) { - Some("text_delta") => { - if let Some(text) = delta.get("text").and_then(Value::as_str) { - emit_event(LlmEvent::TextDelta(text.to_string()), accumulator, sink); - } - }, - Some("thinking_delta") => { - if let Some(text) = delta.get("thinking").and_then(Value::as_str) { - emit_event(LlmEvent::ThinkingDelta(text.to_string()), accumulator, sink); - } - }, - Some("signature_delta") => { - if let Some(signature) = delta.get("signature").and_then(Value::as_str) { - emit_event( - LlmEvent::ThinkingSignature(signature.to_string()), - accumulator, - sink, - ); - } - }, - Some("input_json_delta") => { - // 工具调用参数增量,partial_json 是 JSON 的片段 - emit_event( - LlmEvent::ToolCallDelta { - index, - id: None, - name: None, - arguments_delta: delta - .get("partial_json") - .and_then(Value::as_str) - .unwrap_or_default() - .to_string(), - }, - accumulator, - sink, - ); - }, - _ => {}, - } - Ok(SseProcessResult::default()) - }, - "message_stop" => Ok(SseProcessResult { - done: true, - ..SseProcessResult::default() - }), - // message_delta 可能包含 stop_reason (P4.2) - "message_delta" => { - let stop_reason = payload - .get("delta") - .and_then(|d| d.get("stop_reason")) - .and_then(Value::as_str) - .map(str::to_string); - Ok(SseProcessResult { - stop_reason, - usage: extract_usage_from_payload(&event_type, &payload), - ..SseProcessResult::default() - }) - }, - "message_start" => Ok(SseProcessResult { - usage: extract_usage_from_payload(&event_type, &payload), - ..SseProcessResult::default() - }), - "content_block_stop" | "ping" => Ok(SseProcessResult::default()), - "error" => Err(anthropic_stream_error(&payload)), - other => { - warn!("anthropic: unknown sse event: {}", other); - Ok(SseProcessResult::default()) - }, - } -} - -/// 为模型生成 extended thinking 配置。 -/// -/// 当 max_tokens >= 2 时启用 thinking 模式,预算 token 数为 max_tokens 的 75%(向下取整)。 -/// -/// ## 设计动机 -/// -/// Extended thinking 让模型在输出前进行深度推理,提升复杂任务的回答质量。 -/// 预算设为 75% 是为了保留至少 25% 的 token 给实际输出内容。 -/// 如果预算为 0 或等于 max_tokens,则不启用(避免无意义配置)。 -/// -/// 默认为所有模型启用此功能。如果模型不支持,API 会忽略此参数。 -fn thinking_config_for_model(_model: &str, max_tokens: u32) -> Option { - if max_tokens < 2 { - return None; - } - - let budget_tokens = max_tokens.saturating_mul(3) / 4; - if budget_tokens == 0 || budget_tokens >= max_tokens { - return None; - } - - Some(AnthropicThinking { - type_: "enabled".to_string(), - budget_tokens, - }) -} - -/// 在 SSE 缓冲区中查找下一个完整的 SSE 块边界。 -/// -/// Anthropic SSE 块由双换行符分隔(`\r\n\r\n` 或 `\n\n`)。 -/// 返回 `(块结束位置, 分隔符长度)`,如果未找到完整块则返回 `None`。 -fn next_sse_block(buffer: &str) -> Option<(usize, usize)> { - if let Some(idx) = buffer.find("\r\n\r\n") { - return Some((idx, 4)); - } - if let Some(idx) = buffer.find("\n\n") { - return Some((idx, 2)); - } - None -} - -fn consume_sse_text_chunk( - chunk_text: &str, - sse_buffer: &mut String, - accumulator: &mut LlmAccumulator, - sink: &EventSink, - stop_reason_out: &mut Option, - usage_out: &mut AnthropicUsage, -) -> Result { - sse_buffer.push_str(chunk_text); - - while let Some((block_end, delimiter_len)) = next_sse_block(sse_buffer) { - let block: String = sse_buffer.drain(..block_end + delimiter_len).collect(); - let block = &block[..block_end]; - - let result = process_sse_block(block, accumulator, sink)?; - if let Some(r) = result.stop_reason { - *stop_reason_out = Some(r); - } - if let Some(usage) = result.usage { - usage_out.merge_from(usage); - } - if result.done { - return Ok(true); - } - } - - Ok(false) -} - -fn flush_sse_buffer( - sse_buffer: &mut String, - accumulator: &mut LlmAccumulator, - sink: &EventSink, - stop_reason_out: &mut Option, - usage_out: &mut AnthropicUsage, -) -> Result<()> { - if sse_buffer.trim().is_empty() { - sse_buffer.clear(); - return Ok(()); - } - - let result = process_sse_block(sse_buffer, accumulator, sink)?; - if let Some(r) = result.stop_reason { - *stop_reason_out = Some(r); - } - if let Some(usage) = result.usage { - usage_out.merge_from(usage); - } - sse_buffer.clear(); - Ok(()) -} - -// --------------------------------------------------------------------------- -// Anthropic API 请求/响应 DTO(仅用于 serde 序列化/反序列化) -// --------------------------------------------------------------------------- - -/// Anthropic Messages API 请求体。 -/// -/// 注意:`stream` 字段为 `Option`,`None` 时表示非流式模式, -/// 这样可以在序列化时省略该字段(Anthropic API 默认非流式)。 -#[derive(Debug, Serialize)] -struct AnthropicRequest { - model: String, - max_tokens: u32, - #[serde(skip_serializing_if = "Option::is_none")] - cache_control: Option, - messages: Vec, - #[serde(skip_serializing_if = "Option::is_none")] - system: Option, - #[serde(skip_serializing_if = "Option::is_none")] - tools: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - stream: Option, - #[serde(skip_serializing_if = "Option::is_none")] - thinking: Option, -} - -#[derive(Debug, Serialize)] -#[serde(untagged)] -enum AnthropicSystemPrompt { - Text(String), - Blocks(Vec), -} - -#[derive(Debug, Serialize)] -struct AnthropicSystemBlock { - #[serde(rename = "type")] - type_: String, - text: String, - #[serde(skip_serializing_if = "Option::is_none")] - cache_control: Option, -} - -/// Anthropic extended thinking 配置。 -/// -/// `budget_tokens` 指定推理过程可使用的最大 token 数, -/// 不计入最终输出的 `max_tokens` 限制。 -/// -/// ## 设计动机 -/// -/// Extended thinking 让 Claude 在输出前进行深度推理,提升复杂任务的回答质量。 -/// 预算设为 75% 是为了保留至少 25% 的 token 给实际输出内容。 -#[derive(Debug, Serialize)] -struct AnthropicThinking { - #[serde(rename = "type")] - type_: String, - budget_tokens: u32, -} - -/// Anthropic 消息(包含角色和内容块数组)。 -/// -/// Anthropic 的消息结构与 OpenAI 不同:`content` 是内容块数组而非纯文本, -/// 这使得单条消息可以混合文本、推理、工具调用等多种内容类型。 -#[derive(Debug, Serialize)] -struct AnthropicMessage { - role: String, - content: Vec, -} - -/// Anthropic 内容块——消息内容由多个块组成。 -/// -/// 使用 `#[serde(tag = "type")]` 实现内部标记序列化, -/// 每个变体对应一个 `type` 值(`text`、`thinking`、`tool_use`、`tool_result`)。 -/// -/// ## 缓存控制 -/// -/// 每个块可选携带 `cache_control` 字段,标记为 `ephemeral` 类型时, -/// Anthropic 后端会将该块作为缓存前缀的一部分,用于 KV cache 复用。 -#[derive(Debug, Serialize)] -#[serde(tag = "type", rename_all = "snake_case")] -enum AnthropicContentBlock { - Text { - text: String, - #[serde(skip_serializing_if = "Option::is_none")] - cache_control: Option, - }, - Thinking { - thinking: String, - #[serde(skip_serializing_if = "Option::is_none")] - signature: Option, - #[serde(skip_serializing_if = "Option::is_none")] - cache_control: Option, - }, - ToolUse { - id: String, - name: String, - input: Value, - #[serde(skip_serializing_if = "Option::is_none")] - cache_control: Option, - }, - ToolResult { - tool_use_id: String, - content: String, - #[serde(skip_serializing_if = "Option::is_none")] - cache_control: Option, - }, -} - -/// Anthropic prompt caching 控制标记。 -/// -/// `type: "ephemeral"` 告诉 Anthropic 后端该块可作为缓存前缀的一部分。 -/// 缓存是临时的(ephemeral),不保证长期有效,但在短时间内重复请求可以显著减少延迟。 -#[derive(Debug, Clone, Serialize)] -struct AnthropicCacheControl { - #[serde(rename = "type")] - type_: String, -} - -impl AnthropicCacheControl { - /// 创建 ephemeral 类型的缓存控制标记。 - fn ephemeral() -> Self { - Self { - type_: "ephemeral".to_string(), - } - } -} - -impl AnthropicContentBlock { - fn block_type(&self) -> &'static str { - match self { - AnthropicContentBlock::Text { .. } => "text", - AnthropicContentBlock::Thinking { .. } => "thinking", - AnthropicContentBlock::ToolUse { .. } => "tool_use", - AnthropicContentBlock::ToolResult { .. } => "tool_result", - } - } - - fn has_cache_control(&self) -> bool { - match self { - AnthropicContentBlock::Text { cache_control, .. } - | AnthropicContentBlock::Thinking { cache_control, .. } - | AnthropicContentBlock::ToolUse { cache_control, .. } - | AnthropicContentBlock::ToolResult { cache_control, .. } => cache_control.is_some(), - } - } - - /// 判断内容块是否适合显式 `cache_control`。 - /// - /// 出于兼容网关的稳健性,显式缓存断点仅打在 text 块上; - /// thinking / tool_use / tool_result 不设置 cache_control,避免部分兼容实现的参数校验失败。 - fn can_use_explicit_cache_control(&self) -> bool { - match self { - AnthropicContentBlock::Text { text, .. } => cacheable_text(text), - AnthropicContentBlock::Thinking { .. } => false, - AnthropicContentBlock::ToolUse { .. } => false, - AnthropicContentBlock::ToolResult { .. } => false, - } - } - - /// 为允许显式缓存的内容块设置或清除 `cache_control` 标记。 - fn set_cache_control_if_allowed(&mut self, enabled: bool) -> bool { - if enabled && !self.can_use_explicit_cache_control() { - return false; - } - - let control = if enabled { - Some(AnthropicCacheControl::ephemeral()) - } else { - None - }; - match self { - AnthropicContentBlock::Text { cache_control, .. } => *cache_control = control, - AnthropicContentBlock::Thinking { .. } => return false, - AnthropicContentBlock::ToolUse { cache_control, .. } => *cache_control = control, - AnthropicContentBlock::ToolResult { cache_control, .. } => *cache_control = control, - } - true - } -} - -/// Anthropic 工具定义。 -/// -/// 与 OpenAI 不同,Anthropic 工具定义不需要 `type` 字段, -/// 直接使用 `name`、`description`、`input_schema` 三个字段。 -#[derive(Debug, Serialize)] -struct AnthropicTool { - name: String, - description: String, - input_schema: Value, - - #[serde(skip_serializing_if = "Option::is_none")] - cache_control: Option, -} - -/// Anthropic Messages API 非流式响应体。 -/// -/// NOTE: `content` 使用 `Vec` 而非强类型结构体, -/// 因为 Anthropic 响应可能包含多种内容块类型(text / tool_use / thinking), -/// 使用 `Value` 可以灵活处理未知或新增的块类型,避免每次 API 更新都要修改 DTO。 -#[derive(Debug, serde::Deserialize)] -struct AnthropicResponse { - content: Vec, - #[allow(dead_code)] - stop_reason: Option, - #[serde(default)] - usage: Option, -} - -/// Anthropic 响应中的 token 用量统计。 -/// -/// 两个字段均为 `Option` 且带 `#[serde(default)]`, -/// 因为某些旧版 API 或特殊响应可能不包含用量信息。 -#[derive(Debug, Clone, Default, serde::Deserialize)] -struct AnthropicUsage { - #[serde(default)] - input_tokens: Option, - #[serde(default)] - output_tokens: Option, - #[serde(default)] - cache_creation_input_tokens: Option, - #[serde(default)] - cache_read_input_tokens: Option, - #[serde(default)] - cache_creation: Option, -} - -#[derive(Debug, Clone, Default, serde::Deserialize)] -struct AnthropicCacheCreationUsage { - #[serde(default)] - ephemeral_5m_input_tokens: Option, - #[serde(default)] - ephemeral_1h_input_tokens: Option, -} - -impl AnthropicUsage { - fn merge_from(&mut self, other: Self) { - self.input_tokens = other.input_tokens.or(self.input_tokens); - self.cache_creation_input_tokens = other - .cache_creation_input_tokens - .or(self.cache_creation_input_tokens); - self.cache_read_input_tokens = other - .cache_read_input_tokens - .or(self.cache_read_input_tokens); - self.cache_creation = other.cache_creation.or_else(|| self.cache_creation.take()); - // output_tokens 在流式事件里通常是累计值,优先保留最新的非空值。 - self.output_tokens = other.output_tokens.or(self.output_tokens); - } - - fn into_llm_usage(self) -> Option { - let cache_creation_input_tokens = self.cache_creation_input_tokens.or_else(|| { - self.cache_creation - .as_ref() - .map(AnthropicCacheCreationUsage::total_input_tokens) - }); - - if self.input_tokens.is_none() - && self.output_tokens.is_none() - && cache_creation_input_tokens.is_none() - && self.cache_read_input_tokens.is_none() - { - return None; - } - - Some(LlmUsage { - input_tokens: self.input_tokens.unwrap_or_default() as usize, - output_tokens: self.output_tokens.unwrap_or_default() as usize, - cache_creation_input_tokens: cache_creation_input_tokens.unwrap_or_default() as usize, - cache_read_input_tokens: self.cache_read_input_tokens.unwrap_or_default() as usize, - }) - } -} - -impl AnthropicCacheCreationUsage { - fn total_input_tokens(&self) -> u64 { - self.ephemeral_5m_input_tokens - .unwrap_or_default() - .saturating_add(self.ephemeral_1h_input_tokens.unwrap_or_default()) - } -} - -#[derive(Debug, Default)] -struct SseProcessResult { - done: bool, - stop_reason: Option, - usage: Option, -} - -fn extract_usage_from_payload(event_type: &str, payload: &Value) -> Option { - match event_type { - "message_start" => payload - .get("message") - .and_then(|message| message.get("usage")) - .and_then(parse_usage_value), - "message_delta" => payload - .get("usage") - .or_else(|| payload.get("delta").and_then(|delta| delta.get("usage"))) - .and_then(parse_usage_value), - _ => None, - } -} - -fn parse_usage_value(value: &Value) -> Option { - serde_json::from_value::(value.clone()).ok() -} - -#[cfg(test)] -mod tests { - use std::sync::{Arc, Mutex}; - - use astrcode_core::UserMessageOrigin; - use serde_json::json; - - use super::*; - use crate::sink_collector; - - #[test] - fn to_anthropic_messages_does_not_inject_empty_text_block_for_tool_use() { - let messages = vec![LlmMessage::Assistant { - content: "".to_string(), - tool_calls: vec![ToolCallRequest { - id: "call_123".to_string(), - name: "test_tool".to_string(), - args: json!({"arg": "value"}), - }], - reasoning: None, - }]; - - let anthropic_messages = to_anthropic_messages( - &messages, - MessageBuildOptions { - include_reasoning_blocks: true, - }, - ); - assert_eq!(anthropic_messages.len(), 1); - - let msg = &anthropic_messages[0]; - assert_eq!(msg.role, "assistant"); - assert_eq!(msg.content.len(), 1); - - match &msg.content[0] { - AnthropicContentBlock::ToolUse { id, name, .. } => { - assert_eq!(id, "call_123"); - assert_eq!(name, "test_tool"); - }, - _ => panic!("Expected ToolUse block"), - } - } - - #[test] - fn to_anthropic_messages_groups_consecutive_tool_results_into_one_user_message() { - let messages = vec![ - LlmMessage::Assistant { - content: String::new(), - tool_calls: vec![ - ToolCallRequest { - id: "call_1".to_string(), - name: "read_file".to_string(), - args: json!({"path": "a.rs"}), - }, - ToolCallRequest { - id: "call_2".to_string(), - name: "grep".to_string(), - args: json!({"pattern": "spawn"}), - }, - ], - reasoning: None, - }, - LlmMessage::Tool { - tool_call_id: "call_1".to_string(), - content: "file content".to_string(), - }, - LlmMessage::Tool { - tool_call_id: "call_2".to_string(), - content: "grep result".to_string(), - }, - ]; - - let anthropic_messages = to_anthropic_messages( - &messages, - MessageBuildOptions { - include_reasoning_blocks: true, - }, - ); - - assert_eq!(anthropic_messages.len(), 2); - assert_eq!(anthropic_messages[0].role, "assistant"); - assert_eq!(anthropic_messages[1].role, "user"); - assert_eq!(anthropic_messages[1].content.len(), 2); - assert!(matches!( - &anthropic_messages[1].content[0], - AnthropicContentBlock::ToolResult { tool_use_id, content, .. } - if tool_use_id == "call_1" && content == "file content" - )); - assert!(matches!( - &anthropic_messages[1].content[1], - AnthropicContentBlock::ToolResult { tool_use_id, content, .. } - if tool_use_id == "call_2" && content == "grep result" - )); - } - - #[test] - fn to_anthropic_messages_keeps_user_text_after_tool_results_in_same_message() { - let messages = vec![ - LlmMessage::Assistant { - content: String::new(), - tool_calls: vec![ToolCallRequest { - id: "call_1".to_string(), - name: "read_file".to_string(), - args: json!({"path": "a.rs"}), - }], - reasoning: None, - }, - LlmMessage::Tool { - tool_call_id: "call_1".to_string(), - content: "file content".to_string(), - }, - LlmMessage::User { - content: "请继续总结发现。".to_string(), - origin: UserMessageOrigin::User, - }, - ]; - - let anthropic_messages = to_anthropic_messages( - &messages, - MessageBuildOptions { - include_reasoning_blocks: true, - }, - ); - - assert_eq!(anthropic_messages.len(), 2); - assert_eq!(anthropic_messages[1].role, "user"); - assert_eq!(anthropic_messages[1].content.len(), 2); - assert!(matches!( - &anthropic_messages[1].content[0], - AnthropicContentBlock::ToolResult { tool_use_id, content, .. } - if tool_use_id == "call_1" && content == "file content" - )); - assert!(matches!( - &anthropic_messages[1].content[1], - AnthropicContentBlock::Text { text, .. } if text == "请继续总结发现。" - )); - } - - #[test] - fn response_to_output_parses_text_tool_use_and_thinking() { - let output = response_to_output(AnthropicResponse { - content: vec![ - json!({ "type": "text", "text": "hello " }), - json!({ - "type": "tool_use", - "id": "call_1", - "name": "search", - "input": { "q": "rust" } - }), - json!({ "type": "text", "text": "world" }), - json!({ "type": "thinking", "thinking": "pondering", "signature": "sig-1" }), - ], - stop_reason: Some("tool_use".to_string()), - usage: None, - }); - - assert_eq!(output.content, "hello world"); - assert_eq!(output.tool_calls.len(), 1); - assert_eq!(output.tool_calls[0].id, "call_1"); - assert_eq!(output.tool_calls[0].args, json!({ "q": "rust" })); - assert_eq!( - output.reasoning, - Some(ReasoningContent { - content: "pondering".to_string(), - signature: Some("sig-1".to_string()), - }) - ); - } - - #[test] - fn streaming_sse_parses_tool_calls_and_text() { - let mut accumulator = LlmAccumulator::default(); - let events = Arc::new(Mutex::new(Vec::new())); - let sink = sink_collector(events.clone()); - let mut sse_buffer = String::new(); - - let chunk = concat!( - "event: content_block_start\n", - "data: {\"index\":1,\"type\":\"tool_use\",\"id\":\"call_1\",\"name\":\"search\"}\n\n", - "event: content_block_delta\n", - "data: {\"index\":1,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"{\\\"\ - q\\\":\\\"ru\"}}\n\n", - "event: content_block_delta\n", - "data: {\"index\":1,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"st\\\"\ - }\"}}\n\n", - "event: content_block_delta\n", - "data: {\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"hello\"}}\n\n", - "event: message_stop\n", - "data: {\"type\":\"message_stop\"}\n\n" - ); - - let mut stop_reason_out: Option = None; - let mut usage_out = AnthropicUsage::default(); - let done = consume_sse_text_chunk( - chunk, - &mut sse_buffer, - &mut accumulator, - &sink, - &mut stop_reason_out, - &mut usage_out, - ) - .expect("stream chunk should parse"); - - assert!(done); - let output = accumulator.finish(); - let events = events.lock().expect("lock").clone(); - - assert!(events.iter().any(|event| { - matches!( - event, - LlmEvent::ToolCallDelta { index, id, name, arguments_delta } - if *index == 1 - && id.as_deref() == Some("call_1") - && name.as_deref() == Some("search") - && arguments_delta.is_empty() - ) - })); - assert!( - events - .iter() - .any(|event| matches!(event, LlmEvent::TextDelta(text) if text == "hello")) - ); - assert_eq!(output.content, "hello"); - assert_eq!(output.tool_calls.len(), 1); - assert_eq!(output.tool_calls[0].args, json!({ "q": "rust" })); - } - - #[test] - fn parse_sse_block_accepts_data_lines_without_space_after_colon() { - let block = concat!( - "event:content_block_delta\n", - "data:{\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"hello\"}}\n" - ); - - let parsed = parse_sse_block(block) - .expect("block should parse") - .expect("block should contain payload"); - - assert_eq!(parsed.0, "content_block_delta"); - assert_eq!(parsed.1["delta"]["text"], json!("hello")); - } - - #[test] - fn parse_sse_block_treats_done_sentinel_as_message_stop() { - let parsed = parse_sse_block("data: [DONE]\n") - .expect("done sentinel should parse") - .expect("done sentinel should produce payload"); - - assert_eq!(parsed.0, "message_stop"); - assert_eq!(parsed.1["type"], json!("message_stop")); - } - - #[test] - fn parse_sse_block_ignores_empty_data_payload() { - let parsed = parse_sse_block("event: ping\ndata:\n"); - assert!(matches!(parsed, Ok(None))); - } - - #[test] - fn streaming_sse_error_event_surfaces_structured_provider_failure() { - let mut accumulator = LlmAccumulator::default(); - let events = Arc::new(Mutex::new(Vec::new())); - let sink = sink_collector(events); - let mut sse_buffer = String::new(); - let mut stop_reason_out: Option = None; - let mut usage_out = AnthropicUsage::default(); - - let error = consume_sse_text_chunk( - concat!( - "event: error\n", - "data: {\"type\":\"error\",\"error\":{\"type\":\"overloaded_error\",", - "\"message\":\"capacity exhausted\"}}\n\n" - ), - &mut sse_buffer, - &mut accumulator, - &sink, - &mut stop_reason_out, - &mut usage_out, - ) - .expect_err("error event should terminate the stream with a structured error"); - - match error { - AstrError::LlmRequestFailed { status, body } => { - assert_eq!(status, 529); - assert!(body.contains("overloaded_error")); - assert!(body.contains("capacity exhausted")); - }, - other => panic!("unexpected error variant: {other:?}"), - } - } - - #[test] - fn streaming_sse_error_event_without_type_still_maps_to_request_error() { - let mut accumulator = LlmAccumulator::default(); - let events = Arc::new(Mutex::new(Vec::new())); - let sink = sink_collector(events); - let mut sse_buffer = String::new(); - let mut stop_reason_out: Option = None; - let mut usage_out = AnthropicUsage::default(); - - let error = consume_sse_text_chunk( - concat!( - "event: error\n", - "data: {\"type\":\"error\",\"error\":{\"message\":\"messages 参数非法\"}}\n\n" - ), - &mut sse_buffer, - &mut accumulator, - &sink, - &mut stop_reason_out, - &mut usage_out, - ) - .expect_err("error event should terminate the stream with a structured error"); - - match error { - AstrError::LlmRequestFailed { status, body } => { - assert_eq!(status, 400); - assert!(body.contains("invalid_request_error")); - assert!(body.contains("messages 参数非法")); - }, - other => panic!("unexpected error variant: {other:?}"), - } - } - - #[test] - fn build_request_serializes_system_and_thinking_when_applicable() { - let provider = AnthropicProvider::new( - "https://api.anthropic.com/v1/messages".to_string(), - "sk-ant-test".to_string(), - "claude-sonnet-4-5".to_string(), - ModelLimits { - context_window: 200_000, - max_output_tokens: 8096, - }, - LlmClientConfig::default(), - ) - .expect("provider should build"); - let request = provider.build_request( - &[LlmMessage::User { - content: "hi".to_string(), - origin: UserMessageOrigin::User, - }], - &[], - Some("Follow the rules"), - &[], - true, - ); - let body = serde_json::to_value(&request).expect("request should serialize"); - - assert_eq!(body["cache_control"]["type"], json!("ephemeral")); - assert_eq!( - body.get("system").and_then(Value::as_str), - Some("Follow the rules") - ); - assert_eq!( - body.get("thinking") - .and_then(|value| value.get("type")) - .and_then(Value::as_str), - Some("enabled") - ); - } - - fn count_cache_control_fields(value: &Value) -> usize { - match value { - Value::Object(map) => { - usize::from(map.contains_key("cache_control")) - + map.values().map(count_cache_control_fields).sum::() - }, - Value::Array(values) => values.iter().map(count_cache_control_fields).sum(), - _ => 0, - } - } - - #[test] - fn official_anthropic_uses_automatic_cache_and_caps_explicit_breakpoints() { - let provider = AnthropicProvider::new( - "https://api.anthropic.com/v1/messages".to_string(), - "sk-ant-test".to_string(), - "claude-sonnet-4-5".to_string(), - ModelLimits { - context_window: 200_000, - max_output_tokens: 8096, - }, - LlmClientConfig::default(), - ) - .expect("provider should build"); - let system_blocks = (0..5) - .map(|index| SystemPromptBlock { - title: format!("Stable {index}"), - content: format!("stable content {index}"), - cache_boundary: true, - layer: SystemPromptLayer::Stable, - }) - .collect::>(); - let tools = vec![ToolDefinition { - name: "search".to_string(), - description: "Search indexed data.".to_string(), - parameters: json!({ "type": "object" }), - }]; - let request = provider.build_request( - &[LlmMessage::User { - content: "hi".to_string(), - origin: UserMessageOrigin::User, - }], - &tools, - None, - &system_blocks, - false, - ); - let body = serde_json::to_value(&request).expect("request should serialize"); - - assert_eq!(body["cache_control"]["type"], json!("ephemeral")); - assert!( - count_cache_control_fields(&body) <= ANTHROPIC_CACHE_BREAKPOINT_LIMIT, - "official request should keep automatic + explicit cache controls within the provider \ - limit" - ); - assert!( - body["messages"][0]["content"][0] - .get("cache_control") - .is_none(), - "official endpoint uses top-level automatic cache for the message tail" - ); - } - - #[test] - fn custom_anthropic_gateway_uses_explicit_message_tail_without_top_level_cache() { - let provider = AnthropicProvider::new( - "https://gateway.example.com/anthropic/v1/messages".to_string(), - "sk-ant-test".to_string(), - "claude-sonnet-4-5".to_string(), - ModelLimits { - context_window: 200_000, - max_output_tokens: 8096, - }, - LlmClientConfig::default(), - ) - .expect("provider should build"); - let request = provider.build_request( - &[ - LlmMessage::User { - content: "first".to_string(), - origin: UserMessageOrigin::User, - }, - LlmMessage::User { - content: "second".to_string(), - origin: UserMessageOrigin::User, - }, - ], - &[], - None, - &[], - false, - ); - let body = serde_json::to_value(&request).expect("request should serialize"); - - assert!(body.get("cache_control").is_none()); - assert_eq!(body["messages"].as_array().map(Vec::len), Some(1)); - assert_eq!( - body["messages"][0]["content"][1]["cache_control"]["type"], - json!("ephemeral") - ); - assert!( - count_cache_control_fields(&body) <= ANTHROPIC_CACHE_BREAKPOINT_LIMIT, - "custom gateways only receive explicit cache controls within the provider limit" - ); - } - - #[test] - fn custom_gateway_request_disables_extended_thinking_payloads() { - let provider = AnthropicProvider::new( - "https://gateway.example.com/anthropic/v1/messages".to_string(), - "sk-ant-test".to_string(), - "claude-sonnet-4-5".to_string(), - ModelLimits { - context_window: 200_000, - max_output_tokens: 8096, - }, - LlmClientConfig::default(), - ) - .expect("provider should build"); - let request = provider.build_request( - &[LlmMessage::Assistant { - content: "".to_string(), - tool_calls: vec![], - reasoning: Some(ReasoningContent { - content: "thinking".to_string(), - signature: Some("sig".to_string()), - }), - }], - &[], - None, - &[], - false, - ); - let body = serde_json::to_value(&request).expect("request should serialize"); - - assert!(body.get("thinking").is_none()); - assert_eq!(body["messages"][0]["content"][0]["type"], json!("text")); - assert_eq!(body["messages"][0]["content"][0]["text"], json!("")); - } - - #[test] - fn provider_keeps_custom_messages_api_url() { - let provider = AnthropicProvider::new( - "https://gateway.example.com/anthropic/v1/messages".to_string(), - "sk-ant-test".to_string(), - "claude-sonnet-4-5".to_string(), - ModelLimits { - context_window: 200_000, - max_output_tokens: 8096, - }, - LlmClientConfig::default(), - ) - .expect("provider should build"); - - assert_eq!( - provider.messages_api_url, - "https://gateway.example.com/anthropic/v1/messages" - ); - } - - #[test] - fn build_request_serializes_system_blocks_with_cache_boundaries() { - let provider = AnthropicProvider::new( - "https://api.anthropic.com/v1/messages".to_string(), - "sk-ant-test".to_string(), - "claude-sonnet-4-5".to_string(), - ModelLimits { - context_window: 200_000, - max_output_tokens: 8096, - }, - LlmClientConfig::default(), - ) - .expect("provider should build"); - let request = provider.build_request( - &[LlmMessage::User { - content: "hi".to_string(), - origin: UserMessageOrigin::User, - }], - &[], - Some("ignored fallback"), - &[SystemPromptBlock { - title: "Stable".to_string(), - content: "stable".to_string(), - cache_boundary: true, - layer: astrcode_core::SystemPromptLayer::Stable, - }], - false, - ); - let body = serde_json::to_value(&request).expect("request should serialize"); - - assert!(body.get("system").is_some_and(Value::is_array)); - assert_eq!( - body["system"][0]["cache_control"]["type"], - json!("ephemeral") - ); - } - - #[test] - fn build_request_only_marks_cache_boundaries_at_layer_transitions() { - let provider = AnthropicProvider::new( - "https://api.anthropic.com/v1/messages".to_string(), - "sk-ant-test".to_string(), - "claude-sonnet-4-5".to_string(), - ModelLimits { - context_window: 200_000, - max_output_tokens: 8096, - }, - LlmClientConfig::default(), - ) - .expect("provider should build"); - let request = provider.build_request( - &[LlmMessage::User { - content: "hi".to_string(), - origin: UserMessageOrigin::User, - }], - &[], - Some("ignored fallback"), - &[ - SystemPromptBlock { - title: "Stable 1".to_string(), - content: "stable content 1".to_string(), - cache_boundary: false, - layer: astrcode_core::SystemPromptLayer::Stable, - }, - SystemPromptBlock { - title: "Stable 2".to_string(), - content: "stable content 2".to_string(), - cache_boundary: false, - layer: astrcode_core::SystemPromptLayer::Stable, - }, - SystemPromptBlock { - title: "Stable 3".to_string(), - content: "stable content 3".to_string(), - cache_boundary: true, - layer: astrcode_core::SystemPromptLayer::Stable, - }, - SystemPromptBlock { - title: "Semi 1".to_string(), - content: "semi content 1".to_string(), - cache_boundary: false, - layer: astrcode_core::SystemPromptLayer::SemiStable, - }, - SystemPromptBlock { - title: "Semi 2".to_string(), - content: "semi content 2".to_string(), - cache_boundary: true, - layer: astrcode_core::SystemPromptLayer::SemiStable, - }, - SystemPromptBlock { - title: "Inherited 1".to_string(), - content: "inherited content 1".to_string(), - cache_boundary: true, - layer: astrcode_core::SystemPromptLayer::Inherited, - }, - SystemPromptBlock { - title: "Dynamic 1".to_string(), - content: "dynamic content 1".to_string(), - cache_boundary: true, - layer: astrcode_core::SystemPromptLayer::Dynamic, - }, - ], - false, - ); - let body = serde_json::to_value(&request).expect("request should serialize"); - - assert!(body.get("system").is_some_and(Value::is_array)); - assert_eq!( - body["system"] - .as_array() - .expect("system should be an array") - .len(), - 7 - ); - - // Stable 层内的前两个 block 不应该有 cache_control - assert!( - body["system"][0].get("cache_control").is_none(), - "stable1 should not have cache_control" - ); - assert!( - body["system"][1].get("cache_control").is_none(), - "stable2 should not have cache_control" - ); - - // Stable 层的最后一个 block 应该有 cache_control - assert_eq!( - body["system"][2]["cache_control"]["type"], - json!("ephemeral"), - "stable3 should have cache_control" - ); - - // SemiStable 层的第一个 block 不应该有 cache_control - assert!( - body["system"][3].get("cache_control").is_none(), - "semi1 should not have cache_control" - ); - - // SemiStable 层的最后一个 block 应该有 cache_control - assert_eq!( - body["system"][4]["cache_control"]["type"], - json!("ephemeral"), - "semi2 should have cache_control" - ); - - // Inherited 层允许独立缓存 - assert_eq!( - body["system"][5]["cache_control"]["type"], - json!("ephemeral"), - "inherited1 should have cache_control" - ); - - // Dynamic 层不缓存(避免浪费,因为内容变化频繁) - // TODO: 更好的做法?实现更好的kv缓存? - assert!( - body["system"][6].get("cache_control").is_none(), - "dynamic1 should not have cache_control (Dynamic layer is not cached)" - ); - } - - #[test] - fn response_to_output_parses_cache_usage_fields() { - let output = response_to_output(AnthropicResponse { - content: vec![json!({ "type": "text", "text": "ok" })], - stop_reason: Some("end_turn".to_string()), - usage: Some(AnthropicUsage { - input_tokens: Some(100), - output_tokens: Some(20), - cache_creation_input_tokens: Some(80), - cache_read_input_tokens: Some(60), - cache_creation: None, - }), - }); - - assert_eq!( - output.usage, - Some(LlmUsage { - input_tokens: 100, - output_tokens: 20, - cache_creation_input_tokens: 80, - cache_read_input_tokens: 60, - }) - ); - } - - #[test] - fn response_to_output_parses_nested_cache_creation_usage_fields() { - let output = response_to_output(AnthropicResponse { - content: vec![json!({ "type": "text", "text": "ok" })], - stop_reason: Some("end_turn".to_string()), - usage: Some(AnthropicUsage { - input_tokens: Some(100), - output_tokens: Some(20), - cache_creation_input_tokens: None, - cache_read_input_tokens: Some(60), - cache_creation: Some(AnthropicCacheCreationUsage { - ephemeral_5m_input_tokens: Some(30), - ephemeral_1h_input_tokens: Some(50), - }), - }), - }); - - assert_eq!( - output.usage, - Some(LlmUsage { - input_tokens: 100, - output_tokens: 20, - cache_creation_input_tokens: 80, - cache_read_input_tokens: 60, - }) - ); - } - - #[test] - fn anthropic_provider_reports_cache_metrics_support() { - let provider = AnthropicProvider::new( - "https://api.anthropic.com/v1/messages".to_string(), - "sk-ant-test".to_string(), - "claude-sonnet-4-5".to_string(), - ModelLimits { - context_window: 200_000, - max_output_tokens: 8096, - }, - LlmClientConfig::default(), - ) - .expect("provider should build"); - - assert!(provider.supports_cache_metrics()); - } - - #[test] - fn streaming_sse_extracts_usage_from_message_events() { - let mut accumulator = LlmAccumulator::default(); - let events = Arc::new(Mutex::new(Vec::new())); - let sink = sink_collector(events); - let mut usage_out = AnthropicUsage::default(); - let mut stop_reason_out = None; - let mut sse_buffer = String::new(); - - let chunk = concat!( - "event: message_start\n", - "data: {\"type\":\"message_start\",\"message\":{\"usage\":{\"input_tokens\":120,\"\ - cache_creation_input_tokens\":90,\"cache_read_input_tokens\":70}}}\n\n", - "event: message_delta\n", - "data: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\"},\"usage\":\ - {\"output_tokens\":33}}\n\n", - "event: message_stop\n", - "data: {\"type\":\"message_stop\"}\n\n" - ); - - let done = consume_sse_text_chunk( - chunk, - &mut sse_buffer, - &mut accumulator, - &sink, - &mut stop_reason_out, - &mut usage_out, - ) - .expect("stream chunk should parse"); - - assert!(done); - assert_eq!(stop_reason_out.as_deref(), Some("end_turn")); - assert_eq!( - usage_out.into_llm_usage(), - Some(LlmUsage { - input_tokens: 120, - output_tokens: 33, - cache_creation_input_tokens: 90, - cache_read_input_tokens: 70, - }) - ); - } - - #[test] - fn streaming_sse_handles_multibyte_text_split_across_chunks() { - let mut accumulator = LlmAccumulator::default(); - let events = Arc::new(Mutex::new(Vec::new())); - let sink = sink_collector(events.clone()); - let mut sse_buffer = String::new(); - let mut decoder = Utf8StreamDecoder::default(); - let mut stop_reason_out = None; - let mut usage_out = AnthropicUsage::default(); - let chunk = concat!( - "event: content_block_delta\n", - "data: {\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"你", - "好\"}}\n\n", - "event: message_stop\n", - "data: {\"type\":\"message_stop\"}\n\n" - ); - let bytes = chunk.as_bytes(); - let split_index = chunk - .find("好") - .expect("chunk should contain multibyte char") - + 1; - - let first_text = decoder - .push( - &bytes[..split_index], - "anthropic response stream was not valid utf-8", - ) - .expect("first split should decode"); - let second_text = decoder - .push( - &bytes[split_index..], - "anthropic response stream was not valid utf-8", - ) - .expect("second split should decode"); - - let first_done = first_text - .as_deref() - .map(|text| { - consume_sse_text_chunk( - text, - &mut sse_buffer, - &mut accumulator, - &sink, - &mut stop_reason_out, - &mut usage_out, - ) - }) - .transpose() - .expect("first chunk should parse") - .unwrap_or(false); - let second_done = second_text - .as_deref() - .map(|text| { - consume_sse_text_chunk( - text, - &mut sse_buffer, - &mut accumulator, - &sink, - &mut stop_reason_out, - &mut usage_out, - ) - }) - .transpose() - .expect("second chunk should parse") - .unwrap_or(false); - - assert!(!first_done); - assert!(second_done); - let output = accumulator.finish(); - let events = events.lock().expect("lock").clone(); - - assert!( - events - .iter() - .any(|event| matches!(event, LlmEvent::TextDelta(text) if text == "你好")) - ); - assert_eq!(output.content, "你好"); - } -} diff --git a/crates/adapter-llm/src/anthropic/dto.rs b/crates/adapter-llm/src/anthropic/dto.rs new file mode 100644 index 00000000..79007da9 --- /dev/null +++ b/crates/adapter-llm/src/anthropic/dto.rs @@ -0,0 +1,310 @@ +use serde::Serialize; +use serde_json::Value; + +use crate::LlmUsage; + +pub(super) fn cacheable_text(text: &str) -> bool { + !text.is_empty() +} + +/// Anthropic Messages API 请求体。 +/// +/// 注意:`stream` 字段为 `Option`,`None` 时表示非流式模式, +/// 这样可以在序列化时省略该字段(Anthropic API 默认非流式)。 +#[derive(Debug, Serialize)] +pub(super) struct AnthropicRequest { + pub(super) model: String, + pub(super) max_tokens: u32, + #[serde(skip_serializing_if = "Option::is_none")] + pub(super) cache_control: Option, + pub(super) messages: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub(super) system: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(super) tools: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub(super) stream: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(super) thinking: Option, +} + +#[derive(Debug, Serialize)] +#[serde(untagged)] +pub(super) enum AnthropicSystemPrompt { + Text(String), + Blocks(Vec), +} + +#[derive(Debug, Serialize)] +pub(super) struct AnthropicSystemBlock { + #[serde(rename = "type")] + pub(super) type_: String, + pub(super) text: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub(super) cache_control: Option, +} + +/// Anthropic extended thinking 配置。 +/// +/// `budget_tokens` 指定推理过程可使用的最大 token 数, +/// 不计入最终输出的 `max_tokens` 限制。 +/// +/// ## 设计动机 +/// +/// Extended thinking 让 Claude 在输出前进行深度推理,提升复杂任务的回答质量。 +/// 预算设为 75% 是为了保留至少 25% 的 token 给实际输出内容。 +#[derive(Debug, Serialize)] +pub(super) struct AnthropicThinking { + #[serde(rename = "type")] + pub(super) type_: String, + pub(super) budget_tokens: u32, +} + +/// Anthropic 消息(包含角色和内容块数组)。 +/// +/// Anthropic 的消息结构与 OpenAI 不同:`content` 是内容块数组而非纯文本, +/// 这使得单条消息可以混合文本、推理、工具调用等多种内容类型。 +#[derive(Debug, Serialize)] +pub(super) struct AnthropicMessage { + pub(super) role: String, + pub(super) content: Vec, +} + +/// Anthropic 内容块——消息内容由多个块组成。 +/// +/// 使用 `#[serde(tag = "type")]` 实现内部标记序列化, +/// 每个变体对应一个 `type` 值(`text`、`thinking`、`tool_use`、`tool_result`)。 +/// +/// ## 缓存控制 +/// +/// 每个块可选携带 `cache_control` 字段,标记为 `ephemeral` 类型时, +/// Anthropic 后端会将该块作为缓存前缀的一部分,用于 KV cache 复用。 +#[derive(Debug, Serialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub(super) enum AnthropicContentBlock { + Text { + text: String, + #[serde(skip_serializing_if = "Option::is_none")] + cache_control: Option, + }, + Thinking { + thinking: String, + #[serde(skip_serializing_if = "Option::is_none")] + signature: Option, + #[serde(skip_serializing_if = "Option::is_none")] + cache_control: Option, + }, + ToolUse { + id: String, + name: String, + input: Value, + #[serde(skip_serializing_if = "Option::is_none")] + cache_control: Option, + }, + ToolResult { + tool_use_id: String, + content: String, + #[serde(skip_serializing_if = "Option::is_none")] + cache_control: Option, + }, +} + +/// Anthropic prompt caching 控制标记。 +/// +/// `type: "ephemeral"` 告诉 Anthropic 后端该块可作为缓存前缀的一部分。 +/// 缓存是临时的(ephemeral),不保证长期有效,但在短时间内重复请求可以显著减少延迟。 +#[derive(Debug, Clone, Serialize)] +pub(super) struct AnthropicCacheControl { + #[serde(rename = "type")] + type_: String, +} + +impl AnthropicCacheControl { + /// 创建 ephemeral 类型的缓存控制标记。 + pub(super) fn ephemeral() -> Self { + Self { + type_: "ephemeral".to_string(), + } + } +} + +impl AnthropicContentBlock { + pub(super) fn block_type(&self) -> &'static str { + match self { + AnthropicContentBlock::Text { .. } => "text", + AnthropicContentBlock::Thinking { .. } => "thinking", + AnthropicContentBlock::ToolUse { .. } => "tool_use", + AnthropicContentBlock::ToolResult { .. } => "tool_result", + } + } + + pub(super) fn has_cache_control(&self) -> bool { + match self { + AnthropicContentBlock::Text { cache_control, .. } + | AnthropicContentBlock::Thinking { cache_control, .. } + | AnthropicContentBlock::ToolUse { cache_control, .. } + | AnthropicContentBlock::ToolResult { cache_control, .. } => cache_control.is_some(), + } + } + + /// 判断内容块是否适合显式 `cache_control`。 + /// + /// 出于兼容网关的稳健性,显式缓存断点仅打在 text 块上; + /// thinking / tool_use / tool_result 不设置 cache_control,避免部分兼容实现的参数校验失败。 + pub(super) fn can_use_explicit_cache_control(&self) -> bool { + match self { + AnthropicContentBlock::Text { text, .. } => cacheable_text(text), + AnthropicContentBlock::Thinking { .. } => false, + AnthropicContentBlock::ToolUse { .. } => false, + AnthropicContentBlock::ToolResult { .. } => false, + } + } + + /// 为允许显式缓存的内容块设置或清除 `cache_control` 标记。 + pub(super) fn set_cache_control_if_allowed(&mut self, enabled: bool) -> bool { + if enabled && !self.can_use_explicit_cache_control() { + return false; + } + + let control = if enabled { + Some(AnthropicCacheControl::ephemeral()) + } else { + None + }; + match self { + AnthropicContentBlock::Text { cache_control, .. } => *cache_control = control, + AnthropicContentBlock::Thinking { .. } => return false, + AnthropicContentBlock::ToolUse { cache_control, .. } => *cache_control = control, + AnthropicContentBlock::ToolResult { cache_control, .. } => *cache_control = control, + } + true + } +} + +/// Anthropic 工具定义。 +/// +/// 与 OpenAI 不同,Anthropic 工具定义不需要 `type` 字段, +/// 直接使用 `name`、`description`、`input_schema` 三个字段。 +#[derive(Debug, Serialize)] +pub(super) struct AnthropicTool { + pub(super) name: String, + pub(super) description: String, + pub(super) input_schema: Value, + + #[serde(skip_serializing_if = "Option::is_none")] + pub(super) cache_control: Option, +} + +/// Anthropic Messages API 非流式响应体。 +/// +/// NOTE: `content` 使用 `Vec` 而非强类型结构体, +/// 因为 Anthropic 响应可能包含多种内容块类型(text / tool_use / thinking), +/// 使用 `Value` 可以灵活处理未知或新增的块类型,避免每次 API 更新都要修改 DTO。 +#[derive(Debug, serde::Deserialize)] +pub(super) struct AnthropicResponse { + pub(super) content: Vec, + #[allow(dead_code)] + pub(super) stop_reason: Option, + #[serde(default)] + pub(super) usage: Option, +} + +/// Anthropic 响应中的 token 用量统计。 +/// +/// 两个字段均为 `Option` 且带 `#[serde(default)]`, +/// 因为某些旧版 API 或特殊响应可能不包含用量信息。 +#[derive(Debug, Clone, Default, serde::Deserialize)] +pub(super) struct AnthropicUsage { + #[serde(default)] + pub(super) input_tokens: Option, + #[serde(default)] + pub(super) output_tokens: Option, + #[serde(default)] + pub(super) cache_creation_input_tokens: Option, + #[serde(default)] + pub(super) cache_read_input_tokens: Option, + #[serde(default)] + pub(super) cache_creation: Option, +} + +#[derive(Debug, Clone, Default, serde::Deserialize)] +pub(super) struct AnthropicCacheCreationUsage { + #[serde(default)] + pub(super) ephemeral_5m_input_tokens: Option, + #[serde(default)] + pub(super) ephemeral_1h_input_tokens: Option, +} + +impl AnthropicUsage { + pub(super) fn merge_from(&mut self, other: Self) { + self.input_tokens = other.input_tokens.or(self.input_tokens); + self.cache_creation_input_tokens = other + .cache_creation_input_tokens + .or(self.cache_creation_input_tokens); + self.cache_read_input_tokens = other + .cache_read_input_tokens + .or(self.cache_read_input_tokens); + self.cache_creation = other.cache_creation.or_else(|| self.cache_creation.take()); + // output_tokens 在流式事件里通常是累计值,优先保留最新的非空值。 + self.output_tokens = other.output_tokens.or(self.output_tokens); + } + + pub(super) fn into_llm_usage(self) -> Option { + let cache_creation_input_tokens = self.cache_creation_input_tokens.or_else(|| { + self.cache_creation + .as_ref() + .map(AnthropicCacheCreationUsage::total_input_tokens) + }); + + if self.input_tokens.is_none() + && self.output_tokens.is_none() + && cache_creation_input_tokens.is_none() + && self.cache_read_input_tokens.is_none() + { + return None; + } + + Some(LlmUsage { + input_tokens: self.input_tokens.unwrap_or_default() as usize, + output_tokens: self.output_tokens.unwrap_or_default() as usize, + cache_creation_input_tokens: cache_creation_input_tokens.unwrap_or_default() as usize, + cache_read_input_tokens: self.cache_read_input_tokens.unwrap_or_default() as usize, + }) + } +} + +impl AnthropicCacheCreationUsage { + fn total_input_tokens(&self) -> u64 { + self.ephemeral_5m_input_tokens + .unwrap_or_default() + .saturating_add(self.ephemeral_1h_input_tokens.unwrap_or_default()) + } +} + +#[derive(Debug, Default)] +pub(super) struct SseProcessResult { + pub(super) done: bool, + pub(super) stop_reason: Option, + pub(super) usage: Option, +} + +pub(super) fn extract_usage_from_payload( + event_type: &str, + payload: &Value, +) -> Option { + match event_type { + "message_start" => payload + .get("message") + .and_then(|message| message.get("usage")) + .and_then(parse_usage_value), + "message_delta" => payload + .get("usage") + .or_else(|| payload.get("delta").and_then(|delta| delta.get("usage"))) + .and_then(parse_usage_value), + _ => None, + } +} + +fn parse_usage_value(value: &Value) -> Option { + serde_json::from_value::(value.clone()).ok() +} diff --git a/crates/adapter-llm/src/anthropic/mod.rs b/crates/adapter-llm/src/anthropic/mod.rs new file mode 100644 index 00000000..9a091c6d --- /dev/null +++ b/crates/adapter-llm/src/anthropic/mod.rs @@ -0,0 +1,32 @@ +//! # Anthropic Messages API 提供者 +//! +//! 实现了 [`LlmProvider`] trait,对接 Anthropic Claude 系列模型。 +//! +//! ## 协议特性 +//! +//! - **Extended Thinking**: 自动为 Claude 模型启用深度推理模式(`thinking` 配置), 预算 token 设为 +//! `max_tokens` 的 75%,保留至少 25% 给实际输出 +//! - **Prompt Caching**: 优先对分层 system blocks 放置 `ephemeral` breakpoint,并在消息尾部保留 +//! 一个缓存边界,复用 KV cache +//! - **SSE 流式解析**: Anthropic 使用多行 SSE 块格式(`event: ...\ndata: {...}\n\n`), 与 OpenAI +//! 的单行 `data: {...}` 不同,因此有独立的解析逻辑 +//! - **内容块模型**: Anthropic 响应由多种内容块组成(text / tool_use / thinking), 使用 +//! `Vec` 灵活处理未知或新增的块类型 +//! +//! ## 流式事件分派 +//! +//! Anthropic SSE 事件类型: +//! - `content_block_start`: 新内容块开始(文本或工具调用) +//! - `content_block_delta`: 增量内容(text_delta / thinking_delta / signature_delta / +//! input_json_delta) +//! - `message_stop`: 流结束信号 +//! - `message_start / message_delta`: 提取 usage / stop_reason 等元数据 +//! - `content_block_stop / ping`: 元数据事件,静默忽略 + +mod dto; +mod provider; +mod request; +mod response; +mod stream; + +pub use provider::AnthropicProvider; diff --git a/crates/adapter-llm/src/anthropic/provider.rs b/crates/adapter-llm/src/anthropic/provider.rs new file mode 100644 index 00000000..c5a9f175 --- /dev/null +++ b/crates/adapter-llm/src/anthropic/provider.rs @@ -0,0 +1,491 @@ +use std::{ + fmt, + sync::{Arc, Mutex}, +}; + +use astrcode_core::{ + AstrError, CancelToken, LlmMessage, Result, SystemPromptBlock, ToolDefinition, +}; +use async_trait::async_trait; +use futures_util::StreamExt; +use log::{debug, warn}; +use tokio::select; + +use super::{ + dto::{AnthropicCacheControl, AnthropicRequest, AnthropicResponse, AnthropicUsage}, + request::{ + ANTHROPIC_CACHE_BREAKPOINT_LIMIT, MessageBuildOptions, enable_message_caching, + is_official_anthropic_api_url, summarize_request_for_diagnostics, + thinking_config_for_model, to_anthropic_messages, to_anthropic_system, to_anthropic_tools, + }, + response::response_to_output, + stream::{consume_sse_text_chunk, flush_sse_buffer}, +}; +use crate::{ + EventSink, FinishReason, LlmAccumulator, LlmClientConfig, LlmOutput, LlmProvider, LlmRequest, + LlmUsage, ModelLimits, Utf8StreamDecoder, build_http_client, cache_tracker::CacheTracker, + classify_http_error, is_retryable_status, wait_retry_delay, +}; + +const ANTHROPIC_VERSION: &str = "2023-06-01"; + +/// Anthropic Claude API 提供者实现。 +/// +/// 封装了 HTTP 客户端、API 密钥和模型配置,提供统一的 [`LlmProvider`] 接口。 +/// +/// ## 设计要点 +/// +/// - HTTP 客户端在构造时创建,使用共享的超时策略(连接 10s / 读取 90s) +/// - `limits.max_output_tokens` 同时控制请求体的上限和 extended thinking 的预算计算 +/// - Debug 实现会隐藏 API 密钥(显示为 ``) +#[derive(Clone)] +pub struct AnthropicProvider { + client: reqwest::Client, + client_config: LlmClientConfig, + messages_api_url: String, + api_key: String, + model: String, + /// 运行时已解析好的模型 limits。 + /// + /// Anthropic 的上下文窗口来自 Models API,不应该继续在 provider 内写死。 + limits: ModelLimits, + /// 缓存失效检测跟踪器 + cache_tracker: Arc>, +} + +impl fmt::Debug for AnthropicProvider { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("AnthropicProvider") + .field("client", &self.client) + .field("messages_api_url", &self.messages_api_url) + .field("api_key", &"") + .field("model", &self.model) + .field("limits", &self.limits) + .field("client_config", &self.client_config) + .field("cache_tracker", &"") + .finish() + } +} + +impl AnthropicProvider { + /// 创建新的 Anthropic 提供者实例。 + /// + /// `limits.max_output_tokens` 同时用于: + /// 1. 请求体中的 `max_tokens` 字段(输出上限) + /// 2. Extended thinking 预算计算(75% 的 max_tokens) + pub fn new( + messages_api_url: String, + api_key: String, + model: String, + limits: ModelLimits, + client_config: LlmClientConfig, + ) -> Result { + Ok(Self { + client: build_http_client(client_config)?, + client_config, + messages_api_url, + api_key, + model, + limits, + cache_tracker: Arc::new(Mutex::new(CacheTracker::new())), + }) + } + + /// 构建 Anthropic Messages API 请求体。 + /// + /// - 将 `LlmMessage` 转换为 Anthropic 格式的内容块数组 + /// - 对分层 system blocks 和消息尾部启用 prompt caching(KV cache 复用) + /// - 如果启用了工具,附加工具定义 + /// - 根据模型名称和 max_tokens 自动配置 extended thinking + pub(super) fn build_request( + &self, + messages: &[LlmMessage], + tools: &[ToolDefinition], + system_prompt: Option<&str>, + system_prompt_blocks: &[SystemPromptBlock], + stream: bool, + ) -> AnthropicRequest { + let use_official_endpoint = is_official_anthropic_api_url(&self.messages_api_url); + let use_automatic_cache = use_official_endpoint; + let mut remaining_cache_breakpoints = ANTHROPIC_CACHE_BREAKPOINT_LIMIT; + let request_cache_control = if use_automatic_cache { + remaining_cache_breakpoints = remaining_cache_breakpoints.saturating_sub(1); + Some(AnthropicCacheControl::ephemeral()) + } else { + None + }; + + let mut anthropic_messages = to_anthropic_messages( + messages, + MessageBuildOptions { + include_reasoning_blocks: use_official_endpoint, + }, + ); + let tools = if tools.is_empty() { + None + } else { + Some(to_anthropic_tools(tools, &mut remaining_cache_breakpoints)) + }; + let system = to_anthropic_system( + system_prompt, + system_prompt_blocks, + &mut remaining_cache_breakpoints, + ); + + if !use_automatic_cache { + enable_message_caching(&mut anthropic_messages, remaining_cache_breakpoints); + } + + AnthropicRequest { + model: self.model.clone(), + max_tokens: self.limits.max_output_tokens.min(u32::MAX as usize) as u32, + cache_control: request_cache_control, + messages: anthropic_messages, + system, + tools, + stream: stream.then_some(true), + // Why: 第三方 Anthropic 兼容网关常见只支持基础 messages 子集; + // 在非官方 endpoint 下关闭 `thinking` 字段,避免触发参数校验失败。 + thinking: if use_official_endpoint { + thinking_config_for_model( + &self.model, + self.limits.max_output_tokens.min(u32::MAX as usize) as u32, + ) + } else { + None + }, + } + } + + async fn send_request( + &self, + request: &AnthropicRequest, + cancel: CancelToken, + ) -> Result { + // 调试日志:打印请求信息(不暴露完整 API Key) + let api_key_preview = if self.api_key.len() > 8 { + format!( + "{}...{}", + &self.api_key[..4], + &self.api_key[self.api_key.len() - 4..] + ) + } else { + "****".to_string() + }; + debug!( + "Anthropic request: url={}, api_key_preview={}, model={}", + self.messages_api_url, api_key_preview, self.model + ); + if !is_official_anthropic_api_url(&self.messages_api_url) { + debug!( + "Anthropic-compatible request summary: {}", + summarize_request_for_diagnostics(request) + ); + } + + for attempt in 0..=self.client_config.max_retries { + let send_future = self + .client + .post(&self.messages_api_url) + .header("x-api-key", &self.api_key) + .header("anthropic-version", ANTHROPIC_VERSION) + .header(reqwest::header::CONTENT_TYPE, "application/json") + .json(request) + .send(); + + let response = select! { + _ = crate::cancelled(cancel.clone()) => { + return Err(AstrError::LlmInterrupted); + } + result = send_future => result.map_err(|e| AstrError::http("failed to call anthropic endpoint", e)) + }; + + match response { + Ok(response) => { + let status = response.status(); + if status == reqwest::StatusCode::UNAUTHORIZED { + // 读取响应体以便调试 + let body = response.text().await.unwrap_or_default(); + warn!( + "Anthropic 401 Unauthorized: url={}, api_key_preview={}, response={}", + self.messages_api_url, + if self.api_key.len() > 8 { + format!( + "{}...{}", + &self.api_key[..4], + &self.api_key[self.api_key.len() - 4..] + ) + } else { + "****".to_string() + }, + body + ); + return Err(AstrError::InvalidApiKey("Anthropic".to_string())); + } + if status.is_success() { + return Ok(response); + } + + let body = response.text().await.unwrap_or_default(); + if is_retryable_status(status) && attempt < self.client_config.max_retries { + wait_retry_delay( + attempt, + cancel.clone(), + self.client_config.retry_base_delay, + ) + .await?; + continue; + } + + if status.is_client_error() + && !is_official_anthropic_api_url(&self.messages_api_url) + { + warn!( + "Anthropic-compatible request rejected: url={}, status={}, \ + request_summary={}, response={}", + self.messages_api_url, + status.as_u16(), + summarize_request_for_diagnostics(request), + body + ); + } + + // 使用结构化错误分类 (P4.3) + return Err(classify_http_error(status.as_u16(), &body).into()); + }, + Err(error) => { + if error.is_retryable() && attempt < self.client_config.max_retries { + wait_retry_delay( + attempt, + cancel.clone(), + self.client_config.retry_base_delay, + ) + .await?; + continue; + } + return Err(error); + }, + } + } + + // 所有路径都会通过 return 退出循环;若到达此处说明逻辑有误, + // 返回 Internal 而非 panic 以保证运行时安全 + Err(AstrError::Internal( + "retry loop should have returned on all paths".into(), + )) + } +} + +#[async_trait] +impl LlmProvider for AnthropicProvider { + fn supports_cache_metrics(&self) -> bool { + true + } + + fn prompt_metrics_input_tokens(&self, usage: LlmUsage) -> usize { + usage + .input_tokens + .saturating_add(usage.cache_read_input_tokens) + } + + async fn generate(&self, request: LlmRequest, sink: Option) -> Result { + let cancel = request.cancel; + + // 检测缓存失效并记录原因 + let system_prompt_text = request.system_prompt.as_deref().unwrap_or(""); + let tool_names: Vec = request.tools.iter().map(|t| t.name.clone()).collect(); + + if let Ok(mut tracker) = self.cache_tracker.lock() { + let break_reasons = + tracker.check_and_update(system_prompt_text, &tool_names, &self.model, "anthropic"); + + if !break_reasons.is_empty() { + debug!("[CACHE] Cache break detected: {:?}", break_reasons); + } + } + + let body = self.build_request( + &request.messages, + &request.tools, + request.system_prompt.as_deref(), + &request.system_prompt_blocks, + sink.is_some(), + ); + let response = self.send_request(&body, cancel.clone()).await?; + + match sink { + None => { + let payload: AnthropicResponse = response + .json() + .await + .map_err(|e| AstrError::http("failed to parse anthropic response", e))?; + Ok(response_to_output(payload)) + }, + Some(sink) => { + let mut stream = response.bytes_stream(); + let mut sse_buffer = String::new(); + let mut utf8_decoder = Utf8StreamDecoder::default(); + let mut accumulator = LlmAccumulator::default(); + // 流式路径下从 message_delta 的 stop_reason 提取 (P4.2) + let mut stream_stop_reason: Option = None; + let mut stream_usage = AnthropicUsage::default(); + + loop { + let next_item = select! { + _ = crate::cancelled(cancel.clone()) => { + return Err(AstrError::LlmInterrupted); + } + item = stream.next() => item, + }; + + let Some(item) = next_item else { + break; + }; + + let bytes = item.map_err(|e| { + AstrError::http("failed to read anthropic response stream", e) + })?; + let Some(chunk_text) = utf8_decoder + .push(&bytes, "anthropic response stream was not valid utf-8")? + else { + continue; + }; + + if consume_sse_text_chunk( + &chunk_text, + &mut sse_buffer, + &mut accumulator, + &sink, + &mut stream_stop_reason, + &mut stream_usage, + )? { + let mut output = accumulator.finish(); + // 优先使用 API 返回的 stop_reason,否则使用推断值 + if let Some(reason) = stream_stop_reason.as_deref() { + output.finish_reason = FinishReason::from_api_value(reason); + } + output.usage = stream_usage.into_llm_usage(); + + // 记录流式响应的缓存状态 + if let Some(ref u) = output.usage { + let input = u.input_tokens; + let cache_read = u.cache_read_input_tokens; + let cache_creation = u.cache_creation_input_tokens; + let total_prompt_tokens = input.saturating_add(cache_read); + + if cache_read == 0 && cache_creation > 0 { + debug!( + "Cache miss (streaming): writing {} tokens to cache (total \ + prompt: {}, uncached input: {})", + cache_creation, total_prompt_tokens, input + ); + } else if cache_read > 0 { + let hit_rate = + (cache_read as f32 / total_prompt_tokens as f32) * 100.0; + debug!( + "Cache hit (streaming): {:.1}% ({} / {} prompt tokens, \ + creation: {}, uncached input: {})", + hit_rate, + cache_read, + total_prompt_tokens, + cache_creation, + input + ); + } else { + debug!( + "Cache disabled or unavailable (streaming, total prompt: {} \ + tokens)", + total_prompt_tokens + ); + } + } + + return Ok(output); + } + } + + if let Some(tail_text) = + utf8_decoder.finish("anthropic response stream was not valid utf-8")? + { + let done = consume_sse_text_chunk( + &tail_text, + &mut sse_buffer, + &mut accumulator, + &sink, + &mut stream_stop_reason, + &mut stream_usage, + )?; + if done { + let mut output = accumulator.finish(); + if let Some(reason) = stream_stop_reason.as_deref() { + output.finish_reason = FinishReason::from_api_value(reason); + } + output.usage = stream_usage.into_llm_usage(); + return Ok(output); + } + } + + flush_sse_buffer( + &mut sse_buffer, + &mut accumulator, + &sink, + &mut stream_stop_reason, + &mut stream_usage, + )?; + let mut output = accumulator.finish(); + if let Some(reason) = stream_stop_reason.as_deref() { + output.finish_reason = FinishReason::from_api_value(reason); + } + output.usage = stream_usage.into_llm_usage(); + Ok(output) + }, + } + } + + fn model_limits(&self) -> ModelLimits { + self.limits + } +} + +#[cfg(test)] +mod tests { + use super::AnthropicProvider; + use crate::{LlmClientConfig, LlmProvider, ModelLimits}; + + #[test] + fn provider_keeps_custom_messages_api_url() { + let provider = AnthropicProvider::new( + "https://gateway.example.com/anthropic/v1/messages".to_string(), + "sk-ant-test".to_string(), + "claude-sonnet-4-5".to_string(), + ModelLimits { + context_window: 200_000, + max_output_tokens: 8096, + }, + LlmClientConfig::default(), + ) + .expect("provider should build"); + + assert_eq!( + provider.messages_api_url, + "https://gateway.example.com/anthropic/v1/messages" + ); + } + + #[test] + fn anthropic_provider_reports_cache_metrics_support() { + let provider = AnthropicProvider::new( + "https://api.anthropic.com/v1/messages".to_string(), + "sk-ant-test".to_string(), + "claude-sonnet-4-5".to_string(), + ModelLimits { + context_window: 200_000, + max_output_tokens: 8096, + }, + LlmClientConfig::default(), + ) + .expect("provider should build"); + + assert!(provider.supports_cache_metrics()); + } +} diff --git a/crates/adapter-llm/src/anthropic/request.rs b/crates/adapter-llm/src/anthropic/request.rs new file mode 100644 index 00000000..1bb3bc35 --- /dev/null +++ b/crates/adapter-llm/src/anthropic/request.rs @@ -0,0 +1,812 @@ +use astrcode_core::{LlmMessage, SystemPromptBlock, SystemPromptLayer, ToolDefinition}; +use serde_json::{Value, json}; + +use super::dto::{ + AnthropicCacheControl, AnthropicContentBlock, AnthropicMessage, AnthropicRequest, + AnthropicSystemBlock, AnthropicSystemPrompt, AnthropicThinking, AnthropicTool, cacheable_text, +}; + +pub(super) const ANTHROPIC_CACHE_BREAKPOINT_LIMIT: usize = 4; + +/// 将 `LlmMessage` 转换为 Anthropic 格式的消息结构。 +/// +/// Anthropic 使用内容块数组(而非纯文本),因此需要按消息类型分派: +/// - User 消息 → 单个 `text` 内容块 +/// - Assistant 消息 → 可能包含 `thinking`、`text`、`tool_use` 多个块 +/// - Tool 消息 → 单个 `tool_result` 内容块 +#[derive(Clone, Copy)] +pub(super) struct MessageBuildOptions { + pub(super) include_reasoning_blocks: bool, +} + +pub(super) fn summarize_request_for_diagnostics(request: &AnthropicRequest) -> Value { + let messages = request + .messages + .iter() + .map(|message| { + let block_types = message + .content + .iter() + .map(AnthropicContentBlock::block_type) + .collect::>(); + json!({ + "role": message.role, + "blockTypes": block_types, + "blockCount": message.content.len(), + "cacheControlCount": message + .content + .iter() + .filter(|block| block.has_cache_control()) + .count(), + }) + }) + .collect::>(); + let system = match &request.system { + None => Value::Null, + Some(AnthropicSystemPrompt::Text(text)) => json!({ + "kind": "text", + "chars": text.chars().count(), + }), + Some(AnthropicSystemPrompt::Blocks(blocks)) => json!({ + "kind": "blocks", + "count": blocks.len(), + "cacheControlCount": blocks + .iter() + .filter(|block| block.cache_control.is_some()) + .count(), + "chars": blocks.iter().map(|block| block.text.chars().count()).sum::(), + }), + }; + let tools = request.tools.as_ref().map(|tools| { + json!({ + "count": tools.len(), + "names": tools.iter().map(|tool| tool.name.clone()).collect::>(), + "cacheControlCount": tools + .iter() + .filter(|tool| tool.cache_control.is_some()) + .count(), + }) + }); + + json!({ + "model": request.model, + "maxTokens": request.max_tokens, + "topLevelCacheControl": request.cache_control.is_some(), + "hasThinking": request.thinking.is_some(), + "stream": request.stream.unwrap_or(false), + "system": system, + "messages": messages, + "tools": tools, + }) +} + +pub(super) fn to_anthropic_messages( + messages: &[LlmMessage], + options: MessageBuildOptions, +) -> Vec { + let mut anthropic_messages = Vec::with_capacity(messages.len()); + let mut pending_user_blocks = Vec::new(); + + let flush_pending_user_blocks = + |anthropic_messages: &mut Vec, + pending_user_blocks: &mut Vec| { + if pending_user_blocks.is_empty() { + return; + } + + anthropic_messages.push(AnthropicMessage { + role: "user".to_string(), + content: std::mem::take(pending_user_blocks), + }); + }; + + for message in messages { + match message { + LlmMessage::User { content, .. } => { + pending_user_blocks.push(AnthropicContentBlock::Text { + text: content.clone(), + cache_control: None, + }); + }, + LlmMessage::Assistant { + content, + tool_calls, + reasoning, + } => { + flush_pending_user_blocks(&mut anthropic_messages, &mut pending_user_blocks); + + let mut blocks = Vec::new(); + if options.include_reasoning_blocks { + if let Some(reasoning) = reasoning { + blocks.push(AnthropicContentBlock::Thinking { + thinking: reasoning.content.clone(), + signature: reasoning.signature.clone(), + cache_control: None, + }); + } + } + // Anthropic assistant 消息可以直接包含 tool_use 块,不要求前置 text 块。 + // 仅在确实有文本时写入 text 块,避免向兼容网关发送空 text 导致参数校验失败。 + if !content.is_empty() { + blocks.push(AnthropicContentBlock::Text { + text: content.clone(), + cache_control: None, + }); + } + blocks.extend( + tool_calls + .iter() + .map(|call| AnthropicContentBlock::ToolUse { + id: call.id.clone(), + name: call.name.clone(), + input: call.args.clone(), + cache_control: None, + }), + ); + if blocks.is_empty() { + blocks.push(AnthropicContentBlock::Text { + text: String::new(), + cache_control: None, + }); + } + + anthropic_messages.push(AnthropicMessage { + role: "assistant".to_string(), + content: blocks, + }); + }, + LlmMessage::Tool { + tool_call_id, + content, + } => { + pending_user_blocks.push(AnthropicContentBlock::ToolResult { + tool_use_id: tool_call_id.clone(), + content: content.clone(), + cache_control: None, + }); + }, + } + } + + flush_pending_user_blocks(&mut anthropic_messages, &mut pending_user_blocks); + anthropic_messages +} + +/// 在最近的消息内容块上启用显式 prompt caching。 +/// +/// 只有在自定义 Anthropic 网关上才需要这条兜底路径。官方 Anthropic endpoint 使用顶层 +/// 自动缓存来追踪不断增长的对话尾部,避免显式断点超过 4 个 slot。 +pub(super) fn enable_message_caching( + messages: &mut [AnthropicMessage], + max_breakpoints: usize, +) -> usize { + if messages.is_empty() || max_breakpoints == 0 { + return 0; + } + + let mut used = 0; + for msg in messages.iter_mut().rev() { + if used >= max_breakpoints { + break; + } + + let Some(block) = msg + .content + .iter_mut() + .rev() + .find(|block| block.can_use_explicit_cache_control()) + else { + continue; + }; + + if block.set_cache_control_if_allowed(true) { + used += 1; + } + } + + used +} + +fn consume_cache_breakpoint(remaining: &mut usize) -> bool { + if *remaining == 0 { + return false; + } + + *remaining -= 1; + true +} + +pub(super) fn is_official_anthropic_api_url(url: &str) -> bool { + reqwest::Url::parse(url) + .ok() + .and_then(|url| { + url.host_str() + .map(|host| host.eq_ignore_ascii_case("api.anthropic.com")) + }) + .unwrap_or(false) +} + +fn cache_control_if_allowed(remaining: &mut usize) -> Option { + consume_cache_breakpoint(remaining).then(AnthropicCacheControl::ephemeral) +} + +// Dynamic 层不参与缓存,动态内容每轮都变 +fn cacheable_system_layer(layer: SystemPromptLayer) -> bool { + !matches!(layer, SystemPromptLayer::Dynamic) +} + +/// 将 `ToolDefinition` 转换为 Anthropic 工具定义格式。 +pub(super) fn to_anthropic_tools( + tools: &[ToolDefinition], + remaining_cache_breakpoints: &mut usize, +) -> Vec { + if tools.is_empty() { + return Vec::new(); + } + + let last_cacheable_index = tools + .iter() + .rposition(|tool| cacheable_text(&tool.name) || cacheable_text(&tool.description)); + + tools + .iter() + .enumerate() + .map(|(index, tool)| { + let cache_control = if Some(index) == last_cacheable_index { + cache_control_if_allowed(remaining_cache_breakpoints) + } else { + None + }; + + AnthropicTool { + name: tool.name.clone(), + description: tool.description.clone(), + input_schema: tool.parameters.clone(), + cache_control, + } + }) + .collect() +} + +pub(super) fn to_anthropic_system( + system_prompt: Option<&str>, + system_prompt_blocks: &[SystemPromptBlock], + remaining_cache_breakpoints: &mut usize, +) -> Option { + if !system_prompt_blocks.is_empty() { + return Some(AnthropicSystemPrompt::Blocks( + system_prompt_blocks + .iter() + .map(|block| { + let text = block.render(); + let cache_control = if block.cache_boundary + && cacheable_system_layer(block.layer) + && cacheable_text(&text) + { + cache_control_if_allowed(remaining_cache_breakpoints) + } else { + None + }; + + AnthropicSystemBlock { + type_: "text".to_string(), + text, + cache_control, + } + }) + .collect(), + )); + } + + system_prompt.map(|value| AnthropicSystemPrompt::Text(value.to_string())) +} + +/// 为模型生成 extended thinking 配置。 +/// +/// 当 max_tokens >= 2 时启用 thinking 模式,预算 token 数为 max_tokens 的 75%(向下取整)。 +/// +/// ## 设计动机 +/// +/// Extended thinking 让模型在输出前进行深度推理,提升复杂任务的回答质量。 +/// 预算设为 75% 是为了保留至少 25% 的 token 给实际输出内容。 +/// 如果预算为 0 或等于 max_tokens,则不启用(避免无意义配置)。 +/// +/// 默认为所有模型启用此功能。如果模型不支持,API 会忽略此参数。 +pub(super) fn thinking_config_for_model( + _model: &str, + max_tokens: u32, +) -> Option { + if max_tokens < 2 { + return None; + } + + let budget_tokens = max_tokens.saturating_mul(3) / 4; + if budget_tokens == 0 || budget_tokens >= max_tokens { + return None; + } + + Some(AnthropicThinking { + type_: "enabled".to_string(), + budget_tokens, + }) +} + +#[cfg(test)] +mod tests { + use astrcode_core::{ + LlmMessage, ReasoningContent, SystemPromptBlock, SystemPromptLayer, ToolCallRequest, + ToolDefinition, UserMessageOrigin, + }; + use serde_json::{Value, json}; + + use super::{ANTHROPIC_CACHE_BREAKPOINT_LIMIT, MessageBuildOptions, to_anthropic_messages}; + use crate::{ + LlmClientConfig, ModelLimits, + anthropic::{dto::AnthropicContentBlock, provider::AnthropicProvider}, + }; + + #[test] + fn to_anthropic_messages_does_not_inject_empty_text_block_for_tool_use() { + let messages = vec![LlmMessage::Assistant { + content: "".to_string(), + tool_calls: vec![ToolCallRequest { + id: "call_123".to_string(), + name: "test_tool".to_string(), + args: json!({"arg": "value"}), + }], + reasoning: None, + }]; + + let anthropic_messages = to_anthropic_messages( + &messages, + MessageBuildOptions { + include_reasoning_blocks: true, + }, + ); + assert_eq!(anthropic_messages.len(), 1); + + let msg = &anthropic_messages[0]; + assert_eq!(msg.role, "assistant"); + assert_eq!(msg.content.len(), 1); + + match &msg.content[0] { + AnthropicContentBlock::ToolUse { id, name, .. } => { + assert_eq!(id, "call_123"); + assert_eq!(name, "test_tool"); + }, + _ => panic!("Expected ToolUse block"), + } + } + + #[test] + fn to_anthropic_messages_groups_consecutive_tool_results_into_one_user_message() { + let messages = vec![ + LlmMessage::Assistant { + content: String::new(), + tool_calls: vec![ + ToolCallRequest { + id: "call_1".to_string(), + name: "read_file".to_string(), + args: json!({"path": "a.rs"}), + }, + ToolCallRequest { + id: "call_2".to_string(), + name: "grep".to_string(), + args: json!({"pattern": "spawn"}), + }, + ], + reasoning: None, + }, + LlmMessage::Tool { + tool_call_id: "call_1".to_string(), + content: "file content".to_string(), + }, + LlmMessage::Tool { + tool_call_id: "call_2".to_string(), + content: "grep result".to_string(), + }, + ]; + + let anthropic_messages = to_anthropic_messages( + &messages, + MessageBuildOptions { + include_reasoning_blocks: true, + }, + ); + + assert_eq!(anthropic_messages.len(), 2); + assert_eq!(anthropic_messages[0].role, "assistant"); + assert_eq!(anthropic_messages[1].role, "user"); + assert_eq!(anthropic_messages[1].content.len(), 2); + assert!(matches!( + &anthropic_messages[1].content[0], + AnthropicContentBlock::ToolResult { tool_use_id, content, .. } + if tool_use_id == "call_1" && content == "file content" + )); + assert!(matches!( + &anthropic_messages[1].content[1], + AnthropicContentBlock::ToolResult { tool_use_id, content, .. } + if tool_use_id == "call_2" && content == "grep result" + )); + } + + #[test] + fn to_anthropic_messages_keeps_user_text_after_tool_results_in_same_message() { + let messages = vec![ + LlmMessage::Assistant { + content: String::new(), + tool_calls: vec![ToolCallRequest { + id: "call_1".to_string(), + name: "read_file".to_string(), + args: json!({"path": "a.rs"}), + }], + reasoning: None, + }, + LlmMessage::Tool { + tool_call_id: "call_1".to_string(), + content: "file content".to_string(), + }, + LlmMessage::User { + content: "请继续总结发现。".to_string(), + origin: UserMessageOrigin::User, + }, + ]; + + let anthropic_messages = to_anthropic_messages( + &messages, + MessageBuildOptions { + include_reasoning_blocks: true, + }, + ); + + assert_eq!(anthropic_messages.len(), 2); + assert_eq!(anthropic_messages[1].role, "user"); + assert_eq!(anthropic_messages[1].content.len(), 2); + assert!(matches!( + &anthropic_messages[1].content[0], + AnthropicContentBlock::ToolResult { tool_use_id, content, .. } + if tool_use_id == "call_1" && content == "file content" + )); + assert!(matches!( + &anthropic_messages[1].content[1], + AnthropicContentBlock::Text { text, .. } if text == "请继续总结发现。" + )); + } + + #[test] + fn build_request_serializes_system_and_thinking_when_applicable() { + let provider = AnthropicProvider::new( + "https://api.anthropic.com/v1/messages".to_string(), + "sk-ant-test".to_string(), + "claude-sonnet-4-5".to_string(), + ModelLimits { + context_window: 200_000, + max_output_tokens: 8096, + }, + LlmClientConfig::default(), + ) + .expect("provider should build"); + let request = provider.build_request( + &[LlmMessage::User { + content: "hi".to_string(), + origin: UserMessageOrigin::User, + }], + &[], + Some("Follow the rules"), + &[], + true, + ); + let body = serde_json::to_value(&request).expect("request should serialize"); + + assert_eq!(body["cache_control"]["type"], json!("ephemeral")); + assert_eq!( + body.get("system").and_then(Value::as_str), + Some("Follow the rules") + ); + assert_eq!( + body.get("thinking") + .and_then(|value| value.get("type")) + .and_then(Value::as_str), + Some("enabled") + ); + } + + fn count_cache_control_fields(value: &Value) -> usize { + match value { + Value::Object(map) => { + usize::from(map.contains_key("cache_control")) + + map.values().map(count_cache_control_fields).sum::() + }, + Value::Array(values) => values.iter().map(count_cache_control_fields).sum(), + _ => 0, + } + } + + #[test] + fn official_anthropic_uses_automatic_cache_and_caps_explicit_breakpoints() { + let provider = AnthropicProvider::new( + "https://api.anthropic.com/v1/messages".to_string(), + "sk-ant-test".to_string(), + "claude-sonnet-4-5".to_string(), + ModelLimits { + context_window: 200_000, + max_output_tokens: 8096, + }, + LlmClientConfig::default(), + ) + .expect("provider should build"); + let system_blocks = (0..5) + .map(|index| SystemPromptBlock { + title: format!("Stable {index}"), + content: format!("stable content {index}"), + cache_boundary: true, + layer: SystemPromptLayer::Stable, + }) + .collect::>(); + let tools = vec![ToolDefinition { + name: "search".to_string(), + description: "Search indexed data.".to_string(), + parameters: json!({ "type": "object" }), + }]; + let request = provider.build_request( + &[LlmMessage::User { + content: "hi".to_string(), + origin: UserMessageOrigin::User, + }], + &tools, + None, + &system_blocks, + false, + ); + let body = serde_json::to_value(&request).expect("request should serialize"); + + assert_eq!(body["cache_control"]["type"], json!("ephemeral")); + assert!( + count_cache_control_fields(&body) <= ANTHROPIC_CACHE_BREAKPOINT_LIMIT, + "official request should keep automatic + explicit cache controls within the provider \ + limit" + ); + assert!( + body["messages"][0]["content"][0] + .get("cache_control") + .is_none(), + "official endpoint uses top-level automatic cache for the message tail" + ); + } + + #[test] + fn custom_anthropic_gateway_uses_explicit_message_tail_without_top_level_cache() { + let provider = AnthropicProvider::new( + "https://gateway.example.com/anthropic/v1/messages".to_string(), + "sk-ant-test".to_string(), + "claude-sonnet-4-5".to_string(), + ModelLimits { + context_window: 200_000, + max_output_tokens: 8096, + }, + LlmClientConfig::default(), + ) + .expect("provider should build"); + let request = provider.build_request( + &[ + LlmMessage::User { + content: "first".to_string(), + origin: UserMessageOrigin::User, + }, + LlmMessage::User { + content: "second".to_string(), + origin: UserMessageOrigin::User, + }, + ], + &[], + None, + &[], + false, + ); + let body = serde_json::to_value(&request).expect("request should serialize"); + + assert!(body.get("cache_control").is_none()); + assert_eq!(body["messages"].as_array().map(Vec::len), Some(1)); + assert_eq!( + body["messages"][0]["content"][1]["cache_control"]["type"], + json!("ephemeral") + ); + assert!( + count_cache_control_fields(&body) <= ANTHROPIC_CACHE_BREAKPOINT_LIMIT, + "custom gateways only receive explicit cache controls within the provider limit" + ); + } + + #[test] + fn custom_gateway_request_disables_extended_thinking_payloads() { + let provider = AnthropicProvider::new( + "https://gateway.example.com/anthropic/v1/messages".to_string(), + "sk-ant-test".to_string(), + "claude-sonnet-4-5".to_string(), + ModelLimits { + context_window: 200_000, + max_output_tokens: 8096, + }, + LlmClientConfig::default(), + ) + .expect("provider should build"); + let request = provider.build_request( + &[LlmMessage::Assistant { + content: "".to_string(), + tool_calls: vec![], + reasoning: Some(ReasoningContent { + content: "thinking".to_string(), + signature: Some("sig".to_string()), + }), + }], + &[], + None, + &[], + false, + ); + let body = serde_json::to_value(&request).expect("request should serialize"); + + assert!(body.get("thinking").is_none()); + assert_eq!(body["messages"][0]["content"][0]["type"], json!("text")); + assert_eq!(body["messages"][0]["content"][0]["text"], json!("")); + } + + #[test] + fn build_request_serializes_system_blocks_with_cache_boundaries() { + let provider = AnthropicProvider::new( + "https://api.anthropic.com/v1/messages".to_string(), + "sk-ant-test".to_string(), + "claude-sonnet-4-5".to_string(), + ModelLimits { + context_window: 200_000, + max_output_tokens: 8096, + }, + LlmClientConfig::default(), + ) + .expect("provider should build"); + let request = provider.build_request( + &[LlmMessage::User { + content: "hi".to_string(), + origin: UserMessageOrigin::User, + }], + &[], + Some("ignored fallback"), + &[SystemPromptBlock { + title: "Stable".to_string(), + content: "stable".to_string(), + cache_boundary: true, + layer: SystemPromptLayer::Stable, + }], + false, + ); + let body = serde_json::to_value(&request).expect("request should serialize"); + + assert!(body.get("system").is_some_and(Value::is_array)); + assert_eq!( + body["system"][0]["cache_control"]["type"], + json!("ephemeral") + ); + } + + #[test] + fn build_request_only_marks_cache_boundaries_at_layer_transitions() { + let provider = AnthropicProvider::new( + "https://api.anthropic.com/v1/messages".to_string(), + "sk-ant-test".to_string(), + "claude-sonnet-4-5".to_string(), + ModelLimits { + context_window: 200_000, + max_output_tokens: 8096, + }, + LlmClientConfig::default(), + ) + .expect("provider should build"); + let request = provider.build_request( + &[LlmMessage::User { + content: "hi".to_string(), + origin: UserMessageOrigin::User, + }], + &[], + Some("ignored fallback"), + &[ + SystemPromptBlock { + title: "Stable 1".to_string(), + content: "stable content 1".to_string(), + cache_boundary: false, + layer: SystemPromptLayer::Stable, + }, + SystemPromptBlock { + title: "Stable 2".to_string(), + content: "stable content 2".to_string(), + cache_boundary: false, + layer: SystemPromptLayer::Stable, + }, + SystemPromptBlock { + title: "Stable 3".to_string(), + content: "stable content 3".to_string(), + cache_boundary: true, + layer: SystemPromptLayer::Stable, + }, + SystemPromptBlock { + title: "Semi 1".to_string(), + content: "semi content 1".to_string(), + cache_boundary: false, + layer: SystemPromptLayer::SemiStable, + }, + SystemPromptBlock { + title: "Semi 2".to_string(), + content: "semi content 2".to_string(), + cache_boundary: true, + layer: SystemPromptLayer::SemiStable, + }, + SystemPromptBlock { + title: "Inherited 1".to_string(), + content: "inherited content 1".to_string(), + cache_boundary: true, + layer: SystemPromptLayer::Inherited, + }, + SystemPromptBlock { + title: "Dynamic 1".to_string(), + content: "dynamic content 1".to_string(), + cache_boundary: true, + layer: SystemPromptLayer::Dynamic, + }, + ], + false, + ); + let body = serde_json::to_value(&request).expect("request should serialize"); + + assert!(body.get("system").is_some_and(Value::is_array)); + assert_eq!( + body["system"] + .as_array() + .expect("system should be an array") + .len(), + 7 + ); + + // Stable 层内的前两个 block 不应该有 cache_control + assert!( + body["system"][0].get("cache_control").is_none(), + "stable1 should not have cache_control" + ); + assert!( + body["system"][1].get("cache_control").is_none(), + "stable2 should not have cache_control" + ); + + // Stable 层的最后一个 block 应该有 cache_control + assert_eq!( + body["system"][2]["cache_control"]["type"], + json!("ephemeral"), + "stable3 should have cache_control" + ); + + // SemiStable 层的第一个 block 不应该有 cache_control + assert!( + body["system"][3].get("cache_control").is_none(), + "semi1 should not have cache_control" + ); + + // SemiStable 层的最后一个 block 应该有 cache_control + assert_eq!( + body["system"][4]["cache_control"]["type"], + json!("ephemeral"), + "semi2 should have cache_control" + ); + + // Inherited 层允许独立缓存 + assert_eq!( + body["system"][5]["cache_control"]["type"], + json!("ephemeral"), + "inherited1 should have cache_control" + ); + + // Dynamic 层不缓存(避免浪费,因为内容变化频繁) + // TODO: 更好的做法?实现更好的kv缓存? + assert!( + body["system"][6].get("cache_control").is_none(), + "dynamic1 should not have cache_control (Dynamic layer is not cached)" + ); + } +} diff --git a/crates/adapter-llm/src/anthropic/response.rs b/crates/adapter-llm/src/anthropic/response.rs new file mode 100644 index 00000000..2fcae911 --- /dev/null +++ b/crates/adapter-llm/src/anthropic/response.rs @@ -0,0 +1,226 @@ +use astrcode_core::{ReasoningContent, ToolCallRequest}; +use log::{debug, warn}; +use serde_json::Value; + +use super::dto::{AnthropicResponse, AnthropicUsage}; +use crate::{FinishReason, LlmOutput}; + +/// 将 Anthropic 非流式响应转换为统一的 `LlmOutput`。 +/// +/// 遍历内容块数组,根据块类型分派: +/// - `text`: 拼接到输出内容 +/// - `tool_use`: 提取 id、name、input 构造工具调用请求 +/// - `thinking`: 提取推理内容和签名 +/// - 未知类型:记录警告并跳过 +/// +/// TODO:更好的办法? +/// `stop_reason` 映射到统一的 `FinishReason` (P4.2): +/// - `end_turn` → Stop +/// - `max_tokens` → MaxTokens +/// - `tool_use` → ToolCalls +/// - `stop_sequence` → Stop +pub(super) fn response_to_output(response: AnthropicResponse) -> LlmOutput { + let usage = response.usage.and_then(AnthropicUsage::into_llm_usage); + + // 记录缓存状态 + if let Some(ref u) = usage { + let input = u.input_tokens; + let cache_read = u.cache_read_input_tokens; + let cache_creation = u.cache_creation_input_tokens; + let total_prompt_tokens = input.saturating_add(cache_read); + + if cache_read == 0 && cache_creation > 0 { + debug!( + "Cache miss: writing {} tokens to cache (total prompt: {}, uncached input: {})", + cache_creation, total_prompt_tokens, input + ); + } else if cache_read > 0 { + let hit_rate = (cache_read as f32 / total_prompt_tokens as f32) * 100.0; + debug!( + "Cache hit: {:.1}% ({} / {} prompt tokens, creation: {}, uncached input: {})", + hit_rate, cache_read, total_prompt_tokens, cache_creation, input + ); + } else { + debug!( + "Cache disabled or unavailable (total prompt: {} tokens)", + total_prompt_tokens + ); + } + } + + let mut content = String::new(); + let mut tool_calls = Vec::new(); + let mut reasoning = None; + + for block in response.content { + match block_type(&block) { + Some("text") => { + if let Some(text) = block.get("text").and_then(Value::as_str) { + content.push_str(text); + } + }, + Some("tool_use") => { + let id = match block.get("id").and_then(Value::as_str) { + Some(id) if !id.is_empty() => id.to_string(), + _ => { + warn!("anthropic: tool_use block missing non-empty id, skipping"); + continue; + }, + }; + let name = match block.get("name").and_then(Value::as_str) { + Some(name) if !name.is_empty() => name.to_string(), + _ => { + warn!("anthropic: tool_use block missing non-empty name, skipping"); + continue; + }, + }; + let args = block.get("input").cloned().unwrap_or(Value::Null); + tool_calls.push(ToolCallRequest { id, name, args }); + }, + Some("thinking") => { + if let Some(thinking) = block.get("thinking").and_then(Value::as_str) { + reasoning = Some(ReasoningContent { + content: thinking.to_string(), + signature: block + .get("signature") + .and_then(Value::as_str) + .map(str::to_string), + }); + } + }, + Some(other) => { + warn!("anthropic: unknown content block type: {}", other); + }, + None => { + warn!("anthropic: content block missing type"); + }, + } + } + + // Anthropic stop_reason 映射到统一 FinishReason + let finish_reason = response + .stop_reason + .as_deref() + .map(|reason| match reason { + "end_turn" | "stop_sequence" => FinishReason::Stop, + "max_tokens" => FinishReason::MaxTokens, + "tool_use" => FinishReason::ToolCalls, + other => FinishReason::Other(other.to_string()), + }) + .unwrap_or_else(|| { + if !tool_calls.is_empty() { + FinishReason::ToolCalls + } else { + FinishReason::Stop + } + }); + + LlmOutput { + content, + tool_calls, + reasoning, + usage, + finish_reason, + } +} + +/// 从 JSON Value 中提取内容块的类型字段。 +fn block_type(value: &Value) -> Option<&str> { + value.get("type").and_then(Value::as_str) +} + +#[cfg(test)] +mod tests { + use astrcode_core::ReasoningContent; + use serde_json::json; + + use super::response_to_output; + use crate::{ + LlmUsage, + anthropic::dto::{AnthropicCacheCreationUsage, AnthropicResponse, AnthropicUsage}, + }; + + #[test] + fn response_to_output_parses_text_tool_use_and_thinking() { + let output = response_to_output(AnthropicResponse { + content: vec![ + json!({ "type": "text", "text": "hello " }), + json!({ + "type": "tool_use", + "id": "call_1", + "name": "search", + "input": { "q": "rust" } + }), + json!({ "type": "text", "text": "world" }), + json!({ "type": "thinking", "thinking": "pondering", "signature": "sig-1" }), + ], + stop_reason: Some("tool_use".to_string()), + usage: None, + }); + + assert_eq!(output.content, "hello world"); + assert_eq!(output.tool_calls.len(), 1); + assert_eq!(output.tool_calls[0].id, "call_1"); + assert_eq!(output.tool_calls[0].args, json!({ "q": "rust" })); + assert_eq!( + output.reasoning, + Some(ReasoningContent { + content: "pondering".to_string(), + signature: Some("sig-1".to_string()), + }) + ); + } + + #[test] + fn response_to_output_parses_cache_usage_fields() { + let output = response_to_output(AnthropicResponse { + content: vec![json!({ "type": "text", "text": "ok" })], + stop_reason: Some("end_turn".to_string()), + usage: Some(AnthropicUsage { + input_tokens: Some(100), + output_tokens: Some(20), + cache_creation_input_tokens: Some(80), + cache_read_input_tokens: Some(60), + cache_creation: None, + }), + }); + + assert_eq!( + output.usage, + Some(LlmUsage { + input_tokens: 100, + output_tokens: 20, + cache_creation_input_tokens: 80, + cache_read_input_tokens: 60, + }) + ); + } + + #[test] + fn response_to_output_parses_nested_cache_creation_usage_fields() { + let output = response_to_output(AnthropicResponse { + content: vec![json!({ "type": "text", "text": "ok" })], + stop_reason: Some("end_turn".to_string()), + usage: Some(AnthropicUsage { + input_tokens: Some(100), + output_tokens: Some(20), + cache_creation_input_tokens: None, + cache_read_input_tokens: Some(60), + cache_creation: Some(AnthropicCacheCreationUsage { + ephemeral_5m_input_tokens: Some(30), + ephemeral_1h_input_tokens: Some(50), + }), + }), + }); + + assert_eq!( + output.usage, + Some(LlmUsage { + input_tokens: 100, + output_tokens: 20, + cache_creation_input_tokens: 80, + cache_read_input_tokens: 60, + }) + ); + } +} diff --git a/crates/adapter-llm/src/anthropic/stream.rs b/crates/adapter-llm/src/anthropic/stream.rs new file mode 100644 index 00000000..b2b10624 --- /dev/null +++ b/crates/adapter-llm/src/anthropic/stream.rs @@ -0,0 +1,608 @@ +use astrcode_core::{AstrError, Result}; +use log::warn; +use serde_json::{Value, json}; + +use super::dto::{AnthropicUsage, SseProcessResult, extract_usage_from_payload}; +use crate::{EventSink, LlmAccumulator, LlmEvent, classify_http_error, emit_event}; + +/// 解析单个 Anthropic SSE 块。 +/// +/// Anthropic SSE 块由多行组成(`event: ...\ndata: {...}\n\n`), +/// 本函数提取事件类型和 JSON payload,支持事件类型回退到 payload 中的 `type` 字段。 +pub(super) fn parse_sse_block(block: &str) -> Result> { + let trimmed = block.trim(); + if trimmed.is_empty() { + return Ok(None); + } + + let mut event_type = None; + let mut data_lines = Vec::new(); + + for line in trimmed.lines() { + if let Some(value) = sse_field_value(line, "event") { + event_type = Some(value.trim().to_string()); + } else if let Some(value) = sse_field_value(line, "data") { + data_lines.push(value); + } + } + + if data_lines.is_empty() { + return Ok(None); + } + + let data = data_lines.join("\n"); + let data = data.trim(); + if data.is_empty() { + return Ok(None); + } + + // 兼容部分 Anthropic 网关沿用 OpenAI 风格的流结束哨兵。 + // 如果这里严格要求 JSON,会在流尾直接误报 parse error。 + if data == "[DONE]" { + return Ok(Some(( + "message_stop".to_string(), + json!({ "type": "message_stop" }), + ))); + } + + let payload = serde_json::from_str::(data) + .map_err(|error| AstrError::parse("failed to parse anthropic sse payload", error))?; + let event_type = event_type + .or_else(|| { + payload + .get("type") + .and_then(Value::as_str) + .map(str::to_string) + }) + .unwrap_or_default(); + + Ok(Some((event_type, payload))) +} + +fn sse_field_value<'a>(line: &'a str, field: &str) -> Option<&'a str> { + let value = line.strip_prefix(field)?.strip_prefix(':')?; + + // SSE 规范只忽略冒号后的一个可选空格;这里兼容 `data:...` 和 `data: ...`, + // 同时保留业务数据中其余前导空白,避免悄悄改写 payload。 + Some(value.strip_prefix(' ').unwrap_or(value)) +} + +/// 从 `content_block_start` 事件 payload 中提取内容块。 +/// +/// Anthropic 在 `content_block_start` 事件中将块数据放在 `content_block` 字段, +/// 但某些事件可能直接放在根级别,因此有回退逻辑。 +fn extract_start_block(payload: &Value) -> &Value { + payload.get("content_block").unwrap_or(payload) +} + +/// 从 `content_block_delta` 事件 payload 中提取增量数据。 +/// +/// Anthropic 在 `content_block_delta` 事件中将增量数据放在 `delta` 字段。 +fn extract_delta_block(payload: &Value) -> &Value { + payload.get("delta").unwrap_or(payload) +} + +pub(super) fn anthropic_stream_error(payload: &Value) -> AstrError { + let error = payload.get("error").unwrap_or(payload); + let message = error + .get("message") + .or_else(|| error.get("msg")) + .or_else(|| payload.get("message")) + .and_then(Value::as_str) + .unwrap_or("anthropic stream returned an error event"); + + let mut error_type = error + .get("type") + .or_else(|| error.get("code")) + .or_else(|| payload.get("error_type")) + .or_else(|| payload.get("code")) + .and_then(Value::as_str) + .unwrap_or("unknown_error"); + + // Why: 部分兼容网关不回传结构化 error.type,只给中文文案。 + // 这类错误本质仍是请求参数错误,不应退化成 internal stream error。 + let message_lower = message.to_lowercase(); + if matches!(error_type, "unknown_error" | "error") + && (message_lower.contains("参数非法") + || message_lower.contains("invalid request") + || message_lower.contains("invalid parameter") + || message_lower.contains("invalid arguments") + || (message_lower.contains("messages") && message_lower.contains("illegal"))) + { + error_type = "invalid_request_error"; + } + + let detail = format!("{error_type}: {message}"); + + match error_type { + "invalid_request_error" => classify_http_error(400, &detail).into(), + "authentication_error" => classify_http_error(401, &detail).into(), + "permission_error" => classify_http_error(403, &detail).into(), + "not_found_error" => classify_http_error(404, &detail).into(), + "rate_limit_error" => classify_http_error(429, &detail).into(), + "overloaded_error" => classify_http_error(529, &detail).into(), + "api_error" => classify_http_error(500, &detail).into(), + _ => classify_http_error(400, &detail).into(), + } +} + +/// 处理单个 Anthropic SSE 块,返回 `(is_done, stop_reason)`。 +/// +/// Anthropic SSE 事件类型分派: +/// - `content_block_start`: 新内容块开始(可能是文本或工具调用) +/// - `content_block_delta`: 增量内容(文本/思考/签名/工具参数) +/// - `message_stop`: 流结束信号,返回 is_done=true +/// - `message_delta`: 包含 `stop_reason`,用于检测 max_tokens 截断 (P4.2) +/// - `message_start/content_block_stop/ping`: 元数据事件,静默忽略 +fn process_sse_block( + block: &str, + accumulator: &mut LlmAccumulator, + sink: &EventSink, +) -> Result { + let Some((event_type, payload)) = parse_sse_block(block)? else { + return Ok(SseProcessResult::default()); + }; + + match event_type.as_str() { + "content_block_start" => { + let index = payload + .get("index") + .and_then(Value::as_u64) + .unwrap_or_default() as usize; + let block = extract_start_block(&payload); + + // 工具调用块开始时,发射 ToolCallDelta(id + name,参数为空) + if block_type(block) == Some("tool_use") { + emit_event( + LlmEvent::ToolCallDelta { + index, + id: block.get("id").and_then(Value::as_str).map(str::to_string), + name: block + .get("name") + .and_then(Value::as_str) + .map(str::to_string), + arguments_delta: String::new(), + }, + accumulator, + sink, + ); + } + Ok(SseProcessResult::default()) + }, + "content_block_delta" => { + let index = payload + .get("index") + .and_then(Value::as_u64) + .unwrap_or_default() as usize; + let delta = extract_delta_block(&payload); + + // 根据增量类型分派到对应的事件 + match block_type(delta) { + Some("text_delta") => { + if let Some(text) = delta.get("text").and_then(Value::as_str) { + emit_event(LlmEvent::TextDelta(text.to_string()), accumulator, sink); + } + }, + Some("thinking_delta") => { + if let Some(text) = delta.get("thinking").and_then(Value::as_str) { + emit_event(LlmEvent::ThinkingDelta(text.to_string()), accumulator, sink); + } + }, + Some("signature_delta") => { + if let Some(signature) = delta.get("signature").and_then(Value::as_str) { + emit_event( + LlmEvent::ThinkingSignature(signature.to_string()), + accumulator, + sink, + ); + } + }, + Some("input_json_delta") => { + // 工具调用参数增量,partial_json 是 JSON 的片段 + emit_event( + LlmEvent::ToolCallDelta { + index, + id: None, + name: None, + arguments_delta: delta + .get("partial_json") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(), + }, + accumulator, + sink, + ); + }, + _ => {}, + } + Ok(SseProcessResult::default()) + }, + "message_stop" => Ok(SseProcessResult { + done: true, + ..SseProcessResult::default() + }), + // message_delta 可能包含 stop_reason (P4.2) + "message_delta" => { + let stop_reason = payload + .get("delta") + .and_then(|d| d.get("stop_reason")) + .and_then(Value::as_str) + .map(str::to_string); + Ok(SseProcessResult { + stop_reason, + usage: extract_usage_from_payload(&event_type, &payload), + ..SseProcessResult::default() + }) + }, + "message_start" => Ok(SseProcessResult { + usage: extract_usage_from_payload(&event_type, &payload), + ..SseProcessResult::default() + }), + "content_block_stop" | "ping" => Ok(SseProcessResult::default()), + "error" => Err(anthropic_stream_error(&payload)), + other => { + warn!("anthropic: unknown sse event: {}", other); + Ok(SseProcessResult::default()) + }, + } +} + +/// 在 SSE 缓冲区中查找下一个完整的 SSE 块边界。 +/// +/// Anthropic SSE 块由双换行符分隔(`\r\n\r\n` 或 `\n\n`)。 +/// 返回 `(块结束位置, 分隔符长度)`,如果未找到完整块则返回 `None`。 +fn next_sse_block(buffer: &str) -> Option<(usize, usize)> { + if let Some(idx) = buffer.find("\r\n\r\n") { + return Some((idx, 4)); + } + if let Some(idx) = buffer.find("\n\n") { + return Some((idx, 2)); + } + None +} + +pub(super) fn consume_sse_text_chunk( + chunk_text: &str, + sse_buffer: &mut String, + accumulator: &mut LlmAccumulator, + sink: &EventSink, + stop_reason_out: &mut Option, + usage_out: &mut AnthropicUsage, +) -> Result { + sse_buffer.push_str(chunk_text); + + while let Some((block_end, delimiter_len)) = next_sse_block(sse_buffer) { + let block: String = sse_buffer.drain(..block_end + delimiter_len).collect(); + let block = &block[..block_end]; + + let result = process_sse_block(block, accumulator, sink)?; + if let Some(r) = result.stop_reason { + *stop_reason_out = Some(r); + } + if let Some(usage) = result.usage { + usage_out.merge_from(usage); + } + if result.done { + return Ok(true); + } + } + + Ok(false) +} + +pub(super) fn flush_sse_buffer( + sse_buffer: &mut String, + accumulator: &mut LlmAccumulator, + sink: &EventSink, + stop_reason_out: &mut Option, + usage_out: &mut AnthropicUsage, +) -> Result<()> { + if sse_buffer.trim().is_empty() { + sse_buffer.clear(); + return Ok(()); + } + + let result = process_sse_block(sse_buffer, accumulator, sink)?; + if let Some(r) = result.stop_reason { + *stop_reason_out = Some(r); + } + if let Some(usage) = result.usage { + usage_out.merge_from(usage); + } + sse_buffer.clear(); + Ok(()) +} + +fn block_type(value: &Value) -> Option<&str> { + value.get("type").and_then(Value::as_str) +} + +#[cfg(test)] +mod tests { + use std::sync::{Arc, Mutex}; + + use serde_json::json; + + use super::{consume_sse_text_chunk, parse_sse_block}; + use crate::{ + LlmAccumulator, LlmEvent, LlmUsage, Utf8StreamDecoder, anthropic::dto::AnthropicUsage, + sink_collector, + }; + + #[test] + fn streaming_sse_parses_tool_calls_and_text() { + let mut accumulator = LlmAccumulator::default(); + let events = Arc::new(Mutex::new(Vec::new())); + let sink = sink_collector(events.clone()); + let mut sse_buffer = String::new(); + + let chunk = concat!( + "event: content_block_start\n", + "data: {\"index\":1,\"type\":\"tool_use\",\"id\":\"call_1\",\"name\":\"search\"}\n\n", + "event: content_block_delta\n", + "data: {\"index\":1,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"{\\\"\ + q\\\":\\\"ru\"}}\n\n", + "event: content_block_delta\n", + "data: {\"index\":1,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"st\\\"\ + }\"}}\n\n", + "event: content_block_delta\n", + "data: {\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"hello\"}}\n\n", + "event: message_stop\n", + "data: {\"type\":\"message_stop\"}\n\n" + ); + + let mut stop_reason_out: Option = None; + let mut usage_out = AnthropicUsage::default(); + let done = consume_sse_text_chunk( + chunk, + &mut sse_buffer, + &mut accumulator, + &sink, + &mut stop_reason_out, + &mut usage_out, + ) + .expect("stream chunk should parse"); + + assert!(done); + let output = accumulator.finish(); + let events = events.lock().expect("lock").clone(); + + assert!(events.iter().any(|event| { + matches!( + event, + LlmEvent::ToolCallDelta { index, id, name, arguments_delta } + if *index == 1 + && id.as_deref() == Some("call_1") + && name.as_deref() == Some("search") + && arguments_delta.is_empty() + ) + })); + assert!( + events + .iter() + .any(|event| matches!(event, LlmEvent::TextDelta(text) if text == "hello")) + ); + assert_eq!(output.content, "hello"); + assert_eq!(output.tool_calls.len(), 1); + assert_eq!(output.tool_calls[0].args, json!({ "q": "rust" })); + } + + #[test] + fn parse_sse_block_accepts_data_lines_without_space_after_colon() { + let block = concat!( + "event:content_block_delta\n", + "data:{\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"hello\"}}\n" + ); + + let parsed = parse_sse_block(block) + .expect("block should parse") + .expect("block should contain payload"); + + assert_eq!(parsed.0, "content_block_delta"); + assert_eq!(parsed.1["delta"]["text"], json!("hello")); + } + + #[test] + fn parse_sse_block_treats_done_sentinel_as_message_stop() { + let parsed = parse_sse_block("data: [DONE]\n") + .expect("done sentinel should parse") + .expect("done sentinel should produce payload"); + + assert_eq!(parsed.0, "message_stop"); + assert_eq!(parsed.1["type"], json!("message_stop")); + } + + #[test] + fn parse_sse_block_ignores_empty_data_payload() { + let parsed = parse_sse_block("event: ping\ndata:\n"); + assert!(matches!(parsed, Ok(None))); + } + + #[test] + fn streaming_sse_error_event_surfaces_structured_provider_failure() { + let mut accumulator = LlmAccumulator::default(); + let events = Arc::new(Mutex::new(Vec::new())); + let sink = sink_collector(events); + let mut sse_buffer = String::new(); + let mut stop_reason_out: Option = None; + let mut usage_out = AnthropicUsage::default(); + + let error = consume_sse_text_chunk( + concat!( + "event: error\n", + "data: {\"type\":\"error\",\"error\":{\"type\":\"overloaded_error\",", + "\"message\":\"capacity exhausted\"}}\n\n" + ), + &mut sse_buffer, + &mut accumulator, + &sink, + &mut stop_reason_out, + &mut usage_out, + ) + .expect_err("error event should terminate the stream with a structured error"); + + match error { + astrcode_core::AstrError::LlmRequestFailed { status, body } => { + assert_eq!(status, 529); + assert!(body.contains("overloaded_error")); + assert!(body.contains("capacity exhausted")); + }, + other => panic!("unexpected error variant: {other:?}"), + } + } + + #[test] + fn streaming_sse_error_event_without_type_still_maps_to_request_error() { + let mut accumulator = LlmAccumulator::default(); + let events = Arc::new(Mutex::new(Vec::new())); + let sink = sink_collector(events); + let mut sse_buffer = String::new(); + let mut stop_reason_out: Option = None; + let mut usage_out = AnthropicUsage::default(); + + let error = consume_sse_text_chunk( + concat!( + "event: error\n", + "data: {\"type\":\"error\",\"error\":{\"message\":\"messages 参数非法\"}}\n\n" + ), + &mut sse_buffer, + &mut accumulator, + &sink, + &mut stop_reason_out, + &mut usage_out, + ) + .expect_err("error event should terminate the stream with a structured error"); + + match error { + astrcode_core::AstrError::LlmRequestFailed { status, body } => { + assert_eq!(status, 400); + assert!(body.contains("invalid_request_error")); + assert!(body.contains("messages 参数非法")); + }, + other => panic!("unexpected error variant: {other:?}"), + } + } + + #[test] + fn streaming_sse_extracts_usage_from_message_events() { + let mut accumulator = LlmAccumulator::default(); + let events = Arc::new(Mutex::new(Vec::new())); + let sink = sink_collector(events); + let mut usage_out = AnthropicUsage::default(); + let mut stop_reason_out = None; + let mut sse_buffer = String::new(); + + let chunk = concat!( + "event: message_start\n", + "data: {\"type\":\"message_start\",\"message\":{\"usage\":{\"input_tokens\":120,\"\ + cache_creation_input_tokens\":90,\"cache_read_input_tokens\":70}}}\n\n", + "event: message_delta\n", + "data: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\"},\"usage\":\ + {\"output_tokens\":33}}\n\n", + "event: message_stop\n", + "data: {\"type\":\"message_stop\"}\n\n" + ); + + let done = consume_sse_text_chunk( + chunk, + &mut sse_buffer, + &mut accumulator, + &sink, + &mut stop_reason_out, + &mut usage_out, + ) + .expect("stream chunk should parse"); + + assert!(done); + assert_eq!(stop_reason_out.as_deref(), Some("end_turn")); + assert_eq!( + usage_out.into_llm_usage(), + Some(LlmUsage { + input_tokens: 120, + output_tokens: 33, + cache_creation_input_tokens: 90, + cache_read_input_tokens: 70, + }) + ); + } + + #[test] + fn streaming_sse_handles_multibyte_text_split_across_chunks() { + let mut accumulator = LlmAccumulator::default(); + let events = Arc::new(Mutex::new(Vec::new())); + let sink = sink_collector(events.clone()); + let mut sse_buffer = String::new(); + let mut decoder = Utf8StreamDecoder::default(); + let mut stop_reason_out = None; + let mut usage_out = AnthropicUsage::default(); + let chunk = concat!( + "event: content_block_delta\n", + "data: {\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"你", + "好\"}}\n\n", + "event: message_stop\n", + "data: {\"type\":\"message_stop\"}\n\n" + ); + let bytes = chunk.as_bytes(); + let split_index = chunk + .find("好") + .expect("chunk should contain multibyte char") + + 1; + + let first_text = decoder + .push( + &bytes[..split_index], + "anthropic response stream was not valid utf-8", + ) + .expect("first split should decode"); + let second_text = decoder + .push( + &bytes[split_index..], + "anthropic response stream was not valid utf-8", + ) + .expect("second split should decode"); + + let first_done = first_text + .as_deref() + .map(|text| { + consume_sse_text_chunk( + text, + &mut sse_buffer, + &mut accumulator, + &sink, + &mut stop_reason_out, + &mut usage_out, + ) + }) + .transpose() + .expect("first chunk should parse") + .unwrap_or(false); + let second_done = second_text + .as_deref() + .map(|text| { + consume_sse_text_chunk( + text, + &mut sse_buffer, + &mut accumulator, + &sink, + &mut stop_reason_out, + &mut usage_out, + ) + }) + .transpose() + .expect("second chunk should parse") + .unwrap_or(false); + + assert!(!first_done); + assert!(second_done); + let output = accumulator.finish(); + let events = events.lock().expect("lock").clone(); + + assert!( + events + .iter() + .any(|event| matches!(event, LlmEvent::TextDelta(text) if text == "你好")) + ); + assert_eq!(output.content, "你好"); + } +} diff --git a/crates/application/src/terminal/mod.rs b/crates/application/src/terminal/mod.rs index b32710a6..8d673e1f 100644 --- a/crates/application/src/terminal/mod.rs +++ b/crates/application/src/terminal/mod.rs @@ -70,7 +70,6 @@ pub enum TerminalSlashAction { CreateSession, OpenResume, RequestCompact, - OpenSkillPalette, InsertText { text: String }, } @@ -255,10 +254,6 @@ pub fn summarize_conversation_slash_candidate( ConversationSlashActionSummary::ExecuteCommand, "/compact".to_string(), ), - TerminalSlashAction::OpenSkillPalette => ( - ConversationSlashActionSummary::ExecuteCommand, - "/skill".to_string(), - ), TerminalSlashAction::InsertText { text } => { (ConversationSlashActionSummary::InsertText, text.clone()) }, diff --git a/crates/application/src/terminal_use_cases.rs b/crates/application/src/terminal_use_cases.rs index 79fbc152..7e3743b9 100644 --- a/crates/application/src/terminal_use_cases.rs +++ b/crates/application/src/terminal_use_cases.rs @@ -415,15 +415,6 @@ fn terminal_builtin_candidates(control: &TerminalControlFacts) -> Vec AppController @@ -50,18 +51,18 @@ where } } - pub(super) async fn execute_overlay_action(&mut self, action: OverlayAction) -> Result<()> { + pub(super) async fn execute_palette_action(&mut self, action: PaletteAction) -> Result<()> { match action { - OverlayAction::SwitchSession { session_id } => { - self.state.close_overlay(); + PaletteAction::SwitchSession { session_id } => { + self.state.close_palette(); self.begin_session_hydration(session_id).await; }, - OverlayAction::ReplaceInput { text } => { - self.state.close_overlay(); + PaletteAction::ReplaceInput { text } => { + self.state.close_palette(); self.state.replace_input(text); }, - OverlayAction::RunCommand(command) => { - self.state.close_overlay(); + PaletteAction::RunCommand(command) => { + self.state.close_palette(); self.execute_command(command).await; }, } @@ -92,6 +93,11 @@ where let query = query.unwrap_or_default(); let items = filter_resume_sessions(&self.state.conversation.sessions, query.as_str()); + self.state.replace_input(if query.is_empty() { + "/resume".to_string() + } else { + format!("/resume {query}") + }); self.state.set_resume_query(query, items); self.refresh_sessions().await; }, @@ -131,7 +137,13 @@ where }); }, Command::Skill { query } => { - self.open_slash_palette(query.unwrap_or_default()).await; + let query = query.unwrap_or_default(); + self.state.replace_input(if query.is_empty() { + "/skill".to_string() + } else { + format!("/skill {query}") + }); + self.open_slash_palette(query).await; }, Command::Unknown { raw } => { self.state @@ -213,6 +225,16 @@ where } pub(super) async fn open_slash_palette(&mut self, query: String) { + if !self + .state + .interaction + .composer + .input + .trim_start() + .starts_with('/') + { + self.state.replace_input("/".to_string()); + } let items = if query.trim().is_empty() { self.state.conversation.slash_candidates.clone() } else { @@ -236,25 +258,50 @@ where }); } - pub(super) async fn refresh_overlay_query(&mut self) { - match &self.state.interaction.overlay { - OverlayState::Resume(resume) => { - let items = filter_resume_sessions( - &self.state.conversation.sessions, - resume.query.as_str(), - ); - self.state.set_resume_query(resume.query.clone(), items); + pub(super) async fn refresh_palette_query(&mut self) { + match &self.state.interaction.palette { + PaletteState::Resume(_) => { + if !self + .state + .interaction + .composer + .input + .trim_start() + .starts_with("/resume") + { + self.state.close_palette(); + return; + } + let query = resume_query_from_input(self.state.interaction.composer.input.as_str()); + let items = + filter_resume_sessions(&self.state.conversation.sessions, query.as_str()); + self.state.set_resume_query(query, items); }, - OverlayState::SlashPalette(palette) => { - self.refresh_slash_candidates(palette.query.clone()).await; + PaletteState::Slash(_) => { + if !self + .state + .interaction + .composer + .input + .trim_start() + .starts_with('/') + { + self.state.close_palette(); + return; + } + let query = self.slash_query_for_current_input(); + self.state.set_slash_query( + query.clone(), + filter_slash_candidates(&self.state.conversation.slash_candidates, &query), + ); + self.refresh_slash_candidates(query).await; }, - OverlayState::DebugLogs(_) => {}, - OverlayState::None => {}, + PaletteState::Closed => {}, } } - pub(super) fn refresh_resume_overlay(&mut self) { - let OverlayState::Resume(resume) = &self.state.interaction.overlay else { + pub(super) fn refresh_resume_palette(&mut self) { + let PaletteState::Resume(resume) = &self.state.interaction.palette else { return; }; let items = diff --git a/crates/cli/src/app/mod.rs b/crates/cli/src/app/mod.rs index 7d8130fe..91f6c5ce 100644 --- a/crates/cli/src/app/mod.rs +++ b/crates/cli/src/app/mod.rs @@ -37,10 +37,10 @@ use tokio::{ use crate::{ capability::TerminalCapabilities, - command::overlay_action, + command::palette_action, launcher::{LaunchOptions, Launcher, LauncherSession, SystemManagedServer}, render, - state::{CliState, OverlayState, PaneFocus, StreamRenderMode}, + state::{CliState, PaletteState, PaneFocus, StreamRenderMode}, }; #[derive(Debug, Parser)] @@ -329,6 +329,7 @@ where } let (mode, pending, oldest) = self.stream_pacer.update_mode(); self.state.set_stream_mode(mode, pending, oldest); + self.state.advance_thinking_playback(); }, Action::Quit => self.should_quit = true, Action::Resize { width, height } => self.state.set_viewport_size(width, height), @@ -337,7 +338,7 @@ where Action::SessionsRefreshed(result) => match result { Ok(sessions) => { self.state.update_sessions(sessions); - self.refresh_resume_overlay(); + self.refresh_resume_palette(); }, Err(error) => self.apply_status_error(error), }, @@ -399,7 +400,7 @@ where self.state.set_stream_mode(mode, pending, oldest); }, Action::SlashCandidatesLoaded { query, result } => { - let OverlayState::SlashPalette(palette) = &self.state.interaction.overlay else { + let PaletteState::Slash(palette) = &self.state.interaction.palette else { return Ok(()); }; if palette.query != query { @@ -452,74 +453,102 @@ where return Ok(()); } + if key.modifiers.contains(KeyModifiers::CONTROL) && matches!(key.code, KeyCode::Char('o')) { + if matches!(self.state.interaction.pane_focus, PaneFocus::Transcript) + && self.state.selected_cell_is_thinking() + { + self.state.toggle_selected_cell_expanded(); + } + return Ok(()); + } + match key.code { - KeyCode::F(2) => self.state.toggle_debug_overlay(), - KeyCode::Esc => self.state.close_overlay(), - KeyCode::Left => { - if !matches!(self.state.interaction.overlay, OverlayState::None) { + KeyCode::Esc => { + if self.state.interaction.has_palette() { + self.state.close_palette(); + } else { + self.state.clear_surface_state(); + } + }, + KeyCode::BackTab => { + if !matches!(self.state.interaction.palette, PaletteState::Closed) { return Ok(()); } self.state.cycle_focus_backward(); }, - KeyCode::Right => { - if !matches!(self.state.interaction.overlay, OverlayState::None) { + KeyCode::Tab => { + if !matches!(self.state.interaction.palette, PaletteState::Closed) { return Ok(()); } self.state.cycle_focus_forward(); }, KeyCode::Up => { - if !matches!(self.state.interaction.overlay, OverlayState::None) { - self.state.overlay_prev(); - } else if matches!(self.state.interaction.pane_focus, PaneFocus::ChildPane) { - self.state.child_prev(); + if !matches!(self.state.interaction.palette, PaletteState::Closed) { + self.state.palette_prev(); } else { - self.state.scroll_up(); + match self.state.interaction.pane_focus { + PaneFocus::Transcript => self.state.transcript_prev(), + PaneFocus::Composer | PaneFocus::Palette => {}, + } } }, KeyCode::Down => { - if !matches!(self.state.interaction.overlay, OverlayState::None) { - self.state.overlay_next(); - } else if matches!(self.state.interaction.pane_focus, PaneFocus::ChildPane) { - self.state.child_next(); + if !matches!(self.state.interaction.palette, PaletteState::Closed) { + self.state.palette_next(); } else { - self.state.scroll_down(); + match self.state.interaction.pane_focus { + PaneFocus::Transcript => self.state.transcript_next(), + PaneFocus::Composer | PaneFocus::Palette => {}, + } } }, KeyCode::Enter => { if key.modifiers.contains(KeyModifiers::SHIFT) { - if matches!(self.state.interaction.overlay, OverlayState::None) + if matches!(self.state.interaction.palette, PaletteState::Closed) && matches!(self.state.interaction.pane_focus, PaneFocus::Composer) { self.state.insert_newline(); } - } else if let Some(selection) = self.state.selected_overlay() { - self.execute_overlay_action(overlay_action(selection)) + } else if let Some(selection) = self.state.selected_palette() { + self.execute_palette_action(palette_action(selection)) .await?; - } else if matches!(self.state.interaction.pane_focus, PaneFocus::ChildPane) { - self.state.toggle_child_focus(); } else { - self.submit_current_input().await; + match self.state.interaction.pane_focus { + PaneFocus::Transcript => self.handle_transcript_enter(), + PaneFocus::Composer => self.submit_current_input().await, + PaneFocus::Palette => {}, + } } }, KeyCode::Backspace => { - if matches!(self.state.interaction.overlay, OverlayState::None) { + if matches!(self.state.interaction.palette, PaletteState::Closed) { self.state.pop_input(); } else { - self.state.overlay_query_pop(); - self.refresh_overlay_query().await; + self.state.interaction.composer.input.pop(); + self.refresh_palette_query().await; } }, - KeyCode::Tab => { - let query = self.slash_query_for_current_input(); - self.open_slash_palette(query).await; - }, KeyCode::Char(ch) => { - if matches!(self.state.interaction.overlay, OverlayState::None) { - self.state.interaction.pane_focus = PaneFocus::Composer; - self.state.push_input(ch); + if !matches!(self.state.interaction.palette, PaletteState::Closed) { + self.state.interaction.composer.input.push(ch); + self.refresh_palette_query().await; } else { - self.state.overlay_query_push(ch); - self.refresh_overlay_query().await; + match self.state.interaction.pane_focus { + PaneFocus::Transcript if matches!(ch, 'j' | 'k') => { + if ch == 'j' { + self.state.transcript_next(); + } else { + self.state.transcript_prev(); + } + }, + _ => { + self.state.push_input(ch); + if ch == '/' { + let query = self.slash_query_for_current_input(); + self.open_slash_palette(query).await; + } + }, + } } }, _ => {}, @@ -529,16 +558,25 @@ where } async fn handle_paste(&mut self, text: String) -> Result<()> { - if matches!(self.state.interaction.overlay, OverlayState::None) { - self.state.interaction.pane_focus = PaneFocus::Composer; + if matches!(self.state.interaction.palette, PaletteState::Closed) { self.state.append_input(text.as_str()); } else { - self.state.overlay_query_append(text.as_str()); - self.refresh_overlay_query().await; + self.state + .interaction + .composer + .input + .push_str(text.as_str()); + self.refresh_palette_query().await; } Ok(()) } + fn handle_transcript_enter(&mut self) { + if !self.state.selected_cell_is_thinking() { + self.state.toggle_selected_cell_expanded(); + } + } + fn active_session_matches(&self, session_id: &str) -> bool { self.state.conversation.active_session_id.as_deref() == Some(session_id) } @@ -658,6 +696,15 @@ fn slash_query_from_input(input: &str) -> String { trimmed.trim_start_matches('/').trim().to_string() } +fn resume_query_from_input(input: &str) -> String { + input + .trim() + .strip_prefix("/resume") + .map(str::trim) + .unwrap_or_default() + .to_string() +} + #[cfg(test)] mod tests { use std::{ @@ -1165,7 +1212,7 @@ mod tests { }) .await; handle_next_action(&mut controller).await; - let OverlayState::SlashPalette(palette) = &controller.state.interaction.overlay else { + let PaletteState::Slash(palette) = &controller.state.interaction.palette else { panic!("skill command should open slash palette"); }; assert_eq!(palette.query, "review"); @@ -1183,17 +1230,17 @@ mod tests { query: Some("repo-b".to_string()), }) .await; - let OverlayState::Resume(resume) = &controller.state.interaction.overlay else { - panic!("resume command should open resume overlay"); + let PaletteState::Resume(resume) = &controller.state.interaction.palette else { + panic!("resume command should open resume palette"); }; assert_eq!(resume.query, "repo-b"); handle_next_action(&mut controller).await; let selection = controller .state - .selected_overlay() - .expect("resume overlay should keep a selection"); + .selected_palette() + .expect("resume palette should keep a selection"); controller - .execute_overlay_action(overlay_action(selection)) + .execute_palette_action(palette_action(selection)) .await .expect("resume selection should switch session"); handle_next_action(&mut controller).await; diff --git a/crates/cli/src/command/mod.rs b/crates/cli/src/command/mod.rs index d88a2d82..73c5a807 100644 --- a/crates/cli/src/command/mod.rs +++ b/crates/cli/src/command/mod.rs @@ -2,7 +2,7 @@ use astrcode_client::{ AstrcodeConversationSlashActionKindDto, AstrcodeConversationSlashCandidateDto, }; -use crate::state::OverlaySelection; +use crate::state::PaletteSelection; #[derive(Debug, Clone, PartialEq, Eq)] pub enum InputAction { @@ -21,7 +21,7 @@ pub enum Command { } #[derive(Debug, Clone, PartialEq, Eq)] -pub enum OverlayAction { +pub enum PaletteAction { SwitchSession { session_id: String }, ReplaceInput { text: String }, RunCommand(Command), @@ -42,15 +42,15 @@ pub fn classify_input(input: &str) -> InputAction { InputAction::RunCommand(parse_command(trimmed)) } -pub fn overlay_action(selection: OverlaySelection) -> OverlayAction { +pub fn palette_action(selection: PaletteSelection) -> PaletteAction { match selection { - OverlaySelection::ResumeSession(session_id) => OverlayAction::SwitchSession { session_id }, - OverlaySelection::SlashCandidate(candidate) => match candidate.action_kind { - AstrcodeConversationSlashActionKindDto::InsertText => OverlayAction::ReplaceInput { + PaletteSelection::ResumeSession(session_id) => PaletteAction::SwitchSession { session_id }, + PaletteSelection::SlashCandidate(candidate) => match candidate.action_kind { + AstrcodeConversationSlashActionKindDto::InsertText => PaletteAction::ReplaceInput { text: candidate.action_value, }, AstrcodeConversationSlashActionKindDto::ExecuteCommand => { - OverlayAction::RunCommand(parse_command(candidate.action_value.as_str())) + PaletteAction::RunCommand(parse_command(candidate.action_value.as_str())) }, }, } diff --git a/crates/cli/src/render/mod.rs b/crates/cli/src/render/mod.rs index 2acb5ad0..9e897624 100644 --- a/crates/cli/src/render/mod.rs +++ b/crates/cli/src/render/mod.rs @@ -1,120 +1,56 @@ use ratatui::{ Frame, - layout::{Constraint, Direction, Flex, Layout, Rect}, - widgets::{Block, Borders, Clear, Paragraph, Wrap}, + layout::{Constraint, Direction, Layout, Rect}, + widgets::{Block, Clear, Paragraph, Wrap}, }; use crate::{ - state::CliState, - ui::{self, BottomPaneView, CodexTheme, ComposerPane, ThemePalette}, + state::{CliState, PaneFocus}, + ui::{self, CodexTheme, ThemePalette}, }; pub fn render(frame: &mut Frame<'_>, state: &mut CliState) { state.set_viewport_size(frame.area().width, frame.area().height); - - let header_height = ui::header_lines(state, frame.area().width).len() as u16; - let bottom_height = ComposerPane::new(state) - .desired_height(frame.area().width) - .clamp( - 5, - frame.area().height.saturating_sub(header_height + 2).max(5), - ); - - let layout = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Length(header_height), - Constraint::Min(1), - Constraint::Length(bottom_height), - ]) - .split(frame.area()); - let theme = CodexTheme::new(state.shell.capabilities); + frame.render_widget(Block::default().style(theme.app_background()), frame.area()); - frame.render_widget( - Paragraph::new( - ui::header_lines(state, layout[0].width) - .iter() - .map(|line| ui::line_to_ratatui(line, state.shell.capabilities)) - .collect::>(), - ) - .wrap(Wrap { trim: false }), - layout[0], - ); - - let side_pane_visible = - !state.conversation.child_summaries.is_empty() && layout[1].width >= 105; - let body = if side_pane_visible { - Layout::default() - .direction(Direction::Horizontal) - .constraints([ - Constraint::Min(1), - Constraint::Length(1), - Constraint::Length(30), - ]) - .split(layout[1]) - } else { - Layout::default() - .direction(Direction::Horizontal) - .constraints([Constraint::Min(1)]) - .split(layout[1]) + let footer_area = Rect { + x: frame.area().x, + y: frame.area().bottom().saturating_sub(4), + width: frame.area().width, + height: 4, + }; + let transcript_height = frame.area().height.saturating_sub(footer_area.height); + let transcript_area = Rect { + x: frame.area().x, + y: frame.area().y, + width: frame.area().width, + height: transcript_height, }; - render_transcript(frame, state, body[0]); - if side_pane_visible { - render_vertical_divider(frame, body[1], &theme); - render_side_pane(frame, state, body[2]); - } - - render_bottom_pane(frame, state, layout[2], &ComposerPane::new(state), &theme); + render_transcript(frame, state, transcript_area); + render_footer(frame, state, footer_area); - if let Some(title) = ui::overlay_title(&state.interaction.overlay) { - let overlay_lines = - ui::centered_overlay_lines(state, frame.area().width.saturating_sub(20)); - let overlay_height = overlay_lines.len().saturating_add(2).clamp(6, 22) as u16; - let overlay_area = centered_rect(72, overlay_height, frame.area()); - frame.render_widget(Clear, overlay_area); - frame.render_widget( - Paragraph::new( - overlay_lines - .iter() - .map(|line| ui::line_to_ratatui(line, state.shell.capabilities)) - .collect::>(), - ) - .block( - Block::default() - .title(title) - .borders(Borders::ALL) - .border_style(theme.overlay_border_style()), - ) - .wrap(Wrap { trim: false }), - overlay_area, - ); + if ui::palette_visible(&state.interaction.palette) { + render_palette(frame, state, transcript_area, footer_area, &theme); } } -fn render_transcript(frame: &mut Frame<'_>, state: &mut CliState, area: Rect) { - let transcript_width = area.width; - let transcript_lines = if state.render.transcript_cache.width == transcript_width - && state.render.transcript_cache.revision == state.render.transcript_revision - { - state.render.transcript_cache.lines.clone() - } else { - let lines = ui::transcript_lines(state, transcript_width); - state.update_transcript_cache(transcript_width, lines.clone()); - lines - }; - +fn render_transcript(frame: &mut Frame<'_>, state: &CliState, area: Rect) { + let transcript = ui::transcript_lines(state, area.width.saturating_sub(2)); + let viewport_height = area.height.saturating_sub(1); let scroll = transcript_scroll_offset( - transcript_lines.len(), - area.height, + transcript.lines.len(), + viewport_height, state.interaction.scroll_anchor, state.interaction.follow_transcript_tail, + transcript.selected_line_range, + matches!(state.interaction.pane_focus, PaneFocus::Transcript), ); - frame.render_widget( Paragraph::new( - transcript_lines + transcript + .lines .iter() .map(|line| ui::line_to_ratatui(line, state.shell.capabilities)) .collect::>(), @@ -125,52 +61,79 @@ fn render_transcript(frame: &mut Frame<'_>, state: &mut CliState, area: Rect) { ); } -fn render_side_pane(frame: &mut Frame<'_>, state: &CliState, area: Rect) { +fn render_footer(frame: &mut Frame<'_>, state: &CliState, area: Rect) { + let theme = CodexTheme::new(state.shell.capabilities); + let lines = ui::footer_lines(state, area.width.saturating_sub(2)); + let layout = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(1), + Constraint::Length(1), + Constraint::Length(1), + Constraint::Length(1), + ]) + .split(area); + frame.render_widget( - Paragraph::new( - ui::child_pane_lines(state, area.width) - .iter() - .map(|line| ui::line_to_ratatui(line, state.shell.capabilities)) - .collect::>(), - ) - .wrap(Wrap { trim: false }), - area, + Paragraph::new(theme.divider().repeat(usize::from(area.width))) + .style(theme.line_style(crate::state::WrappedLineStyle::Divider)), + layout[0], + ); + frame.render_widget( + Paragraph::new(vec![ui::line_to_ratatui( + &lines[0], + state.shell.capabilities, + )]), + layout[1], + ); + frame.render_widget( + Paragraph::new(theme.divider().repeat(usize::from(area.width))) + .style(theme.line_style(crate::state::WrappedLineStyle::Divider)), + layout[2], + ); + frame.render_widget( + Paragraph::new(vec![ui::line_to_ratatui( + &lines[1], + state.shell.capabilities, + )]), + layout[3], ); } -fn render_bottom_pane( +fn render_palette( frame: &mut Frame<'_>, state: &CliState, - area: Rect, - composer: &ComposerPane<'_>, + transcript_area: Rect, + footer_area: Rect, theme: &CodexTheme, ) { + let menu_lines = ui::palette_lines( + &state.interaction.palette, + usize::from(footer_area.width.saturating_sub(4)), + theme, + ); + let menu_height = menu_lines.len().clamp(2, 10) as u16; + let menu_width = footer_area.width.saturating_sub(2).min(112).max(52); + let menu_area = Rect { + x: transcript_area.x.saturating_add(1), + y: footer_area + .y + .saturating_sub(menu_height) + .max(transcript_area.y), + width: menu_width, + height: menu_height, + }; + frame.render_widget(Clear, menu_area); frame.render_widget( Paragraph::new( - composer - .lines(area.width) + menu_lines .iter() .map(|line| ui::line_to_ratatui(line, state.shell.capabilities)) .collect::>(), ) - .wrap(Wrap { trim: false }) - .style(theme.muted_block_style()), - area, - ); -} - -fn render_vertical_divider(frame: &mut Frame<'_>, area: Rect, theme: &CodexTheme) { - let line = theme - .vertical_divider() - .repeat(usize::from(area.height.max(1))); - let divider = line - .chars() - .map(|glyph| glyph.to_string()) - .collect::>() - .join("\n"); - frame.render_widget( - Paragraph::new(divider).style(theme.line_style(crate::state::WrappedLineStyle::Border)), - area, + .style(theme.menu_block_style()) + .wrap(Wrap { trim: false }), + menu_area, ); } @@ -179,45 +142,42 @@ fn transcript_scroll_offset( viewport_height: u16, anchor_from_bottom: u16, follow_tail: bool, + selected_line_range: Option<(usize, usize)>, + selection_drives_scroll: bool, ) -> u16 { let max_scroll = total_lines.saturating_sub(usize::from(viewport_height)); - let top_offset = if follow_tail { + let mut top_offset = if follow_tail { max_scroll } else { max_scroll.saturating_sub(usize::from(anchor_from_bottom)) }; + if selection_drives_scroll { + if let Some((selected_start, selected_end)) = selected_line_range { + let viewport_height = usize::from(viewport_height); + if selected_start < top_offset { + top_offset = selected_start; + } else if selected_end >= top_offset.saturating_add(viewport_height) { + top_offset = selected_end + .saturating_add(1) + .saturating_sub(viewport_height); + } + } + } + top_offset = top_offset.min(max_scroll); top_offset.try_into().unwrap_or(u16::MAX) } -fn centered_rect(width_percent: u16, height: u16, area: Rect) -> Rect { - let vertical = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Length(area.height.saturating_sub(height) / 2), - Constraint::Length(height), - Constraint::Min(0), - ]) - .split(area); - - Layout::default() - .direction(Direction::Horizontal) - .constraints([Constraint::Percentage(width_percent)]) - .flex(Flex::Center) - .split(vertical[1])[0] -} - #[cfg(test)] mod tests { use astrcode_client::{ - AstrcodeConversationAgentLifecycleDto, AstrcodeConversationSlashActionKindDto, - AstrcodeConversationSlashCandidateDto, + AstrcodeConversationSlashActionKindDto, AstrcodeConversationSlashCandidateDto, }; use ratatui::{Terminal, backend::TestBackend}; use super::render; use crate::{ capability::{ColorLevel, GlyphMode, TerminalCapabilities}, - state::CliState, + state::{CliState, PaneFocus, TranscriptCell, TranscriptCellKind, TranscriptCellStatus}, }; fn capabilities(glyphs: GlyphMode) -> TerminalCapabilities { @@ -231,8 +191,8 @@ mod tests { } #[test] - fn renders_workspace_scaffold() { - let backend = TestBackend::new(100, 30); + fn renders_minimal_layout() { + let backend = TestBackend::new(100, 28); let mut terminal = Terminal::new(backend).expect("terminal"); let mut state = CliState::new( "http://127.0.0.1:5529".to_string(), @@ -250,12 +210,13 @@ mod tests { .iter() .map(|cell| cell.symbol()) .collect::(); - assert!(text.contains("Astrcode workspace")); - assert!(text.contains("Find and fix a bug in @filename")); + assert!(text.contains("Astrcode")); + assert!(text.contains("commands")); + assert!(!text.contains("Navigation")); } #[test] - fn renders_ascii_dividers_in_ascii_mode() { + fn renders_ascii_fallback_symbols() { let backend = TestBackend::new(80, 24); let mut terminal = Terminal::new(backend).expect("terminal"); let mut state = CliState::new( @@ -274,30 +235,30 @@ mod tests { .iter() .map(|cell| cell.symbol()) .collect::(); - assert!(text.contains("-")); assert!(text.contains(">")); + assert!(text.contains("-")); } #[test] - fn renders_child_agents_side_pane() { - let backend = TestBackend::new(120, 30); + fn renders_inline_slash_menu() { + let backend = TestBackend::new(110, 28); let mut terminal = Terminal::new(backend).expect("terminal"); let mut state = CliState::new( "http://127.0.0.1:5529".to_string(), None, capabilities(GlyphMode::Unicode), ); - state.conversation.child_summaries.push( - astrcode_client::AstrcodeConversationChildSummaryDto { - child_session_id: "child-1".to_string(), - child_agent_id: "agent-1".to_string(), - title: "Repo inspector".to_string(), - lifecycle: AstrcodeConversationAgentLifecycleDto::Running, - latest_output_summary: Some("checking repository layout".to_string()), - child_ref: None, - }, + state.set_slash_query( + "review", + vec![AstrcodeConversationSlashCandidateDto { + id: "review".to_string(), + title: "Review current changes".to_string(), + description: "对当前工作区变更运行 review".to_string(), + keywords: vec!["review".to_string()], + action_kind: AstrcodeConversationSlashActionKindDto::ExecuteCommand, + action_value: "/review".to_string(), + }], ); - terminal .draw(|frame| render(frame, &mut state)) .expect("draw"); @@ -308,30 +269,28 @@ mod tests { .iter() .map(|cell| cell.symbol()) .collect::(); - assert!(text.contains("child sessions")); - assert!(text.contains("Repo inspector")); + assert!(text.contains("/ commands")); + assert!(text.contains("Review current changes")); } #[test] - fn renders_embedded_command_palette_in_bottom_pane() { - let backend = TestBackend::new(100, 30); + fn renders_thinking_cell_with_preview() { + let backend = TestBackend::new(100, 28); let mut terminal = Terminal::new(backend).expect("terminal"); let mut state = CliState::new( "http://127.0.0.1:5529".to_string(), None, capabilities(GlyphMode::Unicode), ); - state.set_slash_query( - "review", - vec![AstrcodeConversationSlashCandidateDto { - id: "review".to_string(), - title: "Review current changes".to_string(), - description: "对当前工作区变更运行 review".to_string(), - keywords: vec!["review".to_string()], - action_kind: AstrcodeConversationSlashActionKindDto::ExecuteCommand, - action_value: "/review".to_string(), - }], - ); + state.conversation.transcript_cells.push(TranscriptCell { + id: "thinking-1".to_string(), + expanded: false, + kind: TranscriptCellKind::Thinking { + body: "".to_string(), + status: TranscriptCellStatus::Streaming, + }, + }); + state.interaction.set_focus(PaneFocus::Transcript); terminal .draw(|frame| render(frame, &mut state)) @@ -343,17 +302,18 @@ mod tests { .iter() .map(|cell| cell.symbol()) .collect::(); - assert!(text.contains("commands")); - assert!(text.contains("Review current changes")); - } - - #[test] - fn transcript_scroll_offset_pins_tail_when_follow_is_enabled() { - assert_eq!(super::transcript_scroll_offset(48, 10, 3, true), 38); + assert!(text.contains("Thinking")); } #[test] - fn transcript_scroll_offset_uses_anchor_when_follow_is_disabled() { - assert_eq!(super::transcript_scroll_offset(48, 10, 3, false), 35); + fn transcript_scroll_offset_keeps_selected_range_visible() { + assert_eq!( + super::transcript_scroll_offset(64, 10, 0, false, Some((20, 24)), true), + 20 + ); + assert_eq!( + super::transcript_scroll_offset(64, 10, 0, false, Some((3, 5)), true), + 3 + ); } } diff --git a/crates/cli/src/state/conversation.rs b/crates/cli/src/state/conversation.rs index ef319f36..a536e231 100644 --- a/crates/cli/src/state/conversation.rs +++ b/crates/cli/src/state/conversation.rs @@ -1,3 +1,5 @@ +use std::collections::BTreeSet; + use astrcode_client::{ AstrcodeConversationBannerDto, AstrcodeConversationBlockDto, AstrcodeConversationBlockPatchDto, AstrcodeConversationBlockStatusDto, AstrcodeConversationChildSummaryDto, @@ -7,7 +9,7 @@ use astrcode_client::{ AstrcodeConversationStreamEnvelopeDto, AstrcodePhaseDto, AstrcodeSessionListItem, }; -use super::{ChildPaneState, RenderState, TranscriptCell}; +use super::{RenderState, TranscriptCell}; #[derive(Debug, Clone, PartialEq, Eq, Default)] pub struct ConversationState { @@ -49,10 +51,10 @@ impl ConversationState { &mut self, envelope: AstrcodeConversationStreamEnvelopeDto, render: &mut RenderState, - child_pane: &mut ChildPaneState, + expanded_ids: &BTreeSet, ) { self.cursor = Some(envelope.cursor); - self.apply_delta(envelope.delta, render, child_pane); + self.apply_delta(envelope.delta, render, expanded_ids); } pub fn set_banner_error(&mut self, error: AstrcodeConversationErrorEnvelopeDto) { @@ -67,35 +69,16 @@ impl ConversationState { self.control.as_ref().map(|control| control.phase) } - pub fn selected_child_summary( - &self, - child_pane: &ChildPaneState, - ) -> Option<&AstrcodeConversationChildSummaryDto> { - self.child_summaries.get(child_pane.selected) - } - - pub fn focused_child_summary( - &self, - child_pane: &ChildPaneState, - ) -> Option<&AstrcodeConversationChildSummaryDto> { - let Some(child_session_id) = child_pane.focused_child_session_id.as_deref() else { - return self.selected_child_summary(child_pane); - }; - self.child_summaries - .iter() - .find(|summary| summary.child_session_id == child_session_id) - } - fn apply_delta( &mut self, delta: AstrcodeConversationDeltaDto, render: &mut RenderState, - child_pane: &mut ChildPaneState, + expanded_ids: &BTreeSet, ) { match delta { AstrcodeConversationDeltaDto::AppendBlock { block } => { self.transcript_cells - .push(TranscriptCell::from_block(&block)); + .push(TranscriptCell::from_block(&block, expanded_ids)); self.transcript.push(block); render.invalidate_transcript_cache(); }, @@ -107,7 +90,7 @@ impl ConversationState { .find(|(_, block)| block_id_of(block) == block_id) { apply_block_patch(block, patch); - self.transcript_cells[index] = TranscriptCell::from_block(block); + self.transcript_cells[index] = TranscriptCell::from_block(block, expanded_ids); render.invalidate_transcript_cache(); } }, @@ -119,7 +102,7 @@ impl ConversationState { .find(|(_, block)| block_id_of(block) == block_id) { set_block_status(block, status); - self.transcript_cells[index] = TranscriptCell::from_block(block); + self.transcript_cells[index] = TranscriptCell::from_block(block, expanded_ids); render.invalidate_transcript_cache(); } }, @@ -136,22 +119,10 @@ impl ConversationState { } else { self.child_summaries.push(child); } - if child_pane.selected >= self.child_summaries.len() - && !self.child_summaries.is_empty() - { - child_pane.selected = self.child_summaries.len() - 1; - } }, AstrcodeConversationDeltaDto::RemoveChildSummary { child_session_id } => { self.child_summaries .retain(|child| child.child_session_id != child_session_id); - if child_pane.selected >= self.child_summaries.len() { - child_pane.selected = self.child_summaries.len().saturating_sub(1); - } - if child_pane.focused_child_session_id.as_deref() == Some(child_session_id.as_str()) - { - child_pane.focused_child_session_id = None; - } }, AstrcodeConversationDeltaDto::ReplaceSlashCandidates { candidates } => { self.slash_candidates = candidates; @@ -169,10 +140,16 @@ impl ConversationState { } fn rebuild_transcript_cells(&mut self) { + let expanded_ids = self + .transcript_cells + .iter() + .filter(|cell| cell.expanded) + .map(|cell| cell.id.clone()) + .collect::>(); self.transcript_cells = self .transcript .iter() - .map(TranscriptCell::from_block) + .map(|block| TranscriptCell::from_block(block, &expanded_ids)) .collect(); } } diff --git a/crates/cli/src/state/interaction.rs b/crates/cli/src/state/interaction.rs index a07d1351..840c8254 100644 --- a/crates/cli/src/state/interaction.rs +++ b/crates/cli/src/state/interaction.rs @@ -1,12 +1,13 @@ +use std::collections::BTreeSet; + use astrcode_client::{AstrcodeConversationSlashCandidateDto, AstrcodeSessionListItem}; #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum PaneFocus { Transcript, - ChildPane, #[default] Composer, - Overlay, + Palette, } #[derive(Debug, Clone, PartialEq, Eq, Default)] @@ -24,36 +25,30 @@ impl ComposerState { } } -#[derive(Debug, Clone, PartialEq, Eq, Default)] -pub struct ResumeOverlayState { - pub query: String, - pub items: Vec, - pub selected: usize, -} - -#[derive(Debug, Clone, PartialEq, Eq, Default)] +#[derive(Debug, Clone, PartialEq, Eq)] pub struct SlashPaletteState { pub query: String, pub items: Vec, pub selected: usize, } -#[derive(Debug, Clone, PartialEq, Eq, Default)] -pub struct DebugOverlayState { - pub scroll: usize, +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ResumePaletteState { + pub query: String, + pub items: Vec, + pub selected: usize, } #[derive(Debug, Clone, PartialEq, Eq, Default)] -pub enum OverlayState { +pub enum PaletteState { #[default] - None, - Resume(ResumeOverlayState), - SlashPalette(SlashPaletteState), - DebugLogs(DebugOverlayState), + Closed, + Slash(SlashPaletteState), + Resume(ResumePaletteState), } #[derive(Debug, Clone, PartialEq, Eq)] -pub enum OverlaySelection { +pub enum PaletteSelection { ResumeSession(String), SlashCandidate(AstrcodeConversationSlashCandidateDto), } @@ -74,9 +69,9 @@ impl Default for StatusLine { } #[derive(Debug, Clone, PartialEq, Eq, Default)] -pub struct ChildPaneState { - pub selected: usize, - pub focused_child_session_id: Option, +pub struct TranscriptState { + pub selected_cell: usize, + pub expanded_cells: BTreeSet, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -85,9 +80,10 @@ pub struct InteractionState { pub scroll_anchor: u16, pub follow_transcript_tail: bool, pub pane_focus: PaneFocus, + pub last_non_palette_focus: PaneFocus, pub composer: ComposerState, - pub overlay: OverlayState, - pub child_pane: ChildPaneState, + pub palette: PaletteState, + pub transcript: TranscriptState, } impl Default for InteractionState { @@ -97,9 +93,10 @@ impl Default for InteractionState { scroll_anchor: 0, follow_transcript_tail: true, pane_focus: PaneFocus::default(), + last_non_palette_focus: PaneFocus::default(), composer: ComposerState::default(), - overlay: OverlayState::default(), - child_pane: ChildPaneState::default(), + palette: PaletteState::default(), + transcript: TranscriptState::default(), } } } @@ -120,14 +117,17 @@ impl InteractionState { } pub fn push_input(&mut self, ch: char) { + self.set_focus(PaneFocus::Composer); self.composer.input.push(ch); } pub fn append_input(&mut self, value: &str) { + self.set_focus(PaneFocus::Composer); self.composer.input.push_str(value); } pub fn insert_newline(&mut self) { + self.set_focus(PaneFocus::Composer); self.composer.input.push('\n'); } @@ -136,6 +136,7 @@ impl InteractionState { } pub fn replace_input(&mut self, input: impl Into) { + self.set_focus(PaneFocus::Composer); self.composer.input = input.into(); } @@ -158,83 +159,87 @@ impl InteractionState { self.follow_transcript_tail = true; } - pub fn cycle_focus_forward(&mut self, has_children: bool) { - self.pane_focus = match self.pane_focus { - PaneFocus::Transcript => { - if has_children { - PaneFocus::ChildPane - } else { - PaneFocus::Composer - } - }, - PaneFocus::ChildPane => PaneFocus::Composer, + pub fn cycle_focus_forward(&mut self) { + self.set_focus(match self.pane_focus { + PaneFocus::Transcript => PaneFocus::Composer, PaneFocus::Composer => PaneFocus::Transcript, - PaneFocus::Overlay => PaneFocus::Overlay, - }; + PaneFocus::Palette => PaneFocus::Palette, + }); } - pub fn cycle_focus_backward(&mut self, has_children: bool) { - self.pane_focus = match self.pane_focus { - PaneFocus::Transcript => PaneFocus::Composer, - PaneFocus::ChildPane => PaneFocus::Transcript, - PaneFocus::Composer => { - if has_children { - PaneFocus::ChildPane - } else { - PaneFocus::Transcript - } - }, - PaneFocus::Overlay => PaneFocus::Overlay, - }; + pub fn cycle_focus_backward(&mut self) { + self.cycle_focus_forward(); } - pub fn child_next(&mut self, child_count: usize) { - if child_count == 0 { + pub fn set_focus(&mut self, focus: PaneFocus) { + self.pane_focus = focus; + if !matches!(focus, PaneFocus::Palette) { + self.last_non_palette_focus = focus; + } + } + + pub fn transcript_next(&mut self, cell_count: usize) { + if cell_count == 0 { return; } - self.child_pane.selected = (self.child_pane.selected + 1) % child_count; + self.set_focus(PaneFocus::Transcript); + self.transcript.selected_cell = (self.transcript.selected_cell + 1) % cell_count; + self.follow_transcript_tail = false; } - pub fn child_prev(&mut self, child_count: usize) { - if child_count == 0 { + pub fn transcript_prev(&mut self, cell_count: usize) { + if cell_count == 0 { return; } - self.child_pane.selected = (self.child_pane.selected + child_count - 1) % child_count; + self.set_focus(PaneFocus::Transcript); + self.transcript.selected_cell = + (self.transcript.selected_cell + cell_count - 1) % cell_count; + self.follow_transcript_tail = false; } - pub fn toggle_child_focus(&mut self, selected_child_session_id: Option<&str>) { - let Some(selected_child_session_id) = selected_child_session_id else { + pub fn sync_transcript_cells(&mut self, cell_count: usize) { + if cell_count == 0 { + self.transcript.selected_cell = 0; + self.transcript.expanded_cells.clear(); return; - }; - if self.child_pane.focused_child_session_id.as_deref() == Some(selected_child_session_id) { - self.child_pane.focused_child_session_id = None; - } else { - self.child_pane.focused_child_session_id = Some(selected_child_session_id.to_string()); } + if self.transcript.selected_cell >= cell_count { + self.transcript.selected_cell = cell_count - 1; + } + } + + pub fn toggle_cell_expanded(&mut self, cell_id: &str) { + if !self.transcript.expanded_cells.insert(cell_id.to_string()) { + self.transcript.expanded_cells.remove(cell_id); + } + } + + pub fn is_cell_expanded(&self, cell_id: &str) -> bool { + self.transcript.expanded_cells.contains(cell_id) } pub fn reset_for_snapshot(&mut self) { self.reset_scroll(); - self.overlay = OverlayState::None; - self.pane_focus = PaneFocus::Composer; - self.child_pane = ChildPaneState::default(); + self.palette = PaletteState::Closed; + self.transcript = TranscriptState::default(); + self.set_focus(PaneFocus::Composer); } - pub fn set_resume_query( + pub fn set_resume_palette( &mut self, query: impl Into, items: Vec, ) { - self.pane_focus = PaneFocus::Overlay; - self.overlay = OverlayState::Resume(ResumeOverlayState { + self.palette = PaletteState::Resume(ResumePaletteState { query: query.into(), items, selected: 0, }); + self.pane_focus = PaneFocus::Palette; } pub fn sync_resume_items(&mut self, items: Vec) { - if let OverlayState::Resume(resume) = &mut self.overlay { + if let PaletteState::Resume(resume) = &mut self.palette { resume.items = items; if resume.selected >= resume.items.len() { resume.selected = 0; @@ -242,21 +247,21 @@ impl InteractionState { } } - pub fn set_slash_query( + pub fn set_slash_palette( &mut self, query: impl Into, items: Vec, ) { - self.pane_focus = PaneFocus::Overlay; - self.overlay = OverlayState::SlashPalette(SlashPaletteState { + self.palette = PaletteState::Slash(SlashPaletteState { query: query.into(), items, selected: 0, }); + self.pane_focus = PaneFocus::Palette; } pub fn sync_slash_items(&mut self, items: Vec) { - if let OverlayState::SlashPalette(palette) = &mut self.overlay { + if let PaletteState::Slash(palette) = &mut self.palette { palette.items = items; if palette.selected >= palette.items.len() { palette.selected = 0; @@ -264,100 +269,100 @@ impl InteractionState { } } - pub fn overlay_query_push(&mut self, ch: char) { - match &mut self.overlay { - OverlayState::Resume(resume) => resume.query.push(ch), - OverlayState::SlashPalette(palette) => palette.query.push(ch), - OverlayState::DebugLogs(_) => {}, - OverlayState::None => self.push_input(ch), - } - } - - pub fn overlay_query_append(&mut self, value: &str) { - match &mut self.overlay { - OverlayState::Resume(resume) => resume.query.push_str(value), - OverlayState::SlashPalette(palette) => palette.query.push_str(value), - OverlayState::DebugLogs(_) => {}, - OverlayState::None => self.append_input(value), - } - } - - pub fn overlay_query_pop(&mut self) { - match &mut self.overlay { - OverlayState::Resume(resume) => { - resume.query.pop(); - }, - OverlayState::SlashPalette(palette) => { - palette.query.pop(); - }, - OverlayState::DebugLogs(_) => {}, - OverlayState::None => self.pop_input(), - } + pub fn close_palette(&mut self) { + self.palette = PaletteState::Closed; + self.set_focus(self.last_non_palette_focus); } - pub fn close_overlay(&mut self) { - self.overlay = OverlayState::None; - self.pane_focus = PaneFocus::Composer; + pub fn has_palette(&self) -> bool { + !matches!(self.palette, PaletteState::Closed) } - pub fn has_overlay(&self) -> bool { - !matches!(self.overlay, OverlayState::None) - } - - pub fn overlay_next(&mut self) { - match &mut self.overlay { - OverlayState::Resume(resume) if !resume.items.is_empty() => { + pub fn palette_next(&mut self) { + match &mut self.palette { + PaletteState::Resume(resume) if !resume.items.is_empty() => { resume.selected = (resume.selected + 1) % resume.items.len(); }, - OverlayState::SlashPalette(palette) if !palette.items.is_empty() => { + PaletteState::Slash(palette) if !palette.items.is_empty() => { palette.selected = (palette.selected + 1) % palette.items.len(); }, - OverlayState::DebugLogs(debug) => { - debug.scroll = debug.scroll.saturating_add(1); - }, - _ => {}, + PaletteState::Closed | PaletteState::Resume(_) | PaletteState::Slash(_) => {}, } } - pub fn overlay_prev(&mut self) { - match &mut self.overlay { - OverlayState::Resume(resume) if !resume.items.is_empty() => { + pub fn palette_prev(&mut self) { + match &mut self.palette { + PaletteState::Resume(resume) if !resume.items.is_empty() => { resume.selected = (resume.selected + resume.items.len().saturating_sub(1)) % resume.items.len(); }, - OverlayState::SlashPalette(palette) if !palette.items.is_empty() => { + PaletteState::Slash(palette) if !palette.items.is_empty() => { palette.selected = (palette.selected + palette.items.len().saturating_sub(1)) % palette.items.len(); }, - OverlayState::DebugLogs(debug) => { - debug.scroll = debug.scroll.saturating_sub(1); - }, - _ => {}, + PaletteState::Closed | PaletteState::Resume(_) | PaletteState::Slash(_) => {}, } } - pub fn selected_overlay(&self) -> Option { - match &self.overlay { - OverlayState::Resume(resume) => resume + pub fn selected_palette(&self) -> Option { + match &self.palette { + PaletteState::Resume(resume) => resume .items .get(resume.selected) - .map(|item| OverlaySelection::ResumeSession(item.session_id.clone())), - OverlayState::SlashPalette(palette) => palette + .map(|item| PaletteSelection::ResumeSession(item.session_id.clone())), + PaletteState::Slash(palette) => palette .items .get(palette.selected) .cloned() - .map(OverlaySelection::SlashCandidate), - OverlayState::DebugLogs(_) => None, - OverlayState::None => None, + .map(PaletteSelection::SlashCandidate), + PaletteState::Closed => None, } } - pub fn toggle_debug_overlay(&mut self) { - if matches!(self.overlay, OverlayState::DebugLogs(_)) { - self.close_overlay(); - } else { - self.pane_focus = PaneFocus::Overlay; - self.overlay = OverlayState::DebugLogs(DebugOverlayState::default()); + pub fn clear_surface_state(&mut self) { + match self.pane_focus { + PaneFocus::Transcript => { + self.reset_scroll(); + self.transcript.expanded_cells.clear(); + }, + PaneFocus::Composer => { + self.status = StatusLine::default(); + }, + PaneFocus::Palette => self.close_palette(), } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn tab_flow_cycles_two_surfaces() { + let mut state = InteractionState::default(); + state.set_focus(PaneFocus::Transcript); + state.cycle_focus_forward(); + assert_eq!(state.pane_focus, PaneFocus::Composer); + state.cycle_focus_forward(); + assert_eq!(state.pane_focus, PaneFocus::Transcript); + } + + #[test] + fn close_palette_restores_previous_focus() { + let mut state = InteractionState::default(); + state.set_focus(PaneFocus::Transcript); + state.set_slash_palette("", Vec::new()); + assert_eq!(state.pane_focus, PaneFocus::Palette); + state.close_palette(); + assert_eq!(state.pane_focus, PaneFocus::Transcript); + } + + #[test] + fn transcript_expansion_toggles_by_cell_id() { + let mut state = InteractionState::default(); + state.toggle_cell_expanded("assistant-1"); + assert!(state.is_cell_expanded("assistant-1")); + state.toggle_cell_expanded("assistant-1"); + assert!(!state.is_cell_expanded("assistant-1")); + } +} diff --git a/crates/cli/src/state/mod.rs b/crates/cli/src/state/mod.rs index f7b155b2..b09ec677 100644 --- a/crates/cli/src/state/mod.rs +++ b/crates/cli/src/state/mod.rs @@ -3,23 +3,25 @@ mod debug; mod interaction; mod render; mod shell; +mod thinking; mod transcript_cell; use std::{path::PathBuf, time::Duration}; use astrcode_client::{ - AstrcodeConversationChildSummaryDto, AstrcodeConversationErrorEnvelopeDto, - AstrcodeConversationSlashCandidateDto, AstrcodeConversationSnapshotResponseDto, - AstrcodeConversationStreamEnvelopeDto, AstrcodePhaseDto, AstrcodeSessionListItem, + AstrcodeConversationErrorEnvelopeDto, AstrcodeConversationSlashCandidateDto, + AstrcodeConversationSnapshotResponseDto, AstrcodeConversationStreamEnvelopeDto, + AstrcodePhaseDto, AstrcodeSessionListItem, }; pub use conversation::ConversationState; pub use debug::DebugChannelState; pub use interaction::{ - ChildPaneState, ComposerState, DebugOverlayState, InteractionState, OverlaySelection, - OverlayState, PaneFocus, ResumeOverlayState, SlashPaletteState, StatusLine, + ComposerState, InteractionState, PaletteSelection, PaletteState, PaneFocus, ResumePaletteState, + SlashPaletteState, StatusLine, }; pub use render::{RenderState, StreamViewState, TranscriptRenderCache}; pub use shell::ShellState; +pub use thinking::{ThinkingPlaybackDriver, ThinkingPresentationState, ThinkingSnippetPool}; pub use transcript_cell::{TranscriptCell, TranscriptCellKind, TranscriptCellStatus}; use crate::capability::TerminalCapabilities; @@ -40,16 +42,24 @@ pub struct WrappedLine { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum WrappedLineStyle { Plain, - Dim, + Muted, Accent, - Success, - Warning, - Error, - User, - Header, - Footer, + Divider, Selection, - Border, + UserLabel, + UserBody, + AssistantLabel, + AssistantBody, + ThinkingLabel, + ThinkingBody, + ToolLabel, + ToolBody, + ErrorText, + FooterInput, + FooterStatus, + PaletteTitle, + PaletteItem, + PaletteMeta, } #[derive(Debug, Clone, PartialEq, Eq, Default)] @@ -60,6 +70,8 @@ pub struct CliState { pub render: RenderState, pub stream_view: StreamViewState, pub debug: DebugChannelState, + pub thinking_pool: ThinkingSnippetPool, + pub thinking_playback: ThinkingPlaybackDriver, } impl CliState { @@ -70,6 +82,8 @@ impl CliState { ) -> Self { Self { shell: ShellState::new(connection_origin, working_dir, capabilities), + thinking_pool: ThinkingSnippetPool::default(), + thinking_playback: ThinkingPlaybackDriver::default(), ..Default::default() } } @@ -127,41 +141,46 @@ impl CliState { } pub fn cycle_focus_forward(&mut self) { - self.interaction - .cycle_focus_forward(!self.conversation.child_summaries.is_empty()); + self.interaction.cycle_focus_forward(); } pub fn cycle_focus_backward(&mut self) { - self.interaction - .cycle_focus_backward(!self.conversation.child_summaries.is_empty()); + self.interaction.cycle_focus_backward(); } - pub fn child_next(&mut self) { + pub fn transcript_next(&mut self) { self.interaction - .child_next(self.conversation.child_summaries.len()); + .transcript_next(self.conversation.transcript_cells.len()); } - pub fn child_prev(&mut self) { + pub fn transcript_prev(&mut self) { self.interaction - .child_prev(self.conversation.child_summaries.len()); + .transcript_prev(self.conversation.transcript_cells.len()); } - pub fn toggle_child_focus(&mut self) { - let selected_child_session_id = self - .selected_child_summary() - .map(|summary| summary.child_session_id.clone()); - self.interaction - .toggle_child_focus(selected_child_session_id.as_deref()); + pub fn selected_transcript_cell(&self) -> Option<&TranscriptCell> { + self.conversation + .transcript_cells + .get(self.interaction.transcript.selected_cell) } - pub fn selected_child_summary(&self) -> Option<&AstrcodeConversationChildSummaryDto> { - self.conversation - .selected_child_summary(&self.interaction.child_pane) + pub fn is_cell_expanded(&self, cell_id: &str) -> bool { + self.interaction.is_cell_expanded(cell_id) } - pub fn focused_child_summary(&self) -> Option<&AstrcodeConversationChildSummaryDto> { - self.conversation - .focused_child_summary(&self.interaction.child_pane) + pub fn selected_cell_is_thinking(&self) -> bool { + self.selected_transcript_cell() + .is_some_and(|cell| matches!(cell.kind, TranscriptCellKind::Thinking { .. })) + } + + pub fn toggle_selected_cell_expanded(&mut self) { + if let Some(cell_id) = self.selected_transcript_cell().map(|cell| cell.id.clone()) { + self.interaction.toggle_cell_expanded(cell_id.as_str()); + } + } + + pub fn clear_surface_state(&mut self) { + self.interaction.clear_surface_state(); } pub fn update_sessions(&mut self, sessions: Vec) { @@ -175,7 +194,7 @@ impl CliState { query: impl Into, items: Vec, ) { - self.interaction.set_resume_query(query, items); + self.interaction.set_resume_palette(query, items); } pub fn set_slash_query( @@ -183,56 +202,48 @@ impl CliState { query: impl Into, items: Vec, ) { - self.interaction.set_slash_query(query, items); - } - - pub fn overlay_query_push(&mut self, ch: char) { - self.interaction.overlay_query_push(ch); - } - - pub fn overlay_query_append(&mut self, value: &str) { - self.interaction.overlay_query_append(value); + self.interaction.set_slash_palette(query, items); } - pub fn overlay_query_pop(&mut self) { - self.interaction.overlay_query_pop(); + pub fn close_palette(&mut self) { + self.interaction.close_palette(); } - pub fn close_overlay(&mut self) { - self.interaction.close_overlay(); + pub fn palette_next(&mut self) { + self.interaction.palette_next(); } - pub fn overlay_next(&mut self) { - self.interaction.overlay_next(); + pub fn palette_prev(&mut self) { + self.interaction.palette_prev(); } - pub fn overlay_prev(&mut self) { - self.interaction.overlay_prev(); - } - - pub fn selected_overlay(&self) -> Option { - self.interaction.selected_overlay() + pub fn selected_palette(&self) -> Option { + self.interaction.selected_palette() } pub fn activate_snapshot(&mut self, snapshot: AstrcodeConversationSnapshotResponseDto) { self.conversation .activate_snapshot(snapshot, &mut self.render); self.interaction.reset_for_snapshot(); + self.interaction + .sync_transcript_cells(self.conversation.transcript_cells.len()); + self.thinking_playback + .sync_session(self.conversation.active_session_id.as_deref()); } pub fn apply_stream_envelope(&mut self, envelope: AstrcodeConversationStreamEnvelopeDto) { - self.conversation.apply_stream_envelope( - envelope, - &mut self.render, - &mut self.interaction.child_pane, - ); + let expanded_ids = self.interaction.transcript.expanded_cells.clone(); + self.conversation + .apply_stream_envelope(envelope, &mut self.render, &expanded_ids); + self.interaction + .sync_transcript_cells(self.conversation.transcript_cells.len()); self.interaction .sync_slash_items(self.conversation.slash_candidates.clone()); } pub fn set_banner_error(&mut self, error: AstrcodeConversationErrorEnvelopeDto) { self.conversation.set_banner_error(error); - self.interaction.pane_focus = PaneFocus::Composer; + self.interaction.set_focus(PaneFocus::Composer); } pub fn clear_banner(&mut self) { @@ -247,8 +258,19 @@ impl CliState { self.debug.push(line); } - pub fn toggle_debug_overlay(&mut self) { - self.interaction.toggle_debug_overlay(); + pub fn advance_thinking_playback(&mut self) { + if self.conversation.transcript_cells.iter().any(|cell| { + matches!( + cell.kind, + TranscriptCellKind::Thinking { + status: TranscriptCellStatus::Streaming, + .. + } + ) + }) { + self.thinking_playback.advance(); + self.render.invalidate_transcript_cache(); + } } } @@ -363,7 +385,7 @@ mod tests { } #[test] - fn overlay_selection_tracks_resume_and_slash_items() { + fn palette_selection_tracks_resume_and_slash_items() { let mut state = CliState::new("http://127.0.0.1:5529".to_string(), None, capabilities()); state.set_slash_query( "review", @@ -378,11 +400,11 @@ mod tests { ); assert!(matches!( - state.selected_overlay(), - Some(OverlaySelection::SlashCandidate(_)) + state.selected_palette(), + Some(PaletteSelection::SlashCandidate(_)) )); - state.close_overlay(); - assert!(matches!(state.interaction.overlay, OverlayState::None)); + state.set_resume_query("repo", Vec::new()); + assert!(matches!(state.interaction.palette, PaletteState::Resume(_))); } #[test] @@ -417,13 +439,18 @@ mod tests { } #[test] - fn activating_snapshot_resets_transcript_follow_state() { + fn ticking_advances_streaming_thinking() { let mut state = CliState::new("http://127.0.0.1:5529".to_string(), None, capabilities()); - state.scroll_up(); - - state.activate_snapshot(sample_snapshot()); - - assert_eq!(state.interaction.scroll_anchor, 0); - assert!(state.interaction.follow_transcript_tail); + state.conversation.transcript_cells.push(TranscriptCell { + id: "thinking-1".to_string(), + expanded: false, + kind: TranscriptCellKind::Thinking { + body: "".to_string(), + status: TranscriptCellStatus::Streaming, + }, + }); + let frame = state.thinking_playback.frame; + state.advance_thinking_playback(); + assert!(state.thinking_playback.frame > frame); } } diff --git a/crates/cli/src/state/thinking.rs b/crates/cli/src/state/thinking.rs new file mode 100644 index 00000000..2b825b02 --- /dev/null +++ b/crates/cli/src/state/thinking.rs @@ -0,0 +1,182 @@ +use super::TranscriptCellStatus; + +const DEFAULT_THINKING_SNIPPETS: &[&str] = &[ + "整理上下文与约束边界", + "对比可行路径并压缩实现范围", + "检查已有抽象能否复用", + "收敛风险最高的变更点", + "把交互细节拆成可验证步骤", + "准备把输出整理成最小可行修改", +]; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ThinkingPresentationState { + pub label: String, + pub preview: String, + pub expanded_body: String, + pub is_playing: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ThinkingSnippetPool { + snippets: &'static [&'static str], +} + +impl Default for ThinkingSnippetPool { + fn default() -> Self { + Self { + snippets: DEFAULT_THINKING_SNIPPETS, + } + } +} + +impl ThinkingSnippetPool { + pub fn sequence(&self, seed: u64, count: usize) -> Vec<&'static str> { + if self.snippets.is_empty() { + return vec!["thinking"]; + } + + (0..count.max(1)) + .map(|offset| { + let index = ((seed as usize).wrapping_add(offset * 3)) % self.snippets.len(); + self.snippets[index] + }) + .collect() + } + + pub fn sample(&self, seed: u64, frame: u64) -> &'static str { + if self.snippets.is_empty() { + return "thinking"; + } + let index = ((seed as usize).wrapping_add(frame as usize)) % self.snippets.len(); + self.snippets[index] + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct ThinkingPlaybackDriver { + pub session_seed: u64, + pub frame: u64, +} + +impl ThinkingPlaybackDriver { + pub fn sync_session(&mut self, session_id: Option<&str>) { + self.session_seed = session_id.map(stable_hash).unwrap_or_default(); + self.frame = 0; + } + + pub fn advance(&mut self) { + self.frame = self.frame.wrapping_add(1); + } + + pub fn present( + &self, + pool: &ThinkingSnippetPool, + cell_id: &str, + raw_body: &str, + status: TranscriptCellStatus, + expanded: bool, + ) -> ThinkingPresentationState { + let seed = stable_hash(cell_id) ^ self.session_seed; + let playlist = pool.sequence(seed, 4); + let scripted_body = playlist.join("\n"); + let summary = first_non_empty_line(raw_body) + .map(str::to_string) + .unwrap_or_else(|| playlist[0].to_string()); + + let is_streaming = matches!(status, TranscriptCellStatus::Streaming); + let label = if expanded { + "Thinking · Ctrl+O 收起".to_string() + } else if is_streaming { + "Thinking... · Ctrl+O 展开".to_string() + } else { + "Thinking · Ctrl+O 展开".to_string() + }; + + let preview = if is_streaming { + pool.sample(seed, self.frame).to_string() + } else { + summary + }; + + let expanded_body = if raw_body.trim().is_empty() { + scripted_body + } else { + raw_body.trim().to_string() + }; + + ThinkingPresentationState { + label, + preview, + expanded_body, + is_playing: is_streaming, + } + } +} + +fn first_non_empty_line(text: &str) -> Option<&str> { + text.lines().map(str::trim).find(|line| !line.is_empty()) +} + +fn stable_hash(input: &str) -> u64 { + let mut hash = 0xcbf29ce484222325_u64; + for byte in input.as_bytes() { + hash ^= u64::from(*byte); + hash = hash.wrapping_mul(0x100000001b3); + } + hash +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sequence_is_stable_for_same_seed() { + let pool = ThinkingSnippetPool::default(); + assert_eq!(pool.sequence(42, 4), pool.sequence(42, 4)); + } + + #[test] + fn sequence_differs_for_different_seed() { + let pool = ThinkingSnippetPool::default(); + assert_ne!(pool.sequence(1, 4), pool.sequence(2, 4)); + } + + #[test] + fn streaming_preview_advances_with_frame() { + let pool = ThinkingSnippetPool::default(); + let mut driver = ThinkingPlaybackDriver::default(); + driver.sync_session(Some("session-1")); + let first = driver.present( + &pool, + "thinking-1", + "", + TranscriptCellStatus::Streaming, + false, + ); + driver.advance(); + let second = driver.present( + &pool, + "thinking-1", + "", + TranscriptCellStatus::Streaming, + false, + ); + assert_ne!(first.preview, second.preview); + } + + #[test] + fn complete_state_prefers_raw_summary() { + let pool = ThinkingSnippetPool::default(); + let driver = ThinkingPlaybackDriver::default(); + let presentation = driver.present( + &pool, + "thinking-1", + "先读取代码\n再收敛改动", + TranscriptCellStatus::Complete, + false, + ); + assert_eq!(presentation.preview, "先读取代码"); + } +} diff --git a/crates/cli/src/state/transcript_cell.rs b/crates/cli/src/state/transcript_cell.rs index 7a764524..1df7c6bf 100644 --- a/crates/cli/src/state/transcript_cell.rs +++ b/crates/cli/src/state/transcript_cell.rs @@ -1,3 +1,5 @@ +use std::collections::BTreeSet; + use astrcode_client::{ AstrcodeConversationAgentLifecycleDto, AstrcodeConversationBlockDto, AstrcodeConversationBlockStatusDto, @@ -6,6 +8,7 @@ use astrcode_client::{ #[derive(Debug, Clone, PartialEq, Eq)] pub struct TranscriptCell { pub id: String, + pub expanded: bool, pub kind: TranscriptCellKind, } @@ -60,30 +63,52 @@ pub enum TranscriptCellKind { } impl TranscriptCell { - pub fn from_block(block: &AstrcodeConversationBlockDto) -> Self { + pub fn from_block( + block: &AstrcodeConversationBlockDto, + expanded_ids: &BTreeSet, + ) -> Self { + let id = match block { + AstrcodeConversationBlockDto::User(block) => block.id.clone(), + AstrcodeConversationBlockDto::Assistant(block) => block.id.clone(), + AstrcodeConversationBlockDto::Thinking(block) => block.id.clone(), + AstrcodeConversationBlockDto::ToolCall(block) => block.id.clone(), + AstrcodeConversationBlockDto::Error(block) => block.id.clone(), + AstrcodeConversationBlockDto::SystemNote(block) => block.id.clone(), + AstrcodeConversationBlockDto::ChildHandoff(block) => block.id.clone(), + }; + let expanded = expanded_ids.contains(&id) + || matches!( + block, + AstrcodeConversationBlockDto::Thinking(thinking) + if matches!(thinking.status, AstrcodeConversationBlockStatusDto::Streaming) + ); match block { AstrcodeConversationBlockDto::User(block) => Self { - id: block.id.clone(), + id, + expanded, kind: TranscriptCellKind::User { body: block.markdown.clone(), }, }, AstrcodeConversationBlockDto::Assistant(block) => Self { - id: block.id.clone(), + id, + expanded, kind: TranscriptCellKind::Assistant { body: block.markdown.clone(), status: block.status.into(), }, }, AstrcodeConversationBlockDto::Thinking(block) => Self { - id: block.id.clone(), + id, + expanded, kind: TranscriptCellKind::Thinking { body: block.markdown.clone(), status: block.status.into(), }, }, AstrcodeConversationBlockDto::ToolCall(block) => Self { - id: block.id.clone(), + id, + expanded, kind: TranscriptCellKind::ToolCall { tool_name: block.tool_name.clone(), summary: block @@ -112,21 +137,24 @@ impl TranscriptCell { }, }, AstrcodeConversationBlockDto::Error(block) => Self { - id: block.id.clone(), + id, + expanded, kind: TranscriptCellKind::Error { code: format!("{:?}", block.code), message: block.message.clone(), }, }, AstrcodeConversationBlockDto::SystemNote(block) => Self { - id: block.id.clone(), + id, + expanded, kind: TranscriptCellKind::SystemNote { note_kind: format!("{:?}", block.note_kind), markdown: block.markdown.clone(), }, }, AstrcodeConversationBlockDto::ChildHandoff(block) => Self { - id: block.id.clone(), + id, + expanded, kind: TranscriptCellKind::ChildHandoff { handoff_kind: format!("{:?}", block.handoff_kind), title: block.child.title.clone(), diff --git a/crates/cli/src/ui/bottom_pane.rs b/crates/cli/src/ui/bottom_pane.rs deleted file mode 100644 index dd85edf2..00000000 --- a/crates/cli/src/ui/bottom_pane.rs +++ /dev/null @@ -1,231 +0,0 @@ -use super::{ - cells::wrap_text, - theme::{CodexTheme, ThemePalette}, -}; -use crate::{ - capability::TerminalCapabilities, - state::{CliState, OverlayState, PaneFocus, WrappedLine, WrappedLineStyle}, -}; - -pub trait BottomPaneView { - fn desired_height(&self, width: u16) -> u16; - fn lines(&self, width: u16) -> Vec; -} - -pub struct ComposerPane<'a> { - state: &'a CliState, - theme: CodexTheme, -} - -impl<'a> ComposerPane<'a> { - pub fn new(state: &'a CliState) -> Self { - Self { - state, - theme: CodexTheme::new(state.shell.capabilities), - } - } - - fn status_row(&self, width: usize) -> WrappedLine { - let phase = self - .state - .active_phase() - .map(super::phase_label) - .unwrap_or("idle"); - let busy = if self.state.stream_view.pending_chunks > 0 { - format!( - "stream {:?} / {}", - self.state.stream_view.mode, self.state.stream_view.pending_chunks - ) - } else { - "ready".to_string() - }; - let mut segments = vec![format!("{} {phase}", self.theme.glyph("●", "*")), busy]; - if let Some(banner) = &self.state.conversation.banner { - segments.push(format!("error {}", banner.error.message)); - } else if !self.state.interaction.status.message.is_empty() { - segments.push(self.state.interaction.status.message.clone()); - } - - WrappedLine { - style: if self.state.conversation.banner.is_some() - || self.state.interaction.status.is_error - { - WrappedLineStyle::Error - } else { - WrappedLineStyle::Dim - }, - content: truncate_text( - segments.join(" · ").as_str(), - width, - self.state.shell.capabilities, - ), - } - } - - fn composer_lines(&self, width: usize) -> Vec { - let prefix = self.theme.glyph("›", ">"); - let draft = if self.state.interaction.composer.is_empty() { - "Find and fix a bug in @filename".to_string() - } else { - self.state.interaction.composer.input.clone() - }; - let body_style = if self.state.interaction.composer.is_empty() { - WrappedLineStyle::Dim - } else if matches!(self.state.interaction.pane_focus, PaneFocus::Composer) { - WrappedLineStyle::Accent - } else { - WrappedLineStyle::Plain - }; - - wrap_text( - draft.as_str(), - width.saturating_sub(2).max(8), - self.state.shell.capabilities, - ) - .into_iter() - .enumerate() - .map(|(index, line)| WrappedLine { - style: body_style, - content: if index == 0 { - format!("{prefix} {line}") - } else { - format!(" {line}") - }, - }) - .collect() - } - - fn slash_popup_lines( - &self, - width: usize, - capabilities: TerminalCapabilities, - ) -> Vec { - let OverlayState::SlashPalette(palette) = &self.state.interaction.overlay else { - return Vec::new(); - }; - - let mut lines = vec![ - WrappedLine { - style: WrappedLineStyle::Border, - content: self.theme.divider().repeat(width.max(16)), - }, - WrappedLine { - style: WrappedLineStyle::Warning, - content: format!( - "{} commands {}", - self.theme.glyph("↳", ">"), - if palette.query.is_empty() { - "" - } else { - palette.query.as_str() - } - ), - }, - ]; - - if palette.items.is_empty() { - lines.push(WrappedLine { - style: WrappedLineStyle::Dim, - content: " 没有匹配的 commands 或 skills。".to_string(), - }); - return lines; - } - - for (index, item) in palette.items.iter().take(6).enumerate() { - let marker = if index == palette.selected { - self.theme.glyph("›", ">") - } else { - " " - }; - lines.push(WrappedLine { - style: if index == palette.selected { - WrappedLineStyle::Selection - } else { - WrappedLineStyle::Plain - }, - content: format!("{marker} {}", item.title), - }); - for line in wrap_text( - item.description.as_str(), - width.saturating_sub(2).max(8), - capabilities, - ) { - lines.push(WrappedLine { - style: WrappedLineStyle::Dim, - content: format!(" {line}"), - }); - } - } - lines - } - - fn footer_line(&self, width: usize) -> WrappedLine { - let focus = match self.state.interaction.pane_focus { - PaneFocus::Transcript => "transcript", - PaneFocus::ChildPane => "children", - PaneFocus::Composer => "composer", - PaneFocus::Overlay => "overlay", - }; - let parts = [ - format!("focus {focus}"), - "Enter send".to_string(), - "Shift+Enter newline".to_string(), - "Tab commands".to_string(), - "F2 logs".to_string(), - ]; - WrappedLine { - style: WrappedLineStyle::Footer, - content: truncate_text( - parts.join(" · ").as_str(), - width, - self.state.shell.capabilities, - ), - } - } -} - -impl BottomPaneView for ComposerPane<'_> { - fn desired_height(&self, width: u16) -> u16 { - self.lines(width).len().try_into().unwrap_or(u16::MAX) - } - - fn lines(&self, width: u16) -> Vec { - let width = usize::from(width.max(24)); - let mut lines = vec![self.status_row(width)]; - lines.push(WrappedLine { - style: WrappedLineStyle::Border, - content: self.theme.divider().repeat(width), - }); - lines.extend(self.composer_lines(width)); - lines.extend(self.slash_popup_lines(width, self.state.shell.capabilities)); - lines.push(WrappedLine { - style: WrappedLineStyle::Border, - content: self.theme.divider().repeat(width), - }); - lines.push(self.footer_line(width)); - lines - } -} - -fn truncate_text(text: &str, width: usize, capabilities: TerminalCapabilities) -> String { - if width == 0 { - return String::new(); - } - - let mut out = String::new(); - let mut current_width = 0; - for ch in text.chars() { - let ch = ch.to_string(); - let ch_width = if capabilities.ascii_only() { - 1 - } else { - unicode_width::UnicodeWidthStr::width(ch.as_str()).max(1) - }; - if current_width + ch_width > width { - break; - } - current_width += ch_width; - out.push_str(ch.as_str()); - } - out -} diff --git a/crates/cli/src/ui/cells.rs b/crates/cli/src/ui/cells.rs index f7a98c18..997ed021 100644 --- a/crates/cli/src/ui/cells.rs +++ b/crates/cli/src/ui/cells.rs @@ -4,18 +4,25 @@ use super::theme::ThemePalette; use crate::{ capability::TerminalCapabilities, state::{ - TranscriptCell, TranscriptCellKind, TranscriptCellStatus, WrappedLine, WrappedLineStyle, + ThinkingPresentationState, TranscriptCell, TranscriptCellKind, TranscriptCellStatus, + WrappedLine, WrappedLineStyle, }, }; -const LIVE_PREFIX: usize = 2; +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct TranscriptCellView { + pub selected: bool, + pub expanded: bool, + pub thinking: Option, +} pub trait RenderableCell { fn render_lines( &self, width: usize, capabilities: TerminalCapabilities, - theme: &dyn ThemePalette, + _theme: &dyn ThemePalette, + view: &TranscriptCellView, ) -> Vec; } @@ -24,37 +31,22 @@ impl RenderableCell for TranscriptCell { &self, width: usize, capabilities: TerminalCapabilities, - theme: &dyn ThemePalette, + _theme: &dyn ThemePalette, + view: &TranscriptCellView, ) -> Vec { - let width = width.max(20); + let width = width.max(28); match &self.kind { - TranscriptCellKind::User { body } => render_user_cell(body, width, capabilities, theme), - TranscriptCellKind::Assistant { body, status } => render_labeled_cell( - width, - capabilities, - theme, - &format!( - "{} Astrcode{}", - theme.glyph("●", "*"), - status_suffix(*status) - ), - body, - WrappedLineStyle::Header, - WrappedLineStyle::Plain, - ), - TranscriptCellKind::Thinking { body, status } => render_labeled_cell( + TranscriptCellKind::User { body } => { + render_message(body, width, capabilities, view, true) + }, + TranscriptCellKind::Assistant { body, status } => render_message( + format!("Astrcode{} {}", status_suffix(*status), body).trim(), width, capabilities, - theme, - &format!( - "{} thinking{}", - theme.glyph("◌", "o"), - status_suffix(*status) - ), - body, - WrappedLineStyle::Dim, - WrappedLineStyle::Dim, + view, + false, ), + TranscriptCellKind::Thinking { .. } => render_thinking_cell(width, capabilities, view), TranscriptCellKind::ToolCall { tool_name, summary, @@ -65,298 +57,272 @@ impl RenderableCell for TranscriptCell { duration_ms, truncated, child_session_id, - } => render_labeled_cell( + } => render_tool_call_cell( + tool_name, + summary, + *status, + stdout, + stderr, + error.as_deref(), + *duration_ms, + *truncated, + child_session_id.as_deref(), width, capabilities, - theme, - &format!( - "{} tool {}{}", - theme.glyph("↳", ">"), - tool_name, - status_suffix(*status) - ), - &tool_call_body( - summary, - stdout, - stderr, - error.as_deref(), - *duration_ms, - *truncated, - child_session_id.as_deref(), - ), - WrappedLineStyle::Warning, - WrappedLineStyle::Dim, + view, ), - TranscriptCellKind::Error { code, message } => render_labeled_cell( + TranscriptCellKind::Error { code, message } => render_secondary_line( + &format!("{code} {message}"), width, capabilities, - theme, - &format!("{} error {code}", theme.glyph("✕", "x")), - message, - WrappedLineStyle::Error, - WrappedLineStyle::Error, + view, + WrappedLineStyle::ErrorText, ), - TranscriptCellKind::SystemNote { - note_kind, - markdown, - } => render_labeled_cell( + TranscriptCellKind::SystemNote { markdown, .. } => { + render_secondary_line(markdown, width, capabilities, view, WrappedLineStyle::Muted) + }, + TranscriptCellKind::ChildHandoff { title, message, .. } => render_secondary_line( + &format!("{title} · {message}"), width, capabilities, - theme, - &format!("{} {note_kind}", theme.glyph("·", "-")), - markdown, - WrappedLineStyle::Dim, - WrappedLineStyle::Dim, + view, + WrappedLineStyle::Muted, ), - TranscriptCellKind::ChildHandoff { - handoff_kind, - title, - lifecycle, - message, - child_session_id, - child_agent_id, - } => { - let mut lines = render_labeled_cell( - width, - capabilities, - theme, - &format!( - "{} child {} [{} / {}]", - theme.glyph("◇", "*"), - title, - handoff_kind, - lifecycle_label(*lifecycle) - ), - message, - WrappedLineStyle::Accent, - WrappedLineStyle::Plain, - ); - lines.extend([ - prefixed_line( - WrappedLineStyle::Dim, - &format!("session {child_session_id}"), - capabilities, - width, - ), - prefixed_line( - WrappedLineStyle::Dim, - &format!("agent {child_agent_id}"), - capabilities, - width, - ), - WrappedLine { - style: WrappedLineStyle::Plain, - content: String::new(), - }, - ]); - lines - }, } } } -fn tool_call_body( - summary: &str, - stdout: &str, - stderr: &str, - error: Option<&str>, - duration_ms: Option, - truncated: bool, - child_session_id: Option<&str>, -) -> String { - let mut sections = Vec::new(); - if !summary.trim().is_empty() { - sections.push(summary.trim().to_string()); - } - if !stdout.trim().is_empty() { - sections.push(format!("stdout:\n{}", stdout.trim_end())); - } - if !stderr.trim().is_empty() { - sections.push(format!("stderr:\n{}", stderr.trim_end())); - } - if let Some(error) = error.filter(|value| !value.trim().is_empty()) { - sections.push(format!("error: {}", error.trim())); - } - if let Some(duration_ms) = duration_ms { - sections.push(format!("duration: {duration_ms} ms")); - } - if truncated { - sections.push("truncated: true".to_string()); - } - if let Some(child_session_id) = child_session_id.filter(|value| !value.trim().is_empty()) { - sections.push(format!("child session: {child_session_id}")); - } - if sections.is_empty() { - return "正在执行工具调用".to_string(); - } - sections.join("\n\n") -} - -fn render_user_cell( +fn render_message( body: &str, width: usize, capabilities: TerminalCapabilities, - theme: &dyn ThemePalette, + view: &TranscriptCellView, + is_user: bool, ) -> Vec { - let prefix = format!("{} ", theme.glyph("▌", "|")); - let body_width = width.saturating_sub(display_width(prefix.as_str(), capabilities)); - let wrapped = wrap_text(body, body_width.max(8), capabilities); - let mut lines = Vec::with_capacity(wrapped.len() + 1); - for line in wrapped { + let wrapped = wrap_text(body, width.saturating_sub(4), capabilities); + let mut lines = Vec::new(); + for (index, line) in wrapped.into_iter().enumerate() { + let prefix = if index == 0 { + if is_user { + prompt_marker(capabilities) + } else { + assistant_marker(capabilities) + } + } else { + " " + }; lines.push(WrappedLine { - style: WrappedLineStyle::User, - content: format!("{prefix}{line}"), + style: if view.selected { + WrappedLineStyle::Selection + } else if is_user && index == 0 { + WrappedLineStyle::UserLabel + } else if is_user { + WrappedLineStyle::UserBody + } else if index == 0 { + WrappedLineStyle::AssistantLabel + } else { + WrappedLineStyle::AssistantBody + }, + content: format!("{prefix} {line}"), }); } - lines.push(WrappedLine { - style: WrappedLineStyle::Plain, - content: String::new(), - }); + lines.push(blank_line()); lines } -fn render_labeled_cell( +fn render_thinking_cell( width: usize, capabilities: TerminalCapabilities, - _theme: &dyn ThemePalette, - title: &str, - body: &str, - title_style: WrappedLineStyle, - body_style: WrappedLineStyle, + view: &TranscriptCellView, ) -> Vec { - let mut lines = vec![prefixed_line(title_style, title, capabilities, width)]; - for line in wrap_text(body, width.saturating_sub(LIVE_PREFIX).max(8), capabilities) { - lines.push(prefixed_line( - body_style, - line.as_str(), - capabilities, - width, - )); + let Some(thinking) = &view.thinking else { + return Vec::new(); + }; + if !view.expanded { + return vec![ + WrappedLine { + style: if view.selected { + WrappedLineStyle::Selection + } else { + WrappedLineStyle::ThinkingLabel + }, + content: truncate_with_ellipsis( + format!( + "{} {} {}", + thinking_marker(capabilities), + thinking.label, + thinking.preview + ) + .as_str(), + width, + ), + }, + blank_line(), + ]; } - lines.push(WrappedLine { - style: WrappedLineStyle::Plain, - content: String::new(), - }); - lines -} -fn prefixed_line( - style: WrappedLineStyle, - content: &str, - capabilities: TerminalCapabilities, - width: usize, -) -> WrappedLine { - let prefix = " "; - let available = width.saturating_sub(display_width(prefix, capabilities)); - let text = truncate_to_width(content, available.max(1), capabilities); - WrappedLine { - style, - content: format!("{prefix}{text}"), + let mut lines = vec![WrappedLine { + style: if view.selected { + WrappedLineStyle::Selection + } else { + WrappedLineStyle::ThinkingLabel + }, + content: format!("{} {}", thinking_marker(capabilities), thinking.label), + }]; + for line in wrap_text( + thinking.expanded_body.as_str(), + width.saturating_sub(2), + capabilities, + ) { + lines.push(WrappedLine { + style: if view.selected { + WrappedLineStyle::Selection + } else { + WrappedLineStyle::ThinkingBody + }, + content: format!(" {line}"), + }); } + lines.push(blank_line()); + lines } -pub fn wrap_text(text: &str, width: usize, capabilities: TerminalCapabilities) -> Vec { - let width = width.max(1); - let mut out = Vec::new(); - let normalized = if text.trim().is_empty() { - vec![String::new()] - } else { - text.lines().map(ToString::to_string).collect::>() - }; +#[allow(clippy::too_many_arguments)] +fn render_tool_call_cell( + tool_name: &str, + summary: &str, + status: TranscriptCellStatus, + stdout: &str, + stderr: &str, + error: Option<&str>, + duration_ms: Option, + truncated: bool, + child_session_id: Option<&str>, + width: usize, + capabilities: TerminalCapabilities, + view: &TranscriptCellView, +) -> Vec { + let mut lines = vec![WrappedLine { + style: if view.selected { + WrappedLineStyle::Selection + } else { + WrappedLineStyle::ToolLabel + }, + content: truncate_with_ellipsis( + format!( + "{} tool {}{} · {}", + tool_marker(capabilities), + tool_name, + status_suffix(status), + summary.trim() + ) + .as_str(), + width, + ), + }]; - for raw_line in normalized { - if raw_line.is_empty() { - out.push(String::new()); - continue; + if view.expanded { + let mut sections = Vec::new(); + if let Some(duration_ms) = duration_ms { + sections.push(format!("duration {duration_ms}ms")); } - - let words = raw_line.split_whitespace().collect::>(); - if words.len() <= 1 { - wrap_by_width(raw_line.as_str(), width, capabilities, &mut out); - continue; + if truncated { + sections.push("output truncated".to_string()); } - - let mut current = String::new(); - for word in words { - let candidate = if current.is_empty() { - word.to_string() - } else { - format!("{current} {word}") - }; - if display_width(candidate.as_str(), capabilities) > width && !current.is_empty() { - out.push(current); - current = String::new(); - } - - if current.is_empty() && display_width(word, capabilities) > width { - wrap_by_width(word, width, capabilities, &mut out); - } else if current.is_empty() { - current = word.to_string(); - } else { - current = format!("{current} {word}"); - } + if let Some(child_session_id) = child_session_id.filter(|value| !value.is_empty()) { + sections.push(format!("child session {child_session_id}")); } - - if !current.is_empty() { - out.push(current); + if !stdout.trim().is_empty() { + sections.push(format!("stdout\n{}", stdout.trim_end())); + } + if !stderr.trim().is_empty() { + sections.push(format!("stderr\n{}", stderr.trim_end())); + } + if let Some(error) = error.filter(|value| !value.trim().is_empty()) { + sections.push(format!("error\n{}", error.trim())); + } + for line in wrap_text( + sections.join("\n\n").as_str(), + width.saturating_sub(2), + capabilities, + ) { + lines.push(WrappedLine { + style: if view.selected { + WrappedLineStyle::Selection + } else { + WrappedLineStyle::ToolBody + }, + content: format!(" {line}"), + }); } } - out + lines.push(blank_line()); + lines } -fn wrap_by_width( - text: &str, +fn render_secondary_line( + body: &str, width: usize, capabilities: TerminalCapabilities, - out: &mut Vec, -) { - let mut current = String::new(); - let mut current_width = 0; - for ch in text.chars() { - let ch_width = display_width(ch.to_string().as_str(), capabilities).max(1); - if current_width + ch_width > width && !current.is_empty() { - out.push(std::mem::take(&mut current)); - current_width = 0; - } - current.push(ch); - current_width += ch_width; + view: &TranscriptCellView, + style: WrappedLineStyle, +) -> Vec { + let mut lines = Vec::new(); + for line in wrap_text(body, width.saturating_sub(2), capabilities) { + lines.push(WrappedLine { + style: if view.selected { + WrappedLineStyle::Selection + } else { + style + }, + content: format!("{} {line}", secondary_marker(capabilities)), + }); } - if !current.is_empty() { - out.push(current); + lines.push(blank_line()); + lines +} + +fn prompt_marker(capabilities: TerminalCapabilities) -> &'static str { + if capabilities.ascii_only() { + ">" + } else { + "›" } } -fn truncate_to_width(text: &str, width: usize, capabilities: TerminalCapabilities) -> String { - let mut out = String::new(); - let mut current_width = 0; - for ch in text.chars() { - let ch_width = display_width(ch.to_string().as_str(), capabilities).max(1); - if current_width + ch_width > width { - break; - } - current_width += ch_width; - out.push(ch); +fn assistant_marker(capabilities: TerminalCapabilities) -> &'static str { + if capabilities.ascii_only() { + "*" + } else { + "•" } - out } -fn display_width(text: &str, capabilities: TerminalCapabilities) -> usize { +fn thinking_marker(capabilities: TerminalCapabilities) -> &'static str { if capabilities.ascii_only() { - text.chars().count() + "*" } else { - UnicodeWidthStr::width(text) + "✻" } } -fn lifecycle_label( - lifecycle: astrcode_client::AstrcodeConversationAgentLifecycleDto, -) -> &'static str { - match lifecycle { - astrcode_client::AstrcodeConversationAgentLifecycleDto::Pending => "pending", - astrcode_client::AstrcodeConversationAgentLifecycleDto::Running => "running", - astrcode_client::AstrcodeConversationAgentLifecycleDto::Idle => "idle", - astrcode_client::AstrcodeConversationAgentLifecycleDto::Terminated => "terminated", +fn tool_marker(capabilities: TerminalCapabilities) -> &'static str { + if capabilities.ascii_only() { + "-" + } else { + "↳" + } +} + +fn secondary_marker(capabilities: TerminalCapabilities) -> &'static str { + if capabilities.ascii_only() { "-" } else { "·" } +} + +fn blank_line() -> WrappedLine { + WrappedLine { + style: WrappedLineStyle::Plain, + content: String::new(), } } @@ -368,3 +334,54 @@ fn status_suffix(status: TranscriptCellStatus) -> &'static str { TranscriptCellStatus::Cancelled => " · cancelled", } } + +fn truncate_with_ellipsis(text: &str, width: usize) -> String { + if text.chars().count() <= width { + return text.to_string(); + } + if width <= 1 { + return "…".to_string(); + } + let mut value = text.chars().take(width - 1).collect::(); + value.push('…'); + value +} + +pub fn wrap_text(text: &str, width: usize, capabilities: TerminalCapabilities) -> Vec { + if width == 0 { + return vec![String::new()]; + } + let mut lines = Vec::new(); + for paragraph in text.split('\n') { + if paragraph.is_empty() { + lines.push(String::new()); + continue; + } + let mut current = String::new(); + for word in paragraph.split_whitespace() { + let next = if current.is_empty() { + word.to_string() + } else { + format!("{current} {word}") + }; + let fits = if capabilities.ascii_only() { + next.len() <= width + } else { + UnicodeWidthStr::width(next.as_str()) <= width + }; + if fits || current.is_empty() { + current = next; + } else { + lines.push(current); + current = word.to_string(); + } + } + if !current.is_empty() { + lines.push(current); + } + } + if lines.is_empty() { + lines.push(String::new()); + } + lines +} diff --git a/crates/cli/src/ui/footer.rs b/crates/cli/src/ui/footer.rs new file mode 100644 index 00000000..1fe8691d --- /dev/null +++ b/crates/cli/src/ui/footer.rs @@ -0,0 +1,106 @@ +use super::ThemePalette; +use crate::state::{CliState, PaletteState, WrappedLine, WrappedLineStyle}; + +pub fn footer_lines(state: &CliState, width: u16) -> Vec { + let theme = super::CodexTheme::new(state.shell.capabilities); + let width = usize::from(width.max(24)); + let prompt = if state.interaction.composer.is_empty() { + "在这里输入,或键入 /".to_string() + } else { + visible_input( + state.interaction.composer.input.as_str(), + width.saturating_sub(4), + ) + }; + + vec![ + WrappedLine { + style: if state.interaction.composer.is_empty() { + WrappedLineStyle::Muted + } else { + WrappedLineStyle::FooterInput + }, + content: format!("{} {}", theme.glyph("›", ">"), prompt), + }, + WrappedLine { + style: if state.interaction.status.is_error { + WrappedLineStyle::ErrorText + } else { + WrappedLineStyle::FooterStatus + }, + content: truncate(footer_status(state).as_str(), width), + }, + ] +} + +fn footer_status(state: &CliState) -> String { + if state.interaction.status.is_error { + return state.interaction.status.message.clone(); + } + + match &state.interaction.palette { + PaletteState::Slash(palette) => palette + .items + .get(palette.selected) + .map(|item| { + format!( + "{} · {} · Enter 执行 · Esc 关闭", + item.title, item.description + ) + }) + .unwrap_or_else(|| "/ commands · 没有匹配项 · Esc 关闭".to_string()), + PaletteState::Resume(resume) => resume + .items + .get(resume.selected) + .map(|item| { + format!( + "{} · {} · Enter 切换 · Esc 关闭", + item.title, item.working_dir + ) + }) + .unwrap_or_else(|| "/resume · 没有匹配会话 · Esc 关闭".to_string()), + PaletteState::Closed if state.interaction.composer.line_count() > 1 => format!( + "{} 行输入 · Shift+Enter 换行 · Ctrl+O thinking", + state.interaction.composer.line_count() + ), + PaletteState::Closed => { + let phase = state + .active_phase() + .map(|phase| format!("{phase:?}").to_lowercase()) + .unwrap_or_else(|| "idle".to_string()); + let message = state.interaction.status.message.as_str(); + if message.is_empty() || message == "ready" { + format!("{phase} · Enter 发送 · / commands") + } else { + format!("{message} · {phase}") + } + }, + } +} + +fn visible_input(input: &str, width: usize) -> String { + let line = input.lines().last().unwrap_or_default(); + if line.chars().count() <= width { + return line.to_string(); + } + line.chars() + .rev() + .take(width.saturating_sub(1)) + .collect::>() + .into_iter() + .rev() + .collect::() +} + +fn truncate(text: &str, width: usize) -> String { + let count = text.chars().count(); + if count <= width { + return text.to_string(); + } + if width <= 1 { + return "…".to_string(); + } + let mut value = text.chars().take(width - 1).collect::(); + value.push('…'); + value +} diff --git a/crates/cli/src/ui/mod.rs b/crates/cli/src/ui/mod.rs index 44b44a6a..31202c65 100644 --- a/crates/cli/src/ui/mod.rs +++ b/crates/cli/src/ui/mod.rs @@ -1,167 +1,16 @@ -mod bottom_pane; -mod cells; -mod overlay; +pub mod cells; +mod footer; +mod palette; mod theme; +mod transcript; -pub use bottom_pane::{BottomPaneView, ComposerPane}; -pub use cells::{RenderableCell, wrap_text}; -pub use overlay::{OverlayView, overlay_title}; +pub use footer::footer_lines; +pub use palette::{palette_lines, palette_visible}; use ratatui::text::{Line, Span}; pub use theme::{CodexTheme, ThemePalette}; +pub use transcript::transcript_lines; -use crate::{ - capability::TerminalCapabilities, - state::{CliState, OverlayState, WrappedLine, WrappedLineStyle}, -}; - -pub fn transcript_lines(state: &CliState, width: u16) -> Vec { - if state.conversation.transcript_cells.is_empty() { - return empty_state_lines(state, width); - } - - let theme = CodexTheme::new(state.shell.capabilities); - let mut lines = Vec::new(); - for cell in &state.conversation.transcript_cells { - lines.extend(cell.render_lines( - usize::from(width.max(18)), - state.shell.capabilities, - &theme, - )); - } - lines -} - -pub fn child_pane_lines(state: &CliState, width: u16) -> Vec { - let theme = CodexTheme::new(state.shell.capabilities); - let width = usize::from(width.max(18)); - let mut lines = vec![ - WrappedLine { - style: WrappedLineStyle::Header, - content: "child sessions".to_string(), - }, - WrappedLine { - style: WrappedLineStyle::Dim, - content: format!( - "{} active · {} total", - state - .conversation - .child_summaries - .iter() - .filter(|child| { - matches!( - child.lifecycle, - astrcode_client::AstrcodeConversationAgentLifecycleDto::Running - | astrcode_client::AstrcodeConversationAgentLifecycleDto::Pending - ) - }) - .count(), - state.conversation.child_summaries.len() - ), - }, - WrappedLine { - style: WrappedLineStyle::Border, - content: theme.divider().repeat(width), - }, - ]; - - for (index, child) in state.conversation.child_summaries.iter().enumerate() { - let focused = state - .interaction - .child_pane - .focused_child_session_id - .as_deref() - == Some(child.child_session_id.as_str()); - let selected = index == state.interaction.child_pane.selected; - let marker = if focused { - theme.glyph("◆", "*") - } else if selected { - theme.glyph("›", ">") - } else { - " " - }; - lines.push(WrappedLine { - style: if focused || selected { - WrappedLineStyle::Selection - } else { - WrappedLineStyle::Plain - }, - content: format!( - "{marker} {} [{}]", - child.title, - lifecycle_label(child.lifecycle) - ), - }); - if let Some(summary) = child.latest_output_summary.as_deref() { - for line in wrap_text( - summary, - width.saturating_sub(2).max(8), - state.shell.capabilities, - ) { - lines.push(WrappedLine { - style: WrappedLineStyle::Dim, - content: format!(" {line}"), - }); - } - } - } - - lines -} - -pub fn header_lines(state: &CliState, width: u16) -> Vec { - let theme = CodexTheme::new(state.shell.capabilities); - let title = state - .conversation - .active_session_title - .as_deref() - .unwrap_or("Astrcode workspace"); - let model = "gpt-5.4 medium"; - let phase = phase_label( - state - .active_phase() - .unwrap_or(astrcode_client::AstrcodePhaseDto::Idle), - ); - let working_dir = state - .shell - .working_dir - .as_deref() - .map(|path| path.display().to_string()) - .unwrap_or_else(|| "~".to_string()); - - let meta = format!("model {model} · phase {phase} · {working_dir}"); - let meta_width = usize::from(width.max(20)).saturating_sub(2); - - vec![ - WrappedLine { - style: WrappedLineStyle::Header, - content: title.to_string(), - }, - WrappedLine { - style: WrappedLineStyle::Dim, - content: truncate_text(meta.as_str(), meta_width, state.shell.capabilities), - }, - WrappedLine { - style: WrappedLineStyle::Border, - content: theme.divider().repeat(usize::from(width.max(1))), - }, - ] -} - -pub fn centered_overlay_lines(state: &CliState, width: u16) -> Vec { - let theme = CodexTheme::new(state.shell.capabilities); - match &state.interaction.overlay { - OverlayState::Resume(resume) => { - resume.lines(usize::from(width.max(20)), state.shell.capabilities, &theme) - }, - OverlayState::DebugLogs(debug) => debug.lines( - usize::from(width.max(20)), - state.shell.capabilities, - &theme, - &state.debug, - ), - OverlayState::SlashPalette(_) | OverlayState::None => Vec::new(), - } -} +use crate::{capability::TerminalCapabilities, state::WrappedLine}; pub fn line_to_ratatui(line: &WrappedLine, capabilities: TerminalCapabilities) -> Line<'static> { let theme = CodexTheme::new(capabilities); @@ -170,71 +19,3 @@ pub fn line_to_ratatui(line: &WrappedLine, capabilities: TerminalCapabilities) - theme.line_style(line.style), )) } - -pub fn phase_label(phase: astrcode_client::AstrcodePhaseDto) -> &'static str { - match phase { - astrcode_client::AstrcodePhaseDto::Idle => "idle", - astrcode_client::AstrcodePhaseDto::Thinking => "thinking", - astrcode_client::AstrcodePhaseDto::CallingTool => "calling_tool", - astrcode_client::AstrcodePhaseDto::Streaming => "streaming", - astrcode_client::AstrcodePhaseDto::Interrupted => "interrupted", - astrcode_client::AstrcodePhaseDto::Done => "done", - } -} - -fn empty_state_lines(state: &CliState, width: u16) -> Vec { - let theme = CodexTheme::new(state.shell.capabilities); - let divider = theme.divider().repeat(usize::from(width.min(56))); - vec![ - WrappedLine { - style: WrappedLineStyle::Header, - content: "OpenAI Codex style workspace".to_string(), - }, - WrappedLine { - style: WrappedLineStyle::Dim, - content: "fresh session 已准备好。主区只显示会话语义内容,启动噪音已移出。".to_string(), - }, - WrappedLine { - style: WrappedLineStyle::Accent, - content: "› 输入 prompt 开始;Tab 打开 commands;F2 查看 debug logs。".to_string(), - }, - WrappedLine { - style: WrappedLineStyle::Border, - content: divider, - }, - ] -} - -fn lifecycle_label( - lifecycle: astrcode_client::AstrcodeConversationAgentLifecycleDto, -) -> &'static str { - match lifecycle { - astrcode_client::AstrcodeConversationAgentLifecycleDto::Pending => "pending", - astrcode_client::AstrcodeConversationAgentLifecycleDto::Running => "running", - astrcode_client::AstrcodeConversationAgentLifecycleDto::Idle => "idle", - astrcode_client::AstrcodeConversationAgentLifecycleDto::Terminated => "terminated", - } -} - -fn truncate_text(text: &str, width: usize, capabilities: TerminalCapabilities) -> String { - if width == 0 { - return String::new(); - } - - let mut out = String::new(); - let mut current_width = 0; - for ch in text.chars() { - let ch = ch.to_string(); - let ch_width = if capabilities.ascii_only() { - 1 - } else { - unicode_width::UnicodeWidthStr::width(ch.as_str()).max(1) - }; - if current_width + ch_width > width { - break; - } - current_width += ch_width; - out.push_str(ch.as_str()); - } - out -} diff --git a/crates/cli/src/ui/overlay.rs b/crates/cli/src/ui/overlay.rs deleted file mode 100644 index c3189a47..00000000 --- a/crates/cli/src/ui/overlay.rs +++ /dev/null @@ -1,137 +0,0 @@ -use super::{cells::wrap_text, theme::ThemePalette}; -use crate::{ - capability::TerminalCapabilities, - state::{ - DebugChannelState, DebugOverlayState, OverlayState, ResumeOverlayState, WrappedLine, - WrappedLineStyle, - }, -}; - -pub trait OverlayView { - fn title(&self) -> &'static str; - fn lines( - &self, - width: usize, - capabilities: TerminalCapabilities, - theme: &dyn ThemePalette, - ) -> Vec; -} - -impl OverlayView for ResumeOverlayState { - fn title(&self) -> &'static str { - "Resume Session" - } - - fn lines( - &self, - width: usize, - capabilities: TerminalCapabilities, - theme: &dyn ThemePalette, - ) -> Vec { - let mut lines = vec![WrappedLine { - style: WrappedLineStyle::Dim, - content: format!( - "{} query {}", - theme.glyph("·", "-"), - if self.query.is_empty() { - "" - } else { - self.query.as_str() - } - ), - }]; - if self.items.is_empty() { - lines.push(WrappedLine { - style: WrappedLineStyle::Dim, - content: "没有匹配的会话。".to_string(), - }); - return lines; - } - - for (index, item) in self.items.iter().take(10).enumerate() { - lines.push(WrappedLine { - style: if index == self.selected { - WrappedLineStyle::Selection - } else { - WrappedLineStyle::Plain - }, - content: format!( - "{} {}", - if index == self.selected { - theme.glyph("›", ">") - } else { - " " - }, - item.title - ), - }); - for line in wrap_text( - item.working_dir.as_str(), - width.saturating_sub(2).max(8), - capabilities, - ) { - lines.push(WrappedLine { - style: WrappedLineStyle::Dim, - content: format!(" {line}"), - }); - } - } - lines - } -} - -impl DebugOverlayState { - pub fn lines( - &self, - width: usize, - capabilities: TerminalCapabilities, - theme: &dyn ThemePalette, - debug: &DebugChannelState, - ) -> Vec { - let mut lines = vec![ - WrappedLine { - style: WrappedLineStyle::Dim, - content: format!( - "{} launcher/server debug output · Esc close", - theme.glyph("·", "-") - ), - }, - WrappedLine { - style: WrappedLineStyle::Border, - content: theme.divider().repeat(width.max(16)), - }, - ]; - - if debug.is_empty() { - lines.push(WrappedLine { - style: WrappedLineStyle::Dim, - content: "暂无 debug logs。".to_string(), - }); - return lines; - } - - let entries = debug - .entries() - .rev() - .skip(self.scroll) - .take(18) - .collect::>(); - for entry in entries.into_iter().rev() { - for line in wrap_text(entry, width.saturating_sub(2).max(8), capabilities) { - lines.push(WrappedLine { - style: WrappedLineStyle::Plain, - content: line, - }); - } - } - lines - } -} - -pub fn overlay_title(overlay: &OverlayState) -> Option<&'static str> { - match overlay { - OverlayState::None | OverlayState::SlashPalette(_) => None, - OverlayState::Resume(state) => Some(state.title()), - OverlayState::DebugLogs(_) => Some("Debug Logs"), - } -} diff --git a/crates/cli/src/ui/palette.rs b/crates/cli/src/ui/palette.rs new file mode 100644 index 00000000..c9d95fd5 --- /dev/null +++ b/crates/cli/src/ui/palette.rs @@ -0,0 +1,175 @@ +use super::ThemePalette; +use crate::state::{PaletteState, WrappedLine, WrappedLineStyle}; + +pub fn palette_lines( + palette: &PaletteState, + width: usize, + theme: &dyn ThemePalette, +) -> Vec { + match palette { + PaletteState::Closed => Vec::new(), + PaletteState::Slash(slash) => { + let mut lines = vec![WrappedLine { + style: WrappedLineStyle::PaletteTitle, + content: format!( + "{} {}", + theme.glyph("/", "/"), + if slash.query.is_empty() { + "commands".to_string() + } else { + format!("commands · {}", slash.query) + } + ), + }]; + if slash.items.is_empty() { + lines.push(WrappedLine { + style: WrappedLineStyle::Muted, + content: " 没有匹配的命令".to_string(), + }); + return lines; + } + for (absolute_index, item) in visible_window(&slash.items, slash.selected, 8) { + lines.push(WrappedLine { + style: if absolute_index == slash.selected { + WrappedLineStyle::Selection + } else { + WrappedLineStyle::PaletteItem + }, + content: candidate_line( + if absolute_index == slash.selected { + theme.glyph("›", ">") + } else { + " " + }, + item.title.as_str(), + item.description.as_str(), + width, + ), + }); + } + lines + }, + PaletteState::Resume(resume) => { + let mut lines = vec![WrappedLine { + style: WrappedLineStyle::PaletteTitle, + content: format!( + "{} {}", + theme.glyph("/", "/"), + if resume.query.is_empty() { + "resume".to_string() + } else { + format!("resume · {}", resume.query) + } + ), + }]; + if resume.items.is_empty() { + lines.push(WrappedLine { + style: WrappedLineStyle::Muted, + content: " 没有匹配的会话".to_string(), + }); + return lines; + } + for (absolute_index, item) in visible_window(&resume.items, resume.selected, 8) { + lines.push(WrappedLine { + style: if absolute_index == resume.selected { + WrappedLineStyle::Selection + } else { + WrappedLineStyle::PaletteItem + }, + content: candidate_line( + if absolute_index == resume.selected { + theme.glyph("›", ">") + } else { + " " + }, + item.title.as_str(), + item.working_dir.as_str(), + width, + ), + }); + } + lines + }, + } +} + +pub fn palette_visible(palette: &PaletteState) -> bool { + !matches!(palette, PaletteState::Closed) +} + +fn visible_window<'a, T>(items: &'a [T], selected: usize, max_items: usize) -> Vec<(usize, &'a T)> { + if items.is_empty() || max_items == 0 { + return Vec::new(); + } + let total = items.len(); + let start = if total <= max_items { + 0 + } else { + selected + .saturating_sub(max_items / 2) + .min(total - max_items) + }; + items[start..(start + max_items).min(total)] + .iter() + .enumerate() + .map(|(offset, item)| (start + offset, item)) + .collect() +} + +fn candidate_line(prefix: &str, title: &str, meta: &str, width: usize) -> String { + let available = width.saturating_sub(2); + if meta.trim().is_empty() { + return truncate_with_ellipsis(format!("{prefix} {title}").as_str(), available); + } + + let meta_text = truncate_with_ellipsis(meta.trim(), available.saturating_mul(3) / 5); + let title_budget = available + .saturating_sub(meta_text.chars().count()) + .saturating_sub(3) + .max(8); + let title_text = truncate_with_ellipsis(title.trim(), title_budget); + truncate_with_ellipsis( + format!("{prefix} {title_text} · {meta_text}").as_str(), + available, + ) +} + +fn truncate_with_ellipsis(text: &str, width: usize) -> String { + if text.chars().count() <= width { + return text.to_string(); + } + if width <= 1 { + return "…".to_string(); + } + let mut truncated = text.chars().take(width - 1).collect::(); + truncated.push('…'); + truncated +} + +#[cfg(test)] +mod tests { + use super::{candidate_line, visible_window}; + + #[test] + fn visible_window_tracks_selected_item() { + let items = (0..12).collect::>(); + let window = visible_window(&items, 10, 4); + let indexes = window + .into_iter() + .map(|(index, _)| index) + .collect::>(); + assert_eq!(indexes, vec![8, 9, 10, 11]); + } + + #[test] + fn candidate_line_stays_single_row() { + let line = candidate_line( + ">", + "Issue Fixer", + "automatically fix GitHub issues and create pull requests", + 48, + ); + assert!(!line.contains('\n')); + assert!(line.contains("Issue Fixer")); + } +} diff --git a/crates/cli/src/ui/theme.rs b/crates/cli/src/ui/theme.rs index 4d8cd856..2c3b102f 100644 --- a/crates/cli/src/ui/theme.rs +++ b/crates/cli/src/ui/theme.rs @@ -9,7 +9,6 @@ pub trait ThemePalette { fn line_style(&self, style: WrappedLineStyle) -> Style; fn glyph(&self, unicode: &'static str, ascii: &'static str) -> &'static str; fn divider(&self) -> &'static str; - fn vertical_divider(&self) -> &'static str; } #[derive(Debug, Clone, Copy)] @@ -22,61 +21,84 @@ impl CodexTheme { Self { capabilities } } - pub fn muted_block_style(self) -> Style { + pub fn app_background(self) -> Style { + Style::default().bg(self.bg()) + } + + pub fn menu_block_style(self) -> Style { + Style::default().bg(self.surface()).fg(self.text_primary()) + } + + fn bg(self) -> Color { match self.capabilities.color { - ColorLevel::TrueColor => Style::default().bg(Color::Rgb(26, 28, 33)), - ColorLevel::Ansi16 => Style::default().bg(Color::Black), - ColorLevel::None => Style::default(), + ColorLevel::TrueColor => Color::Rgb(26, 24, 22), + ColorLevel::Ansi16 => Color::Black, + ColorLevel::None => Color::Reset, } } - pub fn overlay_border_style(self) -> Style { + fn surface(self) -> Color { match self.capabilities.color { - ColorLevel::TrueColor => Style::default().fg(Color::Rgb(90, 96, 110)), - ColorLevel::Ansi16 => Style::default().fg(Color::DarkGray), - ColorLevel::None => Style::default(), + ColorLevel::TrueColor => Color::Rgb(35, 32, 29), + ColorLevel::Ansi16 => Color::DarkGray, + ColorLevel::None => Color::Reset, } } - fn accent(self) -> Color { + fn surface_alt(self) -> Color { match self.capabilities.color { - ColorLevel::TrueColor => Color::Rgb(72, 196, 255), - _ => Color::Cyan, + ColorLevel::TrueColor => Color::Rgb(48, 43, 37), + ColorLevel::Ansi16 => Color::DarkGray, + ColorLevel::None => Color::Reset, } } - fn magenta(self) -> Color { + fn accent(self) -> Color { match self.capabilities.color { - ColorLevel::TrueColor => Color::Rgb(221, 183, 255), - _ => Color::Magenta, + ColorLevel::TrueColor => Color::Rgb(224, 128, 82), + _ => Color::Yellow, } } - fn dim(self) -> Color { + fn accent_soft(self) -> Color { match self.capabilities.color { - ColorLevel::TrueColor => Color::Rgb(123, 129, 142), - _ => Color::DarkGray, + ColorLevel::TrueColor => Color::Rgb(196, 124, 88), + _ => Color::Yellow, } } - fn warning(self) -> Color { + fn thinking(self) -> Color { match self.capabilities.color { - ColorLevel::TrueColor => Color::Rgb(245, 201, 104), + ColorLevel::TrueColor => Color::Rgb(241, 151, 104), _ => Color::Yellow, } } - fn error(self) -> Color { + fn text_primary(self) -> Color { match self.capabilities.color { - ColorLevel::TrueColor => Color::Rgb(255, 122, 122), - _ => Color::Red, + ColorLevel::TrueColor => Color::Rgb(237, 229, 219), + _ => Color::White, + } + } + + fn text_secondary(self) -> Color { + match self.capabilities.color { + ColorLevel::TrueColor => Color::Rgb(186, 176, 163), + _ => Color::Gray, } } - fn success(self) -> Color { + fn text_muted(self) -> Color { match self.capabilities.color { - ColorLevel::TrueColor => Color::Rgb(121, 214, 121), - _ => Color::Green, + ColorLevel::TrueColor => Color::Rgb(136, 126, 114), + _ => Color::DarkGray, + } + } + + fn error(self) -> Color { + match self.capabilities.color { + ColorLevel::TrueColor => Color::Rgb(227, 111, 111), + _ => Color::Red, } } } @@ -86,42 +108,53 @@ impl ThemePalette for CodexTheme { let base = Style::default(); if matches!(self.capabilities.color, ColorLevel::None) { return match style { - WrappedLineStyle::Accent - | WrappedLineStyle::Success - | WrappedLineStyle::Warning - | WrappedLineStyle::Error - | WrappedLineStyle::Selection - | WrappedLineStyle::Header => base.add_modifier(Modifier::BOLD), - WrappedLineStyle::Dim | WrappedLineStyle::Footer | WrappedLineStyle::Border => { - base.add_modifier(Modifier::DIM) + WrappedLineStyle::Selection + | WrappedLineStyle::UserLabel + | WrappedLineStyle::AssistantLabel + | WrappedLineStyle::ToolLabel + | WrappedLineStyle::FooterInput + | WrappedLineStyle::PaletteTitle => base.add_modifier(Modifier::BOLD), + WrappedLineStyle::ThinkingLabel => { + base.add_modifier(Modifier::BOLD | Modifier::ITALIC) }, - WrappedLineStyle::User => base.add_modifier(Modifier::REVERSED), - WrappedLineStyle::Plain => base, + WrappedLineStyle::Muted + | WrappedLineStyle::Divider + | WrappedLineStyle::FooterStatus + | WrappedLineStyle::PaletteMeta => base.add_modifier(Modifier::DIM), + _ => base, }; } match style { - WrappedLineStyle::Plain => base.fg(Color::White), - WrappedLineStyle::Dim | WrappedLineStyle::Border => { - base.fg(self.dim()).add_modifier(Modifier::DIM) + WrappedLineStyle::Plain => base.fg(self.text_primary()), + WrappedLineStyle::Muted + | WrappedLineStyle::Divider + | WrappedLineStyle::FooterStatus + | WrappedLineStyle::PaletteMeta => base.fg(self.text_muted()), + WrappedLineStyle::Accent | WrappedLineStyle::PaletteTitle => { + base.fg(self.accent()).add_modifier(Modifier::BOLD) }, - WrappedLineStyle::Footer => base.fg(self.dim()), - WrappedLineStyle::Accent => base.fg(self.accent()).add_modifier(Modifier::BOLD), - WrappedLineStyle::Success => base.fg(self.success()).add_modifier(Modifier::BOLD), - WrappedLineStyle::Warning => base.fg(self.warning()), - WrappedLineStyle::Error => base.fg(self.error()).add_modifier(Modifier::BOLD), - WrappedLineStyle::User => base.fg(Color::White).bg(match self.capabilities.color { - ColorLevel::TrueColor => Color::Rgb(35, 40, 52), - _ => Color::Black, - }), WrappedLineStyle::Selection => base - .fg(Color::White) - .bg(match self.capabilities.color { - ColorLevel::TrueColor => Color::Rgb(34, 74, 99), - _ => Color::DarkGray, - }) + .fg(self.text_primary()) + .bg(self.surface_alt()) .add_modifier(Modifier::BOLD), - WrappedLineStyle::Header => base.fg(self.magenta()).add_modifier(Modifier::BOLD), + WrappedLineStyle::UserLabel => base.fg(self.accent_soft()).add_modifier(Modifier::BOLD), + WrappedLineStyle::UserBody => base.fg(self.text_primary()), + WrappedLineStyle::AssistantLabel => { + base.fg(self.text_secondary()).add_modifier(Modifier::BOLD) + }, + WrappedLineStyle::AssistantBody => base.fg(self.text_primary()), + WrappedLineStyle::ThinkingLabel => base + .fg(self.thinking()) + .add_modifier(Modifier::ITALIC | Modifier::BOLD), + WrappedLineStyle::ThinkingBody => base.fg(self.text_secondary()), + WrappedLineStyle::ToolLabel => base.fg(self.accent_soft()).add_modifier(Modifier::BOLD), + WrappedLineStyle::ToolBody => base.fg(self.text_secondary()), + WrappedLineStyle::ErrorText => base.fg(self.error()).add_modifier(Modifier::BOLD), + WrappedLineStyle::FooterInput => { + base.fg(self.text_primary()).add_modifier(Modifier::BOLD) + }, + WrappedLineStyle::PaletteItem => base.fg(self.text_primary()), } } @@ -136,8 +169,4 @@ impl ThemePalette for CodexTheme { fn divider(&self) -> &'static str { self.glyph("─", "-") } - - fn vertical_divider(&self) -> &'static str { - self.glyph("│", "|") - } } diff --git a/crates/cli/src/ui/transcript.rs b/crates/cli/src/ui/transcript.rs new file mode 100644 index 00000000..c57c05c3 --- /dev/null +++ b/crates/cli/src/ui/transcript.rs @@ -0,0 +1,84 @@ +use super::{ + ThemePalette, + cells::{RenderableCell, TranscriptCellView}, +}; +use crate::state::{CliState, WrappedLine, WrappedLineStyle}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TranscriptRenderOutput { + pub lines: Vec, + pub selected_line_range: Option<(usize, usize)>, +} + +pub fn transcript_lines(state: &CliState, width: u16) -> TranscriptRenderOutput { + let theme = super::CodexTheme::new(state.shell.capabilities); + let width = usize::from(width.max(28)); + let mut lines = Vec::new(); + let mut selected_line_range = None; + if let Some(banner) = &state.conversation.banner { + lines.push(WrappedLine { + style: WrappedLineStyle::ErrorText, + content: format!("{} {}", theme.glyph("!", "!"), banner.error.message), + }); + lines.push(WrappedLine { + style: WrappedLineStyle::Muted, + content: " stream 需要重新同步,继续操作前建议等待恢复。".to_string(), + }); + lines.push(WrappedLine { + style: WrappedLineStyle::Plain, + content: String::new(), + }); + } + if state.conversation.transcript_cells.is_empty() { + lines.push(WrappedLine { + style: WrappedLineStyle::Muted, + content: format!("{} Astrcode workspace", theme.glyph("•", "*")), + }); + lines.push(WrappedLine { + style: WrappedLineStyle::Muted, + content: " 输入消息开始,或输入 / commands。".to_string(), + }); + lines.push(WrappedLine { + style: WrappedLineStyle::Muted, + content: " Tab 切换 transcript / composer,Ctrl+O 展开 thinking。".to_string(), + }); + return TranscriptRenderOutput { + lines, + selected_line_range: None, + }; + } + + for (index, cell) in state.conversation.transcript_cells.iter().enumerate() { + let line_start = lines.len(); + let view = TranscriptCellView { + selected: matches!( + state.interaction.pane_focus, + crate::state::PaneFocus::Transcript + ) && state.interaction.transcript.selected_cell == index, + expanded: state.is_cell_expanded(cell.id.as_str()) || cell.expanded, + thinking: match &cell.kind { + crate::state::TranscriptCellKind::Thinking { body, status } => { + Some(state.thinking_playback.present( + &state.thinking_pool, + cell.id.as_str(), + body.as_str(), + *status, + state.is_cell_expanded(cell.id.as_str()) || cell.expanded, + )) + }, + _ => None, + }, + }; + let rendered = cell.render_lines(width, state.shell.capabilities, &theme, &view); + lines.extend(rendered); + if view.selected { + let line_end = lines.len().saturating_sub(1); + selected_line_range = Some((line_start, line_end)); + } + } + + TranscriptRenderOutput { + lines, + selected_line_range, + } +} From 63ca55ae8fa0306aaae311ae46418e61b2e3ab10 Mon Sep 17 00:00:00 2001 From: whatevertogo Date: Fri, 17 Apr 2026 13:53:44 +0800 Subject: [PATCH 16/53] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor(cli):=20?= =?UTF-8?q?=E6=94=B6=E6=95=9B=20CLI=20=E6=B8=B2=E6=9F=93=E7=BC=93=E5=AD=98?= =?UTF-8?q?=E4=B8=8E=E8=BE=93=E5=85=A5=E4=BA=A4=E4=BA=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit crates/cli/REVIEW.md - Why: 记录第一轮 CLI 复审已完成修复后的结论,给后续维护一个明确基线。 - How: 新增复审文档,总结已修复问题并注明实际执行过的 fmt 与测试验证。 crates/cli/REVIEW2.md - Why: 补充第二轮视觉、一致性、性能与最佳实践审查,沉淀剩余改进点。 - How: 新增审查报告,逐项列出观察、风险、建议和优先级。 crates/cli/src/app/coordinator.rs - Why: 异步动作派发和 composer 输入读取路径重复,维护成本偏高。 - How: 抽出 dispatch_async 统一发送 Action,并改为通过 ComposerState 访问当前输入。 crates/cli/src/app/mod.rs - Why: 终端恢复、重绘触发、键鼠输入和 bootstrap 刷新逻辑此前分散且容易遗漏边界。 - How: 引入 RAII 终端恢复守卫、可停止 tick 句柄、按 dirty frame 重绘,补齐鼠标和更多键盘导航,并收敛 bootstrap refresh 与 palette 查询逻辑。 crates/cli/src/command/mod.rs - Why: resume/slash 查询过滤逻辑重复,输入分类还依赖额外借用。 - How: 让 classify_input 直接接收 String,并抽出 fuzzy_contains 复用于 slash 与 session 过滤。 crates/cli/src/launcher/mod.rs - Why: Windows PATHEXT 每次探测都重复解析,存在不必要开销。 - How: 用 OnceLock 缓存可执行扩展名列表,只在首次访问时解析环境变量。 crates/cli/src/render/mod.rs - Why: transcript、footer、palette 每帧现算且布局语义混乱,不利于稳定滚动和光标定位。 - How: 引入统一 cache refresh 流程,显式定义 footer 高度,使用缓存渲染 transcript/footer/palette,并在 footer 中设置 composer 光标位置。 crates/cli/src/state/conversation.rs - Why: transcript block patch/complete 每次线性查找并双写 cell,流式更新路径偏重。 - How: 用 transcript_index 按 block_id 建索引,改为按需投影 TranscriptCell,并在缺失 block 时输出 debug 提示。 crates/cli/src/state/interaction.rs - Why: composer 只能尾部编辑,滚动与选中耦合,焦点切换 backward 分支也不对称。 - How: 为 ComposerState 增加 cursor、插入/删除/移动能力,补充按量滚动与 selection_drives_scroll,并修正 backward focus 与相关测试。 crates/cli/src/state/mod.rs - Why: 状态层没有集中驱动 dirty 区域,transcript cell 仍依赖缓存副本,thinking 动画条件也不够清晰。 - How: 将 WrappedLine 类型迁到 render state,所有输入/滚动/焦点操作都联动 render dirty 标记,改为按需投影 transcript cells,并显式判断 synthetic thinking 播放条件。 crates/cli/src/state/render.rs - Why: 原有 render state 只有 transcript cache,无法细粒度控制 footer、palette 与整帧刷新。 - How: 新增 footer/palette cache、dirty regions、frame_dirty 及对应更新/失效接口,并扩展 WrappedLineStyle 以承载新 UI 语义。 crates/cli/src/state/shell.rs - Why: 默认状态直接探测终端能力会让纯状态构造带入环境副作用。 - How: 将默认 capabilities 收敛为稳定的 ASCII/无色静态值,避免测试和初始化时隐式依赖宿主终端。 crates/cli/src/state/thinking.rs - Why: thinking 展示文案单一,摘要/提示信息不够结构化。 - How: 拆分 verb、summary、hint、preview 字段,引入动词池,并按 streaming/complete 状态生成更清晰的提示文案。 crates/cli/src/state/transcript_cell.rs - Why: 用户可见的错误码、system note 和 handoff kind 仍直接使用 Debug 文本,并存在多余 clone。 - How: 改为通过序列化提取 wire name 作为稳定展示值,同时移除不必要 clone。 crates/cli/src/ui/cells.rs - Why: cell 渲染对 Unicode 宽度、markdown 结构、thinking/tool 展示和 ascii fallback 处理都不够统一。 - How: 重写消息、thinking、tool 和 secondary 渲染路径,统一通过 theme glyph 取标记,新增 markdown/list/table/preformatted 包装逻辑与 synthetic thinking 行生成,并补充相关测试。 crates/cli/src/ui/footer.rs - Why: footer 只展示两行,既看不到完整状态层次,也无法正确表达输入光标位置。 - How: 重构为 status/input/hint 三行输出,返回 cursor_col,按焦点与 palette 状态切换提示,并基于显示宽度裁切当前输入视口。 crates/cli/src/ui/hero.rs - Why: 空 transcript 缺少结构化欢迎区,工作区、phase 和最近会话信息分散。 - How: 新增 hero card,支持横向/紧凑两种布局,汇总版本、会话、目录、phase、快捷键与最近活动。 crates/cli/src/ui/mod.rs - Why: UI 模块需要对外暴露新的 hero、text 与 footer 输出能力,同时避免重复构造 theme。 - How: 导出新模块与类型,并让 line_to_ratatui 直接复用外部传入的主题对象。 crates/cli/src/ui/palette.rs - Why: slash/resume palette 渲染基本重复,条目过多时缺少统一窗口控制和宽度裁切。 - How: 抽出通用 render_palette_items,限制可见条目数,统一选中态样式并用显示宽度裁切标题与元信息。 crates/cli/src/ui/text.rs - Why: 多处需要按终端显示宽度截断文本,不能继续用按字符数截断。 - How: 新增 truncate_to_width,基于 unicode-width 处理 CJK/emoji 宽度并保留省略号。 crates/cli/src/ui/theme.rs - Why: 新的 hero/footer/palette/cell 语义需要更细的样式映射,旧 palette surface 也与新布局不匹配。 - How: 扩展主题颜色与 WrappedLineStyle 映射,新增 selection/footer key/hero 等样式,并让 menu 使用统一背景。 crates/cli/src/ui/transcript.rs - Why: transcript 入口仍依赖旧 transcript_cells 缓存,空状态表现也不够完整。 - How: 改为消费按需投影的 transcript cells,统一接入 hero 区和 synthetic thinking 渲染,并保留选中行范围给滚动逻辑使用。 --- crates/cli/REVIEW.md | 31 + crates/cli/REVIEW2.md | 116 ++++ crates/cli/src/app/coordinator.rs | 54 +- crates/cli/src/app/mod.rs | 284 +++++--- crates/cli/src/command/mod.rs | 33 +- crates/cli/src/launcher/mod.rs | 49 +- crates/cli/src/render/mod.rs | 138 ++-- crates/cli/src/state/conversation.rs | 84 ++- crates/cli/src/state/interaction.rs | 181 ++++- crates/cli/src/state/mod.rs | 223 +++++-- crates/cli/src/state/render.rs | 131 +++- crates/cli/src/state/shell.rs | 8 +- crates/cli/src/state/thinking.rs | 53 +- crates/cli/src/state/transcript_cell.rs | 20 +- crates/cli/src/ui/cells.rs | 844 +++++++++++++++++++----- crates/cli/src/ui/footer.rs | 216 +++--- crates/cli/src/ui/hero.rs | 280 ++++++++ crates/cli/src/ui/mod.rs | 11 +- crates/cli/src/ui/palette.rs | 166 ++--- crates/cli/src/ui/text.rs | 26 + crates/cli/src/ui/theme.rs | 93 ++- crates/cli/src/ui/transcript.rs | 88 ++- 22 files changed, 2416 insertions(+), 713 deletions(-) create mode 100644 crates/cli/REVIEW.md create mode 100644 crates/cli/REVIEW2.md create mode 100644 crates/cli/src/ui/hero.rs create mode 100644 crates/cli/src/ui/text.rs diff --git a/crates/cli/REVIEW.md b/crates/cli/REVIEW.md new file mode 100644 index 00000000..9c0e73bf --- /dev/null +++ b/crates/cli/REVIEW.md @@ -0,0 +1,31 @@ +# CLI Crate 复审结论 + +审查范围:`crates/cli/src/` +复审日期:2026-04-17 +验证状态:已完成修复并通过 `cargo fmt --all`、`cargo test -p astrcode-cli` + +--- + +## 结论 + +此前报告中的问题已完成修复,本次复审未保留新的确认问题。 + +## 已完成的关键修复 + +- 终端生命周期改为 RAII 恢复,异常路径不会泄漏 raw mode / alt screen。 +- 所有 UI 截断统一为 Unicode 显示宽度计算,CJK/emoji 不再错位。 +- focus backward、palette/filter、bootstrap refresh、async dispatch 等重复逻辑已收敛。 +- transcript block patch/complete 改为索引查找,并在 debug 构建下记录未命中 delta。 +- transcript 视图改成按需投影,不再双写维护 `transcript` 与 `transcript_cells`。 +- render 类型已迁移到 `state/render.rs`,theme/glyph/truncation 也已统一。 +- tick/background 任务关闭策略已统一为可停止句柄,不再混用优雅停止和直接 abort。 +- thinking 文案、synthetic thinking 渲染、tool/output 排版与 footer/palette/hero 结构均已收敛。 + +## 当前验证 + +```bash +cargo fmt --all +cargo test -p astrcode-cli +``` + +结果:`astrcode-cli` 40 个测试全部通过。 diff --git a/crates/cli/REVIEW2.md b/crates/cli/REVIEW2.md new file mode 100644 index 00000000..212db92c --- /dev/null +++ b/crates/cli/REVIEW2.md @@ -0,0 +1,116 @@ +# CLI Crate 第二轮审查:视觉与最佳实践 + +审查范围:`crates/cli/src/` 全部文件(修复后的最新版本) +审查日期:2026-04-17 + +--- + +## Visual - 可访问性 + +### 1. ascii_only 模式下 marker 符号碰撞,不同 cell 类型无法区分 +- **文件**: `ui/cells.rs:342-363` +- **描述**: ascii fallback 中 `assistant_marker` 和 `thinking_marker` 都返回 `"*"`,`tool_marker` 和 `secondary_marker` 都返回 `"-"`。无颜色终端下,用户看到 `*` 无法区分 assistant 输出和 thinking,看到 `-` 无法区分 tool call 和系统提示。 +- **建议**: 为每种类型分配唯一的 ascii 符号,如 assistant `*`、thinking `~`、tool `>`、secondary `-`、tool_block `|`。 + +### 2. hero card `two_col_row` 中 `│` 硬编码,ascii_only 模式下会显示错误 +- **文件**: `ui/hero.rs:243` +- **代码**: `format!("{}│{}", ...)` +- **描述**: `framed_rows` 中正确使用了 `theme.glyph("│", "|")`,但 `two_col_row` 内部的列分隔符直接硬编码了 Unicode `│`,在 ascii_only 终端下会显示为乱码或空格。 +- **建议**: 将 theme 传入 `two_col_row` 或提取 `│` 的 glyph 选择。 + +### 3. banner 文案混合中英文术语 +- **文件**: `ui/transcript.rs:32` +- **代码**: `"stream 需要重新同步,继续操作前建议等待恢复。"` +- **描述**: 用户可见的错误提示中混用了英文 "stream" 和中文描述,与 thinking.rs 中已修复的统一语言策略不一致。 +- **建议**: 统一为纯中文,如"流需要重新同步..."。 + +### 4. hero 默认标题 `Astrcode workspace` 是英文 +- **文件**: `ui/hero.rs:19` +- **代码**: `.unwrap_or("Astrcode workspace")` +- **描述**: 其他所有面向用户的文案(提示、状态、footer hint)都是中文,唯独这个 fallback 标题是英文。 +- **建议**: 统一为中文,如 "Astrcode 工作区"。 + +--- + +## Visual - 一致性 + +### 5. `format!("{phase:?}")` 用于用户可见的 phase 标签 +- **文件**: `ui/hero.rs:29`, `ui/footer.rs:99` +- **描述**: 两处通过 Debug 格式化 `AstrcodePhaseDto` 生成用户可见文本(如 "streaming"、"idle")。Debug 输出不是 UI 文案,枚举重命名会导致用户看到的变化。且 hero 和 footer 中的 phase 用途相同但来源独立构造。 +- **建议**: 为 `AstrcodePhaseDto` 添加 `Display` 实现或专用的 `display_label()` 方法。 + +### 6. footer 实际只有 3 行内容,但 `FOOTER_HEIGHT = 5` +- **文件**: `render/mod.rs:12,68-110` +- **描述**: `footer_lines` 返回 3 行(status、input、hint),`render_footer` 渲染 5 行布局(3 内容 + 2 divider)。虽然功能正确,但 `FOOTER_HEIGHT` 的语义不清晰——它代表的是布局高度而非内容行数,且 footer_lines 的 3 行数量与 render_footer 的 5 行布局之间没有编译时保证。如果 footer_lines 返回的行数变化,render_footer 会 panic(`footer.lines[0]` 索引越界)。 +- **建议**: 定义 `const FOOTER_CONTENT_LINES: usize = 3`,并在 render_footer 中用常量而非硬编码索引。或者让 footer_lines 自身返回包含 divider 的完整 5 行。 + +### 7. hero 提示文案与 footer hint 中的快捷键描述不一致 +- **文件**: `ui/hero.rs:96,102,110` vs `ui/footer.rs:89,109` +- **描述**: hero 中写"输入 / 打开 commands",footer 中写"/ commands"。hero 中写"Tab 在 transcript / composer 间切换",footer 中写"Tab 切换焦点"。同一个操作在不同位置有不同描述。 +- **建议**: 统一两处的快捷键描述文案。 + +--- + +## Performance + +### 8. `selected_transcript_cell()` 每次调用都全量投影 transcript +- **文件**: `state/mod.rs:209-213` +- **描述**: `selected_transcript_cell()` 调用 `transcript_cells()` 投影整个 transcript,然后取第 N 个。这个方法被 `toggle_selected_cell_expanded` 和 `selected_cell_is_thinking` 调用,每次操作都是 O(n) 全量投影。 +- **建议**: 添加 `project_single_cell(&self, index: usize)` 方法,只投影一个 cell;或在 `transcript_cells()` 上层缓存结果。 + +### 9. `should_animate_thinking_playback` 遍历 transcript 两次 +- **文件**: `state/mod.rs:345-387` +- **描述**: 先遍历所有 cells 检查 streaming thinking(345-355),然后再遍历一次检查 synthetic 条件(372-387)。两次遍历可以合并为一次。 +- **建议**: 合并为单次遍历,先记录是否已有 streaming thinking/assistant/tool,再决定是否需要 animation。 + +### 10. `apply_stream_envelope` 每次流式 delta 都 clone `slash_candidates` +- **文件**: `state/mod.rs:309` +- **描述**: `self.conversation.slash_candidates.clone()` 在每次 envelope 到达时执行。slash_candidates 列表通常不频繁变化,但每个流式 chunk 都会触发一次完整 clone。 +- **建议**: 仅在 slash_candidates 实际变化时(`ReplaceSlashCandidates` delta)才调用 `sync_slash_items`。 + +### 11. `visible_input_state` 中不必要的 `visible_before.clone()` +- **文件**: `ui/footer.rs:153` +- **描述**: `let mut visible = visible_before.clone()` 后,`visible_before` 不再使用。可以直接 move 而非 clone。 +- **建议**: 改为 `let mut visible = visible_before;`。 + +--- + +## Best Practices + +### 12. `enum_wire_name` 仍通过 serde 序列化判断 stdout/stderr +- **文件**: `state/conversation.rs:244-252` +- **描述**: 虽然比之前的 `format!("{:?}", stream)` 好一些,但仍然通过 `serde_json::to_value` 序列化枚举后取字符串值来判断变体。如果 `ToolOutputStream` 是外部 crate 类型且不暴露变体,这是唯一的方式,但应该有注释说明为什么不能直接 match。 +- **建议**: 添加注释说明限制原因,或者在外部 crate 中为 `ToolOutputStream` 添加 `is_stderr()` 方法。 + +### 13. `render_transcript` 中 `Paragraph::new().wrap()` 可能二次换行 +- **文件**: `render/mod.rs:54-65` +- **描述**: `wrap_text` 已经手动将文本按列宽换行,然后 `Paragraph::new().wrap(Wrap { trim: false })` 又启用了 ratatui 的自动换行。虽然 scroll 机制依赖 Paragraph,但 wrap 是多余的,可能在边界条件下导致意外行为。 +- **建议**: 使用 `Paragraph::new().wrap(Wrap { trim: false })` 是必要的(因为 scroll 依赖 Paragraph 内部换行),但可以验证两者的一致性,或移除手动 wrap 改为完全依赖 ratatui 的 wrap。 + +### 14. `palette_next` / `palette_prev` 仍有 Resume/Slash 两个重复分支 +- **文件**: `state/interaction.rs:413-437` +- **描述**: 两个方法中 Resume 和 Slash 分支的逻辑完全相同(只是 +1 / -1 的区别)。可以提取为辅助方法。 +- **建议**: 提取 `fn advance_selected(items_len: usize, selected: &mut usize, forward: bool)` 辅助函数。 + +### 15. `SharedStreamPacer` 中 `expect("stream pacer lock poisoned")` 出现 5 次 +- **文件**: `app/mod.rs:212, 223, 231, 245, 249` +- **描述**: 每个 lock 操作都有相同的 `expect` 字符串。如果某处 panic,其余地方也会连锁 panic。虽然 lock poisoning 在正常使用中不应发生,但可以统一处理。 +- **建议**: 提取 `fn lock_inner(&self) -> std::sync::MutexGuard<'_, StreamPacerState>` 方法,统一处理 lock poisoning。 + +--- + +## 汇总 + +| 类别 | 数量 | 核心问题 | +|------|------|----------| +| 可访问性 | 4 | ascii marker 碰撞、硬编码 Unicode 字符、中英混用 | +| 视觉一致性 | 3 | Debug 格式化做 UI 文案、footer 布局/内容耦合、快捷键描述不一致 | +| 性能 | 4 | 全量投影、双次遍历、多余 clone、不必要的 clone | +| 最佳实践 | 4 | serde 判枚举、双重 wrap、重复分支、lock poisoning 处理 | + +### 优先修复建议 + +1. **第 2 项**(`│` 硬编码)— ascii_only 模式下直接显示错误 +2. **第 1 项**(ascii marker 碰撞)— 影响无颜色终端用户的基本可用性 +3. **第 10 项**(slash_candidates 多余 clone)— 流式场景下每个 delta 触发,性能影响最大 +4. **第 8 项**(全量投影)— 每次键盘操作触发 O(n) 投影 diff --git a/crates/cli/src/app/coordinator.rs b/crates/cli/src/app/coordinator.rs index 5cf87dc8..bb689405 100644 --- a/crates/cli/src/app/coordinator.rs +++ b/crates/cli/src/app/coordinator.rs @@ -20,9 +20,21 @@ impl AppController where T: AstrcodeClientTransport + 'static, { + fn dispatch_async(&self, operation: F) + where + F: std::future::Future> + Send + 'static, + { + let sender = self.actions_tx.clone(); + tokio::spawn(async move { + if let Some(action) = operation.await { + let _ = sender.send(action); + } + }); + } + pub(super) async fn submit_current_input(&mut self) { let input = self.state.take_input(); - match classify_input(input.as_str()) { + match classify_input(input) { InputAction::Empty => {}, InputAction::SubmitPrompt { text } => { let Some(session_id) = self.state.conversation.active_session_id.clone() else { @@ -31,8 +43,7 @@ where }; self.state.set_status("submitting prompt"); let client = self.client.clone(); - let sender = self.actions_tx.clone(); - tokio::spawn(async move { + self.dispatch_async(async move { let result = client .submit_prompt( &session_id, @@ -42,7 +53,7 @@ where }, ) .await; - let _ = sender.send(Action::PromptSubmitted { session_id, result }); + Some(Action::PromptSubmitted { session_id, result }) }); }, InputAction::RunCommand(command) => { @@ -80,13 +91,12 @@ where }, }; let client = self.client.clone(); - let sender = self.actions_tx.clone(); self.state.set_status("creating session"); - tokio::spawn(async move { + self.dispatch_async(async move { let result = client .create_session(AstrcodeCreateSessionRequest { working_dir }) .await; - let _ = sender.send(Action::SessionCreated(result)); + Some(Action::SessionCreated(result)) }); }, Command::Resume { query } => { @@ -118,9 +128,8 @@ where return; } let client = self.client.clone(); - let sender = self.actions_tx.clone(); self.state.set_status("requesting compact"); - tokio::spawn(async move { + self.dispatch_async(async move { let result = client .request_compact( &session_id, @@ -133,7 +142,7 @@ where }, ) .await; - let _ = sender.send(Action::CompactRequested { session_id, result }); + Some(Action::CompactRequested { session_id, result }) }); }, Command::Skill { query } => { @@ -161,10 +170,9 @@ where self.state .set_status(format!("hydrating session {}", session_id)); let client = self.client.clone(); - let sender = self.actions_tx.clone(); - tokio::spawn(async move { + self.dispatch_async(async move { let result = client.fetch_conversation_snapshot(&session_id, None).await; - let _ = sender.send(Action::SnapshotLoaded { session_id, result }); + Some(Action::SnapshotLoaded { session_id, result }) }); } @@ -217,10 +225,9 @@ where pub(super) async fn refresh_sessions(&self) { let client = self.client.clone(); - let sender = self.actions_tx.clone(); - tokio::spawn(async move { + self.dispatch_async(async move { let result = client.list_sessions().await; - let _ = sender.send(Action::SessionsRefreshed(result)); + Some(Action::SessionsRefreshed(result)) }); } @@ -229,7 +236,7 @@ where .state .interaction .composer - .input + .as_str() .trim_start() .starts_with('/') { @@ -249,12 +256,11 @@ where return; }; let client = self.client.clone(); - let sender = self.actions_tx.clone(); - tokio::spawn(async move { + self.dispatch_async(async move { let result = client .list_conversation_slash_candidates(&session_id, Some(query.as_str())) .await; - let _ = sender.send(Action::SlashCandidatesLoaded { query, result }); + Some(Action::SlashCandidatesLoaded { query, result }) }); } @@ -265,14 +271,14 @@ where .state .interaction .composer - .input + .as_str() .trim_start() .starts_with("/resume") { self.state.close_palette(); return; } - let query = resume_query_from_input(self.state.interaction.composer.input.as_str()); + let query = resume_query_from_input(self.state.interaction.composer.as_str()); let items = filter_resume_sessions(&self.state.conversation.sessions, query.as_str()); self.state.set_resume_query(query, items); @@ -282,7 +288,7 @@ where .state .interaction .composer - .input + .as_str() .trim_start() .starts_with('/') { @@ -346,6 +352,6 @@ where } pub(super) fn slash_query_for_current_input(&self) -> String { - slash_query_from_input(self.state.interaction.composer.input.as_str()) + slash_query_from_input(self.state.interaction.composer.as_str()) } } diff --git a/crates/cli/src/app/mod.rs b/crates/cli/src/app/mod.rs index 91f6c5ce..0cc1ece6 100644 --- a/crates/cli/src/app/mod.rs +++ b/crates/cli/src/app/mod.rs @@ -23,7 +23,8 @@ use clap::Parser; use crossterm::{ event::{ self, DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture, - Event as CrosstermEvent, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, + Event as CrosstermEvent, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, MouseEvent, + MouseEventKind, }, execute, terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode}, @@ -37,7 +38,7 @@ use tokio::{ use crate::{ capability::TerminalCapabilities, - command::palette_action, + command::{fuzzy_contains, palette_action}, launcher::{LaunchOptions, Launcher, LauncherSession, SystemManagedServer}, render, state::{CliState, PaletteState, PaneFocus, StreamRenderMode}, @@ -68,6 +69,7 @@ enum Action { width: u16, height: u16, }, + Mouse(MouseEvent), Quit, SessionsRefreshed(Result, AstrcodeClientError>), SessionCreated(Result), @@ -131,8 +133,7 @@ async fn run_app(launcher_session: LauncherSession) -> Resu capabilities, ), debug_tap, - actions_tx.clone(), - actions_rx, + AppControllerChannels::new(actions_tx.clone(), actions_rx), ); controller.bootstrap().await?; @@ -151,17 +152,8 @@ async fn run_terminal_loop( controller: &mut AppController, actions_tx: mpsc::UnboundedSender, ) -> Result<()> { - enable_raw_mode().context("enable raw mode failed")?; - let mut stdout = io::stdout(); - if controller.state.shell.capabilities.alt_screen { - execute!(stdout, EnterAlternateScreen).context("enter alternate screen failed")?; - } - if controller.state.shell.capabilities.mouse { - execute!(stdout, EnableMouseCapture).context("enable mouse capture failed")?; - } - if controller.state.shell.capabilities.bracketed_paste { - execute!(stdout, EnableBracketedPaste).context("enable bracketed paste failed")?; - } + let terminal_guard = TerminalRestoreGuard::enter(controller.state.shell.capabilities)?; + let stdout = io::stdout(); let backend = CrosstermBackend::new(stdout); let mut terminal = Terminal::new(backend).context("create terminal backend failed")?; @@ -172,22 +164,9 @@ async fn run_terminal_loop( let loop_result = run_event_loop(controller, &mut terminal).await; input_handle.stop(); - tick_handle.abort(); - - disable_raw_mode().context("disable raw mode failed")?; - if controller.state.shell.capabilities.bracketed_paste { - execute!(terminal.backend_mut(), DisableBracketedPaste) - .context("disable bracketed paste failed")?; - } - if controller.state.shell.capabilities.mouse { - execute!(terminal.backend_mut(), DisableMouseCapture) - .context("disable mouse capture failed")?; - } - if controller.state.shell.capabilities.alt_screen { - execute!(terminal.backend_mut(), LeaveAlternateScreen) - .context("leave alternate screen failed")?; - } + tick_handle.stop().await; terminal.show_cursor().context("show cursor failed")?; + drop(terminal_guard); loop_result } @@ -199,12 +178,15 @@ async fn run_event_loop( terminal .draw(|frame| render::render(frame, &mut controller.state)) .context("initial draw failed")?; + controller.state.render.take_frame_dirty(); while let Some(action) = controller.actions_rx.recv().await { controller.handle_action(action).await?; - terminal - .draw(|frame| render::render(frame, &mut controller.state)) - .context("redraw failed")?; + if controller.state.render.take_frame_dirty() { + terminal + .draw(|frame| render::render(frame, &mut controller.state)) + .context("redraw failed")?; + } if controller.should_quit { break; } @@ -282,6 +264,54 @@ struct AppController { should_quit: bool, } +struct AppControllerChannels { + tx: mpsc::UnboundedSender, + rx: mpsc::UnboundedReceiver, +} + +impl AppControllerChannels { + fn new(tx: mpsc::UnboundedSender, rx: mpsc::UnboundedReceiver) -> Self { + Self { tx, rx } + } +} + +struct TerminalRestoreGuard { + capabilities: TerminalCapabilities, +} + +impl TerminalRestoreGuard { + fn enter(capabilities: TerminalCapabilities) -> Result { + enable_raw_mode().context("enable raw mode failed")?; + let mut stdout = io::stdout(); + if capabilities.alt_screen { + execute!(stdout, EnterAlternateScreen).context("enter alternate screen failed")?; + } + if capabilities.mouse { + execute!(stdout, EnableMouseCapture).context("enable mouse capture failed")?; + } + if capabilities.bracketed_paste { + execute!(stdout, EnableBracketedPaste).context("enable bracketed paste failed")?; + } + Ok(Self { capabilities }) + } +} + +impl Drop for TerminalRestoreGuard { + fn drop(&mut self) { + let _ = disable_raw_mode(); + let mut stdout = io::stdout(); + if self.capabilities.bracketed_paste { + let _ = execute!(stdout, DisableBracketedPaste); + } + if self.capabilities.mouse { + let _ = execute!(stdout, DisableMouseCapture); + } + if self.capabilities.alt_screen { + let _ = execute!(stdout, LeaveAlternateScreen); + } + } +} + impl AppController where T: AstrcodeClientTransport + 'static, @@ -290,15 +320,14 @@ where client: AstrcodeClient, state: CliState, debug_tap: Option, - actions_tx: mpsc::UnboundedSender, - actions_rx: mpsc::UnboundedReceiver, + channels: AppControllerChannels, ) -> Self { Self { client, state, debug_tap, - actions_tx, - actions_rx, + actions_tx: channels.tx, + actions_rx: channels.rx, pending_session_id: None, pending_bootstrap_session_refresh: false, stream_task: None, @@ -308,6 +337,7 @@ where } async fn bootstrap(&mut self) -> Result<()> { + // bootstrap 故意复用异步命令链路,避免在初始化阶段维护一套单独的 /new 创建路径。 self.pending_bootstrap_session_refresh = true; self.execute_command(crate::command::Command::New).await; Ok(()) @@ -319,6 +349,13 @@ where } } + async fn consume_bootstrap_refresh(&mut self) { + if self.pending_bootstrap_session_refresh { + self.pending_bootstrap_session_refresh = false; + self.refresh_sessions().await; + } + } + async fn handle_action(&mut self, action: Action) -> Result<()> { match action { Action::Tick => { @@ -333,6 +370,7 @@ where }, Action::Quit => self.should_quit = true, Action::Resize { width, height } => self.state.set_viewport_size(width, height), + Action::Mouse(mouse) => self.handle_mouse(mouse), Action::Key(key) => self.handle_key(key).await?, Action::Paste(text) => self.handle_paste(text).await?, Action::SessionsRefreshed(result) => match result { @@ -354,10 +392,7 @@ where }, Err(error) => { self.apply_status_error(error); - if self.pending_bootstrap_session_refresh { - self.pending_bootstrap_session_refresh = false; - self.refresh_sessions().await; - } + self.consume_bootstrap_refresh().await; }, }, Action::SnapshotLoaded { session_id, result } => { @@ -371,18 +406,12 @@ where self.state .set_status(format!("attached to session {}", session_id)); self.open_stream_for_active_session().await; - if self.pending_bootstrap_session_refresh { - self.pending_bootstrap_session_refresh = false; - self.refresh_sessions().await; - } + self.consume_bootstrap_refresh().await; }, Err(error) => { self.pending_session_id = None; self.apply_hydration_error(error); - if self.pending_bootstrap_session_refresh { - self.pending_bootstrap_session_refresh = false; - self.refresh_sessions().await; - } + self.consume_bootstrap_refresh().await; }, } }, @@ -470,6 +499,31 @@ where self.state.clear_surface_state(); } }, + KeyCode::Left => { + if !matches!(self.state.interaction.pane_focus, PaneFocus::Transcript) { + self.state.move_cursor_left(); + } + }, + KeyCode::Right => { + if !matches!(self.state.interaction.pane_focus, PaneFocus::Transcript) { + self.state.move_cursor_right(); + } + }, + KeyCode::Home => { + if matches!(self.state.interaction.pane_focus, PaneFocus::Transcript) { + self.state.scroll_up_by(u16::MAX); + } else { + self.state.move_cursor_home(); + } + }, + KeyCode::End => { + if matches!(self.state.interaction.pane_focus, PaneFocus::Transcript) { + self.state.interaction.reset_scroll(); + self.state.render.mark_transcript_dirty(); + } else { + self.state.move_cursor_end(); + } + }, KeyCode::BackTab => { if !matches!(self.state.interaction.palette, PaletteState::Closed) { return Ok(()); @@ -502,6 +556,12 @@ where } } }, + KeyCode::PageUp => { + self.state.scroll_up_by(self.scroll_page_step().max(1)); + }, + KeyCode::PageDown => { + self.state.scroll_down_by(self.scroll_page_step().max(1)); + }, KeyCode::Enter => { if key.modifiers.contains(KeyModifiers::SHIFT) { if matches!(self.state.interaction.palette, PaletteState::Closed) @@ -521,16 +581,28 @@ where } }, KeyCode::Backspace => { + if matches!(self.state.interaction.pane_focus, PaneFocus::Transcript) { + return Ok(()); + } if matches!(self.state.interaction.palette, PaletteState::Closed) { self.state.pop_input(); } else { - self.state.interaction.composer.input.pop(); + self.state.pop_input(); + self.refresh_palette_query().await; + } + }, + KeyCode::Delete => { + if matches!(self.state.interaction.pane_focus, PaneFocus::Transcript) { + return Ok(()); + } + self.state.delete_input(); + if !matches!(self.state.interaction.palette, PaletteState::Closed) { self.refresh_palette_query().await; } }, KeyCode::Char(ch) => { if !matches!(self.state.interaction.palette, PaletteState::Closed) { - self.state.interaction.composer.input.push(ch); + self.state.push_input(ch); self.refresh_palette_query().await; } else { match self.state.interaction.pane_focus { @@ -558,19 +630,37 @@ where } async fn handle_paste(&mut self, text: String) -> Result<()> { - if matches!(self.state.interaction.palette, PaletteState::Closed) { - self.state.append_input(text.as_str()); - } else { - self.state - .interaction - .composer - .input - .push_str(text.as_str()); + self.state.append_input(text.as_str()); + if !matches!(self.state.interaction.palette, PaletteState::Closed) { self.refresh_palette_query().await; } Ok(()) } + fn handle_mouse(&mut self, mouse: MouseEvent) { + match mouse.kind { + MouseEventKind::ScrollUp => self.state.scroll_up_by(3), + MouseEventKind::ScrollDown => self.state.scroll_down_by(3), + MouseEventKind::Down(_) => { + let footer_top = self.state.render.viewport_height.saturating_sub(5); + if mouse.row >= footer_top { + self.state.interaction.set_focus(PaneFocus::Composer); + self.state.render.invalidate_transcript_cache(); + self.state.render.mark_footer_dirty(); + return; + } + self.state.interaction.set_focus(PaneFocus::Transcript); + self.state.render.invalidate_transcript_cache(); + self.state.render.mark_footer_dirty(); + }, + _ => {}, + } + } + + fn scroll_page_step(&self) -> u16 { + self.state.render.viewport_height.saturating_sub(7).max(1) + } + fn handle_transcript_enter(&mut self) { if !self.state.selected_cell_is_thinking() { self.state.toggle_selected_cell_expanded(); @@ -609,6 +699,11 @@ impl InputHandle { break; } }, + Ok(CrosstermEvent::Mouse(mouse)) => { + if actions_tx.send(Action::Mouse(mouse)).is_err() { + break; + } + }, Ok(CrosstermEvent::Resize(width, height)) => { if actions_tx.send(Action::Resize { width, height }).is_err() { break; @@ -638,17 +733,44 @@ impl InputHandle { } } -fn spawn_tick_loop(actions_tx: mpsc::UnboundedSender) -> JoinHandle<()> { - tokio::spawn(async move { - let mut interval = time::interval(Duration::from_millis(250)); - interval.set_missed_tick_behavior(MissedTickBehavior::Skip); - loop { - interval.tick().await; - if actions_tx.send(Action::Tick).is_err() { - break; +fn spawn_tick_loop(actions_tx: mpsc::UnboundedSender) -> TickHandle { + TickHandle::spawn(actions_tx) +} + +struct TickHandle { + stop: Arc, + join: Option>, +} + +impl TickHandle { + fn spawn(actions_tx: mpsc::UnboundedSender) -> Self { + let stop = Arc::new(AtomicBool::new(false)); + let stop_flag = Arc::clone(&stop); + let join = tokio::spawn(async move { + let mut interval = time::interval(Duration::from_millis(250)); + interval.set_missed_tick_behavior(MissedTickBehavior::Skip); + loop { + interval.tick().await; + if stop_flag.load(Ordering::Relaxed) { + break; + } + if actions_tx.send(Action::Tick).is_err() { + break; + } } + }); + Self { + stop, + join: Some(join), + } + } + + async fn stop(mut self) { + self.stop.store(true, Ordering::Relaxed); + if let Some(join) = self.join.take() { + let _ = join.await; } - }) + } } fn resolve_working_dir(cli_value: Option) -> Result { @@ -670,17 +792,18 @@ fn filter_resume_sessions( sessions: &[AstrcodeSessionListItem], query: &str, ) -> Vec { - let query = query.trim().to_lowercase(); let mut items = sessions .iter() .filter(|session| { - if query.is_empty() { - return true; - } - session.session_id.to_lowercase().contains(&query) - || session.title.to_lowercase().contains(&query) - || session.display_name.to_lowercase().contains(&query) - || session.working_dir.to_lowercase().contains(&query) + fuzzy_contains( + query, + [ + session.session_id.clone(), + session.title.clone(), + session.display_name.clone(), + session.working_dir.clone(), + ], + ) }) .cloned() .collect::>(); @@ -964,8 +1087,7 @@ mod tests { ascii_capabilities(), ), None, - actions_tx, - actions_rx, + AppControllerChannels::new(actions_tx, actions_rx), ); controller.state.update_sessions(vec![session( "session-old", @@ -1046,8 +1168,7 @@ mod tests { ascii_capabilities(), ), None, - actions_tx, - actions_rx, + AppControllerChannels::new(actions_tx, actions_rx), ); controller.state.update_sessions(vec![existing]); @@ -1185,8 +1306,7 @@ mod tests { ascii_capabilities(), ), None, - actions_tx, - actions_rx, + AppControllerChannels::new(actions_tx, actions_rx), ); controller .state diff --git a/crates/cli/src/command/mod.rs b/crates/cli/src/command/mod.rs index 73c5a807..ede2e798 100644 --- a/crates/cli/src/command/mod.rs +++ b/crates/cli/src/command/mod.rs @@ -27,7 +27,7 @@ pub enum PaletteAction { RunCommand(Command), } -pub fn classify_input(input: &str) -> InputAction { +pub fn classify_input(input: String) -> InputAction { let trimmed = input.trim(); if trimmed.is_empty() { return InputAction::Empty; @@ -42,6 +42,16 @@ pub fn classify_input(input: &str) -> InputAction { InputAction::RunCommand(parse_command(trimmed)) } +pub fn fuzzy_contains(query: &str, fields: impl IntoIterator) -> bool { + let query = query.trim().to_lowercase(); + if query.is_empty() { + return true; + } + fields + .into_iter() + .any(|field| field.to_lowercase().contains(&query)) +} + pub fn palette_action(selection: PaletteSelection) -> PaletteAction { match selection { PaletteSelection::ResumeSession(session_id) => PaletteAction::SwitchSession { session_id }, @@ -81,21 +91,16 @@ pub fn filter_slash_candidates( candidates: &[AstrcodeConversationSlashCandidateDto], query: &str, ) -> Vec { - let query = query.trim().to_lowercase(); - if query.is_empty() { - return candidates.to_vec(); - } - candidates .iter() .filter(|candidate| { - candidate.id.to_lowercase().contains(&query) - || candidate.title.to_lowercase().contains(&query) - || candidate.description.to_lowercase().contains(&query) - || candidate - .keywords - .iter() - .any(|keyword| keyword.to_lowercase().contains(&query)) + fuzzy_contains( + query, + std::iter::once(candidate.id.clone()) + .chain(std::iter::once(candidate.title.clone())) + .chain(std::iter::once(candidate.description.clone())) + .chain(candidate.keywords.iter().cloned()), + ) }) .cloned() .collect() @@ -125,7 +130,7 @@ mod tests { #[test] fn classifies_plain_prompt_without_command_semantics() { assert_eq!( - classify_input("实现 terminal v1"), + classify_input("实现 terminal v1".to_string()), InputAction::SubmitPrompt { text: "实现 terminal v1".to_string() } diff --git a/crates/cli/src/launcher/mod.rs b/crates/cli/src/launcher/mod.rs index c30c1de0..b4e997e3 100644 --- a/crates/cli/src/launcher/mod.rs +++ b/crates/cli/src/launcher/mod.rs @@ -5,7 +5,7 @@ use std::{ fs, path::{Path, PathBuf}, process::Stdio, - sync::{Arc, Mutex}, + sync::{Arc, Mutex, OnceLock}, time::Duration, }; @@ -548,29 +548,34 @@ fn named_binary_exists_in_dir(directory: &Path, binary: &Path) -> bool { } fn windows_binary_extensions() -> Vec { - env::var_os("PATHEXT") - .map(|raw| { - raw.to_string_lossy() - .split(';') - .filter_map(|item| { - let trimmed = item.trim(); - if trimmed.is_empty() { - None - } else { - Some(trimmed.to_ascii_lowercase()) - } + static WINDOWS_BINARY_EXTENSIONS: OnceLock> = OnceLock::new(); + WINDOWS_BINARY_EXTENSIONS + .get_or_init(|| { + env::var_os("PATHEXT") + .map(|raw| { + raw.to_string_lossy() + .split(';') + .filter_map(|item| { + let trimmed = item.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_ascii_lowercase()) + } + }) + .collect::>() + }) + .filter(|extensions| !extensions.is_empty()) + .unwrap_or_else(|| { + vec![ + ".com".to_string(), + ".exe".to_string(), + ".bat".to_string(), + ".cmd".to_string(), + ] }) - .collect::>() - }) - .filter(|extensions| !extensions.is_empty()) - .unwrap_or_else(|| { - vec![ - ".com".to_string(), - ".exe".to_string(), - ".bat".to_string(), - ".cmd".to_string(), - ] }) + .clone() } fn current_directory() -> Option { diff --git a/crates/cli/src/render/mod.rs b/crates/cli/src/render/mod.rs index 9e897624..b808a339 100644 --- a/crates/cli/src/render/mod.rs +++ b/crates/cli/src/render/mod.rs @@ -9,6 +9,8 @@ use crate::{ ui::{self, CodexTheme, ThemePalette}, }; +const FOOTER_HEIGHT: u16 = 5; + pub fn render(frame: &mut Frame<'_>, state: &mut CliState) { state.set_viewport_size(frame.area().width, frame.area().height); let theme = CodexTheme::new(state.shell.capabilities); @@ -16,9 +18,9 @@ pub fn render(frame: &mut Frame<'_>, state: &mut CliState) { let footer_area = Rect { x: frame.area().x, - y: frame.area().bottom().saturating_sub(4), + y: frame.area().bottom().saturating_sub(FOOTER_HEIGHT), width: frame.area().width, - height: 4, + height: FOOTER_HEIGHT, }; let transcript_height = frame.area().height.saturating_sub(footer_area.height); let transcript_area = Rect { @@ -28,16 +30,17 @@ pub fn render(frame: &mut Frame<'_>, state: &mut CliState) { height: transcript_height, }; - render_transcript(frame, state, transcript_area); - render_footer(frame, state, footer_area); + refresh_caches(state, transcript_area, footer_area, &theme); + render_transcript(frame, state, transcript_area, &theme); + render_footer(frame, state, footer_area, &theme); if ui::palette_visible(&state.interaction.palette) { render_palette(frame, state, transcript_area, footer_area, &theme); } } -fn render_transcript(frame: &mut Frame<'_>, state: &CliState, area: Rect) { - let transcript = ui::transcript_lines(state, area.width.saturating_sub(2)); +fn render_transcript(frame: &mut Frame<'_>, state: &mut CliState, area: Rect, theme: &CodexTheme) { + let transcript = &state.render.transcript_cache; let viewport_height = area.height.saturating_sub(1); let scroll = transcript_scroll_offset( transcript.lines.len(), @@ -45,14 +48,15 @@ fn render_transcript(frame: &mut Frame<'_>, state: &CliState, area: Rect) { state.interaction.scroll_anchor, state.interaction.follow_transcript_tail, transcript.selected_line_range, - matches!(state.interaction.pane_focus, PaneFocus::Transcript), + matches!(state.interaction.pane_focus, PaneFocus::Transcript) + && state.interaction.selection_drives_scroll, ); frame.render_widget( Paragraph::new( transcript .lines .iter() - .map(|line| ui::line_to_ratatui(line, state.shell.capabilities)) + .map(|line| ui::line_to_ratatui(line, theme)) .collect::>(), ) .wrap(Wrap { trim: false }) @@ -61,9 +65,8 @@ fn render_transcript(frame: &mut Frame<'_>, state: &CliState, area: Rect) { ); } -fn render_footer(frame: &mut Frame<'_>, state: &CliState, area: Rect) { - let theme = CodexTheme::new(state.shell.capabilities); - let lines = ui::footer_lines(state, area.width.saturating_sub(2)); +fn render_footer(frame: &mut Frame<'_>, state: &CliState, area: Rect, theme: &CodexTheme) { + let footer = &state.render.footer_cache; let layout = Layout::default() .direction(Direction::Vertical) .constraints([ @@ -71,33 +74,39 @@ fn render_footer(frame: &mut Frame<'_>, state: &CliState, area: Rect) { Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), + Constraint::Length(1), ]) .split(area); frame.render_widget( - Paragraph::new(theme.divider().repeat(usize::from(area.width))) - .style(theme.line_style(crate::state::WrappedLineStyle::Divider)), + Paragraph::new(vec![ui::line_to_ratatui(&footer.lines[0], theme)]), layout[0], ); frame.render_widget( - Paragraph::new(vec![ui::line_to_ratatui( - &lines[0], - state.shell.capabilities, - )]), + Paragraph::new(theme.divider().repeat(usize::from(area.width))) + .style(theme.line_style(crate::state::WrappedLineStyle::Divider)), layout[1], ); frame.render_widget( - Paragraph::new(theme.divider().repeat(usize::from(area.width))) - .style(theme.line_style(crate::state::WrappedLineStyle::Divider)), + Paragraph::new(vec![ui::line_to_ratatui(&footer.lines[1], theme)]), layout[2], ); frame.render_widget( - Paragraph::new(vec![ui::line_to_ratatui( - &lines[1], - state.shell.capabilities, - )]), + Paragraph::new(theme.divider().repeat(usize::from(area.width))) + .style(theme.line_style(crate::state::WrappedLineStyle::Divider)), layout[3], ); + frame.render_widget( + Paragraph::new(vec![ui::line_to_ratatui(&footer.lines[2], theme)]), + layout[4], + ); + + if matches!( + state.interaction.pane_focus, + PaneFocus::Composer | PaneFocus::Palette + ) { + frame.set_cursor_position((area.x.saturating_add(footer.cursor_col), layout[2].y)); + } } fn render_palette( @@ -107,13 +116,9 @@ fn render_palette( footer_area: Rect, theme: &CodexTheme, ) { - let menu_lines = ui::palette_lines( - &state.interaction.palette, - usize::from(footer_area.width.saturating_sub(4)), - theme, - ); - let menu_height = menu_lines.len().clamp(2, 10) as u16; - let menu_width = footer_area.width.saturating_sub(2).min(112).max(52); + let menu_lines = &state.render.palette_cache.lines; + let menu_height = menu_lines.len().clamp(1, 5) as u16; + let menu_width = footer_area.width.saturating_sub(2); let menu_area = Rect { x: transcript_area.x.saturating_add(1), y: footer_area @@ -128,7 +133,7 @@ fn render_palette( Paragraph::new( menu_lines .iter() - .map(|line| ui::line_to_ratatui(line, state.shell.capabilities)) + .map(|line| ui::line_to_ratatui(line, theme)) .collect::>(), ) .style(theme.menu_block_style()) @@ -167,9 +172,60 @@ fn transcript_scroll_offset( top_offset.try_into().unwrap_or(u16::MAX) } +pub fn refresh_caches( + state: &mut CliState, + transcript_area: Rect, + footer_area: Rect, + theme: &CodexTheme, +) { + let transcript_width = transcript_area.width.saturating_sub(2); + let transcript_cache_valid = state.render.transcript_cache.width == transcript_width + && state.render.transcript_cache.revision == state.render.transcript_revision + && !state.render.transcript_cache.lines.is_empty(); + if state.render.dirty.transcript || !transcript_cache_valid { + let transcript = ui::transcript_lines(state, transcript_width, theme); + state.update_transcript_cache( + transcript_width, + transcript.lines, + transcript.selected_line_range, + ); + } + + let footer_width = footer_area.width.saturating_sub(2); + let footer_cache_valid = state.render.footer_cache.width == footer_width + && state.render.footer_cache.lines.len() == 3; + if state.render.dirty.footer || !footer_cache_valid { + let footer = ui::footer_lines(state, footer_width, theme); + state + .render + .update_footer_cache(footer_width, footer.lines, footer.cursor_col); + } + + let palette_width = footer_area.width.saturating_sub(2); + let palette_should_show = ui::palette_visible(&state.interaction.palette); + if !palette_should_show { + if !state.render.palette_cache.lines.is_empty() { + state.render.update_palette_cache(palette_width, Vec::new()); + } + state.render.dirty.palette = false; + return; + } + + let palette_cache_valid = state.render.palette_cache.width == palette_width; + if state.render.dirty.palette || !palette_cache_valid { + let menu_lines = ui::palette_lines( + &state.interaction.palette, + usize::from(footer_area.width.saturating_sub(4)), + theme, + ); + state.render.update_palette_cache(palette_width, menu_lines); + } +} + #[cfg(test)] mod tests { use astrcode_client::{ + AstrcodeConversationControlStateDto, AstrcodeConversationCursorDto, AstrcodeConversationSlashActionKindDto, AstrcodeConversationSlashCandidateDto, }; use ratatui::{Terminal, backend::TestBackend}; @@ -177,7 +233,7 @@ mod tests { use super::render; use crate::{ capability::{ColorLevel, GlyphMode, TerminalCapabilities}, - state::{CliState, PaneFocus, TranscriptCell, TranscriptCellKind, TranscriptCellStatus}, + state::{CliState, PaneFocus}, }; fn capabilities(glyphs: GlyphMode) -> TerminalCapabilities { @@ -282,14 +338,16 @@ mod tests { None, capabilities(GlyphMode::Unicode), ); - state.conversation.transcript_cells.push(TranscriptCell { - id: "thinking-1".to_string(), - expanded: false, - kind: TranscriptCellKind::Thinking { - body: "".to_string(), - status: TranscriptCellStatus::Streaming, - }, + state.conversation.control = Some(AstrcodeConversationControlStateDto { + phase: astrcode_client::AstrcodePhaseDto::Thinking, + can_submit_prompt: true, + can_request_compact: true, + compact_pending: false, + compacting: false, + active_turn_id: Some("turn-1".to_string()), + last_compact_meta: None, }); + state.conversation.cursor = Some(AstrcodeConversationCursorDto("1.0".to_string())); state.interaction.set_focus(PaneFocus::Transcript); terminal @@ -302,7 +360,7 @@ mod tests { .iter() .map(|cell| cell.symbol()) .collect::(); - assert!(text.contains("Thinking")); + assert!(text.contains("Ctrl+O")); } #[test] diff --git a/crates/cli/src/state/conversation.rs b/crates/cli/src/state/conversation.rs index a536e231..87976bc5 100644 --- a/crates/cli/src/state/conversation.rs +++ b/crates/cli/src/state/conversation.rs @@ -1,4 +1,4 @@ -use std::collections::BTreeSet; +use std::collections::{BTreeSet, HashMap}; use astrcode_client::{ AstrcodeConversationBannerDto, AstrcodeConversationBlockDto, AstrcodeConversationBlockPatchDto, @@ -19,7 +19,7 @@ pub struct ConversationState { pub cursor: Option, pub control: Option, pub transcript: Vec, - pub transcript_cells: Vec, + pub transcript_index: HashMap, pub child_summaries: Vec, pub slash_candidates: Vec, pub banner: Option, @@ -40,7 +40,7 @@ impl ConversationState { self.cursor = Some(snapshot.cursor); self.control = Some(snapshot.control); self.transcript = snapshot.blocks; - self.rebuild_transcript_cells(); + self.rebuild_transcript_index(); self.child_summaries = snapshot.child_summaries; self.slash_candidates = snapshot.slash_candidates; self.banner = snapshot.banner; @@ -73,41 +73,38 @@ impl ConversationState { &mut self, delta: AstrcodeConversationDeltaDto, render: &mut RenderState, - expanded_ids: &BTreeSet, + _expanded_ids: &BTreeSet, ) { match delta { AstrcodeConversationDeltaDto::AppendBlock { block } => { - self.transcript_cells - .push(TranscriptCell::from_block(&block, expanded_ids)); self.transcript.push(block); + if let Some(block) = self.transcript.last() { + self.transcript_index + .insert(block_id_of(block).to_string(), self.transcript.len() - 1); + } render.invalidate_transcript_cache(); }, AstrcodeConversationDeltaDto::PatchBlock { block_id, patch } => { - if let Some((index, block)) = self - .transcript - .iter_mut() - .enumerate() - .find(|(_, block)| block_id_of(block) == block_id) - { + if let Some((index, block)) = self.find_block_mut(block_id.as_str()) { apply_block_patch(block, patch); - self.transcript_cells[index] = TranscriptCell::from_block(block, expanded_ids); + let _ = index; render.invalidate_transcript_cache(); + } else { + debug_missing_block("patch", block_id.as_str()); } }, AstrcodeConversationDeltaDto::CompleteBlock { block_id, status } => { - if let Some((index, block)) = self - .transcript - .iter_mut() - .enumerate() - .find(|(_, block)| block_id_of(block) == block_id) - { + if let Some((index, block)) = self.find_block_mut(block_id.as_str()) { set_block_status(block, status); - self.transcript_cells[index] = TranscriptCell::from_block(block, expanded_ids); + let _ = index; render.invalidate_transcript_cache(); + } else { + debug_missing_block("complete", block_id.as_str()); } }, AstrcodeConversationDeltaDto::UpdateControlState { control } => { self.control = Some(control); + render.invalidate_transcript_cache(); }, AstrcodeConversationDeltaDto::UpsertChildSummary { child } => { if let Some(existing) = self @@ -129,9 +126,11 @@ impl ConversationState { }, AstrcodeConversationDeltaDto::SetBanner { banner } => { self.banner = Some(banner); + render.invalidate_transcript_cache(); }, AstrcodeConversationDeltaDto::ClearBanner => { self.banner = None; + render.invalidate_transcript_cache(); }, AstrcodeConversationDeltaDto::RehydrateRequired { error } => { self.set_banner_error(error); @@ -139,19 +138,29 @@ impl ConversationState { } } - fn rebuild_transcript_cells(&mut self) { - let expanded_ids = self - .transcript_cells - .iter() - .filter(|cell| cell.expanded) - .map(|cell| cell.id.clone()) - .collect::>(); - self.transcript_cells = self + fn rebuild_transcript_index(&mut self) { + self.transcript_index = self .transcript .iter() - .map(|block| TranscriptCell::from_block(block, &expanded_ids)) + .enumerate() + .map(|(index, block)| (block_id_of(block).to_string(), index)) .collect(); } + + fn find_block_mut( + &mut self, + block_id: &str, + ) -> Option<(usize, &mut AstrcodeConversationBlockDto)> { + let index = *self.transcript_index.get(block_id)?; + self.transcript.get_mut(index).map(|block| (index, block)) + } + + pub fn project_transcript_cells(&self, expanded_ids: &BTreeSet) -> Vec { + self.transcript + .iter() + .map(|block| TranscriptCell::from_block(block, expanded_ids)) + .collect() + } } fn block_id_of(block: &AstrcodeConversationBlockDto) -> &str { @@ -191,7 +200,7 @@ fn apply_block_patch( }, AstrcodeConversationBlockPatchDto::AppendToolStream { stream, chunk } => { if let AstrcodeConversationBlockDto::ToolCall(block) = block { - if format!("{stream:?}").eq_ignore_ascii_case("stderr") { + if enum_wire_name(&stream).as_deref() == Some("stderr") { block.streams.stderr.push_str(&chunk); } else { block.streams.stdout.push_str(&chunk); @@ -232,6 +241,21 @@ fn apply_block_patch( } } +fn enum_wire_name(value: &T) -> Option +where + T: serde::Serialize, +{ + serde_json::to_value(value) + .ok()? + .as_str() + .map(|value| value.trim().to_string()) +} + +fn debug_missing_block(operation: &str, block_id: &str) { + #[cfg(debug_assertions)] + eprintln!("astrcode-cli: ignored {operation} delta for unknown block '{block_id}'"); +} + fn set_block_status( block: &mut AstrcodeConversationBlockDto, status: AstrcodeConversationBlockStatusDto, diff --git a/crates/cli/src/state/interaction.rs b/crates/cli/src/state/interaction.rs index 840c8254..cadc2074 100644 --- a/crates/cli/src/state/interaction.rs +++ b/crates/cli/src/state/interaction.rs @@ -13,6 +13,7 @@ pub enum PaneFocus { #[derive(Debug, Clone, PartialEq, Eq, Default)] pub struct ComposerState { pub input: String, + pub cursor: usize, } impl ComposerState { @@ -23,6 +24,98 @@ impl ComposerState { pub fn is_empty(&self) -> bool { self.input.is_empty() } + + pub fn as_str(&self) -> &str { + self.input.as_str() + } + + pub fn insert_char(&mut self, ch: char) { + self.input.insert(self.cursor, ch); + self.cursor += ch.len_utf8(); + } + + pub fn insert_str(&mut self, value: &str) { + self.input.insert_str(self.cursor, value); + self.cursor += value.len(); + } + + pub fn insert_newline(&mut self) { + self.insert_char('\n'); + } + + pub fn backspace(&mut self) { + let Some(start) = previous_boundary(self.input.as_str(), self.cursor) else { + return; + }; + self.input.drain(start..self.cursor); + self.cursor = start; + } + + pub fn delete_forward(&mut self) { + let Some(end) = next_boundary(self.input.as_str(), self.cursor) else { + return; + }; + self.input.drain(self.cursor..end); + } + + pub fn move_left(&mut self) { + if let Some(cursor) = previous_boundary(self.input.as_str(), self.cursor) { + self.cursor = cursor; + } + } + + pub fn move_right(&mut self) { + if let Some(cursor) = next_boundary(self.input.as_str(), self.cursor) { + self.cursor = cursor; + } + } + + pub fn move_home(&mut self) { + let line_start = self + .input + .get(..self.cursor) + .and_then(|value| value.rfind('\n').map(|index| index + 1)) + .unwrap_or(0); + self.cursor = line_start; + } + + pub fn move_end(&mut self) { + let line_end = self + .input + .get(self.cursor..) + .and_then(|value| value.find('\n').map(|index| self.cursor + index)) + .unwrap_or(self.input.len()); + self.cursor = line_end; + } + + pub fn replace(&mut self, input: impl Into) { + self.input = input.into(); + self.cursor = self.input.len(); + } + + pub fn take(&mut self) -> String { + self.cursor = 0; + std::mem::take(&mut self.input) + } +} + +fn previous_boundary(input: &str, cursor: usize) -> Option { + if cursor == 0 { + return None; + } + input + .get(..cursor)? + .char_indices() + .last() + .map(|(index, _)| index) +} + +fn next_boundary(input: &str, cursor: usize) -> Option { + if cursor >= input.len() { + return None; + } + let ch = input.get(cursor..)?.chars().next()?; + Some(cursor + ch.len_utf8()) } #[derive(Debug, Clone, PartialEq, Eq)] @@ -79,6 +172,7 @@ pub struct InteractionState { pub status: StatusLine, pub scroll_anchor: u16, pub follow_transcript_tail: bool, + pub selection_drives_scroll: bool, pub pane_focus: PaneFocus, pub last_non_palette_focus: PaneFocus, pub composer: ComposerState, @@ -92,6 +186,7 @@ impl Default for InteractionState { status: StatusLine::default(), scroll_anchor: 0, follow_transcript_tail: true, + selection_drives_scroll: true, pane_focus: PaneFocus::default(), last_non_palette_focus: PaneFocus::default(), composer: ComposerState::default(), @@ -118,45 +213,76 @@ impl InteractionState { pub fn push_input(&mut self, ch: char) { self.set_focus(PaneFocus::Composer); - self.composer.input.push(ch); + self.composer.insert_char(ch); } pub fn append_input(&mut self, value: &str) { self.set_focus(PaneFocus::Composer); - self.composer.input.push_str(value); + self.composer.insert_str(value); } pub fn insert_newline(&mut self) { self.set_focus(PaneFocus::Composer); - self.composer.input.push('\n'); + self.composer.insert_newline(); } pub fn pop_input(&mut self) { - self.composer.input.pop(); + self.composer.backspace(); + } + + pub fn delete_input(&mut self) { + self.composer.delete_forward(); + } + + pub fn move_cursor_left(&mut self) { + self.composer.move_left(); + } + + pub fn move_cursor_right(&mut self) { + self.composer.move_right(); + } + + pub fn move_cursor_home(&mut self) { + self.composer.move_home(); + } + + pub fn move_cursor_end(&mut self) { + self.composer.move_end(); } pub fn replace_input(&mut self, input: impl Into) { self.set_focus(PaneFocus::Composer); - self.composer.input = input.into(); + self.composer.replace(input); } pub fn take_input(&mut self) -> String { - std::mem::take(&mut self.composer.input) + self.composer.take() } pub fn scroll_up(&mut self) { + self.scroll_up_by(1); + } + + pub fn scroll_up_by(&mut self, lines: u16) { self.follow_transcript_tail = false; - self.scroll_anchor = self.scroll_anchor.saturating_add(1); + self.selection_drives_scroll = false; + self.scroll_anchor = self.scroll_anchor.saturating_add(lines.max(1)); } pub fn scroll_down(&mut self) { - self.scroll_anchor = self.scroll_anchor.saturating_sub(1); + self.scroll_down_by(1); + } + + pub fn scroll_down_by(&mut self, lines: u16) { + self.scroll_anchor = self.scroll_anchor.saturating_sub(lines.max(1)); self.follow_transcript_tail = self.scroll_anchor == 0; + self.selection_drives_scroll = self.follow_transcript_tail; } pub fn reset_scroll(&mut self) { self.scroll_anchor = 0; self.follow_transcript_tail = true; + self.selection_drives_scroll = true; } pub fn cycle_focus_forward(&mut self) { @@ -168,7 +294,11 @@ impl InteractionState { } pub fn cycle_focus_backward(&mut self) { - self.cycle_focus_forward(); + self.set_focus(match self.pane_focus { + PaneFocus::Transcript => PaneFocus::Composer, + PaneFocus::Composer => PaneFocus::Transcript, + PaneFocus::Palette => PaneFocus::Palette, + }); } pub fn set_focus(&mut self, focus: PaneFocus) { @@ -185,6 +315,7 @@ impl InteractionState { self.set_focus(PaneFocus::Transcript); self.transcript.selected_cell = (self.transcript.selected_cell + 1) % cell_count; self.follow_transcript_tail = false; + self.selection_drives_scroll = true; } pub fn transcript_prev(&mut self, cell_count: usize) { @@ -195,6 +326,7 @@ impl InteractionState { self.transcript.selected_cell = (self.transcript.selected_cell + cell_count - 1) % cell_count; self.follow_transcript_tail = false; + self.selection_drives_scroll = true; } pub fn sync_transcript_cells(&mut self, cell_count: usize) { @@ -365,4 +497,35 @@ mod tests { state.toggle_cell_expanded("assistant-1"); assert!(!state.is_cell_expanded("assistant-1")); } + + #[test] + fn composer_backspace_respects_cursor_position() { + let mut state = InteractionState::default(); + state.replace_input("abcd"); + state.move_cursor_left(); + state.move_cursor_left(); + state.pop_input(); + assert_eq!(state.composer.as_str(), "acd"); + assert_eq!(state.composer.cursor, 1); + } + + #[test] + fn composer_delete_forward_respects_cursor_position() { + let mut state = InteractionState::default(); + state.replace_input("abcd"); + state.move_cursor_left(); + state.move_cursor_left(); + state.delete_input(); + assert_eq!(state.composer.as_str(), "abd"); + assert_eq!(state.composer.cursor, 2); + } + + #[test] + fn manual_scroll_disables_selection_driven_scroll() { + let mut state = InteractionState::default(); + state.transcript_next(4); + assert!(state.selection_drives_scroll); + state.scroll_up(); + assert!(!state.selection_drives_scroll); + } } diff --git a/crates/cli/src/state/mod.rs b/crates/cli/src/state/mod.rs index b09ec677..5b8ee69e 100644 --- a/crates/cli/src/state/mod.rs +++ b/crates/cli/src/state/mod.rs @@ -19,7 +19,9 @@ pub use interaction::{ ComposerState, InteractionState, PaletteSelection, PaletteState, PaneFocus, ResumePaletteState, SlashPaletteState, StatusLine, }; -pub use render::{RenderState, StreamViewState, TranscriptRenderCache}; +pub use render::{ + RenderState, StreamViewState, TranscriptRenderCache, WrappedLine, WrappedLineStyle, +}; pub use shell::ShellState; pub use thinking::{ThinkingPlaybackDriver, ThinkingPresentationState, ThinkingSnippetPool}; pub use transcript_cell::{TranscriptCell, TranscriptCellKind, TranscriptCellStatus}; @@ -33,35 +35,6 @@ pub enum StreamRenderMode { CatchUp, } -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct WrappedLine { - pub style: WrappedLineStyle, - pub content: String, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum WrappedLineStyle { - Plain, - Muted, - Accent, - Divider, - Selection, - UserLabel, - UserBody, - AssistantLabel, - AssistantBody, - ThinkingLabel, - ThinkingBody, - ToolLabel, - ToolBody, - ErrorText, - FooterInput, - FooterStatus, - PaletteTitle, - PaletteItem, - PaletteMeta, -} - #[derive(Debug, Clone, PartialEq, Eq, Default)] pub struct CliState { pub shell: ShellState, @@ -90,78 +63,153 @@ impl CliState { pub fn set_status(&mut self, message: impl Into) { self.interaction.set_status(message); + self.render.mark_footer_dirty(); } pub fn set_error_status(&mut self, message: impl Into) { self.interaction.set_error_status(message); + self.render.mark_footer_dirty(); } - pub fn set_stream_mode(&mut self, mode: StreamRenderMode, pending: usize, oldest: Duration) { + pub fn set_stream_mode( + &mut self, + mode: StreamRenderMode, + pending: usize, + oldest: Duration, + ) -> bool { + let changed = self.stream_view.mode != mode || self.stream_view.pending_chunks != pending; self.stream_view.update(mode, pending, oldest); + if changed { + self.render.mark_footer_dirty(); + } + changed } pub fn set_viewport_size(&mut self, width: u16, height: u16) { self.render.set_viewport_size(width, height); } - pub fn update_transcript_cache(&mut self, width: u16, lines: Vec) { - self.render.update_transcript_cache(width, lines); + pub fn update_transcript_cache( + &mut self, + width: u16, + lines: Vec, + selected_line_range: Option<(usize, usize)>, + ) { + self.render + .update_transcript_cache(width, lines, selected_line_range); } pub fn push_input(&mut self, ch: char) { self.interaction.push_input(ch); + self.render.mark_footer_dirty(); } pub fn append_input(&mut self, value: &str) { self.interaction.append_input(value); + self.render.mark_footer_dirty(); } pub fn insert_newline(&mut self) { self.interaction.insert_newline(); + self.render.mark_footer_dirty(); } pub fn pop_input(&mut self) { self.interaction.pop_input(); + self.render.mark_footer_dirty(); + } + + pub fn delete_input(&mut self) { + self.interaction.delete_input(); + self.render.mark_footer_dirty(); + } + + pub fn move_cursor_left(&mut self) { + self.interaction.move_cursor_left(); + self.render.mark_footer_dirty(); + } + + pub fn move_cursor_right(&mut self) { + self.interaction.move_cursor_right(); + self.render.mark_footer_dirty(); + } + + pub fn move_cursor_home(&mut self) { + self.interaction.move_cursor_home(); + self.render.mark_footer_dirty(); + } + + pub fn move_cursor_end(&mut self) { + self.interaction.move_cursor_end(); + self.render.mark_footer_dirty(); } pub fn replace_input(&mut self, input: impl Into) { self.interaction.replace_input(input); + self.render.mark_footer_dirty(); } pub fn take_input(&mut self) -> String { - self.interaction.take_input() + let input = self.interaction.take_input(); + self.render.mark_footer_dirty(); + input } pub fn scroll_up(&mut self) { self.interaction.scroll_up(); + self.render.mark_transcript_dirty(); } pub fn scroll_down(&mut self) { self.interaction.scroll_down(); + self.render.mark_transcript_dirty(); + } + + pub fn scroll_up_by(&mut self, lines: u16) { + self.interaction.scroll_up_by(lines); + self.render.mark_transcript_dirty(); + } + + pub fn scroll_down_by(&mut self, lines: u16) { + self.interaction.scroll_down_by(lines); + self.render.mark_transcript_dirty(); } pub fn cycle_focus_forward(&mut self) { self.interaction.cycle_focus_forward(); + self.render.invalidate_transcript_cache(); + self.render.mark_footer_dirty(); + self.render.mark_palette_dirty(); } pub fn cycle_focus_backward(&mut self) { self.interaction.cycle_focus_backward(); + self.render.invalidate_transcript_cache(); + self.render.mark_footer_dirty(); + self.render.mark_palette_dirty(); } pub fn transcript_next(&mut self) { self.interaction - .transcript_next(self.conversation.transcript_cells.len()); + .transcript_next(self.conversation.transcript.len()); + self.render.invalidate_transcript_cache(); } pub fn transcript_prev(&mut self) { self.interaction - .transcript_prev(self.conversation.transcript_cells.len()); + .transcript_prev(self.conversation.transcript.len()); + self.render.invalidate_transcript_cache(); } - pub fn selected_transcript_cell(&self) -> Option<&TranscriptCell> { + pub fn transcript_cells(&self) -> Vec { self.conversation - .transcript_cells - .get(self.interaction.transcript.selected_cell) + .project_transcript_cells(&self.interaction.transcript.expanded_cells) + } + + pub fn selected_transcript_cell(&self) -> Option { + self.transcript_cells() + .into_iter() + .nth(self.interaction.transcript.selected_cell) } pub fn is_cell_expanded(&self, cell_id: &str) -> bool { @@ -176,17 +224,24 @@ impl CliState { pub fn toggle_selected_cell_expanded(&mut self) { if let Some(cell_id) = self.selected_transcript_cell().map(|cell| cell.id.clone()) { self.interaction.toggle_cell_expanded(cell_id.as_str()); + self.render.invalidate_transcript_cache(); } } pub fn clear_surface_state(&mut self) { + let invalidate = matches!(self.interaction.pane_focus, PaneFocus::Transcript); self.interaction.clear_surface_state(); + if invalidate { + self.render.invalidate_transcript_cache(); + } } pub fn update_sessions(&mut self, sessions: Vec) { self.conversation.update_sessions(sessions); self.interaction .sync_resume_items(self.conversation.sessions.clone()); + self.render.invalidate_transcript_cache(); + self.render.mark_palette_dirty(); } pub fn set_resume_query( @@ -195,6 +250,8 @@ impl CliState { items: Vec, ) { self.interaction.set_resume_palette(query, items); + self.render.invalidate_footer_cache(); + self.render.invalidate_palette_cache(); } pub fn set_slash_query( @@ -203,18 +260,26 @@ impl CliState { items: Vec, ) { self.interaction.set_slash_palette(query, items); + self.render.invalidate_footer_cache(); + self.render.invalidate_palette_cache(); } pub fn close_palette(&mut self) { self.interaction.close_palette(); + self.render.invalidate_footer_cache(); + self.render.invalidate_palette_cache(); } pub fn palette_next(&mut self) { self.interaction.palette_next(); + self.render.mark_footer_dirty(); + self.render.mark_palette_dirty(); } pub fn palette_prev(&mut self) { self.interaction.palette_prev(); + self.render.mark_footer_dirty(); + self.render.mark_palette_dirty(); } pub fn selected_palette(&self) -> Option { @@ -226,28 +291,37 @@ impl CliState { .activate_snapshot(snapshot, &mut self.render); self.interaction.reset_for_snapshot(); self.interaction - .sync_transcript_cells(self.conversation.transcript_cells.len()); + .sync_transcript_cells(self.conversation.transcript.len()); self.thinking_playback .sync_session(self.conversation.active_session_id.as_deref()); + self.render.invalidate_transcript_cache(); + self.render.invalidate_footer_cache(); + self.render.invalidate_palette_cache(); } pub fn apply_stream_envelope(&mut self, envelope: AstrcodeConversationStreamEnvelopeDto) { - let expanded_ids = self.interaction.transcript.expanded_cells.clone(); + let expanded_ids = &self.interaction.transcript.expanded_cells; self.conversation - .apply_stream_envelope(envelope, &mut self.render, &expanded_ids); + .apply_stream_envelope(envelope, &mut self.render, expanded_ids); self.interaction - .sync_transcript_cells(self.conversation.transcript_cells.len()); + .sync_transcript_cells(self.conversation.transcript.len()); self.interaction .sync_slash_items(self.conversation.slash_candidates.clone()); + self.render.invalidate_transcript_cache(); + self.render.mark_footer_dirty(); + self.render.mark_palette_dirty(); } pub fn set_banner_error(&mut self, error: AstrcodeConversationErrorEnvelopeDto) { self.conversation.set_banner_error(error); self.interaction.set_focus(PaneFocus::Composer); + self.render.invalidate_transcript_cache(); + self.render.mark_footer_dirty(); } pub fn clear_banner(&mut self) { self.conversation.clear_banner(); + self.render.invalidate_transcript_cache(); } pub fn active_phase(&self) -> Option { @@ -258,8 +332,17 @@ impl CliState { self.debug.push(line); } - pub fn advance_thinking_playback(&mut self) { - if self.conversation.transcript_cells.iter().any(|cell| { + pub fn advance_thinking_playback(&mut self) -> bool { + if self.should_animate_thinking_playback() { + self.thinking_playback.advance(); + self.render.invalidate_transcript_cache(); + return true; + } + false + } + + fn should_animate_thinking_playback(&self) -> bool { + if self.transcript_cells().iter().any(|cell| { matches!( cell.kind, TranscriptCellKind::Thinking { @@ -268,9 +351,39 @@ impl CliState { } ) }) { - self.thinking_playback.advance(); - self.render.invalidate_transcript_cache(); + return true; + } + + let Some(control) = &self.conversation.control else { + return false; + }; + if control.active_turn_id.is_none() { + return false; + } + if !matches!( + control.phase, + AstrcodePhaseDto::Thinking + | AstrcodePhaseDto::CallingTool + | AstrcodePhaseDto::Streaming + ) { + return false; } + + !self.transcript_cells().iter().any(|cell| match &cell.kind { + TranscriptCellKind::Thinking { status, .. } => { + matches!( + status, + TranscriptCellStatus::Streaming | TranscriptCellStatus::Complete + ) + }, + TranscriptCellKind::Assistant { status, body } => { + matches!(status, TranscriptCellStatus::Streaming) && !body.trim().is_empty() + }, + TranscriptCellKind::ToolCall { status, .. } => { + matches!(status, TranscriptCellStatus::Streaming) + }, + _ => false, + }) } } @@ -416,6 +529,7 @@ mod tests { style: WrappedLineStyle::Plain, content: "cached".to_string(), }], + None, ); state.scroll_up(); state.set_viewport_size(100, 40); @@ -441,13 +555,14 @@ mod tests { #[test] fn ticking_advances_streaming_thinking() { let mut state = CliState::new("http://127.0.0.1:5529".to_string(), None, capabilities()); - state.conversation.transcript_cells.push(TranscriptCell { - id: "thinking-1".to_string(), - expanded: false, - kind: TranscriptCellKind::Thinking { - body: "".to_string(), - status: TranscriptCellStatus::Streaming, - }, + state.conversation.control = Some(AstrcodeConversationControlStateDto { + phase: AstrcodePhaseDto::Thinking, + can_submit_prompt: true, + can_request_compact: true, + compact_pending: false, + compacting: false, + active_turn_id: Some("turn-1".to_string()), + last_compact_meta: None, }); let frame = state.thinking_playback.frame; state.advance_thinking_playback(); diff --git a/crates/cli/src/state/render.rs b/crates/cli/src/state/render.rs index 517bc809..4fe5412c 100644 --- a/crates/cli/src/state/render.rs +++ b/crates/cli/src/state/render.rs @@ -1,12 +1,66 @@ use std::time::Duration; -use super::{StreamRenderMode, WrappedLine}; +use super::StreamRenderMode; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WrappedLine { + pub style: WrappedLineStyle, + pub content: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WrappedLineStyle { + Plain, + Muted, + Divider, + HeroBorder, + HeroTitle, + HeroBody, + HeroMuted, + HeroFeedTitle, + Selection, + PromptEcho, + ThinkingLabel, + ThinkingPreview, + ThinkingBody, + ToolLabel, + ToolBody, + Notice, + ErrorText, + FooterInput, + FooterStatus, + FooterHint, + FooterKey, + PaletteItem, + PaletteSelected, +} #[derive(Debug, Clone, PartialEq, Eq, Default)] pub struct TranscriptRenderCache { pub width: u16, pub revision: u64, pub lines: Vec, + pub selected_line_range: Option<(usize, usize)>, +} + +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct FooterRenderCache { + pub width: u16, + pub lines: Vec, + pub cursor_col: u16, +} + +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct PaletteRenderCache { + pub width: u16, + pub lines: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct DirtyRegions { + pub transcript: bool, + pub footer: bool, + pub palette: bool, } #[derive(Debug, Clone, PartialEq, Eq, Default)] @@ -14,8 +68,11 @@ pub struct RenderState { pub viewport_width: u16, pub viewport_height: u16, pub transcript_revision: u64, - pub wrap_cache_revision: u64, pub transcript_cache: TranscriptRenderCache, + pub footer_cache: FooterRenderCache, + pub palette_cache: PaletteRenderCache, + pub dirty: DirtyRegions, + pub frame_dirty: bool, } impl RenderState { @@ -25,22 +82,88 @@ impl RenderState { } self.viewport_width = width; self.viewport_height = height; - self.wrap_cache_revision = self.wrap_cache_revision.saturating_add(1); self.transcript_cache = TranscriptRenderCache::default(); + self.footer_cache = FooterRenderCache::default(); + self.palette_cache = PaletteRenderCache::default(); + self.dirty = DirtyRegions { + transcript: true, + footer: true, + palette: true, + }; + self.frame_dirty = true; true } - pub fn update_transcript_cache(&mut self, width: u16, lines: Vec) { + pub fn update_transcript_cache( + &mut self, + width: u16, + lines: Vec, + selected_line_range: Option<(usize, usize)>, + ) { self.transcript_cache = TranscriptRenderCache { width, revision: self.transcript_revision, lines, + selected_line_range, }; + self.dirty.transcript = false; } pub fn invalidate_transcript_cache(&mut self) { self.transcript_revision = self.transcript_revision.saturating_add(1); self.transcript_cache = TranscriptRenderCache::default(); + self.mark_transcript_dirty(); + } + + pub fn update_footer_cache(&mut self, width: u16, lines: Vec, cursor_col: u16) { + self.footer_cache = FooterRenderCache { + width, + lines, + cursor_col, + }; + self.dirty.footer = false; + } + + pub fn invalidate_footer_cache(&mut self) { + self.footer_cache = FooterRenderCache::default(); + self.mark_footer_dirty(); + } + + pub fn update_palette_cache(&mut self, width: u16, lines: Vec) { + self.palette_cache = PaletteRenderCache { width, lines }; + self.dirty.palette = false; + } + + pub fn invalidate_palette_cache(&mut self) { + self.palette_cache = PaletteRenderCache::default(); + self.mark_palette_dirty(); + } + + pub fn mark_transcript_dirty(&mut self) { + self.dirty.transcript = true; + self.frame_dirty = true; + } + + pub fn mark_footer_dirty(&mut self) { + self.dirty.footer = true; + self.frame_dirty = true; + } + + pub fn mark_palette_dirty(&mut self) { + self.dirty.palette = true; + self.frame_dirty = true; + } + pub fn mark_all_dirty(&mut self) { + self.dirty = DirtyRegions { + transcript: true, + footer: true, + palette: true, + }; + self.frame_dirty = true; + } + + pub fn take_frame_dirty(&mut self) -> bool { + std::mem::take(&mut self.frame_dirty) } } diff --git a/crates/cli/src/state/shell.rs b/crates/cli/src/state/shell.rs index 7d5cb2ee..486392d9 100644 --- a/crates/cli/src/state/shell.rs +++ b/crates/cli/src/state/shell.rs @@ -14,7 +14,13 @@ impl Default for ShellState { Self { connection_origin: String::new(), working_dir: None, - capabilities: TerminalCapabilities::detect(), + capabilities: TerminalCapabilities { + color: crate::capability::ColorLevel::None, + glyphs: crate::capability::GlyphMode::Ascii, + alt_screen: false, + mouse: false, + bracketed_paste: false, + }, } } } diff --git a/crates/cli/src/state/thinking.rs b/crates/cli/src/state/thinking.rs index 2b825b02..04154d7d 100644 --- a/crates/cli/src/state/thinking.rs +++ b/crates/cli/src/state/thinking.rs @@ -1,17 +1,28 @@ use super::TranscriptCellStatus; const DEFAULT_THINKING_SNIPPETS: &[&str] = &[ - "整理上下文与约束边界", - "对比可行路径并压缩实现范围", - "检查已有抽象能否复用", - "收敛风险最高的变更点", - "把交互细节拆成可验证步骤", - "准备把输出整理成最小可行修改", + "先确认当前会话状态,再开始改动。", + "把变更收敛到最小但完整的一步。", + "优先复用已有抽象,而不是新增一层。", + "先压测实现里风险最高的分支。", + "让这次交互更容易验证和回归。", + "最终输出保持紧凑且可执行。", +]; + +const DEFAULT_THINKING_VERBS: &[&str] = &[ + "思考中", + "整理中", + "推敲中", + "拆解中", + "校准中", + "交叉检查中", ]; #[derive(Debug, Clone, PartialEq, Eq)] pub struct ThinkingPresentationState { - pub label: String, + pub verb: String, + pub summary: String, + pub hint: String, pub preview: String, pub expanded_body: String, pub is_playing: bool, @@ -20,12 +31,14 @@ pub struct ThinkingPresentationState { #[derive(Debug, Clone, PartialEq, Eq)] pub struct ThinkingSnippetPool { snippets: &'static [&'static str], + verbs: &'static [&'static str], } impl Default for ThinkingSnippetPool { fn default() -> Self { Self { snippets: DEFAULT_THINKING_SNIPPETS, + verbs: DEFAULT_THINKING_VERBS, } } } @@ -51,6 +64,14 @@ impl ThinkingSnippetPool { let index = ((seed as usize).wrapping_add(frame as usize)) % self.snippets.len(); self.snippets[index] } + + pub fn verb(&self, seed: u64, frame: u64) -> &'static str { + if self.verbs.is_empty() { + return "思考中"; + } + let index = ((seed as usize).wrapping_add(frame as usize * 2)) % self.verbs.len(); + self.verbs[index] + } } #[derive(Debug, Clone, PartialEq, Eq, Default)] @@ -83,14 +104,15 @@ impl ThinkingPlaybackDriver { let summary = first_non_empty_line(raw_body) .map(str::to_string) .unwrap_or_else(|| playlist[0].to_string()); + let verb = pool.verb(seed, self.frame).to_string(); let is_streaming = matches!(status, TranscriptCellStatus::Streaming); - let label = if expanded { - "Thinking · Ctrl+O 收起".to_string() + let summary_line = if expanded { + format!("{verb} · Ctrl+O 收起") } else if is_streaming { - "Thinking... · Ctrl+O 展开".to_string() + format!("{verb}… · Ctrl+O 展开") } else { - "Thinking · Ctrl+O 展开".to_string() + format!("{verb} · Ctrl+O 展开") }; let preview = if is_streaming { @@ -98,6 +120,11 @@ impl ThinkingPlaybackDriver { } else { summary }; + let hint = if is_streaming { + "提示:让当前任务自然完成,不要中断流式过程。".to_string() + } else { + "Ctrl+O 可展开完整思考内容。".to_string() + }; let expanded_body = if raw_body.trim().is_empty() { scripted_body @@ -106,7 +133,9 @@ impl ThinkingPlaybackDriver { }; ThinkingPresentationState { - label, + verb, + summary: summary_line, + hint, preview, expanded_body, is_playing: is_streaming, diff --git a/crates/cli/src/state/transcript_cell.rs b/crates/cli/src/state/transcript_cell.rs index 1df7c6bf..badf90fb 100644 --- a/crates/cli/src/state/transcript_cell.rs +++ b/crates/cli/src/state/transcript_cell.rs @@ -122,7 +122,6 @@ impl TranscriptCell { Some("工具输出已更新".to_string()) } }) - .clone() .unwrap_or_else(|| "正在执行工具调用".to_string()), status: block.status.into(), stdout: block.streams.stdout.clone(), @@ -140,7 +139,8 @@ impl TranscriptCell { id, expanded, kind: TranscriptCellKind::Error { - code: format!("{:?}", block.code), + code: enum_wire_name(&block.code) + .unwrap_or_else(|| "unknown_error".to_string()), message: block.message.clone(), }, }, @@ -148,7 +148,8 @@ impl TranscriptCell { id, expanded, kind: TranscriptCellKind::SystemNote { - note_kind: format!("{:?}", block.note_kind), + note_kind: enum_wire_name(&block.note_kind) + .unwrap_or_else(|| "system_note".to_string()), markdown: block.markdown.clone(), }, }, @@ -156,7 +157,8 @@ impl TranscriptCell { id, expanded, kind: TranscriptCellKind::ChildHandoff { - handoff_kind: format!("{:?}", block.handoff_kind), + handoff_kind: enum_wire_name(&block.handoff_kind) + .unwrap_or_else(|| "delegated".to_string()), title: block.child.title.clone(), lifecycle: block.child.lifecycle, message: block @@ -181,3 +183,13 @@ impl From for TranscriptCellStatus { } } } + +fn enum_wire_name(value: &T) -> Option +where + T: serde::Serialize, +{ + serde_json::to_value(value) + .ok()? + .as_str() + .map(|value| value.trim().to_string()) +} diff --git a/crates/cli/src/ui/cells.rs b/crates/cli/src/ui/cells.rs index 997ed021..ba071f0a 100644 --- a/crates/cli/src/ui/cells.rs +++ b/crates/cli/src/ui/cells.rs @@ -1,6 +1,8 @@ -use unicode_width::UnicodeWidthStr; +use std::borrow::Cow; -use super::theme::ThemePalette; +use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; + +use super::{theme::ThemePalette, truncate_to_width}; use crate::{ capability::TerminalCapabilities, state::{ @@ -21,7 +23,7 @@ pub trait RenderableCell { &self, width: usize, capabilities: TerminalCapabilities, - _theme: &dyn ThemePalette, + theme: &dyn ThemePalette, view: &TranscriptCellView, ) -> Vec; } @@ -31,22 +33,25 @@ impl RenderableCell for TranscriptCell { &self, width: usize, capabilities: TerminalCapabilities, - _theme: &dyn ThemePalette, + theme: &dyn ThemePalette, view: &TranscriptCellView, ) -> Vec { let width = width.max(28); match &self.kind { TranscriptCellKind::User { body } => { - render_message(body, width, capabilities, view, true) + render_message(body, width, capabilities, theme, view, true) + }, + TranscriptCellKind::Assistant { body, status } => { + let content = if matches!(status, TranscriptCellStatus::Streaming) { + format!("{body}{}", status_suffix(*status)) + } else { + body.clone() + }; + render_message(content.as_str(), width, capabilities, theme, view, false) + }, + TranscriptCellKind::Thinking { .. } => { + render_thinking_cell(width, capabilities, theme, view) }, - TranscriptCellKind::Assistant { body, status } => render_message( - format!("Astrcode{} {}", status_suffix(*status), body).trim(), - width, - capabilities, - view, - false, - ), - TranscriptCellKind::Thinking { .. } => render_thinking_cell(width, capabilities, view), TranscriptCellKind::ToolCall { tool_name, summary, @@ -58,71 +63,84 @@ impl RenderableCell for TranscriptCell { truncated, child_session_id, } => render_tool_call_cell( - tool_name, - summary, - *status, - stdout, - stderr, - error.as_deref(), - *duration_ms, - *truncated, - child_session_id.as_deref(), + ToolCallView { + tool_name, + summary, + status: *status, + stdout, + stderr, + error: error.as_deref(), + duration_ms: *duration_ms, + truncated: *truncated, + child_session_id: child_session_id.as_deref(), + }, width, capabilities, + theme, view, ), TranscriptCellKind::Error { code, message } => render_secondary_line( &format!("{code} {message}"), width, capabilities, + theme, view, WrappedLineStyle::ErrorText, ), - TranscriptCellKind::SystemNote { markdown, .. } => { - render_secondary_line(markdown, width, capabilities, view, WrappedLineStyle::Muted) - }, + TranscriptCellKind::SystemNote { markdown, .. } => render_secondary_line( + markdown, + width, + capabilities, + theme, + view, + WrappedLineStyle::Notice, + ), TranscriptCellKind::ChildHandoff { title, message, .. } => render_secondary_line( &format!("{title} · {message}"), width, capabilities, + theme, view, - WrappedLineStyle::Muted, + WrappedLineStyle::Notice, ), } } } +impl TranscriptCellView { + fn resolve_style(&self, base: WrappedLineStyle) -> WrappedLineStyle { + if self.selected { + WrappedLineStyle::Selection + } else { + base + } + } +} + fn render_message( body: &str, width: usize, capabilities: TerminalCapabilities, + theme: &dyn ThemePalette, view: &TranscriptCellView, is_user: bool, ) -> Vec { let wrapped = wrap_text(body, width.saturating_sub(4), capabilities); let mut lines = Vec::new(); for (index, line) in wrapped.into_iter().enumerate() { - let prefix = if index == 0 { - if is_user { - prompt_marker(capabilities) - } else { - assistant_marker(capabilities) - } + let prefix = if is_user { + prompt_marker(theme) + } else if index == 0 { + assistant_marker(theme) } else { - " " + " " }; lines.push(WrappedLine { - style: if view.selected { - WrappedLineStyle::Selection - } else if is_user && index == 0 { - WrappedLineStyle::UserLabel - } else if is_user { - WrappedLineStyle::UserBody - } else if index == 0 { - WrappedLineStyle::AssistantLabel + style: view.resolve_style(if is_user { + WrappedLineStyle::PromptEcho } else { - WrappedLineStyle::AssistantBody - }, + WrappedLineStyle::Plain + }), content: format!("{prefix} {line}"), }); } @@ -133,6 +151,7 @@ fn render_message( fn render_thinking_cell( width: usize, capabilities: TerminalCapabilities, + theme: &dyn ThemePalette, view: &TranscriptCellView, ) -> Vec { let Some(thinking) = &view.thinking else { @@ -141,19 +160,16 @@ fn render_thinking_cell( if !view.expanded { return vec![ WrappedLine { - style: if view.selected { - WrappedLineStyle::Selection - } else { - WrappedLineStyle::ThinkingLabel - }, - content: truncate_with_ellipsis( - format!( - "{} {} {}", - thinking_marker(capabilities), - thinking.label, - thinking.preview - ) - .as_str(), + style: view.resolve_style(WrappedLineStyle::ThinkingLabel), + content: truncate_to_width( + format!("{} {}", thinking_marker(theme), thinking.summary).as_str(), + width, + ), + }, + WrappedLine { + style: view.resolve_style(WrappedLineStyle::ThinkingPreview), + content: truncate_to_width( + format!(" {} {}", thinking_preview_prefix(theme), thinking.preview).as_str(), width, ), }, @@ -162,24 +178,20 @@ fn render_thinking_cell( } let mut lines = vec![WrappedLine { - style: if view.selected { - WrappedLineStyle::Selection - } else { - WrappedLineStyle::ThinkingLabel - }, - content: format!("{} {}", thinking_marker(capabilities), thinking.label), + style: view.resolve_style(WrappedLineStyle::ThinkingLabel), + content: format!("{} {}", thinking_marker(theme), thinking.summary), }]; + lines.push(WrappedLine { + style: view.resolve_style(WrappedLineStyle::ThinkingPreview), + content: format!(" {}", thinking.hint), + }); for line in wrap_text( thinking.expanded_body.as_str(), width.saturating_sub(2), capabilities, ) { lines.push(WrappedLine { - style: if view.selected { - WrappedLineStyle::Selection - } else { - WrappedLineStyle::ThinkingBody - }, + style: view.resolve_style(WrappedLineStyle::ThinkingBody), content: format!(" {line}"), }); } @@ -187,34 +199,34 @@ fn render_thinking_cell( lines } -#[allow(clippy::too_many_arguments)] -fn render_tool_call_cell( - tool_name: &str, - summary: &str, +struct ToolCallView<'a> { + tool_name: &'a str, + summary: &'a str, status: TranscriptCellStatus, - stdout: &str, - stderr: &str, - error: Option<&str>, + stdout: &'a str, + stderr: &'a str, + error: Option<&'a str>, duration_ms: Option, truncated: bool, - child_session_id: Option<&str>, + child_session_id: Option<&'a str>, +} + +fn render_tool_call_cell( + tool: ToolCallView<'_>, width: usize, capabilities: TerminalCapabilities, + theme: &dyn ThemePalette, view: &TranscriptCellView, ) -> Vec { let mut lines = vec![WrappedLine { - style: if view.selected { - WrappedLineStyle::Selection - } else { - WrappedLineStyle::ToolLabel - }, - content: truncate_with_ellipsis( + style: view.resolve_style(WrappedLineStyle::ToolLabel), + content: truncate_to_width( format!( "{} tool {}{} · {}", - tool_marker(capabilities), - tool_name, - status_suffix(status), - summary.trim() + tool_marker(theme), + tool.tool_name, + status_suffix(tool.status), + tool.summary.trim() ) .as_str(), width, @@ -222,38 +234,57 @@ fn render_tool_call_cell( }]; if view.expanded { - let mut sections = Vec::new(); - if let Some(duration_ms) = duration_ms { - sections.push(format!("duration {duration_ms}ms")); + let mut metadata = Vec::new(); + if let Some(duration_ms) = tool.duration_ms { + metadata.push(format!("duration {duration_ms}ms")); } - if truncated { - sections.push("output truncated".to_string()); + if tool.truncated { + metadata.push("output truncated".to_string()); } - if let Some(child_session_id) = child_session_id.filter(|value| !value.is_empty()) { - sections.push(format!("child session {child_session_id}")); + if let Some(child_session_id) = tool.child_session_id.filter(|value| !value.is_empty()) { + metadata.push(format!("child session {child_session_id}")); } - if !stdout.trim().is_empty() { - sections.push(format!("stdout\n{}", stdout.trim_end())); + if !metadata.is_empty() { + lines.push(WrappedLine { + style: view.resolve_style(WrappedLineStyle::ToolBody), + content: format!(" meta {}", metadata.join(" · ")), + }); } - if !stderr.trim().is_empty() { - sections.push(format!("stderr\n{}", stderr.trim_end())); + + if !tool.stdout.trim().is_empty() { + append_preformatted_tool_section( + &mut lines, + "stdout", + tool.stdout.trim_end(), + width, + capabilities, + theme, + view, + ); } - if let Some(error) = error.filter(|value| !value.trim().is_empty()) { - sections.push(format!("error\n{}", error.trim())); + + if !tool.stderr.trim().is_empty() { + append_preformatted_tool_section( + &mut lines, + "stderr", + tool.stderr.trim_end(), + width, + capabilities, + theme, + view, + ); } - for line in wrap_text( - sections.join("\n\n").as_str(), - width.saturating_sub(2), - capabilities, - ) { - lines.push(WrappedLine { - style: if view.selected { - WrappedLineStyle::Selection - } else { - WrappedLineStyle::ToolBody - }, - content: format!(" {line}"), - }); + + if let Some(error) = tool.error.filter(|value| !value.trim().is_empty()) { + append_preformatted_tool_section( + &mut lines, + "error", + error.trim(), + width, + capabilities, + theme, + view, + ); } } @@ -261,62 +292,98 @@ fn render_tool_call_cell( lines } +fn append_preformatted_tool_section( + lines: &mut Vec, + label: &str, + body: &str, + width: usize, + capabilities: TerminalCapabilities, + theme: &dyn ThemePalette, + view: &TranscriptCellView, +) { + let section_style = view.resolve_style(WrappedLineStyle::ToolBody); + lines.push(WrappedLine { + style: section_style, + content: format!(" {label}"), + }); + for line in render_preformatted_block(body, width.saturating_sub(4), capabilities) { + lines.push(WrappedLine { + style: section_style, + content: format!(" {} {line}", tool_block_marker(theme)), + }); + } +} + fn render_secondary_line( body: &str, width: usize, capabilities: TerminalCapabilities, + theme: &dyn ThemePalette, view: &TranscriptCellView, style: WrappedLineStyle, ) -> Vec { let mut lines = Vec::new(); for line in wrap_text(body, width.saturating_sub(2), capabilities) { lines.push(WrappedLine { - style: if view.selected { - WrappedLineStyle::Selection - } else { - style - }, - content: format!("{} {line}", secondary_marker(capabilities)), + style: view.resolve_style(style), + content: format!("{} {line}", secondary_marker(theme)), }); } lines.push(blank_line()); lines } -fn prompt_marker(capabilities: TerminalCapabilities) -> &'static str { - if capabilities.ascii_only() { - ">" - } else { - "›" - } +fn prompt_marker(theme: &dyn ThemePalette) -> &'static str { + theme.glyph("›", ">") } -fn assistant_marker(capabilities: TerminalCapabilities) -> &'static str { - if capabilities.ascii_only() { - "*" - } else { - "•" - } +fn assistant_marker(theme: &dyn ThemePalette) -> &'static str { + theme.glyph("•", "*") } -fn thinking_marker(capabilities: TerminalCapabilities) -> &'static str { - if capabilities.ascii_only() { - "*" - } else { - "✻" - } +fn thinking_marker(theme: &dyn ThemePalette) -> &'static str { + theme.glyph("∴", "*") } -fn tool_marker(capabilities: TerminalCapabilities) -> &'static str { - if capabilities.ascii_only() { - "-" - } else { - "↳" - } +fn thinking_preview_prefix(theme: &dyn ThemePalette) -> &'static str { + theme.glyph("└", "|") +} + +fn tool_marker(theme: &dyn ThemePalette) -> &'static str { + theme.glyph("↳", "-") } -fn secondary_marker(capabilities: TerminalCapabilities) -> &'static str { - if capabilities.ascii_only() { "-" } else { "·" } +fn secondary_marker(theme: &dyn ThemePalette) -> &'static str { + theme.glyph("·", "-") +} + +fn tool_block_marker(theme: &dyn ThemePalette) -> &'static str { + theme.glyph("│", "|") +} + +pub(crate) fn synthetic_thinking_lines( + theme: &dyn ThemePalette, + presentation: &ThinkingPresentationState, +) -> Vec { + vec![ + WrappedLine { + style: WrappedLineStyle::ThinkingLabel, + content: format!("{} {}", thinking_marker(theme), presentation.summary), + }, + WrappedLine { + style: WrappedLineStyle::ThinkingPreview, + content: format!( + " {} {}", + thinking_preview_prefix(theme), + presentation.preview + ), + }, + WrappedLine { + style: WrappedLineStyle::ThinkingPreview, + content: format!(" {}", presentation.hint), + }, + blank_line(), + ] } fn blank_line() -> WrappedLine { @@ -335,53 +402,476 @@ fn status_suffix(status: TranscriptCellStatus) -> &'static str { } } -fn truncate_with_ellipsis(text: &str, width: usize) -> String { - if text.chars().count() <= width { - return text.to_string(); - } - if width <= 1 { - return "…".to_string(); - } - let mut value = text.chars().take(width - 1).collect::(); - value.push('…'); - value -} - pub fn wrap_text(text: &str, width: usize, capabilities: TerminalCapabilities) -> Vec { if width == 0 { return vec![String::new()]; } - let mut lines = Vec::new(); - for paragraph in text.split('\n') { - if paragraph.is_empty() { - lines.push(String::new()); + let mut output = Vec::new(); + let source_lines = text.lines().collect::>(); + let mut index = 0; + let mut in_fence = false; + let mut fence_marker = ""; + + while index < source_lines.len() { + let line = source_lines[index]; + let trimmed = line.trim_end(); + + if trimmed.is_empty() { + output.push(String::new()); + index += 1; continue; } - let mut current = String::new(); - for word in paragraph.split_whitespace() { - let next = if current.is_empty() { - word.to_string() - } else { - format!("{current} {word}") - }; - let fits = if capabilities.ascii_only() { - next.len() <= width - } else { - UnicodeWidthStr::width(next.as_str()) <= width - }; - if fits || current.is_empty() { - current = next; - } else { + + if let Some(marker) = fence_delimiter(trimmed) { + in_fence = !in_fence; + fence_marker = if in_fence { marker } else { "" }; + output.extend(wrap_preformatted_line(trimmed, width, capabilities)); + index += 1; + continue; + } + + if in_fence { + if !fence_marker.is_empty() && trimmed.trim_start().starts_with(fence_marker) { + in_fence = false; + fence_marker = ""; + } + output.extend(wrap_preformatted_line(trimmed, width, capabilities)); + index += 1; + continue; + } + + if is_table_line(trimmed) { + let mut block = Vec::new(); + while index < source_lines.len() && is_table_line(source_lines[index].trim_end()) { + block.push(source_lines[index].trim_end()); + index += 1; + } + output.extend(render_table_block(&block, width, capabilities)); + continue; + } + + if let Some((prefix, body)) = parse_list_prefix(trimmed) { + output.extend(wrap_with_prefix( + body, + width, + capabilities, + &prefix, + &indent_like(&prefix), + )); + index += 1; + continue; + } + + if let Some((prefix, body)) = parse_quote_prefix(trimmed) { + output.extend(wrap_with_prefix( + body, + width, + capabilities, + &prefix, + &indent_like(&prefix), + )); + index += 1; + continue; + } + + if is_preformatted_line(line) { + output.extend(wrap_preformatted_line(trimmed, width, capabilities)); + index += 1; + continue; + } + + let mut paragraph = vec![trimmed.trim()]; + index += 1; + while index < source_lines.len() { + let next = source_lines[index].trim_end(); + if next.is_empty() + || fence_delimiter(next).is_some() + || is_table_line(next) + || parse_list_prefix(next).is_some() + || parse_quote_prefix(next).is_some() + || is_preformatted_line(source_lines[index]) + { + break; + } + paragraph.push(next.trim()); + index += 1; + } + output.extend(wrap_paragraph( + paragraph.join(" ").as_str(), + width, + capabilities, + )); + } + + if output.is_empty() { + output.push(String::new()); + } + output +} + +fn fence_delimiter(line: &str) -> Option<&'static str> { + let trimmed = line.trim_start(); + if trimmed.starts_with("```") { + Some("```") + } else if trimmed.starts_with("~~~") { + Some("~~~") + } else { + None + } +} + +fn is_table_line(line: &str) -> bool { + let trimmed = line.trim(); + trimmed.starts_with('|') && trimmed.ends_with('|') && trimmed.matches('|').count() >= 2 +} + +fn is_preformatted_line(line: &str) -> bool { + line.starts_with(" ") || line.starts_with('\t') +} + +fn parse_list_prefix(line: &str) -> Option<(String, &str)> { + let indent_width = line.chars().take_while(|ch| ch.is_whitespace()).count(); + let trimmed = line.trim_start(); + + for marker in ["- ", "* ", "+ "] { + if let Some(rest) = trimmed.strip_prefix(marker) { + return Some(( + format!("{}{}", " ".repeat(indent_width), marker), + rest.trim_start(), + )); + } + } + + let digits = trimmed + .chars() + .take_while(|ch| ch.is_ascii_digit()) + .collect::(); + if digits.is_empty() { + return None; + } + let remainder = &trimmed[digits.len()..]; + for punct in [". ", ") "] { + if let Some(rest) = remainder.strip_prefix(punct) { + return Some(( + format!("{}{}{}", " ".repeat(indent_width), digits, punct), + rest.trim_start(), + )); + } + } + None +} + +fn parse_quote_prefix(line: &str) -> Option<(String, &str)> { + let indent_width = line.chars().take_while(|ch| ch.is_whitespace()).count(); + let trimmed = line.trim_start(); + trimmed + .strip_prefix("> ") + .map(|rest| (format!("{}> ", " ".repeat(indent_width)), rest.trim_start())) +} + +fn indent_like(prefix: &str) -> String { + " ".repeat(display_width(prefix)) +} + +fn wrap_paragraph(text: &str, width: usize, capabilities: TerminalCapabilities) -> Vec { + wrap_with_prefix(text, width, capabilities, "", "") +} + +fn wrap_with_prefix( + text: &str, + width: usize, + capabilities: TerminalCapabilities, + first_prefix: &str, + subsequent_prefix: &str, +) -> Vec { + let mut lines = Vec::new(); + let first_prefix_width = display_width(first_prefix); + let subsequent_prefix_width = display_width(subsequent_prefix); + let first_available = width.saturating_sub(first_prefix_width).max(1); + let subsequent_available = width.saturating_sub(subsequent_prefix_width).max(1); + + let mut current = first_prefix.to_string(); + let mut current_width = first_prefix_width; + let mut current_prefix = first_prefix; + let mut current_available = first_available; + + for token in text.split_whitespace() { + for chunk in split_token_by_width(token, current_available.max(1), capabilities) { + let chunk_width = display_width(chunk.as_ref()); + let needs_space = current_width > display_width(current_prefix); + let next_width = current_width + usize::from(needs_space) + chunk_width; + if next_width <= width { + if needs_space { + current.push(' '); + current_width += 1; + } + current.push_str(chunk.as_ref()); + current_width += chunk_width; + continue; + } + + if current_width > display_width(current_prefix) { lines.push(current); - current = word.to_string(); + current = subsequent_prefix.to_string(); + current_width = subsequent_prefix_width; + current_prefix = subsequent_prefix; + current_available = subsequent_available; + } + + if current_width > 0 && current_width == display_width(current_prefix) { + current.push_str(chunk.as_ref()); + current_width += chunk_width; } } - if !current.is_empty() { - lines.push(current); + } + + if current_width > display_width(current_prefix) || lines.is_empty() { + lines.push(current); + } + lines +} + +fn wrap_preformatted_line( + line: &str, + width: usize, + capabilities: TerminalCapabilities, +) -> Vec { + let indent = line + .chars() + .take_while(|ch| ch.is_whitespace()) + .collect::(); + let content = line[indent.len()..].trim_end(); + let prefix_width = display_width(indent.as_str()); + let available = width.saturating_sub(prefix_width).max(1); + let chunks = split_preserving_width(content, available, capabilities); + if chunks.is_empty() { + return vec![indent]; + } + chunks + .into_iter() + .map(|chunk| format!("{indent}{chunk}")) + .collect() +} + +fn render_preformatted_block( + body: &str, + width: usize, + capabilities: TerminalCapabilities, +) -> Vec { + let mut lines = Vec::new(); + let source_lines = body.lines().collect::>(); + let mut index = 0; + while index < source_lines.len() { + let line = source_lines[index].trim_end(); + if is_table_line(line) { + let mut block = Vec::new(); + while index < source_lines.len() && is_table_line(source_lines[index].trim_end()) { + block.push(source_lines[index].trim_end()); + index += 1; + } + lines.extend(render_table_block(&block, width, capabilities)); + continue; } + lines.extend(wrap_preformatted_line(line, width, capabilities)); + index += 1; } if lines.is_empty() { lines.push(String::new()); } lines } + +fn render_table_block( + lines: &[&str], + width: usize, + capabilities: TerminalCapabilities, +) -> Vec { + let rows = lines + .iter() + .map(|line| parse_table_row(line)) + .collect::>(); + let col_count = rows.iter().map(Vec::len).max().unwrap_or(0); + if col_count == 0 { + return lines + .iter() + .flat_map(|line| wrap_preformatted_line(line, width, capabilities)) + .collect(); + } + + let separator_rows = rows + .iter() + .map(|row| row.iter().all(|cell| is_table_separator(cell.as_str()))) + .collect::>(); + let mut col_widths = vec![3usize; col_count]; + for (row_index, row) in rows.iter().enumerate() { + if separator_rows[row_index] { + continue; + } + for (index, cell) in row.iter().enumerate() { + col_widths[index] = col_widths[index].max(display_width(cell.as_str()).min(40)); + } + } + + let min_widths = vec![3usize; col_count]; + let separator_width = col_count * 3 + 1; + let max_budget = width.saturating_sub(separator_width); + while col_widths.iter().sum::() > max_budget { + let Some((index, _)) = col_widths + .iter() + .enumerate() + .filter(|(index, value)| **value > min_widths[*index]) + .max_by_key(|(_, value)| **value) + else { + break; + }; + col_widths[index] = col_widths[index].saturating_sub(1); + } + + rows.iter() + .enumerate() + .map(|(row_index, row)| { + if separator_rows[row_index] { + render_table_separator(&col_widths) + } else { + render_table_row(row, &col_widths) + } + }) + .collect() +} + +fn parse_table_row(line: &str) -> Vec { + line.trim() + .trim_matches('|') + .split('|') + .map(|cell| cell.trim().to_string()) + .collect() +} + +fn is_table_separator(cell: &str) -> bool { + let trimmed = cell.trim(); + !trimmed.is_empty() + && trimmed + .chars() + .all(|ch| ch == '-' || ch == ':' || ch.is_whitespace()) +} + +fn render_table_separator(col_widths: &[usize]) -> String { + let mut line = String::from("|"); + for width in col_widths { + line.push_str(&"-".repeat(width.saturating_add(2))); + line.push('|'); + } + line +} + +fn render_table_row(row: &[String], col_widths: &[usize]) -> String { + let mut line = String::from("|"); + for (index, width) in col_widths.iter().enumerate() { + let cell = row.get(index).map(String::as_str).unwrap_or(""); + line.push(' '); + line.push_str(pad_to_width(truncate_to_width(cell, *width).as_str(), *width).as_str()); + line.push(' '); + line.push('|'); + } + line +} + +fn split_token_by_width<'a>( + token: &'a str, + width: usize, + capabilities: TerminalCapabilities, +) -> Vec> { + let width = width.max(1); + if display_width(token) <= width { + return vec![Cow::Borrowed(token)]; + } + split_preserving_width(token, width, capabilities) +} + +fn split_preserving_width<'a>( + text: &'a str, + width: usize, + _capabilities: TerminalCapabilities, +) -> Vec> { + let width = width.max(1); + let mut chunks = Vec::new(); + let mut current = String::new(); + let mut current_width = 0; + + for ch in text.chars() { + let ch_width = UnicodeWidthChar::width(ch).unwrap_or(0).max(1); + if current_width + ch_width > width && !current.is_empty() { + chunks.push(Cow::Owned(current)); + current = String::new(); + current_width = 0; + } + current.push(ch); + current_width += ch_width; + } + + if !current.is_empty() { + chunks.push(Cow::Owned(current)); + } + chunks +} + +fn pad_to_width(text: &str, width: usize) -> String { + let current_width = display_width(text); + if current_width >= width { + return text.to_string(); + } + format!("{text}{}", " ".repeat(width - current_width)) +} + +fn display_width(text: &str) -> usize { + UnicodeWidthStr::width(text) +} + +#[cfg(test)] +mod tests { + use super::wrap_text; + use crate::capability::{ColorLevel, GlyphMode, TerminalCapabilities}; + + fn unicode_capabilities() -> TerminalCapabilities { + TerminalCapabilities { + color: ColorLevel::TrueColor, + glyphs: GlyphMode::Unicode, + alt_screen: false, + mouse: false, + bracketed_paste: false, + } + } + + #[test] + fn wrap_text_preserves_hanging_indent_for_lists() { + let lines = wrap_text( + "- 第一项需要被正确换行,并且后续行要和正文对齐", + 18, + unicode_capabilities(), + ); + assert!(lines[0].starts_with("- ")); + assert!(lines[1].starts_with(" ")); + } + + #[test] + fn wrap_text_breaks_cjk_without_spaces() { + let lines = wrap_text( + "这是一个没有空格但是需要自动换行的长句子", + 10, + unicode_capabilities(), + ); + assert!(lines.len() > 1); + assert!(lines.iter().all(|line| !line.is_empty())); + } + + #[test] + fn wrap_text_formats_markdown_tables() { + let lines = wrap_text( + "| 工具 | 说明 |\n| --- | --- |\n| reviewnow | 代码审查 |\n| git-commit | 自动提交 |", + 32, + unicode_capabilities(), + ); + assert!(lines.iter().any(|line| line.contains("| 工具"))); + assert!(lines.iter().any(|line| line.contains("---"))); + } +} diff --git a/crates/cli/src/ui/footer.rs b/crates/cli/src/ui/footer.rs index 1fe8691d..ef40b312 100644 --- a/crates/cli/src/ui/footer.rs +++ b/crates/cli/src/ui/footer.rs @@ -1,106 +1,168 @@ -use super::ThemePalette; -use crate::state::{CliState, PaletteState, WrappedLine, WrappedLineStyle}; +use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; -pub fn footer_lines(state: &CliState, width: u16) -> Vec { - let theme = super::CodexTheme::new(state.shell.capabilities); +use super::{ThemePalette, truncate_to_width}; +use crate::state::{ + CliState, ComposerState, PaletteState, PaneFocus, WrappedLine, WrappedLineStyle, +}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FooterRenderOutput { + pub lines: Vec, + pub cursor_col: u16, +} + +pub fn footer_lines(state: &CliState, width: u16, theme: &dyn ThemePalette) -> FooterRenderOutput { let width = usize::from(width.max(24)); + let prompt_state = visible_input_state(&state.interaction.composer, width.saturating_sub(4)); + let input_focused = matches!( + state.interaction.pane_focus, + PaneFocus::Composer | PaneFocus::Palette + ); let prompt = if state.interaction.composer.is_empty() { - "在这里输入,或键入 /".to_string() + if input_focused { + String::new() + } else { + "在这里输入,或键入 /".to_string() + } } else { - visible_input( - state.interaction.composer.input.as_str(), - width.saturating_sub(4), - ) + prompt_state.visible }; - vec![ - WrappedLine { - style: if state.interaction.composer.is_empty() { - WrappedLineStyle::Muted - } else { - WrappedLineStyle::FooterInput + FooterRenderOutput { + lines: vec![ + WrappedLine { + style: if state.interaction.status.is_error { + WrappedLineStyle::ErrorText + } else { + WrappedLineStyle::FooterStatus + }, + content: truncate_to_width(status_line(state).as_str(), width), }, - content: format!("{} {}", theme.glyph("›", ">"), prompt), - }, - WrappedLine { - style: if state.interaction.status.is_error { - WrappedLineStyle::ErrorText - } else { - WrappedLineStyle::FooterStatus + WrappedLine { + style: if state.interaction.composer.is_empty() { + if input_focused { + WrappedLineStyle::FooterInput + } else { + WrappedLineStyle::Muted + } + } else { + WrappedLineStyle::FooterInput + }, + content: format!("{} {}", theme.glyph("›", ">"), prompt), }, - content: truncate(footer_status(state).as_str(), width), - }, - ] + WrappedLine { + style: WrappedLineStyle::FooterHint, + content: truncate_to_width(footer_hint(state).as_str(), width), + }, + ], + cursor_col: (2 + prompt_state.cursor_columns.min(width.saturating_sub(3))) as u16, + } } -fn footer_status(state: &CliState) -> String { +fn status_line(state: &CliState) -> String { if state.interaction.status.is_error { return state.interaction.status.message.clone(); } match &state.interaction.palette { - PaletteState::Slash(palette) => palette - .items - .get(palette.selected) - .map(|item| { - format!( - "{} · {} · Enter 执行 · Esc 关闭", - item.title, item.description - ) - }) - .unwrap_or_else(|| "/ commands · 没有匹配项 · Esc 关闭".to_string()), - PaletteState::Resume(resume) => resume - .items - .get(resume.selected) - .map(|item| { - format!( - "{} · {} · Enter 切换 · Esc 关闭", - item.title, item.working_dir - ) - }) - .unwrap_or_else(|| "/resume · 没有匹配会话 · Esc 关闭".to_string()), - PaletteState::Closed if state.interaction.composer.line_count() > 1 => format!( - "{} 行输入 · Shift+Enter 换行 · Ctrl+O thinking", - state.interaction.composer.line_count() + PaletteState::Slash(palette) => format!( + "/ commands · {} 条候选 · ↑↓ 选择 · Enter 执行 · Esc 关闭", + palette.items.len() + ), + PaletteState::Resume(resume) => format!( + "/resume · {} 条会话 · ↑↓ 选择 · Enter 切换 · Esc 关闭", + resume.items.len() ), PaletteState::Closed => { - let phase = state - .active_phase() - .map(|phase| format!("{phase:?}").to_lowercase()) - .unwrap_or_else(|| "idle".to_string()); - let message = state.interaction.status.message.as_str(); - if message.is_empty() || message == "ready" { - format!("{phase} · Enter 发送 · / commands") + let status = state.interaction.status.message.trim(); + if status.is_empty() || status == "ready" { + String::new() } else { - format!("{message} · {phase}") + status.to_string() } }, } } -fn visible_input(input: &str, width: usize) -> String { - let line = input.lines().last().unwrap_or_default(); - if line.chars().count() <= width { - return line.to_string(); +fn footer_hint(state: &CliState) -> String { + if !matches!(state.interaction.palette, PaletteState::Closed) { + return "Tab 切换焦点 · Esc 关闭 palette · Ctrl+O thinking".to_string(); + } + + let session = state + .conversation + .active_session_title + .as_deref() + .filter(|title| !title.trim().is_empty()) + .unwrap_or("新会话"); + let phase = state + .active_phase() + .map(|phase| format!("{phase:?}").to_lowercase()) + .unwrap_or_else(|| "idle".to_string()); + + if state.interaction.composer.line_count() > 1 { + format!( + "{session} · {phase} · {} 行输入 · Shift+Enter 换行 · Ctrl+O thinking", + state.interaction.composer.line_count() + ) + } else { + format!("{session} · {phase} · Enter 发送 · / commands · Ctrl+O thinking") } - line.chars() - .rev() - .take(width.saturating_sub(1)) - .collect::>() - .into_iter() - .rev() - .collect::() } -fn truncate(text: &str, width: usize) -> String { - let count = text.chars().count(); - if count <= width { - return text.to_string(); +#[derive(Debug, Clone, PartialEq, Eq)] +struct VisibleInputState { + visible: String, + cursor_columns: usize, +} + +fn visible_input_state(composer: &ComposerState, width: usize) -> VisibleInputState { + let input = composer.as_str(); + let cursor = composer.cursor.min(input.len()); + let line_start = input + .get(..cursor) + .and_then(|value| value.rfind('\n').map(|index| index + 1)) + .unwrap_or(0); + let line_end = input + .get(cursor..) + .and_then(|value| value.find('\n').map(|index| cursor + index)) + .unwrap_or(input.len()); + let line = &input[line_start..line_end]; + let cursor_in_line = cursor.saturating_sub(line_start); + let before_cursor = &line[..cursor_in_line]; + + if UnicodeWidthStr::width(line) <= width { + return VisibleInputState { + visible: line.to_string(), + cursor_columns: UnicodeWidthStr::width(before_cursor), + }; + } + + let left_context_budget = width.saturating_mul(2) / 3; + let mut visible_before = String::new(); + let mut visible_before_width = 0; + for ch in before_cursor.chars().rev() { + let ch_width = UnicodeWidthChar::width(ch).unwrap_or(0); + if visible_before_width + ch_width > left_context_budget && !visible_before.is_empty() { + break; + } + visible_before.insert(0, ch); + visible_before_width += ch_width; } - if width <= 1 { - return "…".to_string(); + + let mut visible = visible_before.clone(); + let mut visible_width = visible_before_width; + for ch in line[cursor_in_line..].chars() { + let ch_width = UnicodeWidthChar::width(ch).unwrap_or(0); + if visible_width + ch_width > width { + break; + } + visible.push(ch); + visible_width += ch_width; + } + + VisibleInputState { + cursor_columns: visible_before_width, + visible, } - let mut value = text.chars().take(width - 1).collect::(); - value.push('…'); - value } diff --git a/crates/cli/src/ui/hero.rs b/crates/cli/src/ui/hero.rs new file mode 100644 index 00000000..568a3c05 --- /dev/null +++ b/crates/cli/src/ui/hero.rs @@ -0,0 +1,280 @@ +use unicode_width::UnicodeWidthStr; + +use super::{ThemePalette, cells::wrap_text, truncate_to_width}; +use crate::{ + capability::TerminalCapabilities, + state::{CliState, WrappedLine, WrappedLineStyle}, +}; + +const MIN_CARD_WIDTH: usize = 44; +const HORIZONTAL_LAYOUT_WIDTH: usize = 70; + +pub fn hero_lines(state: &CliState, width: u16, theme: &dyn ThemePalette) -> Vec { + let width = usize::from(width.max(MIN_CARD_WIDTH as u16)); + let session_title = state + .conversation + .active_session_title + .as_deref() + .filter(|title| !title.trim().is_empty()) + .unwrap_or("Astrcode workspace"); + let working_dir = state + .shell + .working_dir + .as_ref() + .map(|path| path.display().to_string()) + .unwrap_or_else(|| "未附加工作目录".to_string()); + let phase = state + .active_phase() + .map(|phase| format!("{phase:?}").to_lowercase()) + .unwrap_or_else(|| "idle".to_string()); + let recent_sessions = state + .conversation + .sessions + .iter() + .filter(|session| { + Some(session.session_id.as_str()) != state.conversation.active_session_id.as_deref() + }) + .take(3) + .map(|session| { + if session.title.trim().is_empty() { + session.display_name.clone() + } else { + session.title.clone() + } + }) + .collect::>(); + + let title = format!(" Astrcode v{} ", env!("CARGO_PKG_VERSION")); + let context = HeroContext { + width, + title: title.as_str(), + session_title, + working_dir: working_dir.as_str(), + phase: phase.as_str(), + recent_sessions: &recent_sessions, + capabilities: state.shell.capabilities, + theme, + }; + let mut lines = if width >= HORIZONTAL_LAYOUT_WIDTH { + horizontal_card(&context) + } else { + compact_card(&context) + }; + lines.push(WrappedLine { + style: WrappedLineStyle::Plain, + content: String::new(), + }); + lines +} + +struct HeroContext<'a> { + width: usize, + title: &'a str, + session_title: &'a str, + working_dir: &'a str, + phase: &'a str, + recent_sessions: &'a [String], + capabilities: TerminalCapabilities, + theme: &'a dyn ThemePalette, +} + +fn horizontal_card(context: &HeroContext<'_>) -> Vec { + let inner_width = context.width.saturating_sub(2).max(MIN_CARD_WIDTH - 2); + let left_width = (inner_width / 2).clamp(24, 38); + let right_width = inner_width.saturating_sub(left_width + 1); + + let mut rows = vec![ + two_col_row( + "Welcome back!", + "使用提示", + left_width, + right_width, + WrappedLineStyle::HeroTitle, + ), + two_col_row( + context.session_title, + "输入 / 打开 commands", + left_width, + right_width, + WrappedLineStyle::HeroBody, + ), + two_col_row( + " /\\_/\\\\", + "Tab 在 transcript / composer 间切换", + left_width, + right_width, + WrappedLineStyle::HeroBody, + ), + two_col_row( + " .-\" \"-.", + "Ctrl+O 展开或收起 thinking", + left_width, + right_width, + WrappedLineStyle::HeroBody, + ), + two_col_row( + format!("phase · {}", context.phase).as_str(), + "最近活动", + left_width, + right_width, + WrappedLineStyle::HeroFeedTitle, + ), + ]; + + let cwd_lines = wrap_text(context.working_dir, left_width, context.capabilities); + let activity_lines = if context.recent_sessions.is_empty() { + vec!["暂无最近会话".to_string(), "/resume 查看更多".to_string()] + } else { + let mut items = context.recent_sessions.to_vec(); + items.push("/resume 查看更多".to_string()); + items + }; + let line_count = cwd_lines.len().max(activity_lines.len()); + for index in 0..line_count { + rows.push(two_col_row( + cwd_lines.get(index).map(String::as_str).unwrap_or(""), + activity_lines.get(index).map(String::as_str).unwrap_or(""), + left_width, + right_width, + WrappedLineStyle::HeroMuted, + )); + } + + framed_rows(rows, context.width, context.title, context.theme) +} + +fn compact_card(context: &HeroContext<'_>) -> Vec { + let inner_width = context.width.saturating_sub(2).max(MIN_CARD_WIDTH - 2); + let mut rows = vec![ + WrappedLine { + style: WrappedLineStyle::HeroTitle, + content: pad_to_width("Welcome back!", inner_width), + }, + WrappedLine { + style: WrappedLineStyle::HeroBody, + content: pad_to_width(context.session_title, inner_width), + }, + WrappedLine { + style: WrappedLineStyle::HeroBody, + content: pad_to_width(format!("phase · {}", context.phase).as_str(), inner_width), + }, + ]; + + for line in wrap_text(context.working_dir, inner_width, context.capabilities) { + rows.push(WrappedLine { + style: WrappedLineStyle::HeroMuted, + content: pad_to_width(line.as_str(), inner_width), + }); + } + + rows.push(WrappedLine { + style: WrappedLineStyle::HeroFeedTitle, + content: pad_to_width("使用提示", inner_width), + }); + for tip in [ + "输入 / 打开 commands", + "Ctrl+O 展开或收起 thinking", + "Tab 在 transcript / composer 间切换", + ] { + rows.push(WrappedLine { + style: WrappedLineStyle::HeroBody, + content: pad_to_width(tip, inner_width), + }); + } + rows.push(WrappedLine { + style: WrappedLineStyle::HeroFeedTitle, + content: pad_to_width("最近活动", inner_width), + }); + if context.recent_sessions.is_empty() { + rows.push(WrappedLine { + style: WrappedLineStyle::HeroMuted, + content: pad_to_width("暂无最近会话", inner_width), + }); + } else { + for item in context.recent_sessions.iter().take(3) { + rows.push(WrappedLine { + style: WrappedLineStyle::HeroMuted, + content: pad_to_width(item.as_str(), inner_width), + }); + } + } + rows.push(WrappedLine { + style: WrappedLineStyle::HeroMuted, + content: pad_to_width("/resume 查看更多", inner_width), + }); + + framed_rows(rows, context.width, context.title, context.theme) +} + +fn framed_rows( + rows: Vec, + width: usize, + title: &str, + theme: &dyn ThemePalette, +) -> Vec { + let mut lines = vec![WrappedLine { + style: WrappedLineStyle::HeroBorder, + content: frame_top(width, title, theme), + }]; + let vertical = theme.glyph("│", "|"); + for row in rows { + lines.push(WrappedLine { + style: row.style, + content: format!("{vertical}{}{vertical}", row.content), + }); + } + lines.push(WrappedLine { + style: WrappedLineStyle::HeroBorder, + content: frame_bottom(width, theme), + }); + lines +} + +fn two_col_row( + left: &str, + right: &str, + left_width: usize, + right_width: usize, + style: WrappedLineStyle, +) -> WrappedLine { + WrappedLine { + style, + content: format!( + "{}│{}", + pad_to_width(left, left_width), + pad_to_width(right, right_width) + ), + } +} + +fn frame_top(width: usize, title: &str, theme: &dyn ThemePalette) -> String { + let left = theme.glyph("╭", "+"); + let right = theme.glyph("╮", "+"); + let horizontal = theme.glyph("─", "-"); + let inner_width = width.saturating_sub(2); + let title_width = UnicodeWidthStr::width(title); + if title_width >= inner_width { + return format!("{left}{}{right}", truncate_to_width(title, inner_width)); + } + let remaining = inner_width.saturating_sub(title_width); + format!("{left}{title}{}{right}", horizontal.repeat(remaining)) +} + +fn frame_bottom(width: usize, theme: &dyn ThemePalette) -> String { + let left = theme.glyph("╰", "+"); + let right = theme.glyph("╯", "+"); + let horizontal = theme.glyph("─", "-"); + format!( + "{left}{}{right}", + horizontal.repeat(width.saturating_sub(2)) + ) +} + +fn pad_to_width(text: &str, width: usize) -> String { + let value = truncate_to_width(text, width); + let current_width = UnicodeWidthStr::width(value.as_str()); + if current_width >= width { + return value; + } + format!("{value}{}", " ".repeat(width - current_width)) +} diff --git a/crates/cli/src/ui/mod.rs b/crates/cli/src/ui/mod.rs index 31202c65..80006e1f 100644 --- a/crates/cli/src/ui/mod.rs +++ b/crates/cli/src/ui/mod.rs @@ -1,19 +1,22 @@ pub mod cells; mod footer; +mod hero; mod palette; +mod text; mod theme; mod transcript; -pub use footer::footer_lines; +pub use footer::{FooterRenderOutput, footer_lines}; +pub use hero::hero_lines; pub use palette::{palette_lines, palette_visible}; use ratatui::text::{Line, Span}; +pub use text::truncate_to_width; pub use theme::{CodexTheme, ThemePalette}; pub use transcript::transcript_lines; -use crate::{capability::TerminalCapabilities, state::WrappedLine}; +use crate::state::WrappedLine; -pub fn line_to_ratatui(line: &WrappedLine, capabilities: TerminalCapabilities) -> Line<'static> { - let theme = CodexTheme::new(capabilities); +pub fn line_to_ratatui(line: &WrappedLine, theme: &CodexTheme) -> Line<'static> { Line::from(Span::styled( line.content.clone(), theme.line_style(line.style), diff --git a/crates/cli/src/ui/palette.rs b/crates/cli/src/ui/palette.rs index c9d95fd5..4687ec77 100644 --- a/crates/cli/src/ui/palette.rs +++ b/crates/cli/src/ui/palette.rs @@ -1,6 +1,8 @@ -use super::ThemePalette; +use super::{ThemePalette, truncate_to_width}; use crate::state::{PaletteState, WrappedLine, WrappedLineStyle}; +const MAX_VISIBLE_ITEMS: usize = 5; + pub fn palette_lines( palette: &PaletteState, width: usize, @@ -8,88 +10,22 @@ pub fn palette_lines( ) -> Vec { match palette { PaletteState::Closed => Vec::new(), - PaletteState::Slash(slash) => { - let mut lines = vec![WrappedLine { - style: WrappedLineStyle::PaletteTitle, - content: format!( - "{} {}", - theme.glyph("/", "/"), - if slash.query.is_empty() { - "commands".to_string() - } else { - format!("commands · {}", slash.query) - } - ), - }]; - if slash.items.is_empty() { - lines.push(WrappedLine { - style: WrappedLineStyle::Muted, - content: " 没有匹配的命令".to_string(), - }); - return lines; - } - for (absolute_index, item) in visible_window(&slash.items, slash.selected, 8) { - lines.push(WrappedLine { - style: if absolute_index == slash.selected { - WrappedLineStyle::Selection - } else { - WrappedLineStyle::PaletteItem - }, - content: candidate_line( - if absolute_index == slash.selected { - theme.glyph("›", ">") - } else { - " " - }, - item.title.as_str(), - item.description.as_str(), - width, - ), - }); - } - lines - }, - PaletteState::Resume(resume) => { - let mut lines = vec![WrappedLine { - style: WrappedLineStyle::PaletteTitle, - content: format!( - "{} {}", - theme.glyph("/", "/"), - if resume.query.is_empty() { - "resume".to_string() - } else { - format!("resume · {}", resume.query) - } - ), - }]; - if resume.items.is_empty() { - lines.push(WrappedLine { - style: WrappedLineStyle::Muted, - content: " 没有匹配的会话".to_string(), - }); - return lines; - } - for (absolute_index, item) in visible_window(&resume.items, resume.selected, 8) { - lines.push(WrappedLine { - style: if absolute_index == resume.selected { - WrappedLineStyle::Selection - } else { - WrappedLineStyle::PaletteItem - }, - content: candidate_line( - if absolute_index == resume.selected { - theme.glyph("›", ">") - } else { - " " - }, - item.title.as_str(), - item.working_dir.as_str(), - width, - ), - }); - } - lines - }, + PaletteState::Slash(slash) => render_palette_items( + &slash.items, + slash.selected, + width, + theme, + " 没有匹配的命令", + |item| (item.title.as_str(), item.description.as_str()), + ), + PaletteState::Resume(resume) => render_palette_items( + &resume.items, + resume.selected, + width, + theme, + " 没有匹配的会话", + |item| (item.title.as_str(), item.working_dir.as_str()), + ), } } @@ -119,31 +55,63 @@ fn visible_window<'a, T>(items: &'a [T], selected: usize, max_items: usize) -> V fn candidate_line(prefix: &str, title: &str, meta: &str, width: usize) -> String { let available = width.saturating_sub(2); if meta.trim().is_empty() { - return truncate_with_ellipsis(format!("{prefix} {title}").as_str(), available); + return truncate_to_width(format!("{prefix} {title}").as_str(), available); } - let meta_text = truncate_with_ellipsis(meta.trim(), available.saturating_mul(3) / 5); + let available_meta = available.saturating_mul(3) / 5; + let meta_text = truncate_to_width(meta.trim(), available_meta.max(8)); let title_budget = available - .saturating_sub(meta_text.chars().count()) + .saturating_sub(unicode_width::UnicodeWidthStr::width(meta_text.as_str())) .saturating_sub(3) - .max(8); - let title_text = truncate_with_ellipsis(title.trim(), title_budget); - truncate_with_ellipsis( - format!("{prefix} {title_text} · {meta_text}").as_str(), + .max(10); + let title_text = truncate_to_width(title.trim(), title_budget); + truncate_to_width( + format!("{prefix} {title_text} — {meta_text}").as_str(), available, ) } -fn truncate_with_ellipsis(text: &str, width: usize) -> String { - if text.chars().count() <= width { - return text.to_string(); - } - if width <= 1 { - return "…".to_string(); +fn render_palette_items( + items: &[T], + selected: usize, + width: usize, + theme: &dyn ThemePalette, + empty_message: &str, + meta: F, +) -> Vec +where + F: Fn(&T) -> (&str, &str), +{ + if items.is_empty() { + return vec![WrappedLine { + style: WrappedLineStyle::Muted, + content: empty_message.to_string(), + }]; } - let mut truncated = text.chars().take(width - 1).collect::(); - truncated.push('…'); - truncated + + visible_window(items, selected, MAX_VISIBLE_ITEMS) + .into_iter() + .map(|(absolute_index, item)| { + let (title, details) = meta(item); + WrappedLine { + style: if absolute_index == selected { + WrappedLineStyle::PaletteSelected + } else { + WrappedLineStyle::PaletteItem + }, + content: candidate_line( + if absolute_index == selected { + theme.glyph("›", ">") + } else { + " " + }, + title, + details, + width, + ), + } + }) + .collect() } #[cfg(test)] diff --git a/crates/cli/src/ui/text.rs b/crates/cli/src/ui/text.rs new file mode 100644 index 00000000..69cfc16c --- /dev/null +++ b/crates/cli/src/ui/text.rs @@ -0,0 +1,26 @@ +use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; + +pub fn truncate_to_width(text: &str, width: usize) -> String { + if UnicodeWidthStr::width(text) <= width { + return text.to_string(); + } + if width == 0 { + return String::new(); + } + if width == 1 { + return "…".to_string(); + } + + let mut truncated = String::new(); + let mut used = 0; + for ch in text.chars() { + let ch_width = UnicodeWidthChar::width(ch).unwrap_or(0); + if used + ch_width > width.saturating_sub(1) { + break; + } + truncated.push(ch); + used += ch_width; + } + truncated.push('…'); + truncated +} diff --git a/crates/cli/src/ui/theme.rs b/crates/cli/src/ui/theme.rs index 2c3b102f..54f9373a 100644 --- a/crates/cli/src/ui/theme.rs +++ b/crates/cli/src/ui/theme.rs @@ -21,15 +21,15 @@ impl CodexTheme { Self { capabilities } } - pub fn app_background(self) -> Style { + pub fn app_background(&self) -> Style { Style::default().bg(self.bg()) } - pub fn menu_block_style(self) -> Style { - Style::default().bg(self.surface()).fg(self.text_primary()) + pub fn menu_block_style(&self) -> Style { + Style::default().bg(self.bg()).fg(self.text_primary()) } - fn bg(self) -> Color { + fn bg(&self) -> Color { match self.capabilities.color { ColorLevel::TrueColor => Color::Rgb(26, 24, 22), ColorLevel::Ansi16 => Color::Black, @@ -37,70 +37,70 @@ impl CodexTheme { } } - fn surface(self) -> Color { + fn surface_alt(&self) -> Color { match self.capabilities.color { - ColorLevel::TrueColor => Color::Rgb(35, 32, 29), + ColorLevel::TrueColor => Color::Rgb(56, 52, 48), ColorLevel::Ansi16 => Color::DarkGray, ColorLevel::None => Color::Reset, } } - fn surface_alt(self) -> Color { - match self.capabilities.color { - ColorLevel::TrueColor => Color::Rgb(48, 43, 37), - ColorLevel::Ansi16 => Color::DarkGray, - ColorLevel::None => Color::Reset, - } - } - - fn accent(self) -> Color { + fn accent(&self) -> Color { match self.capabilities.color { ColorLevel::TrueColor => Color::Rgb(224, 128, 82), _ => Color::Yellow, } } - fn accent_soft(self) -> Color { + fn accent_soft(&self) -> Color { match self.capabilities.color { ColorLevel::TrueColor => Color::Rgb(196, 124, 88), _ => Color::Yellow, } } - fn thinking(self) -> Color { + fn thinking(&self) -> Color { match self.capabilities.color { ColorLevel::TrueColor => Color::Rgb(241, 151, 104), _ => Color::Yellow, } } - fn text_primary(self) -> Color { + fn text_primary(&self) -> Color { match self.capabilities.color { ColorLevel::TrueColor => Color::Rgb(237, 229, 219), _ => Color::White, } } - fn text_secondary(self) -> Color { + fn text_secondary(&self) -> Color { match self.capabilities.color { - ColorLevel::TrueColor => Color::Rgb(186, 176, 163), + ColorLevel::TrueColor => Color::Rgb(196, 186, 173), _ => Color::Gray, } } - fn text_muted(self) -> Color { + fn text_muted(&self) -> Color { match self.capabilities.color { ColorLevel::TrueColor => Color::Rgb(136, 126, 114), _ => Color::DarkGray, } } - fn error(self) -> Color { + fn error(&self) -> Color { match self.capabilities.color { ColorLevel::TrueColor => Color::Rgb(227, 111, 111), _ => Color::Red, } } + + fn selection(&self) -> Color { + match self.capabilities.color { + ColorLevel::TrueColor => Color::Rgb(70, 65, 60), + ColorLevel::Ansi16 => Color::DarkGray, + ColorLevel::None => Color::Reset, + } + } } impl ThemePalette for CodexTheme { @@ -108,20 +108,31 @@ impl ThemePalette for CodexTheme { let base = Style::default(); if matches!(self.capabilities.color, ColorLevel::None) { return match style { + WrappedLineStyle::Plain + | WrappedLineStyle::HeroBorder + | WrappedLineStyle::HeroBody + | WrappedLineStyle::HeroFeedTitle + | WrappedLineStyle::ThinkingBody + | WrappedLineStyle::ToolBody + | WrappedLineStyle::Notice + | WrappedLineStyle::PaletteItem => base, WrappedLineStyle::Selection - | WrappedLineStyle::UserLabel - | WrappedLineStyle::AssistantLabel + | WrappedLineStyle::HeroTitle + | WrappedLineStyle::PromptEcho | WrappedLineStyle::ToolLabel + | WrappedLineStyle::ErrorText | WrappedLineStyle::FooterInput - | WrappedLineStyle::PaletteTitle => base.add_modifier(Modifier::BOLD), + | WrappedLineStyle::FooterKey + | WrappedLineStyle::PaletteSelected => base.add_modifier(Modifier::BOLD), WrappedLineStyle::ThinkingLabel => { base.add_modifier(Modifier::BOLD | Modifier::ITALIC) }, WrappedLineStyle::Muted | WrappedLineStyle::Divider | WrappedLineStyle::FooterStatus - | WrappedLineStyle::PaletteMeta => base.add_modifier(Modifier::DIM), - _ => base, + | WrappedLineStyle::FooterHint + | WrappedLineStyle::HeroMuted + | WrappedLineStyle::ThinkingPreview => base.add_modifier(Modifier::DIM), }; } @@ -130,31 +141,41 @@ impl ThemePalette for CodexTheme { WrappedLineStyle::Muted | WrappedLineStyle::Divider | WrappedLineStyle::FooterStatus - | WrappedLineStyle::PaletteMeta => base.fg(self.text_muted()), - WrappedLineStyle::Accent | WrappedLineStyle::PaletteTitle => { + | WrappedLineStyle::FooterHint + | WrappedLineStyle::HeroMuted + | WrappedLineStyle::ThinkingPreview => base.fg(self.text_muted()), + WrappedLineStyle::HeroTitle => { base.fg(self.accent()).add_modifier(Modifier::BOLD) }, + WrappedLineStyle::HeroBorder => base.fg(self.accent_soft()), + WrappedLineStyle::HeroBody => base.fg(self.text_primary()), + WrappedLineStyle::HeroFeedTitle => { + base.fg(self.accent_soft()).add_modifier(Modifier::BOLD) + }, WrappedLineStyle::Selection => base + .fg(self.text_primary()) + .bg(self.selection()) + .add_modifier(Modifier::BOLD), + WrappedLineStyle::PromptEcho => base .fg(self.text_primary()) .bg(self.surface_alt()) .add_modifier(Modifier::BOLD), - WrappedLineStyle::UserLabel => base.fg(self.accent_soft()).add_modifier(Modifier::BOLD), - WrappedLineStyle::UserBody => base.fg(self.text_primary()), - WrappedLineStyle::AssistantLabel => { - base.fg(self.text_secondary()).add_modifier(Modifier::BOLD) - }, - WrappedLineStyle::AssistantBody => base.fg(self.text_primary()), WrappedLineStyle::ThinkingLabel => base .fg(self.thinking()) .add_modifier(Modifier::ITALIC | Modifier::BOLD), WrappedLineStyle::ThinkingBody => base.fg(self.text_secondary()), WrappedLineStyle::ToolLabel => base.fg(self.accent_soft()).add_modifier(Modifier::BOLD), WrappedLineStyle::ToolBody => base.fg(self.text_secondary()), + WrappedLineStyle::Notice => base.fg(self.text_secondary()), WrappedLineStyle::ErrorText => base.fg(self.error()).add_modifier(Modifier::BOLD), WrappedLineStyle::FooterInput => { base.fg(self.text_primary()).add_modifier(Modifier::BOLD) }, - WrappedLineStyle::PaletteItem => base.fg(self.text_primary()), + WrappedLineStyle::FooterKey => base.fg(self.accent_soft()).add_modifier(Modifier::BOLD), + WrappedLineStyle::PaletteItem => base.fg(self.text_secondary()), + WrappedLineStyle::PaletteSelected => { + base.fg(self.accent()).add_modifier(Modifier::BOLD) + }, } } diff --git a/crates/cli/src/ui/transcript.rs b/crates/cli/src/ui/transcript.rs index c57c05c3..7a88cd56 100644 --- a/crates/cli/src/ui/transcript.rs +++ b/crates/cli/src/ui/transcript.rs @@ -1,8 +1,10 @@ +use astrcode_client::AstrcodePhaseDto; + use super::{ ThemePalette, - cells::{RenderableCell, TranscriptCellView}, + cells::{RenderableCell, TranscriptCellView, synthetic_thinking_lines}, }; -use crate::state::{CliState, WrappedLine, WrappedLineStyle}; +use crate::state::{CliState, TranscriptCellStatus, WrappedLine, WrappedLineStyle}; #[derive(Debug, Clone, PartialEq, Eq)] pub struct TranscriptRenderOutput { @@ -10,11 +12,15 @@ pub struct TranscriptRenderOutput { pub selected_line_range: Option<(usize, usize)>, } -pub fn transcript_lines(state: &CliState, width: u16) -> TranscriptRenderOutput { - let theme = super::CodexTheme::new(state.shell.capabilities); +pub fn transcript_lines( + state: &CliState, + width: u16, + theme: &dyn ThemePalette, +) -> TranscriptRenderOutput { let width = usize::from(width.max(28)); let mut lines = Vec::new(); let mut selected_line_range = None; + let transcript_cells = state.transcript_cells(); if let Some(banner) = &state.conversation.banner { lines.push(WrappedLine { style: WrappedLineStyle::ErrorText, @@ -29,26 +35,10 @@ pub fn transcript_lines(state: &CliState, width: u16) -> TranscriptRenderOutput content: String::new(), }); } - if state.conversation.transcript_cells.is_empty() { - lines.push(WrappedLine { - style: WrappedLineStyle::Muted, - content: format!("{} Astrcode workspace", theme.glyph("•", "*")), - }); - lines.push(WrappedLine { - style: WrappedLineStyle::Muted, - content: " 输入消息开始,或输入 / commands。".to_string(), - }); - lines.push(WrappedLine { - style: WrappedLineStyle::Muted, - content: " Tab 切换 transcript / composer,Ctrl+O 展开 thinking。".to_string(), - }); - return TranscriptRenderOutput { - lines, - selected_line_range: None, - }; - } - for (index, cell) in state.conversation.transcript_cells.iter().enumerate() { + lines.extend(super::hero_lines(state, width as u16, theme)); + + for (index, cell) in transcript_cells.iter().enumerate() { let line_start = lines.len(); let view = TranscriptCellView { selected: matches!( @@ -69,7 +59,7 @@ pub fn transcript_lines(state: &CliState, width: u16) -> TranscriptRenderOutput _ => None, }, }; - let rendered = cell.render_lines(width, state.shell.capabilities, &theme, &view); + let rendered = cell.render_lines(width, state.shell.capabilities, theme, &view); lines.extend(rendered); if view.selected { let line_end = lines.len().saturating_sub(1); @@ -77,8 +67,58 @@ pub fn transcript_lines(state: &CliState, width: u16) -> TranscriptRenderOutput } } + if should_render_synthetic_thinking(state) { + let presentation = state.thinking_playback.present( + &state.thinking_pool, + state + .conversation + .control + .as_ref() + .and_then(|control| control.active_turn_id.as_deref()) + .unwrap_or("active-thinking"), + "", + TranscriptCellStatus::Streaming, + false, + ); + lines.extend(synthetic_thinking_lines(theme, &presentation)); + } + TranscriptRenderOutput { lines, selected_line_range, } } + +fn should_render_synthetic_thinking(state: &CliState) -> bool { + let Some(control) = &state.conversation.control else { + return false; + }; + if control.active_turn_id.is_none() { + return false; + } + if !matches!( + control.phase, + AstrcodePhaseDto::Thinking | AstrcodePhaseDto::CallingTool | AstrcodePhaseDto::Streaming + ) { + return false; + } + + !state + .transcript_cells() + .iter() + .any(|cell| match &cell.kind { + crate::state::TranscriptCellKind::Thinking { status, .. } => { + matches!( + status, + TranscriptCellStatus::Streaming | TranscriptCellStatus::Complete + ) + }, + crate::state::TranscriptCellKind::Assistant { status, body } => { + matches!(status, TranscriptCellStatus::Streaming) && !body.trim().is_empty() + }, + crate::state::TranscriptCellKind::ToolCall { status, .. } => { + matches!(status, TranscriptCellStatus::Streaming) + }, + _ => false, + }) +} From 5c0ad3d74cfd5f53b2e950458d09ad49b5d3d186 Mon Sep 17 00:00:00 2001 From: whatevertogo Date: Fri, 17 Apr 2026 13:54:51 +0800 Subject: [PATCH 17/53] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor(theme):=20?= =?UTF-8?q?=E7=AE=80=E5=8C=96=20HeroTitle=20=E6=A0=B7=E5=BC=8F=E7=9A=84?= =?UTF-8?q?=E5=AE=9E=E7=8E=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/cli/src/ui/theme.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/crates/cli/src/ui/theme.rs b/crates/cli/src/ui/theme.rs index 54f9373a..cc106d32 100644 --- a/crates/cli/src/ui/theme.rs +++ b/crates/cli/src/ui/theme.rs @@ -144,9 +144,7 @@ impl ThemePalette for CodexTheme { | WrappedLineStyle::FooterHint | WrappedLineStyle::HeroMuted | WrappedLineStyle::ThinkingPreview => base.fg(self.text_muted()), - WrappedLineStyle::HeroTitle => { - base.fg(self.accent()).add_modifier(Modifier::BOLD) - }, + WrappedLineStyle::HeroTitle => base.fg(self.accent()).add_modifier(Modifier::BOLD), WrappedLineStyle::HeroBorder => base.fg(self.accent_soft()), WrappedLineStyle::HeroBody => base.fg(self.text_primary()), WrappedLineStyle::HeroFeedTitle => { From 57703fba59f35ae7d831f0febbe7adc12209cf2a Mon Sep 17 00:00:00 2001 From: whatevertogo Date: Fri, 17 Apr 2026 14:33:26 +0800 Subject: [PATCH 18/53] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor(conversatio?= =?UTF-8?q?n):=20=E4=BF=AE=E6=94=B9=20apply=5Fstream=5Fenvelope=20?= =?UTF-8?q?=E6=96=B9=E6=B3=95=E4=BB=A5=E8=BF=94=E5=9B=9E=E7=8A=B6=E6=80=81?= =?UTF-8?q?=EF=BC=8C=E4=BC=98=E5=8C=96=E7=8A=B6=E6=80=81=E7=AE=A1=E7=90=86?= =?UTF-8?q?=20=E2=99=BB=EF=B8=8F=20refactor(mod):=20=E6=9B=B4=E6=96=B0=20s?= =?UTF-8?q?elected=5Ftranscript=5Fcell=20=E6=96=B9=E6=B3=95=E4=BB=A5?= =?UTF-8?q?=E4=BD=BF=E7=94=A8=20project=5Ftranscript=5Fcell=20=E2=99=BB?= =?UTF-8?q?=EF=B8=8F=20refactor(cells):=20=E9=87=8D=E6=9E=84=20render=5Fme?= =?UTF-8?q?ssage=20=E5=87=BD=E6=95=B0=E4=BB=A5=E7=AE=80=E5=8C=96=E8=A1=8C?= =?UTF-8?q?=E5=89=8D=E7=BC=80=E5=A4=84=E7=90=86=20=E2=99=BB=EF=B8=8F=20ref?= =?UTF-8?q?actor(hero):=20=E6=8F=90=E5=8F=96=E9=87=8D=E5=A4=8D=E4=BB=A3?= =?UTF-8?q?=E7=A0=81=E4=B8=BA=20push=5Ftwo=5Fcol=5Fblock=20=E5=87=BD?= =?UTF-8?q?=E6=95=B0=EF=BC=8C=E4=BC=98=E5=8C=96=E5=8D=A1=E7=89=87=E5=B8=83?= =?UTF-8?q?=E5=B1=80=20=E2=99=BB=EF=B8=8F=20feat(event):=20=E6=B7=BB?= =?UTF-8?q?=E5=8A=A0=20generate=5Fturn=5Fid=20=E5=87=BD=E6=95=B0=E4=BB=A5?= =?UTF-8?q?=E7=94=9F=E6=88=90=E5=85=A8=E5=B1=80=E5=94=AF=E4=B8=80=E7=9A=84?= =?UTF-8?q?=20turn=20ID=20=E2=99=BB=EF=B8=8F=20refactor(submit):=20?= =?UTF-8?q?=E4=BD=BF=E7=94=A8=20generate=5Fturn=5Fid=20=E5=87=BD=E6=95=B0?= =?UTF-8?q?=E7=AE=80=E5=8C=96=20turn=20ID=20=E7=94=9F=E6=88=90=E9=80=BB?= =?UTF-8?q?=E8=BE=91=20=E2=99=BB=EF=B8=8F=20chore(deny):=20=E6=9B=B4?= =?UTF-8?q?=E6=96=B0=20deny.toml=EF=BC=8C=E6=B7=BB=E5=8A=A0=E6=96=B0?= =?UTF-8?q?=E7=9A=84=E4=BE=9D=E8=B5=96=E9=A1=B9=E4=BB=A5=E5=A2=9E=E5=BC=BA?= =?UTF-8?q?=E5=8C=85=E7=AE=A1=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/cli/src/state/conversation.rs | 26 +++- crates/cli/src/state/mod.rs | 22 ++-- crates/cli/src/ui/cells.rs | 127 ++++++++++++++++--- crates/cli/src/ui/hero.rs | 144 +++++++++++++++------- crates/core/src/event/mod.rs | 10 ++ crates/core/src/lib.rs | 2 +- crates/session-runtime/src/turn/submit.rs | 3 +- deny.toml | 13 +- 8 files changed, 263 insertions(+), 84 deletions(-) diff --git a/crates/cli/src/state/conversation.rs b/crates/cli/src/state/conversation.rs index 87976bc5..f216df3d 100644 --- a/crates/cli/src/state/conversation.rs +++ b/crates/cli/src/state/conversation.rs @@ -52,9 +52,9 @@ impl ConversationState { envelope: AstrcodeConversationStreamEnvelopeDto, render: &mut RenderState, expanded_ids: &BTreeSet, - ) { + ) -> bool { self.cursor = Some(envelope.cursor); - self.apply_delta(envelope.delta, render, expanded_ids); + self.apply_delta(envelope.delta, render, expanded_ids) } pub fn set_banner_error(&mut self, error: AstrcodeConversationErrorEnvelopeDto) { @@ -74,7 +74,7 @@ impl ConversationState { delta: AstrcodeConversationDeltaDto, render: &mut RenderState, _expanded_ids: &BTreeSet, - ) { + ) -> bool { match delta { AstrcodeConversationDeltaDto::AppendBlock { block } => { self.transcript.push(block); @@ -83,6 +83,7 @@ impl ConversationState { .insert(block_id_of(block).to_string(), self.transcript.len() - 1); } render.invalidate_transcript_cache(); + false }, AstrcodeConversationDeltaDto::PatchBlock { block_id, patch } => { if let Some((index, block)) = self.find_block_mut(block_id.as_str()) { @@ -92,6 +93,7 @@ impl ConversationState { } else { debug_missing_block("patch", block_id.as_str()); } + false }, AstrcodeConversationDeltaDto::CompleteBlock { block_id, status } => { if let Some((index, block)) = self.find_block_mut(block_id.as_str()) { @@ -101,10 +103,12 @@ impl ConversationState { } else { debug_missing_block("complete", block_id.as_str()); } + false }, AstrcodeConversationDeltaDto::UpdateControlState { control } => { self.control = Some(control); render.invalidate_transcript_cache(); + false }, AstrcodeConversationDeltaDto::UpsertChildSummary { child } => { if let Some(existing) = self @@ -116,24 +120,30 @@ impl ConversationState { } else { self.child_summaries.push(child); } + false }, AstrcodeConversationDeltaDto::RemoveChildSummary { child_session_id } => { self.child_summaries .retain(|child| child.child_session_id != child_session_id); + false }, AstrcodeConversationDeltaDto::ReplaceSlashCandidates { candidates } => { self.slash_candidates = candidates; + true }, AstrcodeConversationDeltaDto::SetBanner { banner } => { self.banner = Some(banner); render.invalidate_transcript_cache(); + false }, AstrcodeConversationDeltaDto::ClearBanner => { self.banner = None; render.invalidate_transcript_cache(); + false }, AstrcodeConversationDeltaDto::RehydrateRequired { error } => { self.set_banner_error(error); + false }, } } @@ -161,6 +171,16 @@ impl ConversationState { .map(|block| TranscriptCell::from_block(block, expanded_ids)) .collect() } + + pub fn project_transcript_cell( + &self, + index: usize, + expanded_ids: &BTreeSet, + ) -> Option { + self.transcript + .get(index) + .map(|block| TranscriptCell::from_block(block, expanded_ids)) + } } fn block_id_of(block: &AstrcodeConversationBlockDto) -> &str { diff --git a/crates/cli/src/state/mod.rs b/crates/cli/src/state/mod.rs index 5b8ee69e..392beba4 100644 --- a/crates/cli/src/state/mod.rs +++ b/crates/cli/src/state/mod.rs @@ -207,9 +207,10 @@ impl CliState { } pub fn selected_transcript_cell(&self) -> Option { - self.transcript_cells() - .into_iter() - .nth(self.interaction.transcript.selected_cell) + self.conversation.project_transcript_cell( + self.interaction.transcript.selected_cell, + &self.interaction.transcript.expanded_cells, + ) } pub fn is_cell_expanded(&self, cell_id: &str) -> bool { @@ -301,15 +302,20 @@ impl CliState { pub fn apply_stream_envelope(&mut self, envelope: AstrcodeConversationStreamEnvelopeDto) { let expanded_ids = &self.interaction.transcript.expanded_cells; - self.conversation - .apply_stream_envelope(envelope, &mut self.render, expanded_ids); + let slash_candidates_changed = + self.conversation + .apply_stream_envelope(envelope, &mut self.render, expanded_ids); self.interaction .sync_transcript_cells(self.conversation.transcript.len()); - self.interaction - .sync_slash_items(self.conversation.slash_candidates.clone()); + if slash_candidates_changed { + self.interaction + .sync_slash_items(self.conversation.slash_candidates.clone()); + } self.render.invalidate_transcript_cache(); self.render.mark_footer_dirty(); - self.render.mark_palette_dirty(); + if slash_candidates_changed { + self.render.mark_palette_dirty(); + } } pub fn set_banner_error(&mut self, error: AstrcodeConversationErrorEnvelopeDto) { diff --git a/crates/cli/src/ui/cells.rs b/crates/cli/src/ui/cells.rs index ba071f0a..27a1f33a 100644 --- a/crates/cli/src/ui/cells.rs +++ b/crates/cli/src/ui/cells.rs @@ -125,23 +125,38 @@ fn render_message( view: &TranscriptCellView, is_user: bool, ) -> Vec { - let wrapped = wrap_text(body, width.saturating_sub(4), capabilities); - let mut lines = Vec::new(); - for (index, line) in wrapped.into_iter().enumerate() { - let prefix = if is_user { + let first_prefix = format!( + "{} ", + if is_user { prompt_marker(theme) - } else if index == 0 { - assistant_marker(theme) } else { - " " - }; + assistant_marker(theme) + } + ); + let subsequent_prefix = " ".repeat(display_width(first_prefix.as_str())); + let wrapped = wrap_text( + body, + width.saturating_sub(display_width(first_prefix.as_str())), + capabilities, + ); + let style = view.resolve_style(if is_user { + WrappedLineStyle::PromptEcho + } else { + WrappedLineStyle::Plain + }); + let mut lines = Vec::new(); + for (index, line) in wrapped.into_iter().enumerate() { lines.push(WrappedLine { - style: view.resolve_style(if is_user { - WrappedLineStyle::PromptEcho - } else { - WrappedLineStyle::Plain - }), - content: format!("{prefix} {line}"), + style, + content: format!( + "{}{}", + if index == 0 { + first_prefix.as_str() + } else { + subsequent_prefix.as_str() + }, + line + ), }); } lines.push(blank_line()); @@ -342,7 +357,7 @@ fn assistant_marker(theme: &dyn ThemePalette) -> &'static str { } fn thinking_marker(theme: &dyn ThemePalette) -> &'static str { - theme.glyph("∴", "*") + theme.glyph("∴", "~") } fn thinking_preview_prefix(theme: &dyn ThemePalette) -> &'static str { @@ -350,7 +365,7 @@ fn thinking_preview_prefix(theme: &dyn ThemePalette) -> &'static str { } fn tool_marker(theme: &dyn ThemePalette) -> &'static str { - theme.glyph("↳", "-") + theme.glyph("↳", "=") } fn secondary_marker(theme: &dyn ThemePalette) -> &'static str { @@ -829,8 +844,15 @@ fn display_width(text: &str) -> usize { #[cfg(test)] mod tests { - use super::wrap_text; - use crate::capability::{ColorLevel, GlyphMode, TerminalCapabilities}; + use super::{ + RenderableCell, TranscriptCellView, assistant_marker, secondary_marker, thinking_marker, + tool_marker, wrap_text, + }; + use crate::{ + capability::{ColorLevel, GlyphMode, TerminalCapabilities}, + state::{TranscriptCell, TranscriptCellKind, TranscriptCellStatus}, + ui::CodexTheme, + }; fn unicode_capabilities() -> TerminalCapabilities { TerminalCapabilities { @@ -842,6 +864,16 @@ mod tests { } } + fn ascii_capabilities() -> TerminalCapabilities { + TerminalCapabilities { + color: ColorLevel::None, + glyphs: GlyphMode::Ascii, + alt_screen: false, + mouse: false, + bracketed_paste: false, + } + } + #[test] fn wrap_text_preserves_hanging_indent_for_lists() { let lines = wrap_text( @@ -874,4 +906,63 @@ mod tests { assert!(lines.iter().any(|line| line.contains("| 工具"))); assert!(lines.iter().any(|line| line.contains("---"))); } + + #[test] + fn ascii_markers_remain_distinct_by_cell_type() { + let theme = CodexTheme::new(ascii_capabilities()); + assert_ne!(assistant_marker(&theme), thinking_marker(&theme)); + assert_ne!(tool_marker(&theme), secondary_marker(&theme)); + } + + #[test] + fn assistant_wrapped_lines_use_hanging_indent() { + let theme = CodexTheme::new(unicode_capabilities()); + let cell = TranscriptCell { + id: "assistant-1".to_string(), + expanded: false, + kind: TranscriptCellKind::Assistant { + body: "你好!我是 AstrCode,你的本地 AI \ + 编码助手。我可以帮你处理代码编写、文件编辑、终端命令、\ + 代码审查等各种开发任务。" + .to_string(), + status: TranscriptCellStatus::Complete, + }, + }; + + let lines = cell.render_lines( + 36, + unicode_capabilities(), + &theme, + &TranscriptCellView::default(), + ); + + assert!(lines.len() >= 3); + assert!(lines[0].content.starts_with("• ")); + assert!(lines[1].content.starts_with(" ")); + assert!(!lines[1].content.starts_with(" ")); + } + + #[test] + fn assistant_rendering_preserves_markdown_line_breaks() { + let theme = CodexTheme::new(unicode_capabilities()); + let cell = TranscriptCell { + id: "assistant-2".to_string(), + expanded: false, + kind: TranscriptCellKind::Assistant { + body: "你好!\n\n- 第一项\n- 第二项".to_string(), + status: TranscriptCellStatus::Complete, + }, + }; + + let lines = cell.render_lines( + 36, + unicode_capabilities(), + &theme, + &TranscriptCellView::default(), + ); + + assert!(lines.iter().any(|line| line.content == " ")); + assert!(lines.iter().any(|line| line.content.contains("- 第一项"))); + assert!(lines.iter().any(|line| line.content.contains("- 第二项"))); + } } diff --git a/crates/cli/src/ui/hero.rs b/crates/cli/src/ui/hero.rs index 568a3c05..a26c5cbc 100644 --- a/crates/cli/src/ui/hero.rs +++ b/crates/cli/src/ui/hero.rs @@ -83,62 +83,89 @@ fn horizontal_card(context: &HeroContext<'_>) -> Vec { let left_width = (inner_width / 2).clamp(24, 38); let right_width = inner_width.saturating_sub(left_width + 1); - let mut rows = vec![ - two_col_row( - "Welcome back!", - "使用提示", - left_width, - right_width, - WrappedLineStyle::HeroTitle, - ), - two_col_row( - context.session_title, - "输入 / 打开 commands", - left_width, - right_width, - WrappedLineStyle::HeroBody, - ), - two_col_row( - " /\\_/\\\\", + let mut rows = Vec::new(); + push_two_col_block( + &mut rows, + &[String::from("Welcome back!")], + &[String::from("使用提示")], + left_width, + right_width, + context.theme, + WrappedLineStyle::HeroTitle, + ); + push_two_col_block( + &mut rows, + &wrap_text(context.session_title, left_width, context.capabilities), + &wrap_text("输入 / 打开 commands", right_width, context.capabilities), + left_width, + right_width, + context.theme, + WrappedLineStyle::HeroBody, + ); + push_two_col_block( + &mut rows, + &[String::from(" /\\_/\\\\")], + &wrap_text( "Tab 在 transcript / composer 间切换", - left_width, right_width, - WrappedLineStyle::HeroBody, + context.capabilities, ), - two_col_row( - " .-\" \"-.", + left_width, + right_width, + context.theme, + WrappedLineStyle::HeroBody, + ); + push_two_col_block( + &mut rows, + &[String::from(" .-\" \"-.")], + &wrap_text( "Ctrl+O 展开或收起 thinking", - left_width, - right_width, - WrappedLineStyle::HeroBody, - ), - two_col_row( - format!("phase · {}", context.phase).as_str(), - "最近活动", - left_width, right_width, - WrappedLineStyle::HeroFeedTitle, + context.capabilities, ), - ]; + left_width, + right_width, + context.theme, + WrappedLineStyle::HeroBody, + ); + push_two_col_block( + &mut rows, + &[format!("phase · {}", context.phase)], + &[String::from("最近活动")], + left_width, + right_width, + context.theme, + WrappedLineStyle::HeroFeedTitle, + ); let cwd_lines = wrap_text(context.working_dir, left_width, context.capabilities); let activity_lines = if context.recent_sessions.is_empty() { - vec!["暂无最近会话".to_string(), "/resume 查看更多".to_string()] + wrap_text("暂无最近会话", right_width, context.capabilities) } else { - let mut items = context.recent_sessions.to_vec(); - items.push("/resume 查看更多".to_string()); - items + context + .recent_sessions + .iter() + .flat_map(|item| wrap_text(item, right_width, context.capabilities)) + .collect::>() }; - let line_count = cwd_lines.len().max(activity_lines.len()); - for index in 0..line_count { - rows.push(two_col_row( - cwd_lines.get(index).map(String::as_str).unwrap_or(""), - activity_lines.get(index).map(String::as_str).unwrap_or(""), - left_width, - right_width, - WrappedLineStyle::HeroMuted, - )); - } + push_two_col_block( + &mut rows, + &cwd_lines, + &activity_lines, + left_width, + right_width, + context.theme, + WrappedLineStyle::HeroMuted, + ); + push_two_col_block( + &mut rows, + &[String::new()], + &wrap_text("/resume 查看更多", right_width, context.capabilities), + left_width, + right_width, + context.theme, + WrappedLineStyle::HeroMuted, + ); framed_rows(rows, context.width, context.title, context.theme) } @@ -235,18 +262,43 @@ fn two_col_row( right: &str, left_width: usize, right_width: usize, + theme: &dyn ThemePalette, style: WrappedLineStyle, ) -> WrappedLine { + let divider = theme.glyph("│", "|"); WrappedLine { style, content: format!( - "{}│{}", + "{}{}{}", pad_to_width(left, left_width), + divider, pad_to_width(right, right_width) ), } } +fn push_two_col_block( + rows: &mut Vec, + left_lines: &[String], + right_lines: &[String], + left_width: usize, + right_width: usize, + theme: &dyn ThemePalette, + style: WrappedLineStyle, +) { + let line_count = left_lines.len().max(right_lines.len()).max(1); + for index in 0..line_count { + rows.push(two_col_row( + left_lines.get(index).map(String::as_str).unwrap_or(""), + right_lines.get(index).map(String::as_str).unwrap_or(""), + left_width, + right_width, + theme, + style, + )); + } +} + fn frame_top(width: usize, title: &str, theme: &dyn ThemePalette) -> String { let left = theme.glyph("╭", "+"); let right = theme.glyph("╮", "+"); diff --git a/crates/core/src/event/mod.rs b/crates/core/src/event/mod.rs index 7d252c52..98e68ed6 100644 --- a/crates/core/src/event/mod.rs +++ b/crates/core/src/event/mod.rs @@ -48,6 +48,16 @@ pub fn generate_session_id() -> String { format!("{dt}-{short}") } +/// 生成全局唯一的 turn ID。 +/// +/// turn 会在极短时间内连续创建,单纯依赖毫秒时间戳会撞 ID, +/// 从而让后续提交错误复用前一个 turn 的终态快照。 +pub fn generate_turn_id() -> String { + let uuid = Uuid::new_v4().simple().to_string(); + let short = &uuid[..8]; + format!("turn-{}-{short}", Utc::now().timestamp_millis()) +} + /// 会话元数据 /// /// 包含会话的基本信息和当前状态,用于会话列表展示。 diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index c5035486..c2ab57b1 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -101,7 +101,7 @@ pub use error::{AstrError, Result, ResultExt}; pub use event::{ AgentEvent, CompactAppliedMeta, CompactMode, CompactTrigger, EventTranslator, Phase, PromptMetricsPayload, StorageEvent, StorageEventPayload, StoredEvent, generate_session_id, - normalize_recovered_phase, phase_of_storage_event, replay_records, + generate_turn_id, normalize_recovered_phase, phase_of_storage_event, replay_records, }; pub use execution_control::ExecutionControl; pub use execution_result::ExecutionResultCommon; diff --git a/crates/session-runtime/src/turn/submit.rs b/crates/session-runtime/src/turn/submit.rs index ea751028..59600ad4 100644 --- a/crates/session-runtime/src/turn/submit.rs +++ b/crates/session-runtime/src/turn/submit.rs @@ -381,8 +381,7 @@ impl SessionRuntime { } let requested_session_id = SessionId::from(crate::state::normalize_session_id(session_id)); - let turn_id = turn_id - .unwrap_or_else(|| TurnId::from(format!("turn-{}", Utc::now().timestamp_millis()))); + let turn_id = turn_id.unwrap_or_else(|| TurnId::from(astrcode_core::generate_turn_id())); let cancel = CancelToken::new(); let submit_target = match busy_policy { SubmitBusyPolicy::BranchOnBusy => Some( diff --git a/deny.toml b/deny.toml index 0e348c3e..05e1c361 100644 --- a/deny.toml +++ b/deny.toml @@ -32,16 +32,17 @@ multiple-versions = "allow" deny = [ { name = "tokio", wrappers = [ + "astrcode-application", + "astrcode-cli", + "astrcode-client", "astrcode-core", + "astrcode-kernel", "astrcode-example-plugin", "astrcode-plugin", - "astrcode-runtime", - "astrcode-runtime-agent-control", - "astrcode-runtime-agent-loop", - "astrcode-runtime-config", "astrcode-adapter-llm", - "astrcode-runtime-registry", - "astrcode-runtime-session", + "astrcode-adapter-mcp", + "astrcode-adapter-storage", + "astrcode-session-runtime", "astrcode-server", "axum", "hyper", From dced3771f357f63cee5ba65c1a9ad78ac808a731 Mon Sep 17 00:00:00 2001 From: whatevertogo Date: Fri, 17 Apr 2026 14:42:17 +0800 Subject: [PATCH 19/53] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor(cli):=20?= =?UTF-8?q?=E4=BC=98=E5=8C=96=E9=94=AE=E7=9B=98=E5=AF=BC=E8=88=AA=E9=80=BB?= =?UTF-8?q?=E8=BE=91=EF=BC=8C=E7=AE=80=E5=8C=96=E4=BB=A3=E7=A0=81=E7=BB=93?= =?UTF-8?q?=E6=9E=84=20=E2=99=BB=EF=B8=8F=20refactor(ui):=20=E4=BF=AE?= =?UTF-8?q?=E6=94=B9=E5=8F=AF=E8=A7=81=E7=AA=97=E5=8F=A3=E5=87=BD=E6=95=B0?= =?UTF-8?q?=E5=8F=82=E6=95=B0=E7=B1=BB=E5=9E=8B=EF=BC=8C=E6=8F=90=E5=8D=87?= =?UTF-8?q?=E7=B1=BB=E5=9E=8B=E5=AE=89=E5=85=A8=20=E2=99=BB=EF=B8=8F=20tes?= =?UTF-8?q?t(Chat):=20=E5=A2=9E=E5=8A=A0=20ToolCallBlock=20=E7=BB=84?= =?UTF-8?q?=E4=BB=B6=E7=9A=84=E9=94=99=E8=AF=AF=E5=A4=84=E7=90=86=E6=B5=8B?= =?UTF-8?q?=E8=AF=95=E7=94=A8=E4=BE=8B=20=E2=99=BB=EF=B8=8F=20feat(Chat):?= =?UTF-8?q?=20=E6=B7=BB=E5=8A=A0=E9=95=BF=E5=AD=97=E7=AC=A6=E4=B8=B2?= =?UTF-8?q?=E6=91=98=E8=A6=81=E5=8A=9F=E8=83=BD=EF=BC=8C=E4=BC=98=E5=8C=96?= =?UTF-8?q?=20JSON=20=E8=A7=86=E5=9B=BE=E5=B1=95=E7=A4=BA=20=E2=99=BB?= =?UTF-8?q?=EF=B8=8F=20test(Chat):=20=E6=96=B0=E5=A2=9E=20useNestedScrollC?= =?UTF-8?q?ontainment=20=E7=9A=84=E5=8D=95=E5=85=83=E6=B5=8B=E8=AF=95=20?= =?UTF-8?q?=E2=99=BB=EF=B8=8F=20feat(Chat):=20=E5=AE=9E=E7=8E=B0=E5=B5=8C?= =?UTF-8?q?=E5=A5=97=E6=BB=9A=E5=8A=A8=E5=AE=B9=E5=99=A8=E7=9A=84=E4=BA=8B?= =?UTF-8?q?=E4=BB=B6=E5=A4=84=E7=90=86=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/cli/src/app/mod.rs | 16 ++-- crates/cli/src/ui/palette.rs | 2 +- .../components/Chat/ToolCallBlock.test.tsx | 29 +++++++ .../src/components/Chat/ToolCallBlock.tsx | 75 +++++++++++++------ .../src/components/Chat/ToolJsonView.test.tsx | 21 ++++++ frontend/src/components/Chat/ToolJsonView.tsx | 28 +++++++ .../Chat/useNestedScrollContainment.test.ts | 22 ++++++ .../Chat/useNestedScrollContainment.ts | 40 ++++++---- 8 files changed, 187 insertions(+), 46 deletions(-) create mode 100644 frontend/src/components/Chat/ToolJsonView.test.tsx create mode 100644 frontend/src/components/Chat/useNestedScrollContainment.test.ts diff --git a/crates/cli/src/app/mod.rs b/crates/cli/src/app/mod.rs index 0cc1ece6..68443d5d 100644 --- a/crates/cli/src/app/mod.rs +++ b/crates/cli/src/app/mod.rs @@ -499,15 +499,15 @@ where self.state.clear_surface_state(); } }, - KeyCode::Left => { - if !matches!(self.state.interaction.pane_focus, PaneFocus::Transcript) { - self.state.move_cursor_left(); - } + KeyCode::Left + if !matches!(self.state.interaction.pane_focus, PaneFocus::Transcript) => + { + self.state.move_cursor_left(); }, - KeyCode::Right => { - if !matches!(self.state.interaction.pane_focus, PaneFocus::Transcript) { - self.state.move_cursor_right(); - } + KeyCode::Right + if !matches!(self.state.interaction.pane_focus, PaneFocus::Transcript) => + { + self.state.move_cursor_right(); }, KeyCode::Home => { if matches!(self.state.interaction.pane_focus, PaneFocus::Transcript) { diff --git a/crates/cli/src/ui/palette.rs b/crates/cli/src/ui/palette.rs index 4687ec77..2f84eec6 100644 --- a/crates/cli/src/ui/palette.rs +++ b/crates/cli/src/ui/palette.rs @@ -33,7 +33,7 @@ pub fn palette_visible(palette: &PaletteState) -> bool { !matches!(palette, PaletteState::Closed) } -fn visible_window<'a, T>(items: &'a [T], selected: usize, max_items: usize) -> Vec<(usize, &'a T)> { +fn visible_window(items: &[T], selected: usize, max_items: usize) -> Vec<(usize, &T)> { if items.is_empty() || max_items == 0 { return Vec::new(); } diff --git a/frontend/src/components/Chat/ToolCallBlock.test.tsx b/frontend/src/components/Chat/ToolCallBlock.test.tsx index 2b03e558..3e8c5210 100644 --- a/frontend/src/components/Chat/ToolCallBlock.test.tsx +++ b/frontend/src/components/Chat/ToolCallBlock.test.tsx @@ -170,4 +170,33 @@ describe('ToolCallBlock', () => { expect(html).toContain('truncated'); expect(html).toContain('失败'); }); + + it('renders explicit tool error even when stdout is present', () => { + const html = renderToStaticMarkup( + + + + ); + + expect(html).toContain('Compiling crate...'); + expect(html).toContain('command exited with code 101'); + expect(html).toContain('错误'); + }); }); diff --git a/frontend/src/components/Chat/ToolCallBlock.tsx b/frontend/src/components/Chat/ToolCallBlock.tsx index d2a67a57..de8c3f6a 100644 --- a/frontend/src/components/Chat/ToolCallBlock.tsx +++ b/frontend/src/components/Chat/ToolCallBlock.tsx @@ -1,4 +1,4 @@ -import { memo, useRef } from 'react'; +import { memo, useEffect, useRef, useState } from 'react'; import type { ToolCallMessage } from '../../types'; import { @@ -106,11 +106,22 @@ function ToolCallBlock({ message }: ToolCallBlockProps) { message.error?.trim() || message.output?.trim() || metadataSummary?.message?.trim() || ''; const structuredFallbackResult = extractStructuredJsonOutput(fallbackResult); const defaultOpen = message.status === 'fail'; + const explicitError = message.error?.trim() || ''; + const [isOpen, setIsOpen] = useState(defaultOpen); + + useEffect(() => { + if (message.status === 'fail') { + setIsOpen(true); + } + }, [message.status]); return (
    { + setIsOpen(event.currentTarget.open); + }} > {message.toolName} @@ -157,29 +168,45 @@ function ToolCallBlock({ message }: ToolCallBlockProps) { >
    {streamSections.length > 0 ? ( - streamSections.map((streamMessage) => ( -
    -
    -
    - - {streamTitle( - message.toolName, - streamMessage.stream, - Boolean(shellDisplay?.command) - )} - - - {streamMessage.stream === 'stderr' ? '错误输出' : '工具结果'} - + <> + {streamSections.map((streamMessage) => ( +
    +
    +
    + + {streamTitle( + message.toolName, + streamMessage.stream, + Boolean(shellDisplay?.command) + )} + + + {streamMessage.stream === 'stderr' ? '错误输出' : '工具结果'} + +
    + {statusLabel(message.status)}
    - {statusLabel(message.status)} -
    - {resultTextSurface( - streamMessage.content, - streamMessage.stream === 'stderr' ? 'error' : 'normal' - )} -
    - )) + {resultTextSurface( + streamMessage.content, + streamMessage.stream === 'stderr' ? 'error' : 'normal' + )} + + ))} + {explicitError && ( +
    +
    +
    + 错误 + + {shellDisplay?.command ? `$ ${shellDisplay.command}` : message.toolName} + +
    + {statusLabel(message.status)} +
    + {resultTextSurface(explicitError, 'error')} +
    + )} + ) : fallbackResult ? ( structuredFallbackResult ? (
    diff --git a/frontend/src/components/Chat/ToolJsonView.test.tsx b/frontend/src/components/Chat/ToolJsonView.test.tsx new file mode 100644 index 00000000..da56adcf --- /dev/null +++ b/frontend/src/components/Chat/ToolJsonView.test.tsx @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest'; + +import { summarizeLongString } from './ToolJsonView'; + +describe('summarizeLongString', () => { + it('keeps short strings unchanged after whitespace normalization', () => { + expect(summarizeLongString('alpha beta')).toBe('alpha beta'); + }); + + it('truncates long strings into a compact preview', () => { + const value = `${'x'.repeat(360)} tail`; + const preview = summarizeLongString(value); + + expect(preview.length).toBeLessThan(value.length); + expect(preview.endsWith('...')).toBe(true); + }); + + it('renders empty strings with a stable placeholder', () => { + expect(summarizeLongString(' \n\t ')).toBe('(empty string)'); + }); +}); diff --git a/frontend/src/components/Chat/ToolJsonView.tsx b/frontend/src/components/Chat/ToolJsonView.tsx index 0932d6de..1436c3f5 100644 --- a/frontend/src/components/Chat/ToolJsonView.tsx +++ b/frontend/src/components/Chat/ToolJsonView.tsx @@ -4,6 +4,7 @@ import type { UnknownRecord } from '../../lib/shared'; import { useNestedScrollContainment } from './useNestedScrollContainment'; const MAX_CHILDREN_PER_NODE = 200; +const MAX_STRING_PREVIEW_CHARS = 320; interface ToolJsonViewProps { value: UnknownRecord | unknown[]; @@ -30,12 +31,39 @@ function summarizeContainer(value: UnknownRecord | unknown[]): string { return `Object (${Object.keys(value).length})`; } +export function summarizeLongString(value: string): string { + const normalized = value.replace(/\s+/g, ' ').trim(); + if (!normalized) { + return '(empty string)'; + } + + if (normalized.length <= MAX_STRING_PREVIEW_CHARS) { + return normalized; + } + + return `${normalized.slice(0, MAX_STRING_PREVIEW_CHARS)}...`; +} + function renderPrimitiveValue(value: unknown): ReactNode { if (value === null) { return null; } if (typeof value === 'string') { + if (value.length > MAX_STRING_PREVIEW_CHARS) { + return ( +
    + + "{summarizeLongString(value)}" + 展开完整内容 ({value.length}) + +
    + "{value}" +
    +
    + ); + } + return ( "{value}" diff --git a/frontend/src/components/Chat/useNestedScrollContainment.test.ts b/frontend/src/components/Chat/useNestedScrollContainment.test.ts new file mode 100644 index 00000000..41f455fd --- /dev/null +++ b/frontend/src/components/Chat/useNestedScrollContainment.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest'; + +import { resolveNestedScrollContainmentMode } from './useNestedScrollContainment'; + +describe('resolveNestedScrollContainmentMode', () => { + it('contains wheel events while the nested container can continue scrolling', () => { + expect(resolveNestedScrollContainmentMode(120, 200, 800, 48)).toBe('contain'); + expect(resolveNestedScrollContainmentMode(120, 200, 800, -48)).toBe('contain'); + }); + + it('lets wheel events bubble at the top boundary', () => { + expect(resolveNestedScrollContainmentMode(0, 200, 800, -48)).toBe('bubble'); + }); + + it('lets wheel events bubble at the bottom boundary', () => { + expect(resolveNestedScrollContainmentMode(600, 200, 800, 48)).toBe('bubble'); + }); + + it('lets wheel events bubble when the nested container cannot scroll', () => { + expect(resolveNestedScrollContainmentMode(0, 200, 200, 48)).toBe('bubble'); + }); +}); diff --git a/frontend/src/components/Chat/useNestedScrollContainment.ts b/frontend/src/components/Chat/useNestedScrollContainment.ts index fde1227a..f12dbbab 100644 --- a/frontend/src/components/Chat/useNestedScrollContainment.ts +++ b/frontend/src/components/Chat/useNestedScrollContainment.ts @@ -1,6 +1,24 @@ import { useEffect } from 'react'; import type { RefObject } from 'react'; +export type NestedScrollContainmentMode = 'contain' | 'bubble'; + +export function resolveNestedScrollContainmentMode( + scrollTop: number, + clientHeight: number, + scrollHeight: number, + deltaY: number +): NestedScrollContainmentMode { + const canScroll = scrollHeight > clientHeight + 1; + if (!canScroll || deltaY === 0) { + return 'bubble'; + } + + const atTop = scrollTop <= 0 && deltaY < 0; + const atBottom = scrollTop + clientHeight >= scrollHeight - 1 && deltaY > 0; + return atTop || atBottom ? 'bubble' : 'contain'; +} + export function useNestedScrollContainment(ref: RefObject) { useEffect(() => { const container = ref.current; @@ -9,22 +27,18 @@ export function useNestedScrollContainment(ref: RefObject } const onWheel = (event: WheelEvent) => { - const canScroll = container.scrollHeight > container.clientHeight + 1; - if (!canScroll) { - return; - } - - const atTop = container.scrollTop <= 0 && event.deltaY < 0; - const atBottom = - container.scrollTop + container.clientHeight >= container.scrollHeight - 1 && - event.deltaY > 0; - - if (!atTop && !atBottom) { - event.stopPropagation(); + if ( + resolveNestedScrollContainmentMode( + container.scrollTop, + container.clientHeight, + container.scrollHeight, + event.deltaY + ) !== 'contain' + ) { return; } - event.preventDefault(); + event.stopPropagation(); }; container.addEventListener('wheel', onWheel, { passive: false }); From a691e474281fa28d783c080aa344c26b0a4cf75f Mon Sep 17 00:00:00 2001 From: whatevertogo Date: Fri, 17 Apr 2026 14:50:19 +0800 Subject: [PATCH 20/53] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor(test):=20?= =?UTF-8?q?=E6=8F=90=E9=AB=98=E6=B5=8B=E8=AF=95=E4=B8=8A=E4=B8=8B=E6=96=87?= =?UTF-8?q?=E7=9A=84=20readFile=20=E5=86=85=E8=81=94=E9=98=88=E5=80=BC?= =?UTF-8?q?=E8=87=B3=201MB=EF=BC=8C=E4=BC=98=E5=8C=96=E6=8C=81=E4=B9=85?= =?UTF-8?q?=E5=8C=96=E5=A4=84=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/adapter-tools/src/test_support.rs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/crates/adapter-tools/src/test_support.rs b/crates/adapter-tools/src/test_support.rs index 1159fd8b..613d9990 100644 --- a/crates/adapter-tools/src/test_support.rs +++ b/crates/adapter-tools/src/test_support.rs @@ -9,9 +9,11 @@ pub fn test_tool_context_for(path: impl Into) -> ToolContext { let session_storage_root = cwd.join(".astrcode-test-state"); ToolContext::new("session-test".to_string().into(), cwd, CancelToken::new()) .with_session_storage_root(session_storage_root) - // 测试上下文使用 100KB 内联阈值,与 readFile 的 max_result_inline_size(100_000) 对齐。 - // 避免工具测试中 readFile 二次持久化已持久化的 grep 结果。 - .with_resolved_inline_limit(100_000) + // 测试上下文把 readFile 的最终内联阈值抬高到 1MB。 + // grep 等工具仍使用各自固定的持久化阈值,所以大结果会按预期先落盘; + // 但后续 readFile 读取这些持久化文件时,不应因为临时目录路径更长等环境差异 + // 再次被持久化,导致测试对输出形态出现非确定性。 + .with_resolved_inline_limit(1_000_000) } pub fn canonical_tool_path(path: impl AsRef) -> PathBuf { From 6fad3ec47d8e2f33d6611708698ad6e9e867ded2 Mon Sep 17 00:00:00 2001 From: whatevertogo Date: Fri, 17 Apr 2026 15:29:36 +0800 Subject: [PATCH 21/53] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor(tool-result?= =?UTF-8?q?s):=20=E6=94=B6=E5=8F=A3=E5=B7=B2=E6=8C=81=E4=B9=85=E5=8C=96?= =?UTF-8?q?=E5=B7=A5=E5=85=B7=E7=BB=93=E6=9E=9C=E7=9A=84=E8=AF=BB=E5=8F=96?= =?UTF-8?q?=E4=B8=8E=E5=B1=95=E7=A4=BA=E5=A5=91=E7=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit crates/core/src/tool_result_persist.rs - Why: 让大工具结果的持久化结果从脆弱的文本约定升级为稳定的结构化契约,避免后续链路继续解析文案。 - How: 引入 PersistedToolOutput/PersistedToolResult,输出绝对路径短引用,保留 preview metadata,并补充新 wrapper 解析测试。 crates/core/src/lib.rs - Why: 让运行时、工具层和前端适配层都能复用新的持久化类型与 helper。 - How: 重新导出 PersistedToolOutput、PersistedToolResult 和 persisted_output_absolute_path。 crates/core/src/event/types.rs - Why: durable event 需要携带完整的 persisted metadata,而不是只带相对路径字符串。 - How: 将 ToolResultReferenceApplied 的 persisted_relative_path 替换为 persisted_output。 crates/core/src/event/translate.rs - Why: runtime 聚合预算替换后的结果也要把 persisted metadata 投影给会话查询和前端。 - How: 将 ToolResultReferenceApplied 翻译为带 persistedOutput metadata 的 synthetic ToolCallResult。 crates/core/src/projection/agent_state.rs - Why: 状态投影测试需要跟上新的 durable payload 和 wrapper 文案。 - How: 用 persisted_output 夹具替换旧字段,并把断言更新到 ~/.astrcode 路径风格。 crates/session-runtime/src/turn/events.rs - Why: 事件构造函数需要和新的 ToolResultReferenceApplied 载荷保持一致。 - How: tool_result_reference_applied_event 改为接收 PersistedToolOutput。 crates/session-runtime/src/turn/tool_result_budget.rs - Why: 聚合预算替换要和工具侧落盘共用同一份 persisted contract,避免再解析旧 wrapper 文本。 - How: 记录 PersistedToolOutput、直接消费 persist_tool_result 返回值,并更新 durable replay 测试。 crates/session-runtime/src/context_window/compaction.rs - Why: 压缩总结 persisted tool output 时不该继续依赖旧的字符串格式。 - How: 改用 persisted_output_absolute_path 提取权威路径。 crates/session-runtime/src/query/conversation.rs - Why: synthetic persisted update 不应把已有工具耗时错误覆盖为 0。 - How: 在替换 duration 时跳过会抹掉已有耗时的 0ms 更新。 crates/adapter-tools/src/builtin_tools/fs_common.rs - Why: readFile 需要识别 session tool-results 的绝对路径,并统一挂载 persisted metadata。 - How: 新增 ResolvedReadTarget/resolve_read_target、绝对路径解析分支和 merge_persisted_tool_output_metadata helper。 crates/adapter-tools/src/builtin_tools/read_file.rs - Why: 已持久化工具结果应该分页读取而不是再次塞回上下文。 - How: 新增 charOffset 字符分页分支、persistedRead metadata、参数校验和覆盖专用读取语义的测试。 crates/adapter-tools/src/builtin_tools/grep.rs - Why: grep 的大结果落盘后需要让后续 readFile 直接按权威路径继续读取。 - How: 输出改用 PersistedToolResult,metadata 挂 persistedOutput,并把回归测试切到 absolutePath + charOffset 流程。 crates/adapter-tools/src/builtin_tools/find_files.rs - Why: findFiles 也要遵守统一的 persisted 输出契约。 - How: 使用 final_output.output 返回正文,并在 metadata 中合并 persistedOutput。 crates/adapter-tools/src/builtin_tools/shell.rs - Why: shell 在超长输出和超时场景下也需要保留终端展示信息并复用同一持久化契约。 - How: 重组 metadata map,统一写入 persistedOutput,并改为消费 PersistedToolResult.output。 crates/adapter-mcp/src/bridge/resource_tool.rs - Why: MCP 资源桥接层需要兼容 maybe_persist_tool_result 的新返回类型。 - How: 读取 rendered.output 作为最终文本字段。 crates/adapter-prompt/src/contributors/capability_prompt.rs - Why: 模型提示需要明确大工具结果的正确消费方式。 - How: 在 tool summary 中补充 persisted reference + readFile chunk 的工作流说明并更新测试。 frontend/src/lib/toolDisplay.ts - Why: 前端需要机器可读地识别 persisted tool result,而不是依赖原始 wrapper 文本。 - How: 新增 PersistedToolOutputMetadata/extractPersistedToolOutput,并在 metadata summary 中补 persisted pills。 frontend/src/components/Chat/ToolCallBlock.tsx - Why: persisted tool result 不应该被当作普通文本块展示。 - How: 根据 persistedOutput 渲染专用卡片,展示绝对路径、相对路径、大小、preview 和建议的 readFile 命令。 frontend/src/lib/toolDisplay.test.ts - Why: 新增的 persisted metadata 提取逻辑需要回归保护。 - How: 补充 persistedOutput 识别和 pill 摘要测试。 frontend/src/components/Chat/ToolCallBlock.test.tsx - Why: UI 需要证明 persisted 结果优先走卡片展示而不是原始 wrapper。 - How: 增加 persisted result card 渲染测试,并把示例路径统一成 ~/.astrcode 风格。 --- .../adapter-mcp/src/bridge/resource_tool.rs | 2 +- .../src/contributors/capability_prompt.rs | 5 +- .../src/builtin_tools/find_files.rs | 23 +- .../src/builtin_tools/fs_common.rs | 121 +++++++- .../adapter-tools/src/builtin_tools/grep.rs | 108 ++++--- .../src/builtin_tools/read_file.rs | 290 +++++++++++++++++- .../adapter-tools/src/builtin_tools/shell.rs | 90 +++--- crates/core/src/event/translate.rs | 36 ++- crates/core/src/event/types.rs | 6 +- crates/core/src/lib.rs | 5 +- crates/core/src/projection/agent_state.rs | 21 +- crates/core/src/tool_result_persist.rs | 150 +++++++-- .../src/context_window/compaction.rs | 11 +- .../session-runtime/src/query/conversation.rs | 3 + crates/session-runtime/src/turn/events.rs | 4 +- .../src/turn/tool_result_budget.rs | 50 +-- .../components/Chat/ToolCallBlock.test.tsx | 46 +++ .../src/components/Chat/ToolCallBlock.tsx | 75 ++++- frontend/src/lib/toolDisplay.test.ts | 28 ++ frontend/src/lib/toolDisplay.ts | 52 ++++ 20 files changed, 950 insertions(+), 176 deletions(-) diff --git a/crates/adapter-mcp/src/bridge/resource_tool.rs b/crates/adapter-mcp/src/bridge/resource_tool.rs index 0b1cb0e9..8b6b5a15 100644 --- a/crates/adapter-mcp/src/bridge/resource_tool.rs +++ b/crates/adapter-mcp/src/bridge/resource_tool.rs @@ -176,7 +176,7 @@ impl CapabilityInvoker for ReadMcpResourceTool { json!({ "uri": content.uri, "mimeType": content.mime_type, - "text": rendered, + "text": rendered.output, }) } else if let Some(blob) = &content.blob { match persist_blob_content( diff --git a/crates/adapter-prompt/src/contributors/capability_prompt.rs b/crates/adapter-prompt/src/contributors/capability_prompt.rs index 729559f5..bbfdb8ea 100644 --- a/crates/adapter-prompt/src/contributors/capability_prompt.rs +++ b/crates/adapter-prompt/src/contributors/capability_prompt.rs @@ -161,7 +161,9 @@ fn build_tool_summary_block( ) -> BlockSpec { let mut content = String::from( "Use the narrowest tool that can answer the request. Prefer read-only inspection before \ - mutation. All paths must stay inside the working directory.", + mutation. All paths must stay inside the working directory. When a tool returns a \ + persisted-result reference for large output, keep the reference in context and inspect \ + it with `readFile` chunks instead of asking the tool to inline the whole result again.", ); if !tool_guides.is_empty() { @@ -445,6 +447,7 @@ mod tests { assert!(read_index < write_index); assert!(write_index < external_tool_index); assert!(content.contains("When To Use `tool_search`")); + assert!(content.contains("persisted-result reference")); assert!( contribution .blocks diff --git a/crates/adapter-tools/src/builtin_tools/find_files.rs b/crates/adapter-tools/src/builtin_tools/find_files.rs index 79401480..9fe4a919 100644 --- a/crates/adapter-tools/src/builtin_tools/find_files.rs +++ b/crates/adapter-tools/src/builtin_tools/find_files.rs @@ -25,7 +25,8 @@ use serde::Deserialize; use serde_json::json; use crate::builtin_tools::fs_common::{ - check_cancel, json_output, resolve_path, session_dir_for_tool_results, + check_cancel, json_output, merge_persisted_tool_output_metadata, resolve_path, + session_dir_for_tool_results, }; /// FindFiles 工具实现。 @@ -185,20 +186,24 @@ impl Tool for FindFilesTool { &output, ctx.resolved_inline_limit(), ); + let mut metadata = serde_json::Map::new(); + metadata.insert("pattern".to_string(), json!(args.pattern)); + metadata.insert("root".to_string(), json!(root.to_string_lossy())); + metadata.insert("count".to_string(), json!(paths.len())); + metadata.insert("truncated".to_string(), json!(truncated)); + metadata.insert( + "respectGitignore".to_string(), + json!(args.respect_gitignore), + ); + merge_persisted_tool_output_metadata(&mut metadata, final_output.persisted.as_ref()); Ok(ToolExecutionResult { tool_call_id, tool_name: "findFiles".to_string(), ok: true, - output: final_output, + output: final_output.output, error: None, - metadata: Some(json!({ - "pattern": args.pattern, - "root": root.to_string_lossy(), - "count": paths.len(), - "truncated": truncated, - "respectGitignore": args.respect_gitignore, - })), + metadata: Some(serde_json::Value::Object(metadata)), child_ref: None, duration_ms: started_at.elapsed().as_millis() as u64, truncated, diff --git a/crates/adapter-tools/src/builtin_tools/fs_common.rs b/crates/adapter-tools/src/builtin_tools/fs_common.rs index 4293cfdf..7f0ba714 100644 --- a/crates/adapter-tools/src/builtin_tools/fs_common.rs +++ b/crates/adapter-tools/src/builtin_tools/fs_common.rs @@ -24,7 +24,10 @@ use std::{ time::SystemTime, }; -use astrcode_core::{AstrError, CancelToken, Result, ToolContext, project::project_dir}; +use astrcode_core::{ + AstrError, CancelToken, PersistedToolOutput, PersistedToolResult, Result, ToolContext, + project::project_dir, +}; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; @@ -92,26 +95,91 @@ pub fn resolve_path(ctx: &ToolContext, path: &Path) -> Result { ) } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ResolvedReadTarget { + pub path: PathBuf, + pub persisted_relative_path: Option, +} + /// 读取工具额外允许访问当前会话下的持久化结果目录。 /// /// `grep` 等工具可能将超大输出写入 `~/.astrcode/projects//sessions//tool-results` /// 供后续 `readFile` 读取。这里仅对 `tool-results/**` 相对路径开放额外根目录, /// 避免把工作区外的任意文件都暴露给只读工具。 pub fn resolve_read_path(ctx: &ToolContext, path: &Path) -> Result { + Ok(resolve_read_target(ctx, path)?.path) +} + +pub fn resolve_read_target(ctx: &ToolContext, path: &Path) -> Result { + if let Some(target) = resolve_session_tool_results_target(ctx, path)? { + return Ok(target); + } + + Ok(ResolvedReadTarget { + path: resolve_path(ctx, path)?, + persisted_relative_path: None, + }) +} + +fn resolve_session_tool_results_target( + ctx: &ToolContext, + path: &Path, +) -> Result> { + let session_root = session_dir_for_tool_results(ctx)?; + let tool_results_root = session_root.join(TOOL_RESULTS_DIR); + + if path.is_absolute() { + if !tool_results_root.exists() { + return Ok(None); + } + + let canonical_tool_results_root = canonicalize_path( + &tool_results_root, + &format!( + "failed to canonicalize session tool-results directory '{}'", + tool_results_root.display() + ), + )?; + let canonical_session_root = canonicalize_path( + &session_root, + &format!( + "failed to canonicalize session directory '{}'", + session_root.display() + ), + )?; + let resolved = resolve_for_boundary_check(&normalize_lexically(path))?; + if !is_path_within_root(&resolved, &canonical_tool_results_root) { + return Ok(None); + } + + let relative_path = resolved + .strip_prefix(&canonical_session_root) + .unwrap_or(&resolved) + .to_string_lossy() + .replace('\\', "/"); + return Ok(Some(ResolvedReadTarget { + path: resolved, + persisted_relative_path: Some(relative_path), + })); + } + if should_use_session_tool_results_root(path) { - let session_root = session_dir_for_tool_results(ctx)?; let session_candidate = session_root.join(path); if session_candidate.exists() { - return resolve_path_with_root( + let resolved = resolve_path_with_root( &session_root, path, "session tool-results directory", "failed to canonicalize session tool-results directory", - ); + )?; + return Ok(Some(ResolvedReadTarget { + path: resolved, + persisted_relative_path: Some(path.to_string_lossy().replace('\\', "/")), + })); } } - resolve_path(ctx, path) + Ok(None) } /// 读取文件内容为 UTF-8 字符串。 @@ -614,9 +682,12 @@ pub fn maybe_persist_large_tool_result( tool_call_id: &str, content: &str, force_inline: bool, -) -> String { +) -> PersistedToolResult { if force_inline { - return content.to_string(); + return PersistedToolResult { + output: content.to_string(), + persisted: None, + }; } astrcode_core::tool_result_persist::maybe_persist_tool_result( session_dir, @@ -626,6 +697,17 @@ pub fn maybe_persist_large_tool_result( ) } +pub fn merge_persisted_tool_output_metadata( + metadata: &mut serde_json::Map, + persisted_output: Option<&PersistedToolOutput>, +) { + let Some(persisted_output) = persisted_output else { + return; + }; + + metadata.insert("persistedOutput".to_string(), json!(persisted_output)); +} + #[cfg(test)] mod tests { use super::*; @@ -720,6 +802,31 @@ mod tests { assert_eq!(resolved, canonical_tool_path(&workspace_file)); } + #[test] + fn resolve_read_target_allows_absolute_session_tool_results_path() { + let temp = tempfile::tempdir().expect("tempdir should be created"); + let ctx = test_tool_context_for(temp.path()); + let session_dir = + session_dir_for_tool_results(&ctx).expect("session tool-results dir should resolve"); + let persisted = session_dir.join(TOOL_RESULTS_DIR).join("absolute.txt"); + fs::create_dir_all( + persisted + .parent() + .expect("persisted file should have a parent"), + ) + .expect("tool-results dir should be created"); + fs::write(&persisted, "persisted").expect("persisted output should be written"); + + let resolved = resolve_read_target(&ctx, &canonical_tool_path(&persisted)) + .expect("absolute persisted path should resolve"); + + assert_eq!(resolved.path, canonical_tool_path(&persisted)); + assert_eq!( + resolved.persisted_relative_path.as_deref(), + Some("tool-results/absolute.txt") + ); + } + #[test] fn session_dir_for_tool_results_prefers_context_override_root() { let temp = tempfile::tempdir().expect("tempdir should be created"); diff --git a/crates/adapter-tools/src/builtin_tools/grep.rs b/crates/adapter-tools/src/builtin_tools/grep.rs index 88cf1853..e71bf4b1 100644 --- a/crates/adapter-tools/src/builtin_tools/grep.rs +++ b/crates/adapter-tools/src/builtin_tools/grep.rs @@ -30,8 +30,8 @@ use serde::{Deserialize, Serialize}; use serde_json::json; use crate::builtin_tools::fs_common::{ - check_cancel, maybe_persist_large_tool_result, read_utf8_file, resolve_path, - session_dir_for_tool_results, + check_cancel, maybe_persist_large_tool_result, merge_persisted_tool_output_metadata, + read_utf8_file, resolve_path, session_dir_for_tool_results, }; /// 匹配行最大显示字符数。 @@ -309,22 +309,32 @@ impl Tool for GrepTool { let session_dir = session_dir_for_tool_results(ctx)?; let final_output = maybe_persist_large_tool_result(&session_dir, &tool_call_id, &output, false); - let is_persisted = final_output.starts_with(""); + let is_persisted = final_output.persisted.is_some(); + let mut metadata = serde_json::Map::new(); + metadata.insert("pattern".to_string(), json!(args.pattern)); + metadata.insert("returned".to_string(), json!(result.matched_files.len())); + metadata.insert("has_more".to_string(), json!(result.has_more)); + metadata.insert( + "truncated".to_string(), + json!(result.has_more || is_persisted), + ); + metadata.insert("skipped_files".to_string(), json!(result.skipped_files)); + metadata.insert( + "message".to_string(), + json!(grep_empty_message(offset, result.matched_files.is_empty())), + ); + metadata.insert("output_mode".to_string(), json!("files_with_matches")); + merge_persisted_tool_output_metadata( + &mut metadata, + final_output.persisted.as_ref(), + ); Ok(ToolExecutionResult { tool_call_id, tool_name: "grep".to_string(), ok: true, - output: final_output, + output: final_output.output, error: None, - metadata: Some(json!({ - "pattern": args.pattern, - "returned": result.matched_files.len(), - "has_more": result.has_more, - "truncated": result.has_more || is_persisted, - "skipped_files": result.skipped_files, - "message": grep_empty_message(offset, result.matched_files.is_empty()), - "output_mode": "files_with_matches", - })), + metadata: Some(serde_json::Value::Object(metadata)), child_ref: None, duration_ms: started_at.elapsed().as_millis() as u64, truncated: result.has_more || is_persisted, @@ -337,21 +347,28 @@ impl Tool for GrepTool { let session_dir = session_dir_for_tool_results(ctx)?; let final_output = maybe_persist_large_tool_result(&session_dir, &tool_call_id, &output, false); - let is_persisted = final_output.starts_with(""); + let is_persisted = final_output.persisted.is_some(); + let mut metadata = serde_json::Map::new(); + metadata.insert("pattern".to_string(), json!(args.pattern)); + metadata.insert("total_files".to_string(), json!(result.counts.len())); + metadata.insert("truncated".to_string(), json!(is_persisted)); + metadata.insert("skipped_files".to_string(), json!(result.skipped_files)); + metadata.insert( + "message".to_string(), + json!(grep_empty_message(0, result.counts.is_empty())), + ); + metadata.insert("output_mode".to_string(), json!("count")); + merge_persisted_tool_output_metadata( + &mut metadata, + final_output.persisted.as_ref(), + ); Ok(ToolExecutionResult { tool_call_id, tool_name: "grep".to_string(), ok: true, - output: final_output, + output: final_output.output, error: None, - metadata: Some(json!({ - "pattern": args.pattern, - "total_files": result.counts.len(), - "truncated": is_persisted, - "skipped_files": result.skipped_files, - "message": grep_empty_message(0, result.counts.is_empty()), - "output_mode": "count", - })), + metadata: Some(serde_json::Value::Object(metadata)), child_ref: None, duration_ms: started_at.elapsed().as_millis() as u64, truncated: is_persisted, @@ -641,23 +658,27 @@ fn build_content_result( // 溢出存盘检查 let session_dir = session_dir_for_tool_results(ctx)?; let final_output = maybe_persist_large_tool_result(&session_dir, &tool_call_id, &output, false); - let is_persisted = final_output.starts_with(""); + let is_persisted = final_output.persisted.is_some(); + let mut metadata = serde_json::Map::new(); + metadata.insert("pattern".to_string(), json!(args.pattern)); + metadata.insert("returned".to_string(), json!(matches.len())); + metadata.insert("has_more".to_string(), json!(has_more)); + metadata.insert("truncated".to_string(), json!(has_more || is_persisted)); + metadata.insert("skipped_files".to_string(), json!(skipped_files)); + metadata.insert("offset_applied".to_string(), json!(offset)); + metadata.insert( + "message".to_string(), + json!(grep_empty_message(offset, matches.is_empty())), + ); + merge_persisted_tool_output_metadata(&mut metadata, final_output.persisted.as_ref()); Ok(ToolExecutionResult { tool_call_id, tool_name: "grep".to_string(), ok: true, - output: final_output, + output: final_output.output, error: None, - metadata: Some(json!({ - "pattern": args.pattern, - "returned": matches.len(), - "has_more": has_more, - "truncated": has_more || is_persisted, - "skipped_files": skipped_files, - "offset_applied": offset, - "message": grep_empty_message(offset, matches.is_empty()), - })), + metadata: Some(serde_json::Value::Object(metadata)), child_ref: None, duration_ms: started_at.elapsed().as_millis() as u64, truncated: has_more || is_persisted, @@ -1455,21 +1476,20 @@ mod tests { .expect("grep should succeed"); assert!(result.output.starts_with("")); - let persisted_relative = result - .output - .lines() - .find_map(|line| line.split("Full output saved to: ").nth(1)) - .expect("persisted output path should be present") - .trim(); + let metadata = result.metadata.as_ref().expect("metadata should exist"); + let persisted_absolute = metadata["persistedOutput"]["absolutePath"] + .as_str() + .expect("persisted absolute path should be present"); + assert!(result.output.contains(persisted_absolute)); let read_tool = ReadFileTool; let read_result = read_tool .execute( "tc-read-persisted".to_string(), json!({ - "path": persisted_relative, - "maxChars": 200000, - "lineNumbers": false + "path": persisted_absolute, + "charOffset": 0, + "maxChars": 200000 }), &ctx, ) @@ -1479,6 +1499,8 @@ mod tests { assert!(read_result.ok); assert!(read_result.output.starts_with('[')); assert!(read_result.output.contains("target_0")); + let read_metadata = read_result.metadata.expect("metadata should exist"); + assert_eq!(read_metadata["persistedRead"], json!(true)); } #[test] diff --git a/crates/adapter-tools/src/builtin_tools/read_file.rs b/crates/adapter-tools/src/builtin_tools/read_file.rs index 232676cb..b5471120 100644 --- a/crates/adapter-tools/src/builtin_tools/read_file.rs +++ b/crates/adapter-tools/src/builtin_tools/read_file.rs @@ -28,7 +28,8 @@ use serde::Deserialize; use serde_json::json; use crate::builtin_tools::fs_common::{ - check_cancel, remember_file_observation, resolve_read_path, session_dir_for_tool_results, + check_cancel, merge_persisted_tool_output_metadata, remember_file_observation, + resolve_read_target, session_dir_for_tool_results, }; /// 二进制检测采样大小(前 N 字节)。 @@ -86,6 +87,9 @@ struct ReadFileArgs { /// 最大返回字符数,默认 20,000。 #[serde(default)] max_chars: Option, + /// 已持久化工具结果的字符窗口起点(0-based)。 + #[serde(default)] + char_offset: Option, /// 起始行号(0-based),用于跳过文件头部。 #[serde(default)] offset: Option, @@ -228,6 +232,11 @@ impl Tool for ReadFileTool { "minimum": 0, "description": "Starting line number (0-based). Skips lines before this offset." }, + "charOffset": { + "type": "integer", + "minimum": 0, + "description": "Starting character offset for persisted tool-result reads." + }, "limit": { "type": "integer", "minimum": 1, @@ -253,18 +262,24 @@ impl Tool for ReadFileTool { .compact_clearable(true) .prompt( ToolPromptMetadata::new( - "Read file contents — supports text, images (base64), targeted line-range \ - reads.", - "Use after `grep`/`findFiles` gives you a path. Set `offset` (**0-based** \ - line) + `limit` to read a specific range. Set `lineNumbers: false` to skip \ - line-number prefixes. `maxChars` (default 20000) includes line-number \ - prefixes in its budget.", + "Read file contents — supports text, images (base64), persisted tool-result \ + chunks, and targeted line-range reads.", + "Use after `grep`/`findFiles` gives you a path. For normal source files, use \ + `offset` (**0-based** line) + `limit` to read a specific range; set \ + `lineNumbers: false` to skip line-number prefixes. For persisted large tool \ + results, prefer chunked reads with `charOffset` + `maxChars` instead of \ + trying to inline the whole file again.", ) .caveat( - "If output is truncated, use `offset` + `limit` to read the next chunk — do \ - not retry with a larger `maxChars`.", + "If output is truncated, continue from the next chunk. For normal files, use \ + `offset` + `limit`; for persisted tool results, use `charOffset` + \ + `maxChars`. Do not retry by requesting the whole large result again.", ) .example("Read lines 50–100: { path: \"src/main.rs\", offset: 50, limit: 50 }") + .example( + "Read the first chunk of a persisted tool result: { path: \ + \"C:/.../tool-results/call-1.txt\", charOffset: 0, maxChars: 20000 }", + ) .prompt_tag("filesystem") .always_include(true), ) @@ -308,7 +323,9 @@ impl Tool for ReadFileTool { }); } - let path = resolve_read_path(ctx, &args.path)?; + let target = resolve_read_target(ctx, &args.path)?; + let path = target.path; + let is_persisted_tool_result = target.persisted_relative_path.is_some(); // 图片文件处理:返回 base64 编码 if is_image_file(&path) { @@ -351,6 +368,58 @@ impl Tool for ReadFileTool { let max_chars = args.max_chars.unwrap_or(20_000); + if is_persisted_tool_result { + if args.offset.is_some() || args.limit.is_some() { + return Err(AstrError::Validation( + "persisted tool-result reads do not support line-based `offset`/`limit`; use \ + `charOffset` + `maxChars` instead" + .to_string(), + )); + } + + let text = read_persisted_tool_result(&path)?; + let char_offset = args.char_offset.unwrap_or(0); + let persisted_chunk = read_persisted_tool_result_chunk(&text, char_offset, max_chars); + let recommended_next_args = persisted_chunk.next_char_offset.map(|next_char_offset| { + json!({ + "path": path.to_string_lossy(), + "charOffset": next_char_offset, + "maxChars": max_chars, + }) + }); + + return Ok(ToolExecutionResult { + tool_call_id, + tool_name: "readFile".to_string(), + ok: true, + output: persisted_chunk.text, + error: None, + metadata: Some(json!({ + "path": path.to_string_lossy(), + "absolutePath": path.to_string_lossy(), + "bytes": total_utf8_bytes(&text), + "persistedRead": true, + "charOffset": char_offset, + "returnedChars": persisted_chunk.returned_chars, + "nextCharOffset": persisted_chunk.next_char_offset, + "hasMore": persisted_chunk.has_more, + "recommendedNextArgs": recommended_next_args, + "relativePath": target.persisted_relative_path, + "truncated": persisted_chunk.has_more, + })), + child_ref: None, + duration_ms: started_at.elapsed().as_millis() as u64, + truncated: persisted_chunk.has_more, + }); + } + + if args.char_offset.is_some() { + return Err(AstrError::Validation( + "`charOffset` is only supported when reading persisted tool-result files" + .to_string(), + )); + } + // 二进制文件检测:避免将二进制文件内容作为乱码返回,浪费 context window if is_binary_file(&path)? { let metadata = std::fs::metadata(&path).ok(); @@ -445,12 +514,13 @@ impl Tool for ReadFileTool { &text, ctx.resolved_inline_limit(), ); + merge_persisted_tool_output_metadata(&mut meta_object, final_output.persisted.as_ref()); Ok(ToolExecutionResult { tool_call_id, tool_name: "readFile".to_string(), ok: true, - output: final_output, + output: final_output.output, error: None, metadata: Some(serde_json::Value::Object(meta_object)), child_ref: None, @@ -460,6 +530,44 @@ impl Tool for ReadFileTool { } } +#[derive(Debug, Clone, PartialEq, Eq)] +struct PersistedToolResultChunk { + text: String, + returned_chars: usize, + next_char_offset: Option, + has_more: bool, +} + +fn read_persisted_tool_result(path: &Path) -> Result { + fs::read_to_string(path) + .map_err(|e| AstrError::io(format!("failed reading file '{}'", path.display()), e)) +} + +fn total_utf8_bytes(text: &str) -> usize { + text.len() +} + +fn read_persisted_tool_result_chunk( + text: &str, + char_offset: usize, + max_chars: usize, +) -> PersistedToolResultChunk { + let total_chars = text.chars().count(); + let start = char_offset.min(total_chars); + let end = start.saturating_add(max_chars).min(total_chars); + let start_byte = char_count_to_byte_offset(text, start); + let end_byte = char_count_to_byte_offset(text, end); + let returned_chars = end.saturating_sub(start); + let has_more = end < total_chars; + + PersistedToolResultChunk { + text: text[start_byte..end_byte].to_string(), + returned_chars, + next_char_offset: has_more.then_some(end), + has_more, + } +} + /// 计算行号的显示宽度(字符数)。 /// /// 例如 999 行需要 3 位宽度, 1000 行需要 4 位宽度。 @@ -616,7 +724,10 @@ fn read_lines_range( #[cfg(test)] mod tests { use super::*; - use crate::test_support::test_tool_context_for; + use crate::{ + builtin_tools::fs_common::session_dir_for_tool_results, + test_support::{canonical_tool_path, test_tool_context_for}, + }; #[tokio::test] async fn read_file_tool_marks_truncated_output() { @@ -929,4 +1040,159 @@ mod tests { assert_eq!(metadata["bytes"], json!(19)); assert!(metadata.get("fileType").is_none()); } + + #[tokio::test] + async fn read_file_reads_first_persisted_chunk_by_absolute_path() { + let temp = tempfile::tempdir().expect("tempdir should be created"); + let ctx = test_tool_context_for(temp.path()); + let session_dir = + session_dir_for_tool_results(&ctx).expect("session tool-results dir should resolve"); + let persisted = session_dir.join("tool-results").join("chunked.json"); + tokio::fs::create_dir_all( + persisted + .parent() + .expect("persisted file should have parent"), + ) + .await + .expect("tool-results dir should be created"); + tokio::fs::write(&persisted, "ABCDEFGHIJ") + .await + .expect("persisted output should be written"); + let tool = ReadFileTool; + + let result = tool + .execute( + "tc-read-persisted-first".to_string(), + json!({ + "path": canonical_tool_path(&persisted).to_string_lossy(), + "charOffset": 0, + "maxChars": 4 + }), + &ctx, + ) + .await + .expect("readFile should open persisted tool result"); + + assert!(result.ok); + assert_eq!(result.output, "ABCD"); + assert!(result.truncated); + let metadata = result.metadata.expect("metadata should exist"); + assert_eq!(metadata["persistedRead"], json!(true)); + assert_eq!(metadata["charOffset"], json!(0)); + assert_eq!(metadata["returnedChars"], json!(4)); + assert_eq!(metadata["nextCharOffset"], json!(4)); + assert_eq!(metadata["hasMore"], json!(true)); + assert_eq!(metadata["relativePath"], json!("tool-results/chunked.json")); + assert_eq!( + metadata["absolutePath"], + json!(canonical_tool_path(&persisted)) + ); + } + + #[tokio::test] + async fn read_file_reads_follow_up_persisted_chunk_without_re_persisting() { + let temp = tempfile::tempdir().expect("tempdir should be created"); + let ctx = test_tool_context_for(temp.path()); + let session_dir = + session_dir_for_tool_results(&ctx).expect("session tool-results dir should resolve"); + let persisted = session_dir.join("tool-results").join("chunked-large.json"); + tokio::fs::create_dir_all( + persisted + .parent() + .expect("persisted file should have parent"), + ) + .await + .expect("tool-results dir should be created"); + let content = "[{\"id\":0}, {\"id\":1}, {\"id\":2}, {\"id\":3}]"; + tokio::fs::write(&persisted, content) + .await + .expect("persisted output should be written"); + let tool = ReadFileTool; + + let result = tool + .execute( + "tc-read-persisted-second".to_string(), + json!({ + "path": canonical_tool_path(&persisted).to_string_lossy(), + "charOffset": 5, + "maxChars": 8 + }), + &ctx, + ) + .await + .expect("readFile should page persisted tool result"); + + assert!(result.ok); + assert_eq!(result.output, "\":0}, {\""); + assert!(!result.output.contains("")); + let metadata = result.metadata.expect("metadata should exist"); + assert_eq!(metadata["persistedRead"], json!(true)); + assert_eq!(metadata["charOffset"], json!(5)); + assert_eq!(metadata["returnedChars"], json!(8)); + } + + #[tokio::test] + async fn read_file_rejects_line_pagination_for_persisted_tool_results() { + let temp = tempfile::tempdir().expect("tempdir should be created"); + let ctx = test_tool_context_for(temp.path()); + let session_dir = + session_dir_for_tool_results(&ctx).expect("session tool-results dir should resolve"); + let persisted = session_dir.join("tool-results").join("chunked.txt"); + tokio::fs::create_dir_all( + persisted + .parent() + .expect("persisted file should have parent"), + ) + .await + .expect("tool-results dir should be created"); + tokio::fs::write(&persisted, "line0\nline1\nline2\n") + .await + .expect("persisted output should be written"); + let tool = ReadFileTool; + + let err = tool + .execute( + "tc-read-persisted-invalid".to_string(), + json!({ + "path": canonical_tool_path(&persisted).to_string_lossy(), + "offset": 1, + "limit": 1 + }), + &ctx, + ) + .await + .expect_err("persisted tool results should reject line pagination"); + + assert!( + err.to_string() + .contains("persisted tool-result reads do not support") + ); + } + + #[tokio::test] + async fn read_file_rejects_char_offset_for_regular_files() { + let temp = tempfile::tempdir().expect("tempdir should be created"); + let file = temp.path().join("sample.txt"); + tokio::fs::write(&file, "line0\nline1\n") + .await + .expect("write should work"); + let tool = ReadFileTool; + + let err = tool + .execute( + "tc-read-regular-invalid".to_string(), + json!({ + "path": file.to_string_lossy(), + "charOffset": 2 + }), + &test_tool_context_for(temp.path()), + ) + .await + .expect_err("regular files should reject charOffset"); + + assert!( + err.to_string() + .contains("only supported when reading persisted") + ); + } } diff --git a/crates/adapter-tools/src/builtin_tools/shell.rs b/crates/adapter-tools/src/builtin_tools/shell.rs index f34536e8..3b49ddc3 100644 --- a/crates/adapter-tools/src/builtin_tools/shell.rs +++ b/crates/adapter-tools/src/builtin_tools/shell.rs @@ -41,7 +41,9 @@ use async_trait::async_trait; use serde::Deserialize; use serde_json::json; -use crate::builtin_tools::fs_common::{check_cancel, resolve_path, session_dir_for_tool_results}; +use crate::builtin_tools::fs_common::{ + check_cancel, merge_persisted_tool_output_metadata, resolve_path, session_dir_for_tool_results, +}; /// Shell 工具实现。 /// @@ -507,28 +509,32 @@ impl Tool for ShellTool { &output, ctx.resolved_inline_limit(), ); + let mut metadata = serde_json::Map::new(); + metadata.insert("command".to_string(), json!(command_text)); + metadata.insert("cwd".to_string(), json!(cwd_text.clone())); + metadata.insert("shell".to_string(), json!(shell_display.clone())); + metadata.insert("exitCode".to_string(), json!(-1)); + metadata.insert("streamed".to_string(), json!(true)); + metadata.insert("timedOut".to_string(), json!(true)); + metadata.insert( + "display".to_string(), + json!({ + "kind": "terminal", + "command": args.command, + "cwd": cwd_text, + "shell": spec.display_shell, + "exitCode": -1, + }), + ); + merge_persisted_tool_output_metadata(&mut metadata, output.persisted.as_ref()); return Ok(ToolExecutionResult { tool_call_id, tool_name: "shell".to_string(), ok: false, - output, + output: output.output, error: Some(format!("shell command timed out after {timeout_secs}s")), - metadata: Some(json!({ - "command": command_text, - "cwd": cwd_text.clone(), - "shell": shell_display.clone(), - "exitCode": -1, - "streamed": true, - "timedOut": true, - "display": { - "kind": "terminal", - "command": args.command, - "cwd": cwd_text, - "shell": spec.display_shell, - "exitCode": -1, - }, - })), + metadata: Some(serde_json::Value::Object(metadata)), child_ref: None, duration_ms: started_at.elapsed().as_millis() as u64, truncated: false, @@ -582,36 +588,46 @@ impl Tool for ShellTool { &output, ctx.resolved_inline_limit(), ); + let mut metadata = serde_json::Map::new(); + metadata.insert("command".to_string(), json!(command_text)); + metadata.insert("cwd".to_string(), json!(cwd_text.clone())); + metadata.insert("shell".to_string(), json!(shell_display)); + metadata.insert("exitCode".to_string(), json!(exit_code)); + metadata.insert("streamed".to_string(), json!(true)); + metadata.insert("stdoutBytes".to_string(), json!(stdout_capture.bytes_read)); + metadata.insert("stderrBytes".to_string(), json!(stderr_capture.bytes_read)); + metadata.insert( + "stdoutTruncated".to_string(), + json!(stdout_capture.truncated), + ); + metadata.insert( + "stderrTruncated".to_string(), + json!(stderr_capture.truncated), + ); + metadata.insert( + "display".to_string(), + json!({ + "kind": "terminal", + "command": args.command, + "cwd": cwd_text, + "shell": spec.display_shell, + "exitCode": exit_code, + }), + ); + metadata.insert("truncated".to_string(), json!(truncated)); + merge_persisted_tool_output_metadata(&mut metadata, output.persisted.as_ref()); Ok(ToolExecutionResult { tool_call_id, tool_name: "shell".to_string(), ok, - output, + output: output.output, error: if ok { None } else { Some(format!("shell command exited with code {}", exit_code)) }, - metadata: Some(json!({ - "command": command_text, - "cwd": cwd_text.clone(), - "shell": shell_display, - "exitCode": exit_code, - "streamed": true, - "stdoutBytes": stdout_capture.bytes_read, - "stderrBytes": stderr_capture.bytes_read, - "stdoutTruncated": stdout_capture.truncated, - "stderrTruncated": stderr_capture.truncated, - "display": { - "kind": "terminal", - "command": args.command, - "cwd": cwd_text, - "shell": spec.display_shell, - "exitCode": exit_code, - }, - "truncated": truncated, - })), + metadata: Some(serde_json::Value::Object(metadata)), child_ref: None, duration_ms: started_at.elapsed().as_millis() as u64, truncated, diff --git a/crates/core/src/event/translate.rs b/crates/core/src/event/translate.rs index 72798882..5bd019e6 100644 --- a/crates/core/src/event/translate.rs +++ b/crates/core/src/event/translate.rs @@ -17,6 +17,8 @@ use std::collections::HashMap; +use serde_json::json; + use super::phase::PhaseTracker; use crate::{ AgentEvent, AgentEventContext, Phase, StorageEvent, StorageEventPayload, StoredEvent, @@ -376,7 +378,39 @@ impl EventTranslator { warn_missing_turn_id(stored.storage_seq, "toolCallResult"); } }, - StorageEventPayload::ToolResultReferenceApplied { .. } => {}, + StorageEventPayload::ToolResultReferenceApplied { + tool_call_id, + persisted_output, + replacement, + .. + } => { + if let Some(turn_id) = turn_id_ref { + push(AgentEvent::ToolCallResult { + turn_id: turn_id.clone(), + agent: agent.clone(), + result: ToolExecutionResult { + tool_call_id: tool_call_id.clone(), + tool_name: self + .tool_call_names + .get(tool_call_id) + .cloned() + .unwrap_or_default(), + ok: true, + output: replacement.clone(), + error: None, + metadata: Some(json!({ + "persistedOutput": persisted_output, + "truncated": true, + })), + child_ref: None, + duration_ms: 0, + truncated: true, + }, + }); + } else { + warn_missing_turn_id(stored.storage_seq, "toolResultReferenceApplied"); + } + }, StorageEventPayload::TurnDone { .. } => { if let Some(turn_id) = turn_id_ref { push(AgentEvent::TurnDone { diff --git a/crates/core/src/event/types.rs b/crates/core/src/event/types.rs index 679649b0..3779f795 100644 --- a/crates/core/src/event/types.rs +++ b/crates/core/src/event/types.rs @@ -15,8 +15,8 @@ use serde_json::Value; use crate::{ AgentCollaborationFact, AgentEventContext, AstrError, ChildAgentRef, ChildSessionNotification, MailboxBatchAckedPayload, MailboxBatchStartedPayload, MailboxDiscardedPayload, - MailboxQueuedPayload, ResolvedExecutionLimitsSnapshot, ResolvedSubagentContextOverrides, - Result, SubRunResult, ToolOutputStream, UserMessageOrigin, + MailboxQueuedPayload, PersistedToolOutput, ResolvedExecutionLimitsSnapshot, + ResolvedSubagentContextOverrides, Result, SubRunResult, ToolOutputStream, UserMessageOrigin, }; /// Prompt/缓存指标共享载荷。 @@ -172,7 +172,7 @@ pub enum StorageEventPayload { /// 将大型工具结果替换为 `` 引用后的 durable 决策。 ToolResultReferenceApplied { tool_call_id: String, - persisted_relative_path: String, + persisted_output: PersistedToolOutput, replacement: String, original_bytes: u64, }, diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index c2ab57b1..690ed79b 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -154,6 +154,7 @@ pub use tool::{ ToolEventSink, ToolPromptMetadata, }; pub use tool_result_persist::{ - DEFAULT_TOOL_RESULT_INLINE_LIMIT, TOOL_RESULT_PREVIEW_LIMIT, TOOL_RESULTS_DIR, - is_persisted_output, maybe_persist_tool_result, persist_tool_result, + DEFAULT_TOOL_RESULT_INLINE_LIMIT, PersistedToolOutput, PersistedToolResult, + TOOL_RESULT_PREVIEW_LIMIT, TOOL_RESULTS_DIR, is_persisted_output, maybe_persist_tool_result, + persist_tool_result, persisted_output_absolute_path, }; diff --git a/crates/core/src/projection/agent_state.rs b/crates/core/src/projection/agent_state.rs index f34c5ff1..70acc370 100644 --- a/crates/core/src/projection/agent_state.rs +++ b/crates/core/src/projection/agent_state.rs @@ -480,7 +480,14 @@ mod tests { agent, StorageEventPayload::ToolResultReferenceApplied { tool_call_id: tool_call_id.into(), - persisted_relative_path: "tool-results/sample.txt".to_string(), + persisted_output: crate::PersistedToolOutput { + storage_kind: "toolResult".to_string(), + absolute_path: "~/.astrcode/tool-results/sample.txt".to_string(), + relative_path: "tool-results/sample.txt".to_string(), + total_bytes: 120, + preview_text: "preview".to_string(), + preview_bytes: 7, + }, replacement: replacement.to_string(), original_bytes: 120, }, @@ -897,8 +904,14 @@ mod tests { None, root_agent(), "tc1", - "\nOutput too large (120 bytes). Full output saved to: \ - tool-results/sample.txt\n", + "\nLarge tool output was saved to a file instead of being \ + inlined.\nPath: ~/.astrcode/tool-results/sample.txt\nBytes: 120\nRead the file \ + with \ + `readFile`.\nIf you only need a section, read a smaller chunk instead of the \ + whole file.\nStart from the first chunk when you do not yet know the right \ + section.\nSuggested first read: { path: \ + \"~/.astrcode/tool-results/sample.txt\", charOffset: 0, maxChars: 20000 }\n\ + ", ), turn_done(None, root_agent(), "completed"), ]; @@ -907,7 +920,7 @@ mod tests { assert!(matches!( &state.messages[2], - LlmMessage::Tool { content, .. } if content.contains("tool-results/sample.txt") + LlmMessage::Tool { content, .. } if content.contains("~/.astrcode/tool-results/sample.txt") )); } diff --git a/crates/core/src/tool_result_persist.rs b/crates/core/src/tool_result_persist.rs index 54a8c400..0d7b242f 100644 --- a/crates/core/src/tool_result_persist.rs +++ b/crates/core/src/tool_result_persist.rs @@ -13,7 +13,9 @@ //! 磁盘写入失败时降级为截断预览,不 panic、不返回错误。 //! 这保证了即使文件系统不可用,工具结果仍然能以截断形式传递给 LLM。 -use std::path::Path; +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; /// 工具结果存盘目录名(相对于 session 目录)。 pub const TOOL_RESULTS_DIR: &str = "tool-results"; @@ -27,14 +29,37 @@ pub const TOOL_RESULT_PREVIEW_LIMIT: usize = 2 * 1024; /// 覆盖为 per-tool 值。 pub const DEFAULT_TOOL_RESULT_INLINE_LIMIT: usize = 32 * 1024; +/// 已持久化工具结果的结构化元数据。 +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct PersistedToolOutput { + pub storage_kind: String, + pub absolute_path: String, + pub relative_path: String, + pub total_bytes: u64, + pub preview_text: String, + pub preview_bytes: u64, +} + +/// 工具结果经落盘决策后的统一返回值。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PersistedToolResult { + pub output: String, + pub persisted: Option, +} + /// 无条件将工具结果持久化到磁盘。 /// /// 不管内容大小,一律写入 `session_dir/tool-results/.txt`, -/// 返回 `` 格式的引用 + 预览。 +/// 返回 `` 格式的短引用。 /// 写入失败时降级为截断预览。 /// /// 供管线聚合预算层调用:当聚合预算超限时,选中的结果不管多大都需要落盘。 -pub fn persist_tool_result(session_dir: &Path, tool_call_id: &str, content: &str) -> String { +pub fn persist_tool_result( + session_dir: &Path, + tool_call_id: &str, + content: &str, +) -> PersistedToolResult { write_to_disk(session_dir, tool_call_id, content) } @@ -48,9 +73,12 @@ pub fn maybe_persist_tool_result( tool_call_id: &str, content: &str, inline_limit: usize, -) -> String { +) -> PersistedToolResult { if content.len() <= inline_limit { - return content.to_string(); + return PersistedToolResult { + output: content.to_string(), + persisted: None, + }; } write_to_disk(session_dir, tool_call_id, content) } @@ -60,6 +88,14 @@ pub fn is_persisted_output(content: &str) -> bool { content.contains("") } +/// 从 persisted wrapper 文本中提取绝对路径。 +pub fn persisted_output_absolute_path(content: &str) -> Option { + content.lines().find_map(|line| { + line.split_once("Path: ") + .map(|(_, path)| path.trim().to_string()) + }) +} + /// 解析工具结果内联阈值,支持环境变量覆盖。 /// /// 优先级(从高到低): @@ -140,8 +176,8 @@ fn camel_to_screaming_snake(s: &str) -> String { /// 包含完整的降级链路: /// 1. `create_dir_all` 失败 → 截断预览 /// 2. `fs::write` 失败 → 截断预览 -/// 3. 成功 → 生成 `` 引用 + 预览 -fn write_to_disk(session_dir: &Path, tool_call_id: &str, content: &str) -> String { +/// 3. 成功 → 生成 `` 短引用 + 结构化 persisted metadata +fn write_to_disk(session_dir: &Path, tool_call_id: &str, content: &str) -> PersistedToolResult { let content_bytes = content.len(); let results_dir = session_dir.join(TOOL_RESULTS_DIR); @@ -150,7 +186,10 @@ fn write_to_disk(session_dir: &Path, tool_call_id: &str, content: &str) -> Strin "tool-result: failed to create dir '{}', falling back to truncation", results_dir.display() ); - return truncate_with_notice(content); + return PersistedToolResult { + output: truncate_with_notice(content), + persisted: None, + }; } let safe_id: String = tool_call_id @@ -165,7 +204,10 @@ fn write_to_disk(session_dir: &Path, tool_call_id: &str, content: &str) -> Strin "tool-result: failed to write '{}', falling back to truncation", path.display() ); - return truncate_with_notice(content); + return PersistedToolResult { + output: truncate_with_notice(content), + persisted: None, + }; } let relative_path = path @@ -173,21 +215,59 @@ fn write_to_disk(session_dir: &Path, tool_call_id: &str, content: &str) -> Strin .unwrap_or(&path) .to_string_lossy() .replace('\\', "/"); + let persisted = PersistedToolOutput { + storage_kind: "toolResult".to_string(), + absolute_path: normalize_absolute_path(&path), + relative_path, + total_bytes: content_bytes as u64, + preview_text: build_preview_text(content), + preview_bytes: TOOL_RESULT_PREVIEW_LIMIT.min(content.len()) as u64, + }; + + PersistedToolResult { + output: format_persisted_output(&persisted), + persisted: Some(persisted), + } +} - format_persisted_output(&relative_path, content_bytes, content) +/// 生成 `` 格式的短引用。 +fn format_persisted_output(persisted: &PersistedToolOutput) -> String { + format!( + "\nLarge tool output was saved to a file instead of being \ + inlined.\nPath: {}\nBytes: {}\nRead the file with `readFile`.\nIf you only need a \ + section, read a smaller chunk instead of the whole file.\nStart from the first chunk \ + when you do not yet know the right section.\nSuggested first read: {{ path: {:?}, \ + charOffset: 0, maxChars: 20000 }}\n", + persisted.absolute_path, persisted.total_bytes, persisted.absolute_path + ) } -/// 生成 `` 格式的引用 + 预览。 -fn format_persisted_output(relative_path: &str, total_bytes: usize, content: &str) -> String { +fn build_preview_text(content: &str) -> String { let preview_limit = TOOL_RESULT_PREVIEW_LIMIT.min(content.len()); let truncated_at = content.floor_char_boundary(preview_limit); - let preview = &content[..truncated_at]; + content[..truncated_at].to_string() +} - format!( - "\nOutput too large ({total_bytes} bytes). Full output saved to: \ - {relative_path}\n\nPreview (first {preview_limit} bytes):\n{preview}\n...\nUse \ - `readFile` with the persisted path to view the full content.\n" - ) +fn normalize_absolute_path(path: &Path) -> String { + normalize_verbatim_path(path.to_path_buf()) + .to_string_lossy() + .to_string() +} + +fn normalize_verbatim_path(path: PathBuf) -> PathBuf { + #[cfg(windows)] + { + if let Some(rendered) = path.to_str() { + if let Some(stripped) = rendered.strip_prefix(r"\\?\UNC\") { + return PathBuf::from(format!(r"\\{}", stripped)); + } + if let Some(stripped) = rendered.strip_prefix(r"\\?\") { + return PathBuf::from(stripped); + } + } + } + + path } /// 截断内容并附加通知。 @@ -213,9 +293,15 @@ mod tests { let content = "x".repeat(100); let result = persist_tool_result(dir.path(), "call-abc123", &content); - assert!(result.contains("")); - assert!(result.contains("tool-results/call-abc123.txt")); - assert!(result.contains("100 bytes")); + assert!(result.output.contains("")); + assert!(result.output.contains("Large tool output was saved")); + let persisted = result.persisted.expect("persisted metadata should exist"); + assert!(result.output.contains(&persisted.absolute_path)); + assert!(result.output.contains("Bytes: 100")); + assert_eq!(persisted.relative_path, "tool-results/call-abc123.txt"); + assert_eq!(persisted.total_bytes, 100); + assert_eq!(persisted.preview_text, content); + assert_eq!(persisted.preview_bytes, 100); let file_path = dir.path().join("tool-results/call-abc123.txt"); assert!(file_path.exists()); @@ -231,7 +317,8 @@ mod tests { let content = "small".to_string(); let result = maybe_persist_tool_result(dir.path(), "call-1", &content, 1024); - assert_eq!(result, "small"); + assert_eq!(result.output, "small"); + assert!(result.persisted.is_none()); assert!(!dir.path().join("tool-results/call-1.txt").exists()); } @@ -241,7 +328,8 @@ mod tests { let content = "x".repeat(100); let result = maybe_persist_tool_result(dir.path(), "call-1", &content, 50); - assert!(result.contains("")); + assert!(result.output.contains("")); + assert!(result.persisted.is_some()); assert!(dir.path().join("tool-results/call-1.txt").exists()); } @@ -259,7 +347,10 @@ mod tests { let content = "x".repeat(100); let result = persist_tool_result(Path::new("/nonexistent/path"), "call-1", &content); // 降级为截断预览或成功写入(取决于平台) - assert!(result.contains("[output truncated") || result.contains("")); + assert!( + result.output.contains("[output truncated") + || result.output.contains("") + ); } #[test] @@ -275,6 +366,17 @@ mod tests { assert!(file.exists()); } + #[test] + fn persisted_output_absolute_path_extracts_new_wrapper_path() { + let wrapper = "\nLarge tool output was saved to a file instead of being \ + inlined.\nPath: ~/.astrcode/tool-results/call-1.txt\nBytes: 42\n\ + "; + assert_eq!( + persisted_output_absolute_path(wrapper), + Some("~/.astrcode/tool-results/call-1.txt".to_string()) + ); + } + #[test] fn camel_to_screaming_snake_converts_correctly() { assert_eq!(camel_to_screaming_snake("readFile"), "READ_FILE"); diff --git a/crates/session-runtime/src/context_window/compaction.rs b/crates/session-runtime/src/context_window/compaction.rs index 9ad023e2..092a0bbb 100644 --- a/crates/session-runtime/src/context_window/compaction.rs +++ b/crates/session-runtime/src/context_window/compaction.rs @@ -18,7 +18,7 @@ use std::sync::OnceLock; use astrcode_core::{ AstrError, CancelToken, CompactAppliedMeta, CompactMode, LlmMessage, LlmRequest, ModelLimits, Result, UserMessageOrigin, format_compact_summary, parse_compact_summary_message, - tool_result_persist::is_persisted_output, + tool_result_persist::{is_persisted_output, persisted_output_absolute_path}, }; use astrcode_kernel::KernelGateway; use chrono::{DateTime, Utc}; @@ -382,13 +382,8 @@ fn normalize_compaction_tool_content(content: &str) -> String { } fn summarize_persisted_tool_output(content: &str) -> String { - let persisted_path = content - .lines() - .find_map(|line| { - line.split_once("Full output saved to: ") - .map(|(_, path)| path.trim()) - }) - .unwrap_or("unknown persisted path"); + let persisted_path = persisted_output_absolute_path(content) + .unwrap_or_else(|| "unknown persisted path".to_string()); format!( "Large tool output was persisted instead of inlined.\nPersisted path: \ {persisted_path}\nPreserve only the conclusion, referenced path, and any error." diff --git a/crates/session-runtime/src/query/conversation.rs b/crates/session-runtime/src/query/conversation.rs index fd5ab482..56a928c6 100644 --- a/crates/session-runtime/src/query/conversation.rs +++ b/crates/session-runtime/src/query/conversation.rs @@ -1045,6 +1045,9 @@ impl ConversationDeltaProjector { fn replace_tool_duration(&mut self, index: usize, duration_ms: u64) -> bool { if let ConversationBlockFacts::ToolCall(block) = &mut self.blocks[index] { + if duration_ms == 0 && block.duration_ms.is_some() { + return false; + } if block.duration_ms == Some(duration_ms) { return false; } diff --git a/crates/session-runtime/src/turn/events.rs b/crates/session-runtime/src/turn/events.rs index 76b24ced..95721757 100644 --- a/crates/session-runtime/src/turn/events.rs +++ b/crates/session-runtime/src/turn/events.rs @@ -255,7 +255,7 @@ pub(crate) fn tool_result_reference_applied_event( turn_id: &str, agent: &AgentEventContext, tool_call_id: &str, - persisted_relative_path: &str, + persisted_output: &astrcode_core::PersistedToolOutput, replacement: &str, original_bytes: u64, ) -> StorageEvent { @@ -264,7 +264,7 @@ pub(crate) fn tool_result_reference_applied_event( agent: agent.clone(), payload: StorageEventPayload::ToolResultReferenceApplied { tool_call_id: tool_call_id.to_string(), - persisted_relative_path: persisted_relative_path.to_string(), + persisted_output: persisted_output.clone(), replacement: replacement.to_string(), original_bytes, }, diff --git a/crates/session-runtime/src/turn/tool_result_budget.rs b/crates/session-runtime/src/turn/tool_result_budget.rs index cc25d184..b00243c3 100644 --- a/crates/session-runtime/src/turn/tool_result_budget.rs +++ b/crates/session-runtime/src/turn/tool_result_budget.rs @@ -10,14 +10,15 @@ use std::{ }; use astrcode_core::{ - LlmMessage, Result, StorageEventPayload, is_persisted_output, persist_tool_result, + LlmMessage, PersistedToolOutput, Result, StorageEventPayload, is_persisted_output, + persist_tool_result, }; use crate::{SessionState, turn::events::tool_result_reference_applied_event}; #[derive(Debug, Clone, PartialEq, Eq)] pub struct ToolResultReplacementRecord { - pub persisted_relative_path: String, + pub persisted_output: PersistedToolOutput, pub replacement: String, pub original_bytes: u64, } @@ -60,7 +61,7 @@ impl ToolResultReplacementState { for stored in session_state.snapshot_recent_stored_events()? { if let StorageEventPayload::ToolResultReferenceApplied { tool_call_id, - persisted_relative_path, + persisted_output, replacement, original_bytes, } = stored.event.payload @@ -68,7 +69,7 @@ impl ToolResultReplacementState { state.replacements.insert( tool_call_id.clone(), ToolResultReplacementRecord { - persisted_relative_path, + persisted_output, replacement, original_bytes, }, @@ -176,13 +177,13 @@ pub fn apply_tool_result_budget( continue; }; let replacement = persist_tool_result(&session_dir, &tool_call_id, content); - let Some(persisted_relative_path) = extract_persisted_relative_path(&replacement) else { + let Some(persisted_output) = replacement.persisted.clone() else { continue; }; - let saved_bytes = original_len.saturating_sub(replacement.len()); + let saved_bytes = original_len.saturating_sub(replacement.output.len()); let record = ToolResultReplacementRecord { - persisted_relative_path: persisted_relative_path.clone(), - replacement: replacement.clone(), + persisted_output: persisted_output.clone(), + replacement: replacement.output.clone(), original_bytes: original_len as u64, }; request @@ -190,19 +191,19 @@ pub fn apply_tool_result_budget( .record_replacement(tool_call_id.clone(), record.clone()); messages[index] = LlmMessage::Tool { tool_call_id: tool_call_id.clone(), - content: replacement.clone(), + content: replacement.output.clone(), }; events.push(tool_result_reference_applied_event( request.turn_id, request.agent, &tool_call_id, - &record.persisted_relative_path, + &record.persisted_output, &record.replacement, record.original_bytes, )); total_bytes = total_bytes .saturating_sub(original_len) - .saturating_add(replacement.len()); + .saturating_add(replacement.output.len()); stats.replacement_count = stats.replacement_count.saturating_add(1); stats.bytes_saved = stats.bytes_saved.saturating_add(saved_bytes); replaced.insert(tool_call_id); @@ -253,13 +254,6 @@ fn resolve_session_dir(working_dir: &Path, session_id: &str) -> Result .join(session_id)) } -fn extract_persisted_relative_path(replacement: &str) -> Option { - replacement.lines().find_map(|line| { - line.split_once("Full output saved to: ") - .map(|(_, path)| path.trim().to_string()) - }) -} - #[cfg(test)] mod tests { use astrcode_core::{AgentEventContext, EventTranslator, StorageEvent, UserMessageOrigin}; @@ -278,8 +272,15 @@ mod tests { let tempdir = tempfile::tempdir().expect("tempdir should exist"); let agent = AgentEventContext::default(); let mut translator = EventTranslator::new(session_state.current_phase().expect("phase")); - let replacement = "\nOutput too large (999 bytes). Full output saved \ - to: tool-results/call-1.txt\n"; + let replacement = "\nLarge tool output was saved to a file instead of \ + being inlined.\nPath: ~/.astrcode/tool-results/call-1.txt\nBytes: \ + 999\nRead the \ + file with `readFile`.\nIf you only need a section, read a smaller \ + chunk instead of the whole file.\nStart from the first chunk when you \ + do not yet know the right section.\nSuggested first read: { path: \ + \"~/.astrcode/tool-results/call-1.txt\", charOffset: 0, maxChars: \ + 20000 \ + }\n"; append_and_broadcast( &session_state, &StorageEvent { @@ -287,7 +288,14 @@ mod tests { agent: agent.clone(), payload: StorageEventPayload::ToolResultReferenceApplied { tool_call_id: "call-1".to_string(), - persisted_relative_path: "tool-results/call-1.txt".to_string(), + persisted_output: PersistedToolOutput { + storage_kind: "toolResult".to_string(), + absolute_path: "~/.astrcode/tool-results/call-1.txt".to_string(), + relative_path: "tool-results/call-1.txt".to_string(), + total_bytes: 999, + preview_text: "preview".to_string(), + preview_bytes: 7, + }, replacement: replacement.to_string(), original_bytes: 999, }, diff --git a/frontend/src/components/Chat/ToolCallBlock.test.tsx b/frontend/src/components/Chat/ToolCallBlock.test.tsx index 3e8c5210..b20b5c09 100644 --- a/frontend/src/components/Chat/ToolCallBlock.test.tsx +++ b/frontend/src/components/Chat/ToolCallBlock.test.tsx @@ -98,6 +98,52 @@ describe('ToolCallBlock', () => { expect(html).toContain('结果'); }); + it('renders a persisted result card instead of the raw wrapper text', () => { + const html = renderToStaticMarkup( + + \nLarge tool output was saved to a file instead of being inlined.\nPath: ~/.astrcode/tool-results/call-1.txt\n', + metadata: { + persistedOutput: { + storageKind: 'toolResult', + absolutePath: '~/.astrcode/tool-results/call-1.txt', + relativePath: 'tool-results/call-1.txt', + totalBytes: 4096, + previewText: '[{"file":"src/lib.rs"}]', + previewBytes: 23, + }, + truncated: true, + }, + streams: { + stdout: '', + stderr: '', + }, + timestamp: Date.now(), + }} + /> + + ); + + expect(html).toContain('已持久化结果'); + expect(html).toContain('~/.astrcode/tool-results/call-1.txt'); + expect(html).toContain('tool-results/call-1.txt'); + expect(html).toContain( + 'readFile { path: "~/.astrcode/tool-results/call-1.txt", charOffset: 0, maxChars: 20000 }' + ); + expect(html).not.toContain('Large tool output was saved to a file instead of being inlined.'); + }); + it('renders child session navigation action from explicit child ref', () => { const html = renderToStaticMarkup( diff --git a/frontend/src/components/Chat/ToolCallBlock.tsx b/frontend/src/components/Chat/ToolCallBlock.tsx index de8c3f6a..fe85d65f 100644 --- a/frontend/src/components/Chat/ToolCallBlock.tsx +++ b/frontend/src/components/Chat/ToolCallBlock.tsx @@ -2,6 +2,7 @@ import { memo, useEffect, useRef, useState } from 'react'; import type { ToolCallMessage } from '../../types'; import { + extractPersistedToolOutput, extractStructuredArgs, extractStructuredJsonOutput, extractToolMetadataSummary, @@ -82,11 +83,47 @@ function resultTextSurface(text: string, tone: 'normal' | 'error') { ); } +function persistedToolResultSurface( + absolutePath: string, + relativePath: string, + totalBytes: number, + previewText: string +) { + const suggestedRead = `readFile { path: "${absolutePath}", charOffset: 0, maxChars: 20000 }`; + + return ( +
    +
    + 已持久化结果 + {totalBytes} bytes +
    +
    +
    +
    绝对路径
    +
    {absolutePath}
    +
    +
    +
    会话相对路径
    +
    {relativePath}
    +
    + {previewText ? ( + + ) : null} +
    +
    建议读取第一页
    +
    {suggestedRead}
    +
    +
    +
    + ); +} + function ToolCallBlock({ message }: ToolCallBlockProps) { const { onOpenChildSession, onOpenSubRun } = useChatScreenContext(); const viewportRef = useRef(null); useNestedScrollContainment(viewportRef); const shellDisplay = extractToolShellDisplay(message.metadata); + const persistedOutput = extractPersistedToolOutput(message.metadata); const summary = formatToolCallSummary( message.toolName, message.args, @@ -103,7 +140,10 @@ function ToolCallBlock({ message }: ToolCallBlockProps) { const structuredArgs = extractStructuredArgs(message.args); const metadataSummary = extractToolMetadataSummary(message.metadata); const fallbackResult = - message.error?.trim() || message.output?.trim() || metadataSummary?.message?.trim() || ''; + message.error?.trim() || + (persistedOutput ? '' : message.output?.trim()) || + metadataSummary?.message?.trim() || + ''; const structuredFallbackResult = extractStructuredJsonOutput(fallbackResult); const defaultOpen = message.status === 'fail'; const explicitError = message.error?.trim() || ''; @@ -206,7 +246,40 @@ function ToolCallBlock({ message }: ToolCallBlockProps) { {resultTextSurface(explicitError, 'error')}
    )} + {persistedOutput && ( +
    +
    +
    + 结果 + persisted output +
    + {statusLabel(message.status)} +
    + {persistedToolResultSurface( + persistedOutput.absolutePath, + persistedOutput.relativePath, + persistedOutput.totalBytes, + persistedOutput.previewText + )} +
    + )} + ) : persistedOutput ? ( +
    +
    +
    + 结果 + persisted output +
    + {statusLabel(message.status)} +
    + {persistedToolResultSurface( + persistedOutput.absolutePath, + persistedOutput.relativePath, + persistedOutput.totalBytes, + persistedOutput.previewText + )} +
    ) : fallbackResult ? ( structuredFallbackResult ? (
    diff --git a/frontend/src/lib/toolDisplay.test.ts b/frontend/src/lib/toolDisplay.test.ts index 21b21434..d2a4d935 100644 --- a/frontend/src/lib/toolDisplay.test.ts +++ b/frontend/src/lib/toolDisplay.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'; import { appendToolDeltaMetadata, + extractPersistedToolOutput, extractStructuredArgs, extractStructuredJsonOutput, extractToolMetadataSummary, @@ -175,6 +176,33 @@ describe('toolDisplay shell metadata helpers', () => { }); }); + it('extracts persisted tool output metadata and surfaces persisted pills', () => { + const metadata = { + persistedOutput: { + storageKind: 'toolResult', + absolutePath: '~/.astrcode/tool-results/call-1.txt', + relativePath: 'tool-results/call-1.txt', + totalBytes: 4096, + previewText: '[{"id":1}]', + previewBytes: 10, + }, + truncated: true, + }; + + expect(extractPersistedToolOutput(metadata)).toEqual({ + storageKind: 'toolResult', + absolutePath: '~/.astrcode/tool-results/call-1.txt', + relativePath: 'tool-results/call-1.txt', + totalBytes: 4096, + previewText: '[{"id":1}]', + previewBytes: 10, + }); + expect(extractToolMetadataSummary(metadata)).toEqual({ + message: undefined, + pills: ['persisted', '4096 bytes', 'truncated'], + }); + }); + it('returns null when metadata has no user-facing summary fields', () => { expect(extractToolMetadataSummary({ path: '/repo/file.ts' })).toBeNull(); }); diff --git a/frontend/src/lib/toolDisplay.ts b/frontend/src/lib/toolDisplay.ts index 3e3b85f2..d7a1a2a8 100644 --- a/frontend/src/lib/toolDisplay.ts +++ b/frontend/src/lib/toolDisplay.ts @@ -19,6 +19,15 @@ export interface ToolMetadataSummary { pills: string[]; } +export interface PersistedToolOutputMetadata { + storageKind: 'toolResult'; + absolutePath: string; + relativePath: string; + totalBytes: number; + previewText: string; + previewBytes: number; +} + export interface StructuredJsonOutput { value: UnknownRecord | unknown[]; summary: string; @@ -92,6 +101,42 @@ export function extractToolShellDisplay(metadata: unknown): ToolShellDisplayMeta }; } +export function extractPersistedToolOutput( + metadata: unknown +): PersistedToolOutputMetadata | null { + const container = asRecord(metadata); + const persisted = asRecord(container?.persistedOutput); + if (!persisted) { + return null; + } + + const absolutePath = pickString(persisted, 'absolutePath'); + const relativePath = pickString(persisted, 'relativePath'); + const totalBytes = pickNumber(persisted, 'totalBytes'); + const previewText = pickString(persisted, 'previewText'); + const previewBytes = pickNumber(persisted, 'previewBytes'); + + if ( + persisted.storageKind !== 'toolResult' || + !absolutePath || + !relativePath || + totalBytes === undefined || + previewText === undefined || + previewBytes === undefined + ) { + return null; + } + + return { + storageKind: 'toolResult', + absolutePath, + relativePath, + totalBytes, + previewText, + previewBytes, + }; +} + export function formatToolShellPreview( display: ToolShellDisplayMetadata | null, fallbackToolName: string, @@ -193,6 +238,13 @@ export function extractToolMetadataSummary(metadata: unknown): ToolMetadataSumma const pills: string[] = []; const message = pickString(container, 'message'); + const persisted = extractPersistedToolOutput(container); + if (persisted) { + pills.push('persisted'); + if (pickNumber(container, 'bytes') === undefined) { + pills.push(`${persisted.totalBytes} bytes`); + } + } pushNumberPill(pills, container, ['count'], (value) => `${value} items`); pushNumberPill(pills, container, ['returned'], (value) => `${value} returned`); From d5f5093d847416abf337b79300c906896dd73020 Mon Sep 17 00:00:00 2001 From: whatevertogo Date: Fri, 17 Apr 2026 18:41:24 +0800 Subject: [PATCH 22/53] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor(tests):=20?= =?UTF-8?q?=E4=BC=98=E5=8C=96=E6=8C=81=E4=B9=85=E5=8C=96=E8=BE=93=E5=87=BA?= =?UTF-8?q?=E7=9A=84=E6=A0=BC=E5=BC=8F=EF=BC=8C=E7=AE=80=E5=8C=96=E8=B7=AF?= =?UTF-8?q?=E5=BE=84=E5=92=8C=E5=AD=97=E8=8A=82=E4=BF=A1=E6=81=AF=E7=9A=84?= =?UTF-8?q?=E5=B1=95=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/core/src/projection/agent_state.rs | 10 ++++------ crates/core/src/tool_result_persist.rs | 4 ++-- .../session-runtime/src/turn/tool_result_budget.rs | 12 +++++------- 3 files changed, 11 insertions(+), 15 deletions(-) diff --git a/crates/core/src/projection/agent_state.rs b/crates/core/src/projection/agent_state.rs index 70acc370..8ed5b3b8 100644 --- a/crates/core/src/projection/agent_state.rs +++ b/crates/core/src/projection/agent_state.rs @@ -906,12 +906,10 @@ mod tests { "tc1", "\nLarge tool output was saved to a file instead of being \ inlined.\nPath: ~/.astrcode/tool-results/sample.txt\nBytes: 120\nRead the file \ - with \ - `readFile`.\nIf you only need a section, read a smaller chunk instead of the \ - whole file.\nStart from the first chunk when you do not yet know the right \ - section.\nSuggested first read: { path: \ - \"~/.astrcode/tool-results/sample.txt\", charOffset: 0, maxChars: 20000 }\n\ - ", + with `readFile`.\nIf you only need a section, read a smaller chunk instead of \ + the whole file.\nStart from the first chunk when you do not yet know the right \ + section.\nSuggested first read: { path: \"~/.astrcode/tool-results/sample.txt\", \ + charOffset: 0, maxChars: 20000 }\n", ), turn_done(None, root_agent(), "completed"), ]; diff --git a/crates/core/src/tool_result_persist.rs b/crates/core/src/tool_result_persist.rs index 0d7b242f..f0b2999e 100644 --- a/crates/core/src/tool_result_persist.rs +++ b/crates/core/src/tool_result_persist.rs @@ -369,8 +369,8 @@ mod tests { #[test] fn persisted_output_absolute_path_extracts_new_wrapper_path() { let wrapper = "\nLarge tool output was saved to a file instead of being \ - inlined.\nPath: ~/.astrcode/tool-results/call-1.txt\nBytes: 42\n\ - "; + inlined.\nPath: ~/.astrcode/tool-results/call-1.txt\nBytes: \ + 42\n"; assert_eq!( persisted_output_absolute_path(wrapper), Some("~/.astrcode/tool-results/call-1.txt".to_string()) diff --git a/crates/session-runtime/src/turn/tool_result_budget.rs b/crates/session-runtime/src/turn/tool_result_budget.rs index b00243c3..575213fe 100644 --- a/crates/session-runtime/src/turn/tool_result_budget.rs +++ b/crates/session-runtime/src/turn/tool_result_budget.rs @@ -274,13 +274,11 @@ mod tests { let mut translator = EventTranslator::new(session_state.current_phase().expect("phase")); let replacement = "\nLarge tool output was saved to a file instead of \ being inlined.\nPath: ~/.astrcode/tool-results/call-1.txt\nBytes: \ - 999\nRead the \ - file with `readFile`.\nIf you only need a section, read a smaller \ - chunk instead of the whole file.\nStart from the first chunk when you \ - do not yet know the right section.\nSuggested first read: { path: \ - \"~/.astrcode/tool-results/call-1.txt\", charOffset: 0, maxChars: \ - 20000 \ - }\n"; + 999\nRead the file with `readFile`.\nIf you only need a section, read \ + a smaller chunk instead of the whole file.\nStart from the first chunk \ + when you do not yet know the right section.\nSuggested first read: { \ + path: \"~/.astrcode/tool-results/call-1.txt\", charOffset: 0, \ + maxChars: 20000 }\n"; append_and_broadcast( &session_state, &StorageEvent { From 66263704a8b21941674334dffe58ebbaa3207450 Mon Sep 17 00:00:00 2001 From: whatevertogo Date: Fri, 17 Apr 2026 19:25:39 +0800 Subject: [PATCH 23/53] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor(transcript)?= =?UTF-8?q?:=20=E6=B7=BB=E5=8A=A0=E6=81=A2=E5=A4=8D=E5=B0=BE=E9=83=A8?= =?UTF-8?q?=E8=B7=9F=E9=9A=8F=E6=A8=A1=E5=BC=8F=E7=9A=84=E6=96=B9=E6=B3=95?= =?UTF-8?q?=EF=BC=8C=E4=BC=98=E5=8C=96=E6=89=8B=E5=8A=A8=E6=BB=9A=E5=8A=A8?= =?UTF-8?q?=E5=90=8E=E7=9A=84=E7=8A=B6=E6=80=81=E7=AE=A1=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/cli/src/app/coordinator.rs | 3 ++ crates/cli/src/app/mod.rs | 52 ++++++++++++++++++++ crates/cli/src/render/mod.rs | 80 ++++++++++++++++++++++++++++++- crates/cli/src/state/mod.rs | 21 ++++++++ 4 files changed, 154 insertions(+), 2 deletions(-) diff --git a/crates/cli/src/app/coordinator.rs b/crates/cli/src/app/coordinator.rs index bb689405..f6a30e77 100644 --- a/crates/cli/src/app/coordinator.rs +++ b/crates/cli/src/app/coordinator.rs @@ -41,6 +41,9 @@ where self.state.set_error_status("no active session"); return; }; + // 新一轮对话开始时恢复 transcript 跟随尾部,避免旧的手动滚动状态 + // 把新回复固定在历史位置,看起来像“后半段没渲染”。 + self.state.resume_transcript_tail(); self.state.set_status("submitting prompt"); let client = self.client.clone(); self.dispatch_async(async move { diff --git a/crates/cli/src/app/mod.rs b/crates/cli/src/app/mod.rs index 68443d5d..c1313b98 100644 --- a/crates/cli/src/app/mod.rs +++ b/crates/cli/src/app/mod.rs @@ -1185,6 +1185,58 @@ mod tests { transport.assert_consumed(); } + #[tokio::test] + async fn submitting_prompt_restores_transcript_tail_follow_mode() { + let transport = MockTransport::default(); + transport.push(MockCall::Request { + expected: AstrcodeTransportRequest { + method: AstrcodeTransportMethod::Post, + url: "http://localhost:5529/api/sessions/session-1/prompts".to_string(), + auth_token: Some("session-token".to_string()), + query: Vec::new(), + json_body: Some(json!({ + "text": "hello" + })), + }, + result: Ok(AstrcodeTransportResponse { + status: 202, + body: json!({ + "sessionId": "session-1", + "accepted": true, + "turnId": "turn-1" + }) + .to_string(), + }), + }); + + let (actions_tx, actions_rx) = mpsc::unbounded_channel(); + let mut controller = AppController::new( + client_with_transport(transport.clone()), + CliState::new( + "http://localhost:5529".to_string(), + Some(PathBuf::from("D:/repo-a")), + ascii_capabilities(), + ), + None, + AppControllerChannels::new(actions_tx, actions_rx), + ); + controller.state.conversation.active_session_id = Some("session-1".to_string()); + controller.state.scroll_up_by(6); + controller.state.replace_input("hello"); + + controller.submit_current_input().await; + + assert_eq!(controller.state.interaction.scroll_anchor, 0); + assert!(controller.state.interaction.follow_transcript_tail); + + handle_next_action(&mut controller).await; + assert_eq!( + controller.state.interaction.status.message, + "prompt accepted: turn turn-1" + ); + transport.assert_consumed(); + } + #[tokio::test] async fn end_to_end_acceptance_covers_resume_compact_skill_and_single_active_stream_switch() { let transport = MockTransport::default(); diff --git a/crates/cli/src/render/mod.rs b/crates/cli/src/render/mod.rs index b808a339..46892b49 100644 --- a/crates/cli/src/render/mod.rs +++ b/crates/cli/src/render/mod.rs @@ -41,7 +41,7 @@ pub fn render(frame: &mut Frame<'_>, state: &mut CliState) { fn render_transcript(frame: &mut Frame<'_>, state: &mut CliState, area: Rect, theme: &CodexTheme) { let transcript = &state.render.transcript_cache; - let viewport_height = area.height.saturating_sub(1); + let viewport_height = area.height; let scroll = transcript_scroll_offset( transcript.lines.len(), viewport_height, @@ -59,7 +59,6 @@ fn render_transcript(frame: &mut Frame<'_>, state: &mut CliState, area: Rect, th .map(|line| ui::line_to_ratatui(line, theme)) .collect::>(), ) - .wrap(Wrap { trim: false }) .scroll((scroll, 0)), area, ); @@ -374,4 +373,81 @@ mod tests { 3 ); } + + #[test] + fn transcript_render_uses_prewrapped_cache_without_extra_paragraph_wrapping() { + use crate::state::{WrappedLine, WrappedLineStyle}; + + let backend = TestBackend::new(40, 10); + let mut terminal = Terminal::new(backend).expect("terminal"); + let mut state = CliState::new( + "http://127.0.0.1:5529".to_string(), + None, + capabilities(GlyphMode::Unicode), + ); + state.set_viewport_size(40, 10); + state.update_transcript_cache( + 38, + vec![ + WrappedLine { + style: WrappedLineStyle::Plain, + content: "这一行故意非常非常非常长,只有在 Paragraph 再次 wrap \ + 时才会额外占用多行。" + .to_string(), + }, + WrappedLine { + style: WrappedLineStyle::Plain, + content: "第二行".to_string(), + }, + WrappedLine { + style: WrappedLineStyle::Plain, + content: "第三行".to_string(), + }, + WrappedLine { + style: WrappedLineStyle::Plain, + content: "第四行".to_string(), + }, + WrappedLine { + style: WrappedLineStyle::Plain, + content: "目标尾行".to_string(), + }, + ], + None, + ); + + terminal + .draw(|frame| render(frame, &mut state)) + .expect("draw"); + let text = terminal + .backend() + .buffer() + .content + .iter() + .map(|cell| cell.symbol()) + .collect::(); + let buffer = terminal.backend().buffer(); + let rendered_rows = (0..buffer.area.height) + .map(|row| { + let start = usize::from(row) * usize::from(buffer.area.width); + let end = start + usize::from(buffer.area.width); + buffer.content[start..end] + .iter() + .map(|cell| cell.symbol()) + .collect::() + .replace(' ', "") + }) + .collect::>(); + + assert!( + rendered_rows + .first() + .is_some_and(|row| row.starts_with("这一行故意非常非常非常长")), + "the first cached line should remain visible when the transcript exactly fits" + ); + assert!( + rendered_rows.iter().any(|row| row.contains("目标尾行")), + "tail-follow transcript should keep the final cached line visible" + ); + assert!(text.contains("第")); + } } diff --git a/crates/cli/src/state/mod.rs b/crates/cli/src/state/mod.rs index 392beba4..53f50247 100644 --- a/crates/cli/src/state/mod.rs +++ b/crates/cli/src/state/mod.rs @@ -175,6 +175,11 @@ impl CliState { self.render.mark_transcript_dirty(); } + pub fn resume_transcript_tail(&mut self) { + self.interaction.reset_scroll(); + self.render.invalidate_transcript_cache(); + } + pub fn cycle_focus_forward(&mut self) { self.interaction.cycle_focus_forward(); self.render.invalidate_transcript_cache(); @@ -558,6 +563,22 @@ mod tests { assert!(state.interaction.follow_transcript_tail); } + #[test] + fn resume_transcript_tail_restores_follow_mode_after_manual_scroll() { + let mut state = CliState::new("http://127.0.0.1:5529".to_string(), None, capabilities()); + + state.scroll_up_by(4); + assert_eq!(state.interaction.scroll_anchor, 4); + assert!(!state.interaction.follow_transcript_tail); + + state.resume_transcript_tail(); + + assert_eq!(state.interaction.scroll_anchor, 0); + assert!(state.interaction.follow_transcript_tail); + assert!(state.interaction.selection_drives_scroll); + assert!(state.render.dirty.transcript); + } + #[test] fn ticking_advances_streaming_thinking() { let mut state = CliState::new("http://127.0.0.1:5529".to_string(), None, capabilities()); From 6ad1ccf8bea70fe1d639f0c860218f4d11cc763b Mon Sep 17 00:00:00 2001 From: whatevertogo Date: Sat, 18 Apr 2026 12:10:49 +0800 Subject: [PATCH 24/53] cli --- AGENTS.md | 34 +- CLAUDE.md | 7 +- Cargo.lock | 645 ++++++++++++++-- crates/application/src/lib.rs | 9 +- .../application/src/ports/composer_skill.rs | 8 + crates/application/src/ports/mod.rs | 2 +- crates/application/src/session_use_cases.rs | 125 +++- crates/application/src/terminal_use_cases.rs | 21 +- crates/cli/Cargo.toml | 6 +- crates/cli/src/app/coordinator.rs | 161 +++- crates/cli/src/app/mod.rs | 453 ++++++++---- crates/cli/src/bottom_pane/layout.rs | 68 ++ crates/cli/src/bottom_pane/mod.rs | 7 + crates/cli/src/bottom_pane/model.rs | 223 ++++++ crates/cli/src/bottom_pane/render.rs | 178 +++++ crates/cli/src/chat/mod.rs | 3 + crates/cli/src/chat/surface.rs | 293 ++++++++ crates/cli/src/command/mod.rs | 92 ++- crates/cli/src/lib.rs | 4 + crates/cli/src/model/events.rs | 55 ++ crates/cli/src/model/mod.rs | 2 + crates/cli/src/model/reducer.rs | 564 ++++++++++++++ crates/cli/src/render/commit.rs | 2 + crates/cli/src/render/live.rs | 2 + crates/cli/src/render/mod.rs | 456 +----------- crates/cli/src/render/wrap.rs | 106 +++ crates/cli/src/state/conversation.rs | 261 ++++++- crates/cli/src/state/interaction.rs | 233 ++++-- crates/cli/src/state/mod.rs | 309 ++++---- crates/cli/src/state/render.rs | 143 +--- crates/cli/src/state/shell.rs | 8 + crates/cli/src/tui/mod.rs | 3 + crates/cli/src/tui/runtime.rs | 141 ++++ crates/cli/src/ui/cells.rs | 690 ++++++++++++++++-- crates/cli/src/ui/composer.rs | 68 ++ crates/cli/src/ui/custom_terminal.rs | 458 ++++++++++++ crates/cli/src/ui/footer.rs | 168 ----- crates/cli/src/ui/hero.rs | 332 --------- crates/cli/src/ui/host.rs | 149 ++++ crates/cli/src/ui/hud.rs | 102 +++ crates/cli/src/ui/insert_history.rs | 307 ++++++++ crates/cli/src/ui/mod.rs | 12 +- crates/cli/src/ui/overlay.rs | 178 +++++ crates/cli/src/ui/palette.rs | 63 +- crates/cli/src/ui/theme.rs | 44 +- crates/cli/src/ui/transcript.rs | 124 ---- crates/client/src/lib.rs | 165 ++++- crates/protocol/src/http/mod.rs | 2 +- crates/protocol/src/http/session.rs | 14 + .../tests/fixtures/terminal/v1/snapshot.json | 10 +- crates/protocol/tests/terminal_conformance.rs | 10 +- .../server/src/bootstrap/composer_skills.rs | 14 +- .../src/http/routes/sessions/mutation.rs | 12 +- .../server/src/tests/config_routes_tests.rs | 83 +++ .../.openspec.yaml | 0 .../design.md | 0 .../proposal.md | 0 .../specs/application-use-cases/spec.md | 0 .../specs/session-runtime/spec.md | 0 .../specs/turn-budget-governance/spec.md | 0 .../specs/turn-observability/spec.md | 0 .../tasks.md | 0 .../.openspec.yaml | 0 .../design.md | 0 .../proposal.md | 0 .../specs/agent-delivery-contracts/spec.md | 0 .../specs/application-use-cases/spec.md | 0 .../specs/kernel/spec.md | 0 .../specs/subrun-status-contracts/spec.md | 0 .../tasks.md | 0 .../.openspec.yaml | 0 .../design.md | 0 .../proposal.md | 0 .../specs/agent-profile-resolution/spec.md | 0 .../specs/application-use-cases/spec.md | 0 .../specs/kernel/spec.md | 0 .../specs/root-agent-execution/spec.md | 0 .../specs/subagent-execution/spec.md | 0 .../tasks.md | 0 .../.openspec.yaml | 0 .../design.md | 0 .../proposal.md | 0 .../specs/application-use-cases/spec.md | 0 .../specs/kernel/spec.md | 0 .../specs/plugin-capability-surface/spec.md | 0 .../specs/plugin-governance-lifecycle/spec.md | 0 .../tasks.md | 0 .../.openspec.yaml | 0 .../design.md | 0 .../proposal.md | 0 .../specs/composer-execution-controls/spec.md | 0 .../specs/root-agent-execution/spec.md | 0 .../specs/subagent-execution/spec.md | 0 .../specs/turn-budget-governance/spec.md | 0 .../tasks.md | 0 .../.openspec.yaml | 0 .../design.md | 0 .../proposal.md | 0 .../runtime-migration-baseline.md | 0 .../specs/adapter-contracts/spec.md | 0 .../specs/application-use-cases/spec.md | 0 .../specs/capability-semantic-model/spec.md | 0 .../specs/kernel/spec.md | 0 .../specs/session-runtime/spec.md | 0 .../tasks.md | 0 .../.openspec.yaml | 0 .../design.md | 0 .../proposal.md | 0 .../specs/capability-semantic-model/spec.md | 0 .../specs/tool-and-skill-discovery/spec.md | 0 .../tasks.md | 0 .../.openspec.yaml | 0 .../design.md | 0 .../proposal.md | 0 .../specs/agent-execution/spec.md | 0 .../specs/agent-profile-resolution/spec.md | 0 .../specs/root-agent-execution/spec.md | 0 .../specs/subagent-execution/spec.md | 0 .../tasks.md | 0 .../.openspec.yaml | 0 .../design.md | 0 .../proposal.md | 0 .../specs/agent-delivery-contracts/spec.md | 0 .../specs/agent-execution/spec.md | 0 .../specs/agent-lifecycle/spec.md | 0 .../specs/subagent-execution/spec.md | 0 .../tasks.md | 0 .../.openspec.yaml | 0 .../design.md | 0 .../proposal.md | 0 .../runtime-observability-pipeline/spec.md | 0 .../specs/turn-observability/spec.md | 0 .../tasks.md | 0 .../.openspec.yaml | 0 .../design.md | 0 .../proposal.md | 0 .../specs/agent-execution/spec.md | 0 .../specs/agent-lifecycle/spec.md | 0 .../specs/auxiliary-features/spec.md | 0 .../specs/plugin-integration/spec.md | 0 .../specs/session-persistence/spec.md | 0 .../specs/turn-orchestration/spec.md | 0 .../tasks.md | 0 .../.openspec.yaml | 0 .../design.md | 0 .../proposal.md | 0 .../specs/application-use-cases/spec.md | 0 .../specs/governance-reload-surface/spec.md | 0 .../specs/plugin-capability-surface/spec.md | 0 .../specs/plugin-governance-lifecycle/spec.md | 0 .../tasks.md | 0 .../.openspec.yaml | 0 .../design.md | 0 .../proposal.md | 0 .../specs/agent-profile-resolution/spec.md | 0 .../agent-profile-watch-invalidation/spec.md | 0 .../tasks.md | 0 .../.openspec.yaml | 0 .../design.md | 0 .../proposal.md | 0 .../specs/turn-budget-governance/spec.md | 0 .../specs/turn-observability/spec.md | 0 .../specs/turn-orchestration/spec.md | 0 .../tasks.md | 0 .../.openspec.yaml | 0 .../proposal.md | 0 .../specs/subagent-execution/spec.md | 0 .../tasks.md | 0 .../.openspec.yaml | 0 .../design.md | 0 .../proposal.md | 0 .../specs/agent-delivery-contracts/spec.md | 0 .../specs/subagent-execution/spec.md | 0 .../tasks.md | 0 .../.openspec.yaml | 0 .../design.md | 0 .../proposal.md | 0 .../specs/turn-budget-governance/spec.md | 0 .../specs/turn-observability/spec.md | 0 .../specs/turn-orchestration/spec.md | 0 .../tasks.md | 0 .../.openspec.yaml | 0 .../design.md | 0 .../proposal.md | 0 .../specs/agent-tool-governance/spec.md | 0 .../specs/subagent-execution/spec.md | 0 .../tasks.md | 0 .../.openspec.yaml | 0 .../runtime-observability-pipeline/spec.md | 0 .../.openspec.yaml | 0 .../design.md | 0 .../manual-checks.md | 0 .../proposal.md | 0 .../specs/agent-tool-evaluation/spec.md | 0 .../runtime-observability-pipeline/spec.md | 0 .../specs/turn-observability/spec.md | 0 .../tasks.md | 0 .../.openspec.yaml | 0 .../design.md | 0 .../proposal.md | 0 .../specs/turn-budget-governance/spec.md | 0 .../specs/turn-observability/spec.md | 0 .../specs/turn-orchestration/spec.md | 0 .../tasks.md | 0 .../.openspec.yaml | 0 .../design.md | 0 .../proposal.md | 0 .../specs/session-persistence/spec.md | 0 .../specs/turn-budget-governance/spec.md | 0 .../specs/turn-observability/spec.md | 0 .../specs/turn-orchestration/spec.md | 0 .../tasks.md | 0 .../.openspec.yaml | 0 .../design.md | 0 .../proposal.md | 0 .../runtime-observability-pipeline/spec.md | 0 .../specs/turn-orchestration/spec.md | 0 .../tasks.md | 0 .../.openspec.yaml | 0 .../design.md | 0 .../proposal.md | 0 .../specs/application-use-cases/spec.md | 0 .../spec.md | 0 .../specs/session-runtime/spec.md | 0 .../tasks.md | 0 .../.openspec.yaml | 0 .../design.md | 0 .../proposal.md | 0 .../specs/agent-delegation-surface/spec.md | 0 .../specs/agent-tool-governance/spec.md | 0 .../specs/subagent-execution/spec.md | 0 .../tasks.md | 0 .../.openspec.yaml | 0 .../design.md | 0 .../proposal.md | 0 .../specs/composer-execution-controls/spec.md | 0 .../specs/terminal-chat-read-model/spec.md | 0 .../specs/terminal-chat-surface/spec.md | 0 .../specs/tool-and-skill-discovery/spec.md | 0 .../tasks.md | 0 .../.openspec.yaml | 0 .../design.md | 0 .../proposal.md | 0 .../specs/agent-delivery-contracts/spec.md | 0 .../specs/agent-tool-governance/spec.md | 0 .../specs/subagent-execution/spec.md | 0 .../tasks.md | 0 .../.openspec.yaml | 0 .../design.md | 0 .../proposal.md | 0 .../specs/agent-tool-governance/spec.md | 0 .../specs/capability-semantic-model/spec.md | 0 .../specs/subagent-execution/spec.md | 0 .../specs/subrun-status-contracts/spec.md | 0 .../tasks.md | 0 255 files changed, 5737 insertions(+), 1892 deletions(-) create mode 100644 crates/cli/src/bottom_pane/layout.rs create mode 100644 crates/cli/src/bottom_pane/mod.rs create mode 100644 crates/cli/src/bottom_pane/model.rs create mode 100644 crates/cli/src/bottom_pane/render.rs create mode 100644 crates/cli/src/chat/mod.rs create mode 100644 crates/cli/src/chat/surface.rs create mode 100644 crates/cli/src/model/events.rs create mode 100644 crates/cli/src/model/mod.rs create mode 100644 crates/cli/src/model/reducer.rs create mode 100644 crates/cli/src/render/commit.rs create mode 100644 crates/cli/src/render/live.rs create mode 100644 crates/cli/src/render/wrap.rs create mode 100644 crates/cli/src/tui/mod.rs create mode 100644 crates/cli/src/tui/runtime.rs create mode 100644 crates/cli/src/ui/composer.rs create mode 100644 crates/cli/src/ui/custom_terminal.rs delete mode 100644 crates/cli/src/ui/footer.rs delete mode 100644 crates/cli/src/ui/hero.rs create mode 100644 crates/cli/src/ui/host.rs create mode 100644 crates/cli/src/ui/hud.rs create mode 100644 crates/cli/src/ui/insert_history.rs create mode 100644 crates/cli/src/ui/overlay.rs delete mode 100644 crates/cli/src/ui/transcript.rs rename openspec/{ => changes}/archive/2026-04-13-complete-turn-orchestration/.openspec.yaml (100%) rename openspec/{ => changes}/archive/2026-04-13-complete-turn-orchestration/design.md (100%) rename openspec/{ => changes}/archive/2026-04-13-complete-turn-orchestration/proposal.md (100%) rename openspec/{ => changes}/archive/2026-04-13-complete-turn-orchestration/specs/application-use-cases/spec.md (100%) rename openspec/{ => changes}/archive/2026-04-13-complete-turn-orchestration/specs/session-runtime/spec.md (100%) rename openspec/{ => changes}/archive/2026-04-13-complete-turn-orchestration/specs/turn-budget-governance/spec.md (100%) rename openspec/{ => changes}/archive/2026-04-13-complete-turn-orchestration/specs/turn-observability/spec.md (100%) rename openspec/{ => changes}/archive/2026-04-13-complete-turn-orchestration/tasks.md (100%) rename openspec/{ => changes}/archive/2026-04-13-expose-agent-control-contracts/.openspec.yaml (100%) rename openspec/{ => changes}/archive/2026-04-13-expose-agent-control-contracts/design.md (100%) rename openspec/{ => changes}/archive/2026-04-13-expose-agent-control-contracts/proposal.md (100%) rename openspec/{ => changes}/archive/2026-04-13-expose-agent-control-contracts/specs/agent-delivery-contracts/spec.md (100%) rename openspec/{ => changes}/archive/2026-04-13-expose-agent-control-contracts/specs/application-use-cases/spec.md (100%) rename openspec/{ => changes}/archive/2026-04-13-expose-agent-control-contracts/specs/kernel/spec.md (100%) rename openspec/{ => changes}/archive/2026-04-13-expose-agent-control-contracts/specs/subrun-status-contracts/spec.md (100%) rename openspec/{ => changes}/archive/2026-04-13-expose-agent-control-contracts/tasks.md (100%) rename openspec/{ => changes}/archive/2026-04-13-implement-agent-execution-use-cases/.openspec.yaml (100%) rename openspec/{ => changes}/archive/2026-04-13-implement-agent-execution-use-cases/design.md (100%) rename openspec/{ => changes}/archive/2026-04-13-implement-agent-execution-use-cases/proposal.md (100%) rename openspec/{ => changes}/archive/2026-04-13-implement-agent-execution-use-cases/specs/agent-profile-resolution/spec.md (100%) rename openspec/{ => changes}/archive/2026-04-13-implement-agent-execution-use-cases/specs/application-use-cases/spec.md (100%) rename openspec/{ => changes}/archive/2026-04-13-implement-agent-execution-use-cases/specs/kernel/spec.md (100%) rename openspec/{ => changes}/archive/2026-04-13-implement-agent-execution-use-cases/specs/root-agent-execution/spec.md (100%) rename openspec/{ => changes}/archive/2026-04-13-implement-agent-execution-use-cases/specs/subagent-execution/spec.md (100%) rename openspec/{ => changes}/archive/2026-04-13-implement-agent-execution-use-cases/tasks.md (100%) rename openspec/{ => changes}/archive/2026-04-13-integrate-plugin-capability-surface/.openspec.yaml (100%) rename openspec/{ => changes}/archive/2026-04-13-integrate-plugin-capability-surface/design.md (100%) rename openspec/{ => changes}/archive/2026-04-13-integrate-plugin-capability-surface/proposal.md (100%) rename openspec/{ => changes}/archive/2026-04-13-integrate-plugin-capability-surface/specs/application-use-cases/spec.md (100%) rename openspec/{ => changes}/archive/2026-04-13-integrate-plugin-capability-surface/specs/kernel/spec.md (100%) rename openspec/{ => changes}/archive/2026-04-13-integrate-plugin-capability-surface/specs/plugin-capability-surface/spec.md (100%) rename openspec/{ => changes}/archive/2026-04-13-integrate-plugin-capability-surface/specs/plugin-governance-lifecycle/spec.md (100%) rename openspec/{ => changes}/archive/2026-04-13-integrate-plugin-capability-surface/tasks.md (100%) rename openspec/{ => changes}/archive/2026-04-13-modernize-composer-execution-controls/.openspec.yaml (100%) rename openspec/{ => changes}/archive/2026-04-13-modernize-composer-execution-controls/design.md (100%) rename openspec/{ => changes}/archive/2026-04-13-modernize-composer-execution-controls/proposal.md (100%) rename openspec/{ => changes}/archive/2026-04-13-modernize-composer-execution-controls/specs/composer-execution-controls/spec.md (100%) rename openspec/{ => changes}/archive/2026-04-13-modernize-composer-execution-controls/specs/root-agent-execution/spec.md (100%) rename openspec/{ => changes}/archive/2026-04-13-modernize-composer-execution-controls/specs/subagent-execution/spec.md (100%) rename openspec/{ => changes}/archive/2026-04-13-modernize-composer-execution-controls/specs/turn-budget-governance/spec.md (100%) rename openspec/{ => changes}/archive/2026-04-13-modernize-composer-execution-controls/tasks.md (100%) rename openspec/{ => changes}/archive/2026-04-13-project-architecture-refactor/.openspec.yaml (100%) rename openspec/{ => changes}/archive/2026-04-13-project-architecture-refactor/design.md (100%) rename openspec/{ => changes}/archive/2026-04-13-project-architecture-refactor/proposal.md (100%) rename openspec/{ => changes}/archive/2026-04-13-project-architecture-refactor/runtime-migration-baseline.md (100%) rename openspec/{ => changes}/archive/2026-04-13-project-architecture-refactor/specs/adapter-contracts/spec.md (100%) rename openspec/{ => changes}/archive/2026-04-13-project-architecture-refactor/specs/application-use-cases/spec.md (100%) rename openspec/{ => changes}/archive/2026-04-13-project-architecture-refactor/specs/capability-semantic-model/spec.md (100%) rename openspec/{ => changes}/archive/2026-04-13-project-architecture-refactor/specs/kernel/spec.md (100%) rename openspec/{ => changes}/archive/2026-04-13-project-architecture-refactor/specs/session-runtime/spec.md (100%) rename openspec/{ => changes}/archive/2026-04-13-project-architecture-refactor/tasks.md (100%) rename openspec/{ => changes}/archive/2026-04-13-rationalize-discovery-and-skill-surface/.openspec.yaml (100%) rename openspec/{ => changes}/archive/2026-04-13-rationalize-discovery-and-skill-surface/design.md (100%) rename openspec/{ => changes}/archive/2026-04-13-rationalize-discovery-and-skill-surface/proposal.md (100%) rename openspec/{ => changes}/archive/2026-04-13-rationalize-discovery-and-skill-surface/specs/capability-semantic-model/spec.md (100%) rename openspec/{ => changes}/archive/2026-04-13-rationalize-discovery-and-skill-surface/specs/tool-and-skill-discovery/spec.md (100%) rename openspec/{ => changes}/archive/2026-04-13-rationalize-discovery-and-skill-surface/tasks.md (100%) rename openspec/{ => changes}/archive/2026-04-13-reconcile-root-and-subagent-profile-driven-execution/.openspec.yaml (100%) rename openspec/{ => changes}/archive/2026-04-13-reconcile-root-and-subagent-profile-driven-execution/design.md (100%) rename openspec/{ => changes}/archive/2026-04-13-reconcile-root-and-subagent-profile-driven-execution/proposal.md (100%) rename openspec/{ => changes}/archive/2026-04-13-reconcile-root-and-subagent-profile-driven-execution/specs/agent-execution/spec.md (100%) rename openspec/{ => changes}/archive/2026-04-13-reconcile-root-and-subagent-profile-driven-execution/specs/agent-profile-resolution/spec.md (100%) rename openspec/{ => changes}/archive/2026-04-13-reconcile-root-and-subagent-profile-driven-execution/specs/root-agent-execution/spec.md (100%) rename openspec/{ => changes}/archive/2026-04-13-reconcile-root-and-subagent-profile-driven-execution/specs/subagent-execution/spec.md (100%) rename openspec/{ => changes}/archive/2026-04-13-reconcile-root-and-subagent-profile-driven-execution/tasks.md (100%) rename openspec/{ => changes}/archive/2026-04-13-restore-child-delivery-parent-wake-pipeline/.openspec.yaml (100%) rename openspec/{ => changes}/archive/2026-04-13-restore-child-delivery-parent-wake-pipeline/design.md (100%) rename openspec/{ => changes}/archive/2026-04-13-restore-child-delivery-parent-wake-pipeline/proposal.md (100%) rename openspec/{ => changes}/archive/2026-04-13-restore-child-delivery-parent-wake-pipeline/specs/agent-delivery-contracts/spec.md (100%) rename openspec/{ => changes}/archive/2026-04-13-restore-child-delivery-parent-wake-pipeline/specs/agent-execution/spec.md (100%) rename openspec/{ => changes}/archive/2026-04-13-restore-child-delivery-parent-wake-pipeline/specs/agent-lifecycle/spec.md (100%) rename openspec/{ => changes}/archive/2026-04-13-restore-child-delivery-parent-wake-pipeline/specs/subagent-execution/spec.md (100%) rename openspec/{ => changes}/archive/2026-04-13-restore-child-delivery-parent-wake-pipeline/tasks.md (100%) rename openspec/{ => changes}/archive/2026-04-13-restore-runtime-observability-pipeline/.openspec.yaml (100%) rename openspec/{ => changes}/archive/2026-04-13-restore-runtime-observability-pipeline/design.md (100%) rename openspec/{ => changes}/archive/2026-04-13-restore-runtime-observability-pipeline/proposal.md (100%) rename openspec/{ => changes}/archive/2026-04-13-restore-runtime-observability-pipeline/specs/runtime-observability-pipeline/spec.md (100%) rename openspec/{ => changes}/archive/2026-04-13-restore-runtime-observability-pipeline/specs/turn-observability/spec.md (100%) rename openspec/{ => changes}/archive/2026-04-13-restore-runtime-observability-pipeline/tasks.md (100%) rename openspec/{ => changes}/archive/2026-04-13-runtime-migration-complete/.openspec.yaml (100%) rename openspec/{ => changes}/archive/2026-04-13-runtime-migration-complete/design.md (100%) rename openspec/{ => changes}/archive/2026-04-13-runtime-migration-complete/proposal.md (100%) rename openspec/{ => changes}/archive/2026-04-13-runtime-migration-complete/specs/agent-execution/spec.md (100%) rename openspec/{ => changes}/archive/2026-04-13-runtime-migration-complete/specs/agent-lifecycle/spec.md (100%) rename openspec/{ => changes}/archive/2026-04-13-runtime-migration-complete/specs/auxiliary-features/spec.md (100%) rename openspec/{ => changes}/archive/2026-04-13-runtime-migration-complete/specs/plugin-integration/spec.md (100%) rename openspec/{ => changes}/archive/2026-04-13-runtime-migration-complete/specs/session-persistence/spec.md (100%) rename openspec/{ => changes}/archive/2026-04-13-runtime-migration-complete/specs/turn-orchestration/spec.md (100%) rename openspec/{ => changes}/archive/2026-04-13-runtime-migration-complete/tasks.md (100%) rename openspec/{ => changes}/archive/2026-04-13-unify-governance-and-reload-surface/.openspec.yaml (100%) rename openspec/{ => changes}/archive/2026-04-13-unify-governance-and-reload-surface/design.md (100%) rename openspec/{ => changes}/archive/2026-04-13-unify-governance-and-reload-surface/proposal.md (100%) rename openspec/{ => changes}/archive/2026-04-13-unify-governance-and-reload-surface/specs/application-use-cases/spec.md (100%) rename openspec/{ => changes}/archive/2026-04-13-unify-governance-and-reload-surface/specs/governance-reload-surface/spec.md (100%) rename openspec/{ => changes}/archive/2026-04-13-unify-governance-and-reload-surface/specs/plugin-capability-surface/spec.md (100%) rename openspec/{ => changes}/archive/2026-04-13-unify-governance-and-reload-surface/specs/plugin-governance-lifecycle/spec.md (100%) rename openspec/{ => changes}/archive/2026-04-13-unify-governance-and-reload-surface/tasks.md (100%) rename openspec/{ => changes}/archive/2026-04-13-wire-agent-profile-watch-invalidation/.openspec.yaml (100%) rename openspec/{ => changes}/archive/2026-04-13-wire-agent-profile-watch-invalidation/design.md (100%) rename openspec/{ => changes}/archive/2026-04-13-wire-agent-profile-watch-invalidation/proposal.md (100%) rename openspec/{ => changes}/archive/2026-04-13-wire-agent-profile-watch-invalidation/specs/agent-profile-resolution/spec.md (100%) rename openspec/{ => changes}/archive/2026-04-13-wire-agent-profile-watch-invalidation/specs/agent-profile-watch-invalidation/spec.md (100%) rename openspec/{ => changes}/archive/2026-04-13-wire-agent-profile-watch-invalidation/tasks.md (100%) rename openspec/{ => changes}/archive/2026-04-14-add-step-local-tool-feedback-summaries/.openspec.yaml (100%) rename openspec/{ => changes}/archive/2026-04-14-add-step-local-tool-feedback-summaries/design.md (100%) rename openspec/{ => changes}/archive/2026-04-14-add-step-local-tool-feedback-summaries/proposal.md (100%) rename openspec/{ => changes}/archive/2026-04-14-add-step-local-tool-feedback-summaries/specs/turn-budget-governance/spec.md (100%) rename openspec/{ => changes}/archive/2026-04-14-add-step-local-tool-feedback-summaries/specs/turn-observability/spec.md (100%) rename openspec/{ => changes}/archive/2026-04-14-add-step-local-tool-feedback-summaries/specs/turn-orchestration/spec.md (100%) rename openspec/{ => changes}/archive/2026-04-14-add-step-local-tool-feedback-summaries/tasks.md (100%) rename openspec/{ => changes}/archive/2026-04-14-configurable-subagent-depth-limit/.openspec.yaml (100%) rename openspec/{ => changes}/archive/2026-04-14-configurable-subagent-depth-limit/proposal.md (100%) rename openspec/{ => changes}/archive/2026-04-14-configurable-subagent-depth-limit/specs/subagent-execution/spec.md (100%) rename openspec/{ => changes}/archive/2026-04-14-configurable-subagent-depth-limit/tasks.md (100%) rename openspec/{ => changes}/archive/2026-04-14-fix-nested-subagent-delivery-escalation/.openspec.yaml (100%) rename openspec/{ => changes}/archive/2026-04-14-fix-nested-subagent-delivery-escalation/design.md (100%) rename openspec/{ => changes}/archive/2026-04-14-fix-nested-subagent-delivery-escalation/proposal.md (100%) rename openspec/{ => changes}/archive/2026-04-14-fix-nested-subagent-delivery-escalation/specs/agent-delivery-contracts/spec.md (100%) rename openspec/{ => changes}/archive/2026-04-14-fix-nested-subagent-delivery-escalation/specs/subagent-execution/spec.md (100%) rename openspec/{ => changes}/archive/2026-04-14-fix-nested-subagent-delivery-escalation/tasks.md (100%) rename openspec/{ => changes}/archive/2026-04-14-formalize-turn-loop-transitions/.openspec.yaml (100%) rename openspec/{ => changes}/archive/2026-04-14-formalize-turn-loop-transitions/design.md (100%) rename openspec/{ => changes}/archive/2026-04-14-formalize-turn-loop-transitions/proposal.md (100%) rename openspec/{ => changes}/archive/2026-04-14-formalize-turn-loop-transitions/specs/turn-budget-governance/spec.md (100%) rename openspec/{ => changes}/archive/2026-04-14-formalize-turn-loop-transitions/specs/turn-observability/spec.md (100%) rename openspec/{ => changes}/archive/2026-04-14-formalize-turn-loop-transitions/specs/turn-orchestration/spec.md (100%) rename openspec/{ => changes}/archive/2026-04-14-formalize-turn-loop-transitions/tasks.md (100%) rename openspec/{ => changes}/archive/2026-04-14-govern-agent-tool-collaboration/.openspec.yaml (100%) rename openspec/{ => changes}/archive/2026-04-14-govern-agent-tool-collaboration/design.md (100%) rename openspec/{ => changes}/archive/2026-04-14-govern-agent-tool-collaboration/proposal.md (100%) rename openspec/{ => changes}/archive/2026-04-14-govern-agent-tool-collaboration/specs/agent-tool-governance/spec.md (100%) rename openspec/{ => changes}/archive/2026-04-14-govern-agent-tool-collaboration/specs/subagent-execution/spec.md (100%) rename openspec/{ => changes}/archive/2026-04-14-govern-agent-tool-collaboration/tasks.md (100%) rename openspec/{ => changes}/archive/2026-04-14-independent-debug-workbench/.openspec.yaml (100%) rename openspec/{ => changes}/archive/2026-04-14-independent-debug-workbench/specs/runtime-observability-pipeline/spec.md (100%) rename openspec/{ => changes}/archive/2026-04-14-measure-agent-tool-effectiveness/.openspec.yaml (100%) rename openspec/{ => changes}/archive/2026-04-14-measure-agent-tool-effectiveness/design.md (100%) rename openspec/{ => changes}/archive/2026-04-14-measure-agent-tool-effectiveness/manual-checks.md (100%) rename openspec/{ => changes}/archive/2026-04-14-measure-agent-tool-effectiveness/proposal.md (100%) rename openspec/{ => changes}/archive/2026-04-14-measure-agent-tool-effectiveness/specs/agent-tool-evaluation/spec.md (100%) rename openspec/{ => changes}/archive/2026-04-14-measure-agent-tool-effectiveness/specs/runtime-observability-pipeline/spec.md (100%) rename openspec/{ => changes}/archive/2026-04-14-measure-agent-tool-effectiveness/specs/turn-observability/spec.md (100%) rename openspec/{ => changes}/archive/2026-04-14-measure-agent-tool-effectiveness/tasks.md (100%) rename openspec/{ => changes}/archive/2026-04-14-recover-truncated-turn-output/.openspec.yaml (100%) rename openspec/{ => changes}/archive/2026-04-14-recover-truncated-turn-output/design.md (100%) rename openspec/{ => changes}/archive/2026-04-14-recover-truncated-turn-output/proposal.md (100%) rename openspec/{ => changes}/archive/2026-04-14-recover-truncated-turn-output/specs/turn-budget-governance/spec.md (100%) rename openspec/{ => changes}/archive/2026-04-14-recover-truncated-turn-output/specs/turn-observability/spec.md (100%) rename openspec/{ => changes}/archive/2026-04-14-recover-truncated-turn-output/specs/turn-orchestration/spec.md (100%) rename openspec/{ => changes}/archive/2026-04-14-recover-truncated-turn-output/tasks.md (100%) rename openspec/{ => changes}/archive/2026-04-14-stabilize-persisted-tool-result-references/.openspec.yaml (100%) rename openspec/{ => changes}/archive/2026-04-14-stabilize-persisted-tool-result-references/design.md (100%) rename openspec/{ => changes}/archive/2026-04-14-stabilize-persisted-tool-result-references/proposal.md (100%) rename openspec/{ => changes}/archive/2026-04-14-stabilize-persisted-tool-result-references/specs/session-persistence/spec.md (100%) rename openspec/{ => changes}/archive/2026-04-14-stabilize-persisted-tool-result-references/specs/turn-budget-governance/spec.md (100%) rename openspec/{ => changes}/archive/2026-04-14-stabilize-persisted-tool-result-references/specs/turn-observability/spec.md (100%) rename openspec/{ => changes}/archive/2026-04-14-stabilize-persisted-tool-result-references/specs/turn-orchestration/spec.md (100%) rename openspec/{ => changes}/archive/2026-04-14-stabilize-persisted-tool-result-references/tasks.md (100%) rename openspec/{ => changes}/archive/2026-04-14-stream-tool-execution-from-llm-deltas/.openspec.yaml (100%) rename openspec/{ => changes}/archive/2026-04-14-stream-tool-execution-from-llm-deltas/design.md (100%) rename openspec/{ => changes}/archive/2026-04-14-stream-tool-execution-from-llm-deltas/proposal.md (100%) rename openspec/{ => changes}/archive/2026-04-14-stream-tool-execution-from-llm-deltas/specs/runtime-observability-pipeline/spec.md (100%) rename openspec/{ => changes}/archive/2026-04-14-stream-tool-execution-from-llm-deltas/specs/turn-orchestration/spec.md (100%) rename openspec/{ => changes}/archive/2026-04-14-stream-tool-execution-from-llm-deltas/tasks.md (100%) rename openspec/{ => changes}/archive/2026-04-14-tighten-session-runtime-subdomain-boundaries/.openspec.yaml (100%) rename openspec/{ => changes}/archive/2026-04-14-tighten-session-runtime-subdomain-boundaries/design.md (100%) rename openspec/{ => changes}/archive/2026-04-14-tighten-session-runtime-subdomain-boundaries/proposal.md (100%) rename openspec/{ => changes}/archive/2026-04-14-tighten-session-runtime-subdomain-boundaries/specs/application-use-cases/spec.md (100%) rename openspec/{ => changes}/archive/2026-04-14-tighten-session-runtime-subdomain-boundaries/specs/session-runtime-subdomain-boundaries/spec.md (100%) rename openspec/{ => changes}/archive/2026-04-14-tighten-session-runtime-subdomain-boundaries/specs/session-runtime/spec.md (100%) rename openspec/{ => changes}/archive/2026-04-14-tighten-session-runtime-subdomain-boundaries/tasks.md (100%) rename openspec/{ => changes}/archive/2026-04-15-enhance-agent-tool-experience/.openspec.yaml (100%) rename openspec/{ => changes}/archive/2026-04-15-enhance-agent-tool-experience/design.md (100%) rename openspec/{ => changes}/archive/2026-04-15-enhance-agent-tool-experience/proposal.md (100%) rename openspec/{ => changes}/archive/2026-04-15-enhance-agent-tool-experience/specs/agent-delegation-surface/spec.md (100%) rename openspec/{ => changes}/archive/2026-04-15-enhance-agent-tool-experience/specs/agent-tool-governance/spec.md (100%) rename openspec/{ => changes}/archive/2026-04-15-enhance-agent-tool-experience/specs/subagent-execution/spec.md (100%) rename openspec/{ => changes}/archive/2026-04-15-enhance-agent-tool-experience/tasks.md (100%) rename openspec/{ => changes}/archive/2026-04-15-release-terminal-astrcode/.openspec.yaml (100%) rename openspec/{ => changes}/archive/2026-04-15-release-terminal-astrcode/design.md (100%) rename openspec/{ => changes}/archive/2026-04-15-release-terminal-astrcode/proposal.md (100%) rename openspec/{ => changes}/archive/2026-04-15-release-terminal-astrcode/specs/composer-execution-controls/spec.md (100%) rename openspec/{ => changes}/archive/2026-04-15-release-terminal-astrcode/specs/terminal-chat-read-model/spec.md (100%) rename openspec/{ => changes}/archive/2026-04-15-release-terminal-astrcode/specs/terminal-chat-surface/spec.md (100%) rename openspec/{ => changes}/archive/2026-04-15-release-terminal-astrcode/specs/tool-and-skill-discovery/spec.md (100%) rename openspec/{ => changes}/archive/2026-04-15-release-terminal-astrcode/tasks.md (100%) rename openspec/{ => changes}/archive/2026-04-15-replace-summary-with-parent-delivery/.openspec.yaml (100%) rename openspec/{ => changes}/archive/2026-04-15-replace-summary-with-parent-delivery/design.md (100%) rename openspec/{ => changes}/archive/2026-04-15-replace-summary-with-parent-delivery/proposal.md (100%) rename openspec/{ => changes}/archive/2026-04-15-replace-summary-with-parent-delivery/specs/agent-delivery-contracts/spec.md (100%) rename openspec/{ => changes}/archive/2026-04-15-replace-summary-with-parent-delivery/specs/agent-tool-governance/spec.md (100%) rename openspec/{ => changes}/archive/2026-04-15-replace-summary-with-parent-delivery/specs/subagent-execution/spec.md (100%) rename openspec/{ => changes}/archive/2026-04-15-replace-summary-with-parent-delivery/tasks.md (100%) rename openspec/{ => changes}/archive/2026-04-15-subagent-effective-capability-profile/.openspec.yaml (100%) rename openspec/{ => changes}/archive/2026-04-15-subagent-effective-capability-profile/design.md (100%) rename openspec/{ => changes}/archive/2026-04-15-subagent-effective-capability-profile/proposal.md (100%) rename openspec/{ => changes}/archive/2026-04-15-subagent-effective-capability-profile/specs/agent-tool-governance/spec.md (100%) rename openspec/{ => changes}/archive/2026-04-15-subagent-effective-capability-profile/specs/capability-semantic-model/spec.md (100%) rename openspec/{ => changes}/archive/2026-04-15-subagent-effective-capability-profile/specs/subagent-execution/spec.md (100%) rename openspec/{ => changes}/archive/2026-04-15-subagent-effective-capability-profile/specs/subrun-status-contracts/spec.md (100%) rename openspec/{ => changes}/archive/2026-04-15-subagent-effective-capability-profile/tasks.md (100%) diff --git a/AGENTS.md b/AGENTS.md index 66bf5cb8..f28af4e5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -70,35 +70,5 @@ node scripts/check-crate-boundaries.mjs --strict # 严格模式 - 使用 `node scripts/check-crate-boundaries.mjs` 验证 crate 依赖规则没有被违反 - `src-tauri` 是 Tauri 薄壳,不含业务逻辑 - `server` 组合根在 `crates/server/src/bootstrap/runtime.rs` - -## TUI style conventions - -See `codex-rs/tui/styles.md`. - -## TUI code conventions - -- Use concise styling helpers from ratatui’s Stylize trait. - - Basic spans: use "text".into() - - Styled spans: use "text".red(), "text".green(), "text".magenta(), "text".dim(), etc. - - Prefer these over constructing styles with `Span::styled` and `Style` directly. - - Example: patch summary file lines - - Desired: vec![" └ ".into(), "M".red(), " ".dim(), "tui/src/app.rs".dim()] - -### TUI Styling (ratatui) - -- Prefer Stylize helpers: use "text".dim(), .bold(), .cyan(), .italic(), .underlined() instead of manual Style where possible. -- Prefer simple conversions: use "text".into() for spans and vec![…].into() for lines; when inference is ambiguous (e.g., Paragraph::new/Cell::from), use Line::from(spans) or Span::from(text). -- Computed styles: if the Style is computed at runtime, using `Span::styled` is OK (`Span::from(text).set_style(style)` is also acceptable). -- Avoid hardcoded white: do not use `.white()`; prefer the default foreground (no color). -- Chaining: combine helpers by chaining for readability (e.g., url.cyan().underlined()). -- Single items: prefer "text".into(); use Line::from(text) or Span::from(text) only when the target type isn’t obvious from context, or when using .into() would require extra type annotations. -- Building lines: use vec![…].into() to construct a Line when the target type is obvious and no extra type annotations are needed; otherwise use Line::from(vec![…]). -- Avoid churn: don’t refactor between equivalent forms (Span::styled ↔ set_style, Line::from ↔ .into()) without a clear readability or functional gain; follow file‑local conventions and do not introduce type annotations solely to satisfy .into(). -- Compactness: prefer the form that stays on one line after rustfmt; if only one of Line::from(vec![…]) or vec![…].into() avoids wrapping, choose that. If both wrap, pick the one with fewer wrapped lines. - -### Text wrapping - -- Always use textwrap::wrap to wrap plain strings. -- If you have a ratatui Line and you want to wrap it, use the helpers in tui/src/wrapping.rs, e.g. word_wrap_lines / word_wrap_line. -- If you need to indent wrapped lines, use the initial_indent / subsequent_indent options from RtOptions if you can, rather than writing custom logic. -- If you have a list of lines and you need to prefix them all with some prefix (optionally different on the first vs subsequent lines), use the `prefix_lines` helper from line_utils. \ No newline at end of file +- `astrcode-cli` 当前使用 `ratatui 0.30.0`;如果要引入第三方 textarea / TUI widget,先确认它不会再拉入另一套 ratatui 类型,否则会在 `Widget`、`Style`、`Frame` 上直接类型冲突 +- `Viewport::Inline + insert_before(...)` 只有在已提交历史把 inline viewport 之上的主屏空间挤满之后,`TestBackend::scrollback()` 才会真正出现对应行;少量 commit 可能仍停留在当前主屏 buffer,而不是 scrollback 断言里 diff --git a/CLAUDE.md b/CLAUDE.md index cd12754a..06e39525 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -83,4 +83,9 @@ ## 目标 目标不是做最小改动。 -目标是以**正确、可维护、可验证**的方式完成任务。 \ No newline at end of file +目标是以**正确、可维护、可验证**的方式完成任务。 + +## 项目提醒 + +- `astrcode-cli` 当前使用 `ratatui 0.30.0`;接第三方 textarea 或其他 TUI widget 之前,先确认它不会额外拉入另一套 ratatui 类型,否则会在 `Widget`、`Style`、`Frame` 上直接类型冲突 +- `Viewport::Inline + insert_before(...)` 在测试里并不等于“立刻进入 scrollback”;只有当提交内容把 inline viewport 上方空间顶满后,`TestBackend::scrollback()` 才会出现对应历史行,少量 commit 仍可能留在当前主屏 buffer diff --git a/Cargo.lock b/Cargo.lock index caa51225..fbaf1b76 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -289,9 +289,11 @@ dependencies = [ "reqwest", "serde", "serde_json", + "textwrap", "thiserror 2.0.18", "tokio", - "unicode-width 0.2.0", + "unicode-segmentation", + "unicode-width", ] [[package]] @@ -507,7 +509,7 @@ dependencies = [ "futures-lite", "parking", "polling", - "rustix 1.1.4", + "rustix", "slab", "windows-sys 0.61.2", ] @@ -549,7 +551,7 @@ dependencies = [ "cfg-if", "event-listener", "futures-lite", - "rustix 1.1.4", + "rustix", ] [[package]] @@ -575,7 +577,7 @@ dependencies = [ "cfg-if", "futures-core", "futures-io", - "rustix 1.1.4", + "rustix", "signal-hook-registry", "slab", "windows-sys 0.61.2", @@ -643,6 +645,15 @@ dependencies = [ "system-deps", ] +[[package]] +name = "atomic" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89cbf775b137e9b968e67227ef7f775587cde3fd31b0d8599dbd0f598a48340" +dependencies = [ + "bytemuck", +] + [[package]] name = "atomic-waker" version = "1.1.2" @@ -775,15 +786,30 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "bit-set" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" +dependencies = [ + "bit-vec 0.6.3", +] + [[package]] name = "bit-set" version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" dependencies = [ - "bit-vec", + "bit-vec 0.8.0", ] +[[package]] +name = "bit-vec" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" + [[package]] name = "bit-vec" version = "0.8.0" @@ -961,12 +987,6 @@ dependencies = [ "toml 0.9.12+spec-1.1.0", ] -[[package]] -name = "cassowary" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" - [[package]] name = "castaway" version = "0.2.4" @@ -1119,9 +1139,9 @@ dependencies = [ [[package]] name = "compact_str" -version = "0.8.1" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b79c4069c6cad78e2e0cdfcbd26275770669fb39fd308a752dc110e83b9af32" +checksum = "3fdb1325a1cece981e8a296ab8f0f9b63ae357bd0784a9faaf548cc7b480707a" dependencies = [ "castaway", "cfg-if", @@ -1146,6 +1166,15 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e" +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "cookie" version = "0.18.1" @@ -1260,15 +1289,17 @@ checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" [[package]] name = "crossterm" -version = "0.28.1" +version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" +checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" dependencies = [ "bitflags 2.11.0", "crossterm_winapi", + "derive_more 2.1.1", + "document-features", "mio", "parking_lot", - "rustix 0.38.44", + "rustix", "signal-hook", "signal-hook-mio", "winapi", @@ -1293,6 +1324,16 @@ dependencies = [ "typenum", ] +[[package]] +name = "csscolorparser" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb2a7d3066da2de787b7f032c736763eb7ae5d355f81a68bab2675a96008b0bf" +dependencies = [ + "lab", + "phf 0.11.3", +] + [[package]] name = "cssparser" version = "0.29.6" @@ -1391,6 +1432,12 @@ dependencies = [ "parking_lot_core", ] +[[package]] +name = "deltae" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5729f5117e208430e437df2f4843f5e5952997175992d1414f94c57d61e270b4" + [[package]] name = "deranged" version = "0.5.8" @@ -1407,7 +1454,7 @@ version = "0.99.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6edb4b64a43d977b8e99788fe3a04d483834fba1215a7e02caa415b626497f7f" dependencies = [ - "convert_case", + "convert_case 0.4.0", "proc-macro2", "quote", "rustc_version", @@ -1429,6 +1476,7 @@ version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" dependencies = [ + "convert_case 0.10.0", "proc-macro2", "quote", "rustc_version", @@ -1521,13 +1569,22 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + [[package]] name = "dom_query" version = "0.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "521e380c0c8afb8d9a1e83a1822ee03556fc3e3e7dbc1fd30be14e37f9cb3f89" dependencies = [ - "bit-set", + "bit-set 0.8.0", "cssparser 0.36.0", "foldhash 0.2.0", "html5ever 0.38.0", @@ -1690,6 +1747,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "euclid" +version = "0.22.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" +dependencies = [ + "num-traits", +] + [[package]] name = "event-listener" version = "5.4.1" @@ -1711,6 +1777,16 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "fancy-regex" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b95f7c0680e4142284cf8b22c14a476e87d61b004a3a0861872b32ef7ead40a2" +dependencies = [ + "bit-set 0.5.3", + "regex", +] + [[package]] name = "fastrand" version = "2.3.0" @@ -1736,12 +1812,35 @@ dependencies = [ "rustc_version", ] +[[package]] +name = "filedescriptor" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d" +dependencies = [ + "libc", + "thiserror 1.0.69", + "winapi", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "finl_unicode" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9844ddc3a6e533d62bba727eb6c28b5d360921d5175e9ff0f1e621a5c590a4d5" + +[[package]] +name = "fixedbitset" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" + [[package]] name = "flate2" version = "1.1.9" @@ -2274,8 +2373,6 @@ version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ - "allocator-api2", - "equivalent", "foldhash 0.1.5", ] @@ -2284,6 +2381,11 @@ name = "hashbrown" version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", +] [[package]] name = "heck" @@ -2724,9 +2826,9 @@ checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" [[package]] name = "itertools" -version = "0.13.0" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" dependencies = [ "either", ] @@ -2872,6 +2974,17 @@ dependencies = [ "serde_json", ] +[[package]] +name = "kasuari" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde5057d6143cc94e861d90f591b9303d6716c6b9602309150bd068853c10899" +dependencies = [ + "hashbrown 0.16.1", + "portable-atomic", + "thiserror 2.0.18", +] + [[package]] name = "keyboard-types" version = "0.7.0" @@ -2915,6 +3028,18 @@ dependencies = [ "selectors 0.24.0", ] +[[package]] +name = "lab" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf36173d4167ed999940f804952e6b08197cae5ad5d572eb4db150ce8ad5d58f" + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + [[package]] name = "leb128fmt" version = "0.1.0" @@ -2981,10 +3106,13 @@ dependencies = [ ] [[package]] -name = "linux-raw-sys" -version = "0.4.15" +name = "line-clipping" +version = "0.3.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" +checksum = "3f50e8f47623268b5407192d26876c4d7f89d686ca130fdc53bced4814cd29f8" +dependencies = [ + "bitflags 2.11.0", +] [[package]] name = "linux-raw-sys" @@ -2998,6 +3126,12 @@ version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + [[package]] name = "lock_api" version = "0.4.14" @@ -3015,11 +3149,11 @@ checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" [[package]] name = "lru" -version = "0.12.5" +version = "0.16.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39" dependencies = [ - "hashbrown 0.15.5", + "hashbrown 0.16.1", ] [[package]] @@ -3034,6 +3168,16 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" +[[package]] +name = "mac_address" +version = "1.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0aeb26bf5e836cc1c341c8106051b573f1766dfa05aa87f0b98be5e51b02303" +dependencies = [ + "nix", + "winapi", +] + [[package]] name = "markup5ever" version = "0.14.1" @@ -3088,6 +3232,12 @@ version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +[[package]] +name = "memmem" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a64a92489e2744ce060c349162be1c5f33c6969234104dbd99ddb5feb08b8c15" + [[package]] name = "memoffset" version = "0.9.1" @@ -3113,6 +3263,12 @@ dependencies = [ "unicase", ] +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -3192,12 +3348,35 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags 2.11.0", + "cfg-if", + "cfg_aliases", + "libc", + "memoffset", +] + [[package]] name = "nodrop" version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb" +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + [[package]] name = "notify" version = "8.2.0" @@ -3231,6 +3410,17 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -3262,6 +3452,21 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "num_threads" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" +dependencies = [ + "libc", +] + +[[package]] +name = "numtoa" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6aa2c4e539b869820a2b82e1aef6ff40aa85e65decdd5185e83fb4b1249cd00f" + [[package]] name = "objc2" version = "0.6.4" @@ -3421,6 +3626,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "ordered-float" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7bb71e1b3fa6ca1c61f383464aaf2bb0e2f8e772a1f01d486832464de363b951" +dependencies = [ + "num-traits", +] + [[package]] name = "ordered-stream" version = "0.2.0" @@ -3495,12 +3709,6 @@ dependencies = [ "windows-link 0.2.1", ] -[[package]] -name = "paste" -version = "1.0.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" - [[package]] name = "pathdiff" version = "0.2.3" @@ -3513,6 +3721,49 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "pest" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0848c601009d37dfa3430c4666e147e49cdcf1b92ecd3e63657d8a5f19da662" +dependencies = [ + "memchr", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11f486f1ea21e6c10ed15d5a7c77165d0ee443402f0780849d1768e7d9d6fe77" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8040c4647b13b210a963c1ed407c1ff4fdfa01c31d6d2a098218702e6664f94f" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "pest_meta" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89815c69d36021a140146f26659a81d6c2afa33d216d736dd4be5381a7362220" +dependencies = [ + "pest", + "sha2", +] + [[package]] name = "phf" version = "0.8.0" @@ -3759,7 +4010,7 @@ dependencies = [ "concurrent-queue", "hermit-abi", "pin-project-lite", - "rustix 1.1.4", + "rustix", "windows-sys 0.61.2", ] @@ -4116,23 +4367,99 @@ dependencies = [ [[package]] name = "ratatui" -version = "0.29.0" +version = "0.30.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" +checksum = "d1ce67fb8ba4446454d1c8dbaeda0557ff5e94d39d5e5ed7f10a65eb4c8266bc" +dependencies = [ + "instability", + "ratatui-core", + "ratatui-crossterm", + "ratatui-macros", + "ratatui-termion", + "ratatui-termwiz", + "ratatui-widgets", +] + +[[package]] +name = "ratatui-core" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ef8dea09a92caaf73bff7adb70b76162e5937524058a7e5bff37869cbbec293" dependencies = [ "bitflags 2.11.0", - "cassowary", "compact_str", - "crossterm", + "hashbrown 0.16.1", "indoc", - "instability", "itertools", + "kasuari", "lru", - "paste", "strum", + "thiserror 2.0.18", "unicode-segmentation", "unicode-truncate", - "unicode-width 0.2.0", + "unicode-width", +] + +[[package]] +name = "ratatui-crossterm" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "577c9b9f652b4c121fb25c6a391dd06406d3b092ba68827e6d2f09550edc54b3" +dependencies = [ + "cfg-if", + "crossterm", + "instability", + "ratatui-core", +] + +[[package]] +name = "ratatui-macros" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7f1342a13e83e4bb9d0b793d0ea762be633f9582048c892ae9041ef39c936f4" +dependencies = [ + "ratatui-core", + "ratatui-widgets", +] + +[[package]] +name = "ratatui-termion" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cade85a8591fbc911e147951422f0d6fd40f4948b271b6216c7dc01838996f8" +dependencies = [ + "instability", + "ratatui-core", + "termion", +] + +[[package]] +name = "ratatui-termwiz" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f76fe0bd0ed4295f0321b1676732e2454024c15a35d01904ddb315afd3d545c" +dependencies = [ + "ratatui-core", + "termwiz", +] + +[[package]] +name = "ratatui-widgets" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7dbfa023cd4e604c2553483820c5fe8aa9d71a42eea5aa77c6e7f35756612db" +dependencies = [ + "bitflags 2.11.0", + "hashbrown 0.16.1", + "indoc", + "instability", + "itertools", + "line-clipping", + "ratatui-core", + "strum", + "time", + "unicode-segmentation", + "unicode-width", ] [[package]] @@ -4303,19 +4630,6 @@ dependencies = [ "semver", ] -[[package]] -name = "rustix" -version = "0.38.44" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" -dependencies = [ - "bitflags 2.11.0", - "errno", - "libc", - "linux-raw-sys 0.4.15", - "windows-sys 0.59.0", -] - [[package]] name = "rustix" version = "1.1.4" @@ -4325,7 +4639,7 @@ dependencies = [ "bitflags 2.11.0", "errno", "libc", - "linux-raw-sys 0.12.1", + "linux-raw-sys", "windows-sys 0.61.2", ] @@ -4876,6 +5190,12 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +[[package]] +name = "smawk" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c388c1b5e93756d0c740965c41e8822f866621d41acbdf6336a6a168f8840c" + [[package]] name = "socket2" version = "0.6.3" @@ -5003,23 +5323,22 @@ checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" [[package]] name = "strum" -version = "0.26.3" +version = "0.27.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" dependencies = [ "strum_macros", ] [[package]] name = "strum_macros" -version = "0.26.4" +version = "0.27.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" dependencies = [ "heck 0.5.0", "proc-macro2", "quote", - "rustversion", "syn 2.0.117", ] @@ -5411,7 +5730,7 @@ dependencies = [ "fastrand", "getrandom 0.4.2", "once_cell", - "rustix 1.1.4", + "rustix", "windows-sys 0.61.2", ] @@ -5436,6 +5755,90 @@ dependencies = [ "utf-8", ] +[[package]] +name = "terminfo" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4ea810f0692f9f51b382fff5893887bb4580f5fa246fde546e0b13e7fcee662" +dependencies = [ + "fnv", + "nom", + "phf 0.11.3", + "phf_codegen 0.11.3", +] + +[[package]] +name = "termion" +version = "4.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f44138a9ae08f0f502f24104d82517ef4da7330c35acd638f1f29d3cd5475ecb" +dependencies = [ + "libc", + "numtoa", +] + +[[package]] +name = "termios" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "411c5bf740737c7918b8b1fe232dca4dc9f8e754b8ad5e20966814001ed0ac6b" +dependencies = [ + "libc", +] + +[[package]] +name = "termwiz" +version = "0.23.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4676b37242ccbd1aabf56edb093a4827dc49086c0ffd764a5705899e0f35f8f7" +dependencies = [ + "anyhow", + "base64 0.22.1", + "bitflags 2.11.0", + "fancy-regex", + "filedescriptor", + "finl_unicode", + "fixedbitset", + "hex", + "lazy_static", + "libc", + "log", + "memmem", + "nix", + "num-derive", + "num-traits", + "ordered-float", + "pest", + "pest_derive", + "phf 0.11.3", + "sha2", + "signal-hook", + "siphasher 1.0.2", + "terminfo", + "termios", + "thiserror 1.0.69", + "ucd-trie", + "unicode-segmentation", + "vtparse", + "wezterm-bidi", + "wezterm-blob-leases", + "wezterm-color-types", + "wezterm-dynamic", + "wezterm-input-types", + "winapi", +] + +[[package]] +name = "textwrap" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057" +dependencies = [ + "smawk", + "unicode-linebreak", + "unicode-width", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -5484,7 +5887,9 @@ checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" dependencies = [ "deranged", "itoa", + "libc", "num-conv", + "num_threads", "powerfmt", "serde_core", "time-core", @@ -5830,6 +6235,12 @@ version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + [[package]] name = "uds_windows" version = "1.2.1" @@ -5894,6 +6305,12 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-linebreak" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" + [[package]] name = "unicode-segmentation" version = "1.13.2" @@ -5902,26 +6319,20 @@ checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" [[package]] name = "unicode-truncate" -version = "1.1.0" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" +checksum = "16b380a1238663e5f8a691f9039c73e1cdae598a30e9855f541d29b08b53e9a5" dependencies = [ "itertools", "unicode-segmentation", - "unicode-width 0.1.14", + "unicode-width", ] [[package]] name = "unicode-width" -version = "0.1.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" - -[[package]] -name = "unicode-width" -version = "0.2.0" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" [[package]] name = "unicode-xid" @@ -5996,6 +6407,7 @@ version = "1.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5ac8b6f42ead25368cf5b098aeb3dc8a1a2c05a3eee8a9a1a68c640edbfc79d9" dependencies = [ + "atomic", "getrandom 0.4.2", "js-sys", "serde_core", @@ -6035,6 +6447,15 @@ dependencies = [ "libc", ] +[[package]] +name = "vtparse" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d9b2acfb050df409c972a37d3b8e08cdea3bddb0c09db9d53137e504cfabed0" +dependencies = [ + "utf8parse", +] + [[package]] name = "walkdir" version = "2.5.0" @@ -6194,7 +6615,7 @@ checksum = "2857dd20b54e916ec7253b3d6b4d5c4d7d4ca2c33c2e11c6c76a99bd8744755d" dependencies = [ "cc", "downcast-rs", - "rustix 1.1.4", + "rustix", "scoped-tls", "smallvec", "wayland-sys", @@ -6207,7 +6628,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "645c7c96bb74690c3189b5c9cb4ca1627062bb23693a4fad9d8c3de958260144" dependencies = [ "bitflags 2.11.0", - "rustix 1.1.4", + "rustix", "wayland-backend", "wayland-scanner", ] @@ -6367,6 +6788,78 @@ dependencies = [ "windows-core 0.61.2", ] +[[package]] +name = "wezterm-bidi" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0a6e355560527dd2d1cf7890652f4f09bb3433b6aadade4c9b5ed76de5f3ec" +dependencies = [ + "log", + "wezterm-dynamic", +] + +[[package]] +name = "wezterm-blob-leases" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "692daff6d93d94e29e4114544ef6d5c942a7ed998b37abdc19b17136ea428eb7" +dependencies = [ + "getrandom 0.3.4", + "mac_address", + "sha2", + "thiserror 1.0.69", + "uuid", +] + +[[package]] +name = "wezterm-color-types" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7de81ef35c9010270d63772bebef2f2d6d1f2d20a983d27505ac850b8c4b4296" +dependencies = [ + "csscolorparser", + "deltae", + "lazy_static", + "wezterm-dynamic", +] + +[[package]] +name = "wezterm-dynamic" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f2ab60e120fd6eaa68d9567f3226e876684639d22a4219b313ff69ec0ccd5ac" +dependencies = [ + "log", + "ordered-float", + "strsim", + "thiserror 1.0.69", + "wezterm-dynamic-derive", +] + +[[package]] +name = "wezterm-dynamic-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46c0cf2d539c645b448eaffec9ec494b8b19bd5077d9e58cb1ae7efece8d575b" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "wezterm-input-types" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7012add459f951456ec9d6c7e6fc340b1ce15d6fc9629f8c42853412c029e57e" +dependencies = [ + "bitflags 1.3.2", + "euclid", + "lazy_static", + "serde", + "wezterm-dynamic", +] + [[package]] name = "winapi" version = "0.3.9" @@ -7042,7 +7535,7 @@ dependencies = [ "hex", "libc", "ordered-stream", - "rustix 1.1.4", + "rustix", "serde", "serde_repr", "tracing", diff --git a/crates/application/src/lib.rs b/crates/application/src/lib.rs index d35908d5..80becb41 100644 --- a/crates/application/src/lib.rs +++ b/crates/application/src/lib.rs @@ -131,7 +131,8 @@ pub use observability::{ resolve_runtime_status_summary, }; pub use ports::{ - AgentKernelPort, AgentSessionPort, AppKernelPort, AppSessionPort, ComposerSkillPort, + AgentKernelPort, AgentSessionPort, AppKernelPort, AppSessionPort, ComposerResolvedSkill, + ComposerSkillPort, }; pub use session_use_cases::summarize_session_meta; pub use terminal::{ @@ -177,6 +178,12 @@ pub struct PromptAcceptedSummary { pub accepted_control: Option, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PromptSkillInvocation { + pub skill_id: String, + pub user_prompt: Option, +} + /// 手动 compact 的共享摘要输入。 #[derive(Debug, Clone, PartialEq, Eq)] pub struct CompactSessionSummary { diff --git a/crates/application/src/ports/composer_skill.rs b/crates/application/src/ports/composer_skill.rs index 6f4752b0..212b3857 100644 --- a/crates/application/src/ports/composer_skill.rs +++ b/crates/application/src/ports/composer_skill.rs @@ -2,10 +2,18 @@ use std::path::Path; use crate::ComposerSkillSummary; +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ComposerResolvedSkill { + pub id: String, + pub description: String, + pub guide: String, +} + /// `App` 依赖的 skill 补全端口。 /// /// Why: composer 输入补全需要看到当前会话可见的 skill, /// 但应用层不应直接依赖 `adapter-skills` 的实现细节。 pub trait ComposerSkillPort: Send + Sync { fn list_skill_summaries(&self, working_dir: &Path) -> Vec; + fn resolve_skill(&self, working_dir: &Path, skill_id: &str) -> Option; } diff --git a/crates/application/src/ports/mod.rs b/crates/application/src/ports/mod.rs index 1315f9df..0213dc30 100644 --- a/crates/application/src/ports/mod.rs +++ b/crates/application/src/ports/mod.rs @@ -8,4 +8,4 @@ pub use agent_kernel::AgentKernelPort; pub use agent_session::AgentSessionPort; pub use app_kernel::AppKernelPort; pub use app_session::AppSessionPort; -pub use composer_skill::ComposerSkillPort; +pub use composer_skill::{ComposerResolvedSkill, ComposerSkillPort}; diff --git a/crates/application/src/session_use_cases.rs b/crates/application/src/session_use_cases.rs index 4fe47d27..c0253532 100644 --- a/crates/application/src/session_use_cases.rs +++ b/crates/application/src/session_use_cases.rs @@ -2,14 +2,15 @@ use std::path::Path; use astrcode_core::{ - AgentEventContext, ChildSessionNode, DeleteProjectResult, ExecutionAccepted, SessionMeta, - StoredEvent, + AgentEventContext, ChildSessionNode, DeleteProjectResult, ExecutionAccepted, PromptDeclaration, + PromptDeclarationKind, PromptDeclarationRenderTarget, PromptDeclarationSource, SessionMeta, + StoredEvent, SystemPromptLayer, }; use crate::{ App, ApplicationError, CompactSessionAccepted, CompactSessionSummary, ExecutionControl, - PromptAcceptedSummary, SessionControlStateSnapshot, SessionListSummary, SessionReplay, - SessionTranscriptSnapshot, + PromptAcceptedSummary, PromptSkillInvocation, SessionControlStateSnapshot, SessionListSummary, + SessionReplay, SessionTranscriptSnapshot, agent::{ IMPLICIT_ROOT_PROFILE_ID, implicit_session_root_agent_id, root_execution_event_context, }, @@ -58,7 +59,7 @@ impl App { session_id: &str, text: String, ) -> Result { - self.submit_prompt_with_control(session_id, text, None) + self.submit_prompt_with_options(session_id, text, None, None) .await } @@ -68,7 +69,18 @@ impl App { text: String, control: Option, ) -> Result { - self.validate_non_empty("prompt", &text)?; + self.submit_prompt_with_options(session_id, text, control, None) + .await + } + + pub async fn submit_prompt_with_options( + &self, + session_id: &str, + text: String, + control: Option, + skill_invocation: Option, + ) -> Result { + let text = normalize_submission_text(text, skill_invocation.as_ref())?; if let Some(control) = &control { control.validate()?; } @@ -90,6 +102,14 @@ impl App { } } let root_agent = self.ensure_session_root_agent_context(session_id).await?; + let prompt_declarations = + match skill_invocation { + Some(skill_invocation) => vec![self.build_submission_skill_declaration( + Path::new(&working_dir), + &skill_invocation, + )?], + None => Vec::new(), + }; self.session_runtime .submit_prompt_for_agent( session_id, @@ -97,6 +117,7 @@ impl App { runtime, astrcode_session_runtime::AgentPromptSubmission { agent: root_agent, + prompt_declarations, ..Default::default() }, ) @@ -109,10 +130,16 @@ impl App { session_id: &str, text: String, control: Option, + skill_invocation: Option, ) -> Result { let accepted_control = normalize_prompt_control(control)?; let accepted = self - .submit_prompt_with_control(session_id, text, accepted_control.clone()) + .submit_prompt_with_options( + session_id, + text, + accepted_control.clone(), + skill_invocation, + ) .await?; Ok(PromptAcceptedSummary { turn_id: accepted.turn_id.to_string(), @@ -301,6 +328,57 @@ impl App { handle.agent_profile, )) } + + fn build_submission_skill_declaration( + &self, + working_dir: &Path, + skill_invocation: &PromptSkillInvocation, + ) -> Result { + let skill = self + .composer_skills + .resolve_skill(working_dir, &skill_invocation.skill_id) + .ok_or_else(|| { + ApplicationError::InvalidArgument(format!( + "unknown skill slash command: /{}", + skill_invocation.skill_id + )) + })?; + let mut content = format!( + "The user explicitly selected the `{}` skill for this turn.\n\nSelected skill:\n- id: \ + {}\n- description: {}\n\nTurn contract:\n- Call the `Skill` tool for `{}` before \ + continuing.\n- Treat the user's message as the task-specific instruction for this \ + skill.\n- If the user message is empty, follow the skill's default workflow and ask \ + only if blocked.\n- Do not silently substitute a different skill unless `{}` is \ + unavailable.", + skill.id, + skill.id, + skill.description.trim(), + skill.id, + skill.id + ); + if let Some(user_prompt) = skill_invocation + .user_prompt + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + { + content.push_str(&format!("\n- User prompt focus: {}", user_prompt)); + } + + Ok(PromptDeclaration { + block_id: format!("submission.skill.{}", skill.id), + title: format!("Selected Skill: {}", skill.id), + content, + render_target: PromptDeclarationRenderTarget::System, + layer: SystemPromptLayer::Dynamic, + kind: PromptDeclarationKind::ExtensionInstruction, + priority_hint: Some(590), + always_include: true, + source: PromptDeclarationSource::Builtin, + capability_name: None, + origin: Some(format!("skill-slash:{}", skill.id)), + }) + } } pub fn summarize_session_meta(meta: SessionMeta) -> SessionListSummary { @@ -326,6 +404,39 @@ fn normalize_prompt_control( Ok(control) } +fn normalize_submission_text( + text: String, + skill_invocation: Option<&PromptSkillInvocation>, +) -> Result { + let text = text.trim().to_string(); + let Some(skill_invocation) = skill_invocation else { + if text.is_empty() { + return Err(ApplicationError::InvalidArgument( + "prompt must not be empty".to_string(), + )); + } + return Ok(text); + }; + + let skill_prompt = skill_invocation + .user_prompt + .as_deref() + .map(str::trim) + .unwrap_or_default() + .to_string(); + if !text.is_empty() && !skill_prompt.is_empty() && text != skill_prompt { + return Err(ApplicationError::InvalidArgument( + "skillInvocation.userPrompt must match prompt text".to_string(), + )); + } + + if !text.is_empty() { + Ok(text) + } else { + Ok(skill_prompt) + } +} + fn normalize_compact_control(control: Option) -> Option { let mut control = control.unwrap_or(ExecutionControl { max_steps: None, diff --git a/crates/application/src/terminal_use_cases.rs b/crates/application/src/terminal_use_cases.rs index 7e3743b9..d912435f 100644 --- a/crates/application/src/terminal_use_cases.rs +++ b/crates/application/src/terminal_use_cases.rs @@ -280,7 +280,7 @@ impl App { keywords: option.keywords, badges: option.badges, action: TerminalSlashAction::InsertText { - text: format!("/skill {}", option.id), + text: format!("/{}", option.id), }, }), ); @@ -509,8 +509,8 @@ mod tests { use super::*; use crate::{ - AppKernelPort, AppSessionPort, ComposerSkillPort, ConfigService, McpConfigScope, McpPort, - McpServerStatusView, McpService, ProfileResolutionService, + AppKernelPort, AppSessionPort, ComposerResolvedSkill, ComposerSkillPort, ConfigService, + McpConfigScope, McpPort, McpServerStatusView, McpService, ProfileResolutionService, agent::{ AgentOrchestrationService, test_support::{TestLlmBehavior, build_agent_test_harness}, @@ -527,6 +527,21 @@ mod tests { fn list_skill_summaries(&self, _working_dir: &Path) -> Vec { self.summaries.clone() } + + fn resolve_skill( + &self, + _working_dir: &Path, + skill_id: &str, + ) -> Option { + self.summaries + .iter() + .find(|summary| summary.id == skill_id) + .map(|summary| ComposerResolvedSkill { + id: summary.id.clone(), + description: summary.description.clone(), + guide: format!("guide for {}", summary.id), + }) + } } struct NoopMcpPort; diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index 08addb77..81fa2120 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -11,11 +11,13 @@ astrcode-core = { path = "../core" } anyhow.workspace = true async-trait.workspace = true clap = { version = "4.5", features = ["derive"] } -crossterm = "0.28" +crossterm = "0.29.0" reqwest.workspace = true -ratatui = "0.29" +ratatui = { version = "0.30.0", features = ["scrolling-regions"] } serde.workspace = true serde_json.workspace = true +textwrap = "0.16.2" thiserror.workspace = true tokio.workspace = true +unicode-segmentation = "1.13.2" unicode-width = "0.2" diff --git a/crates/cli/src/app/coordinator.rs b/crates/cli/src/app/coordinator.rs index f6a30e77..eba2d79d 100644 --- a/crates/cli/src/app/coordinator.rs +++ b/crates/cli/src/app/coordinator.rs @@ -4,11 +4,13 @@ use anyhow::Result; use astrcode_client::{ AstrcodeClientTransport, AstrcodeCompactSessionRequest, AstrcodeConversationBannerErrorCodeDto, AstrcodeConversationErrorEnvelopeDto, AstrcodeCreateSessionRequest, - AstrcodeExecutionControlDto, AstrcodePromptRequest, ConversationStreamItem, + AstrcodeExecutionControlDto, AstrcodePromptRequest, AstrcodePromptSkillInvocation, + AstrcodeSaveActiveSelectionRequest, ConversationStreamItem, }; use super::{ - Action, AppController, filter_resume_sessions, required_working_dir, resume_query_from_input, + Action, AppController, filter_model_options, filter_resume_sessions, model_query_from_input, + required_working_dir, resume_query_from_input, slash_candidates_with_local_commands, slash_query_from_input, }; use crate::{ @@ -34,30 +36,10 @@ where pub(super) async fn submit_current_input(&mut self) { let input = self.state.take_input(); - match classify_input(input) { + match classify_input(input, &self.state.conversation.slash_candidates) { InputAction::Empty => {}, InputAction::SubmitPrompt { text } => { - let Some(session_id) = self.state.conversation.active_session_id.clone() else { - self.state.set_error_status("no active session"); - return; - }; - // 新一轮对话开始时恢复 transcript 跟随尾部,避免旧的手动滚动状态 - // 把新回复固定在历史位置,看起来像“后半段没渲染”。 - self.state.resume_transcript_tail(); - self.state.set_status("submitting prompt"); - let client = self.client.clone(); - self.dispatch_async(async move { - let result = client - .submit_prompt( - &session_id, - AstrcodePromptRequest { - text, - control: None, - }, - ) - .await; - Some(Action::PromptSubmitted { session_id, result }) - }); + self.submit_prompt_request(text, None).await; }, InputAction::RunCommand(command) => { self.execute_command(command).await; @@ -71,6 +53,13 @@ where self.state.close_palette(); self.begin_session_hydration(session_id).await; }, + PaletteAction::SelectModel { + profile_name, + model, + } => { + self.state.close_palette(); + self.apply_model_selection(profile_name, model).await; + }, PaletteAction::ReplaceInput { text } => { self.state.close_palette(); self.state.replace_input(text); @@ -114,6 +103,17 @@ where self.state.set_resume_query(query, items); self.refresh_sessions().await; }, + Command::Model { query } => { + let query = query.unwrap_or_default(); + let items = filter_model_options(&self.state.shell.model_options, query.as_str()); + self.state.replace_input(if query.is_empty() { + "/model".to_string() + } else { + format!("/model {query}") + }); + self.state.set_model_query(query.clone(), items); + self.refresh_model_options(query).await; + }, Command::Compact => { let Some(session_id) = self.state.conversation.active_session_id.clone() else { self.state.set_error_status("no active session"); @@ -148,14 +148,16 @@ where Some(Action::CompactRequested { session_id, result }) }); }, - Command::Skill { query } => { - let query = query.unwrap_or_default(); - self.state.replace_input(if query.is_empty() { - "/skill".to_string() - } else { - format!("/skill {query}") - }); - self.open_slash_palette(query).await; + Command::SkillInvoke { skill_id, prompt } => { + let text = prompt.clone().unwrap_or_default(); + self.submit_prompt_request( + text, + Some(AstrcodePromptSkillInvocation { + skill_id, + user_prompt: prompt, + }), + ) + .await; }, Command::Unknown { raw } => { self.state @@ -234,6 +236,22 @@ where }); } + pub(super) async fn refresh_current_model(&self) { + let client = self.client.clone(); + self.dispatch_async(async move { + let result = client.get_current_model().await; + Some(Action::CurrentModelLoaded(result)) + }); + } + + pub(super) async fn refresh_model_options(&self, query: String) { + let client = self.client.clone(); + self.dispatch_async(async move { + let result = client.list_models().await; + Some(Action::ModelOptionsLoaded { query, result }) + }); + } + pub(super) async fn open_slash_palette(&mut self, query: String) { if !self .state @@ -245,10 +263,14 @@ where { self.state.replace_input("/".to_string()); } + let candidates = slash_candidates_with_local_commands( + &self.state.conversation.slash_candidates, + query.as_str(), + ); let items = if query.trim().is_empty() { - self.state.conversation.slash_candidates.clone() + candidates } else { - filter_slash_candidates(&self.state.conversation.slash_candidates, &query) + filter_slash_candidates(&candidates, &query) }; self.state.set_slash_query(query.clone(), items); self.refresh_slash_candidates(query).await; @@ -299,12 +321,33 @@ where return; } let query = self.slash_query_for_current_input(); - self.state.set_slash_query( - query.clone(), - filter_slash_candidates(&self.state.conversation.slash_candidates, &query), + let candidates = slash_candidates_with_local_commands( + &self.state.conversation.slash_candidates, + query.as_str(), ); + self.state + .set_slash_query(query.clone(), filter_slash_candidates(&candidates, &query)); self.refresh_slash_candidates(query).await; }, + PaletteState::Model(_) => { + if !self + .state + .interaction + .composer + .as_str() + .trim_start() + .starts_with("/model") + { + self.state.close_palette(); + return; + } + let query = model_query_from_input(self.state.interaction.composer.as_str()); + self.state.set_model_query( + query.clone(), + filter_model_options(&self.state.shell.model_options, query.as_str()), + ); + self.refresh_model_options(query).await; + }, PaletteState::Closed => {}, } } @@ -357,4 +400,48 @@ where pub(super) fn slash_query_for_current_input(&self) -> String { slash_query_from_input(self.state.interaction.composer.as_str()) } + + async fn apply_model_selection(&mut self, profile_name: String, model: String) { + self.state.set_status(format!("switching model to {model}")); + let client = self.client.clone(); + self.dispatch_async(async move { + let result = client + .save_active_selection(AstrcodeSaveActiveSelectionRequest { + active_profile: profile_name.clone(), + active_model: model.clone(), + }) + .await; + Some(Action::ModelSelectionSaved { + profile_name, + model, + result, + }) + }); + } + + async fn submit_prompt_request( + &mut self, + text: String, + skill_invocation: Option, + ) { + let Some(session_id) = self.state.conversation.active_session_id.clone() else { + self.state.set_error_status("no active session"); + return; + }; + self.state.set_status("submitting prompt"); + let client = self.client.clone(); + self.dispatch_async(async move { + let result = client + .submit_prompt( + &session_id, + AstrcodePromptRequest { + text, + skill_invocation, + control: None, + }, + ) + .await; + Some(Action::PromptSubmitted { session_id, result }) + }); + } } diff --git a/crates/cli/src/app/mod.rs b/crates/cli/src/app/mod.rs index c1313b98..414824ec 100644 --- a/crates/cli/src/app/mod.rs +++ b/crates/cli/src/app/mod.rs @@ -16,20 +16,20 @@ use anyhow::{Context, Result}; use astrcode_client::{ AstrcodeClient, AstrcodeClientError, AstrcodeClientTransport, AstrcodeConversationSlashCandidatesResponseDto, AstrcodeConversationSnapshotResponseDto, - AstrcodePromptAcceptedResponse, AstrcodeReqwestTransport, AstrcodeSessionListItem, - ClientConfig, ConversationStreamItem, + AstrcodeCurrentModelInfoDto, AstrcodeModelOptionDto, AstrcodePromptAcceptedResponse, + AstrcodeReqwestTransport, AstrcodeSessionListItem, ClientConfig, ConversationStreamItem, }; use clap::Parser; use crossterm::{ event::{ - self, DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture, + self, DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, Event as CrosstermEvent, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, MouseEvent, MouseEventKind, }, execute, - terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode}, + terminal::{disable_raw_mode, enable_raw_mode}, }; -use ratatui::{Terminal, backend::CrosstermBackend}; +use ratatui::backend::CrosstermBackend; use tokio::{ sync::mpsc, task::JoinHandle, @@ -37,11 +37,14 @@ use tokio::{ }; use crate::{ + bottom_pane::{BottomPaneState, SurfaceLayout, render_bottom_pane}, capability::TerminalCapabilities, + chat::ChatSurfaceState, command::{fuzzy_contains, palette_action}, launcher::{LaunchOptions, Launcher, LauncherSession, SystemManagedServer}, - render, state::{CliState, PaletteState, PaneFocus, StreamRenderMode}, + tui::TuiRuntime, + ui::{CodexTheme, overlay::render_browser_overlay}, }; #[derive(Debug, Parser)] @@ -85,10 +88,20 @@ enum Action { query: String, result: Result, }, + CurrentModelLoaded(Result), + ModelOptionsLoaded { + query: String, + result: Result, AstrcodeClientError>, + }, PromptSubmitted { session_id: String, result: Result, }, + ModelSelectionSaved { + profile_name: String, + model: String, + result: Result<(), AstrcodeClientError>, + }, CompactRequested { session_id: String, result: Result, @@ -136,6 +149,8 @@ async fn run_app(launcher_session: LauncherSession) -> Resu AppControllerChannels::new(actions_tx.clone(), actions_rx), ); + controller.refresh_current_model().await; + controller.refresh_model_options(String::new()).await; controller.bootstrap().await?; let terminal_result = run_terminal_loop(&mut controller, actions_tx.clone()).await; @@ -156,16 +171,19 @@ async fn run_terminal_loop( let stdout = io::stdout(); let backend = CrosstermBackend::new(stdout); - let mut terminal = Terminal::new(backend).context("create terminal backend failed")?; + let mut runtime = TuiRuntime::with_backend(backend).context("create TUI runtime failed")?; let input_handle = InputHandle::spawn(actions_tx.clone()); let tick_handle = spawn_tick_loop(actions_tx); - let loop_result = run_event_loop(controller, &mut terminal).await; + let loop_result = run_event_loop(controller, &mut runtime).await; input_handle.stop(); tick_handle.stop().await; - terminal.show_cursor().context("show cursor failed")?; + runtime + .terminal_mut() + .show_cursor() + .context("show cursor failed")?; drop(terminal_guard); loop_result @@ -173,19 +191,15 @@ async fn run_terminal_loop( async fn run_event_loop( controller: &mut AppController, - terminal: &mut Terminal>, + runtime: &mut TuiRuntime>, ) -> Result<()> { - terminal - .draw(|frame| render::render(frame, &mut controller.state)) - .context("initial draw failed")?; + redraw(controller, runtime).context("initial draw failed")?; controller.state.render.take_frame_dirty(); while let Some(action) = controller.actions_rx.recv().await { controller.handle_action(action).await?; if controller.state.render.take_frame_dirty() { - terminal - .draw(|frame| render::render(frame, &mut controller.state)) - .context("redraw failed")?; + redraw(controller, runtime).context("redraw failed")?; } if controller.should_quit { break; @@ -195,6 +209,34 @@ async fn run_event_loop( Ok(()) } +fn redraw( + controller: &mut AppController, + runtime: &mut TuiRuntime>, +) -> Result<()> { + let size = runtime.screen_size().context("read terminal size failed")?; + let theme = CodexTheme::new(controller.state.shell.capabilities); + let mut chat = controller + .chat_surface + .build_frame(&controller.state, &theme, size.width); + runtime.stage_history_lines(std::mem::take(&mut chat.history_lines)); + let pane = BottomPaneState::from_cli(&controller.state, &chat, &theme, size.width); + let layout = SurfaceLayout::new(size, controller.state.render.active_overlay, &pane); + runtime + .draw( + layout.viewport_height(), + controller.state.render.active_overlay.is_open(), + |frame, area| { + if controller.state.render.active_overlay.is_open() { + render_browser_overlay(frame, &controller.state, &theme); + } else { + render_bottom_pane(frame, area, &controller.state, &pane, &theme); + } + }, + ) + .context("draw CLI surface failed")?; + Ok(()) +} + #[derive(Clone, Default)] struct SharedStreamPacer { inner: Arc>, @@ -254,6 +296,7 @@ impl SharedStreamPacer { struct AppController { client: AstrcodeClient, state: CliState, + chat_surface: ChatSurfaceState, debug_tap: Option, actions_tx: mpsc::UnboundedSender, actions_rx: mpsc::UnboundedReceiver, @@ -283,12 +326,6 @@ impl TerminalRestoreGuard { fn enter(capabilities: TerminalCapabilities) -> Result { enable_raw_mode().context("enable raw mode failed")?; let mut stdout = io::stdout(); - if capabilities.alt_screen { - execute!(stdout, EnterAlternateScreen).context("enter alternate screen failed")?; - } - if capabilities.mouse { - execute!(stdout, EnableMouseCapture).context("enable mouse capture failed")?; - } if capabilities.bracketed_paste { execute!(stdout, EnableBracketedPaste).context("enable bracketed paste failed")?; } @@ -303,12 +340,7 @@ impl Drop for TerminalRestoreGuard { if self.capabilities.bracketed_paste { let _ = execute!(stdout, DisableBracketedPaste); } - if self.capabilities.mouse { - let _ = execute!(stdout, DisableMouseCapture); - } - if self.capabilities.alt_screen { - let _ = execute!(stdout, LeaveAlternateScreen); - } + let _ = execute!(stdout, DisableMouseCapture); } } @@ -325,6 +357,7 @@ where Self { client, state, + chat_surface: ChatSurfaceState::default(), debug_tap, actions_tx: channels.tx, actions_rx: channels.rx, @@ -369,7 +402,7 @@ where self.state.advance_thinking_playback(); }, Action::Quit => self.should_quit = true, - Action::Resize { width, height } => self.state.set_viewport_size(width, height), + Action::Resize { width, height } => self.state.note_terminal_resize(width, height), Action::Mouse(mouse) => self.handle_mouse(mouse), Action::Key(key) => self.handle_key(key).await?, Action::Paste(text) => self.handle_paste(text).await?, @@ -402,6 +435,7 @@ where match result { Ok(snapshot) => { self.pending_session_id = None; + self.chat_surface.reset(); self.state.activate_snapshot(snapshot); self.state .set_status(format!("attached to session {}", session_id)); @@ -438,23 +472,65 @@ where match result { Ok(candidates) => { - self.state.set_slash_query(query, candidates.items); + let items = + slash_candidates_with_local_commands(&candidates.items, query.as_str()); + self.state.set_slash_query(query, items); }, Err(error) => self.apply_status_error(error), } }, + Action::CurrentModelLoaded(result) => match result { + Ok(current_model) => self.state.update_current_model(current_model), + Err(error) => self.apply_status_error(error), + }, + Action::ModelOptionsLoaded { query, result } => match result { + Ok(model_options) => { + self.state.update_model_options(model_options.clone()); + if let PaletteState::Model(palette) = &self.state.interaction.palette { + if palette.query == query { + self.state.set_model_query( + query, + filter_model_options(&model_options, palette.query.as_str()), + ); + } + } + }, + Err(error) => self.apply_status_error(error), + }, Action::PromptSubmitted { session_id, result } => { if !self.active_session_matches(session_id.as_str()) { return Ok(()); } match result { - Ok(response) => { - self.state - .set_status(format!("prompt accepted: turn {}", response.turn_id)); - }, + Ok(_response) => self.state.set_status("ready"), Err(error) => self.apply_status_error(error), } }, + Action::ModelSelectionSaved { + profile_name, + model, + result, + } => match result { + Ok(()) => { + let provider_kind = self + .state + .shell + .model_options + .iter() + .find(|option| option.profile_name == profile_name && option.model == model) + .map(|option| option.provider_kind.clone()) + .unwrap_or_else(|| "unknown".to_string()); + self.state + .update_current_model(AstrcodeCurrentModelInfoDto { + profile_name, + model: model.clone(), + provider_kind, + }); + self.state.set_status(format!("ready · model {model}")); + self.refresh_current_model().await; + }, + Err(error) => self.apply_status_error(error), + }, Action::CompactRequested { session_id, result } => { if !self.active_session_matches(session_id.as_str()) { return Ok(()); @@ -482,86 +558,72 @@ where return Ok(()); } + if key.modifiers.contains(KeyModifiers::CONTROL) && matches!(key.code, KeyCode::Char('t')) { + self.state.toggle_browser(); + return Ok(()); + } + if key.modifiers.contains(KeyModifiers::CONTROL) && matches!(key.code, KeyCode::Char('o')) { - if matches!(self.state.interaction.pane_focus, PaneFocus::Transcript) - && self.state.selected_cell_is_thinking() - { - self.state.toggle_selected_cell_expanded(); + return Ok(()); + } + + if self.state.interaction.browser.open { + match key.code { + KeyCode::Esc => { + self.state.toggle_browser(); + }, + KeyCode::Home => self.state.browser_first(), + KeyCode::End => self.state.browser_last(), + KeyCode::Up => self.state.browser_prev(1), + KeyCode::Down => self.state.browser_next(1), + KeyCode::PageUp => self.state.browser_prev(5), + KeyCode::PageDown => self.state.browser_next(5), + KeyCode::Enter => self.state.toggle_selected_cell_expanded(), + _ => {}, } return Ok(()); } match key.code { - KeyCode::Esc => { - if self.state.interaction.has_palette() { - self.state.close_palette(); - } else { - self.state.clear_surface_state(); - } + KeyCode::Esc if self.state.interaction.has_palette() => { + self.state.close_palette(); }, - KeyCode::Left - if !matches!(self.state.interaction.pane_focus, PaneFocus::Transcript) => - { + KeyCode::Esc => {}, + KeyCode::Left => { self.state.move_cursor_left(); }, - KeyCode::Right - if !matches!(self.state.interaction.pane_focus, PaneFocus::Transcript) => - { + KeyCode::Right => { self.state.move_cursor_right(); }, KeyCode::Home => { - if matches!(self.state.interaction.pane_focus, PaneFocus::Transcript) { - self.state.scroll_up_by(u16::MAX); - } else { - self.state.move_cursor_home(); - } + self.state.move_cursor_home(); }, KeyCode::End => { - if matches!(self.state.interaction.pane_focus, PaneFocus::Transcript) { - self.state.interaction.reset_scroll(); - self.state.render.mark_transcript_dirty(); - } else { - self.state.move_cursor_end(); - } + self.state.move_cursor_end(); }, KeyCode::BackTab => { if !matches!(self.state.interaction.palette, PaletteState::Closed) { return Ok(()); } - self.state.cycle_focus_backward(); + self.state.interaction.set_focus(PaneFocus::Composer); + self.state.render.mark_dirty(); }, KeyCode::Tab => { if !matches!(self.state.interaction.palette, PaletteState::Closed) { return Ok(()); } - self.state.cycle_focus_forward(); - }, - KeyCode::Up => { - if !matches!(self.state.interaction.palette, PaletteState::Closed) { - self.state.palette_prev(); - } else { - match self.state.interaction.pane_focus { - PaneFocus::Transcript => self.state.transcript_prev(), - PaneFocus::Composer | PaneFocus::Palette => {}, - } - } - }, - KeyCode::Down => { - if !matches!(self.state.interaction.palette, PaletteState::Closed) { - self.state.palette_next(); - } else { - match self.state.interaction.pane_focus { - PaneFocus::Transcript => self.state.transcript_next(), - PaneFocus::Composer | PaneFocus::Palette => {}, - } - } + self.state.interaction.set_focus(PaneFocus::Composer); + self.state.render.mark_dirty(); }, - KeyCode::PageUp => { - self.state.scroll_up_by(self.scroll_page_step().max(1)); + KeyCode::Up if !matches!(self.state.interaction.palette, PaletteState::Closed) => { + self.state.palette_prev(); }, - KeyCode::PageDown => { - self.state.scroll_down_by(self.scroll_page_step().max(1)); + KeyCode::Up => {}, + KeyCode::Down if !matches!(self.state.interaction.palette, PaletteState::Closed) => { + self.state.palette_next(); }, + KeyCode::Down => {}, + KeyCode::PageUp | KeyCode::PageDown => {}, KeyCode::Enter => { if key.modifiers.contains(KeyModifiers::SHIFT) { if matches!(self.state.interaction.palette, PaletteState::Closed) @@ -574,16 +636,12 @@ where .await?; } else { match self.state.interaction.pane_focus { - PaneFocus::Transcript => self.handle_transcript_enter(), PaneFocus::Composer => self.submit_current_input().await, - PaneFocus::Palette => {}, + PaneFocus::Palette | PaneFocus::Browser => {}, } } }, KeyCode::Backspace => { - if matches!(self.state.interaction.pane_focus, PaneFocus::Transcript) { - return Ok(()); - } if matches!(self.state.interaction.palette, PaletteState::Closed) { self.state.pop_input(); } else { @@ -592,9 +650,6 @@ where } }, KeyCode::Delete => { - if matches!(self.state.interaction.pane_focus, PaneFocus::Transcript) { - return Ok(()); - } self.state.delete_input(); if !matches!(self.state.interaction.palette, PaletteState::Closed) { self.refresh_palette_query().await; @@ -605,21 +660,10 @@ where self.state.push_input(ch); self.refresh_palette_query().await; } else { - match self.state.interaction.pane_focus { - PaneFocus::Transcript if matches!(ch, 'j' | 'k') => { - if ch == 'j' { - self.state.transcript_next(); - } else { - self.state.transcript_prev(); - } - }, - _ => { - self.state.push_input(ch); - if ch == '/' { - let query = self.slash_query_for_current_input(); - self.open_slash_palette(query).await; - } - }, + self.state.push_input(ch); + if ch == '/' { + let query = self.slash_query_for_current_input(); + self.open_slash_palette(query).await; } } }, @@ -639,34 +683,16 @@ where fn handle_mouse(&mut self, mouse: MouseEvent) { match mouse.kind { - MouseEventKind::ScrollUp => self.state.scroll_up_by(3), - MouseEventKind::ScrollDown => self.state.scroll_down_by(3), + MouseEventKind::ScrollUp | MouseEventKind::ScrollDown => {}, MouseEventKind::Down(_) => { - let footer_top = self.state.render.viewport_height.saturating_sub(5); - if mouse.row >= footer_top { - self.state.interaction.set_focus(PaneFocus::Composer); - self.state.render.invalidate_transcript_cache(); - self.state.render.mark_footer_dirty(); - return; - } - self.state.interaction.set_focus(PaneFocus::Transcript); - self.state.render.invalidate_transcript_cache(); - self.state.render.mark_footer_dirty(); + let _ = mouse; + self.state.interaction.set_focus(PaneFocus::Composer); + self.state.render.mark_dirty(); }, _ => {}, } } - fn scroll_page_step(&self) -> u16 { - self.state.render.viewport_height.saturating_sub(7).max(1) - } - - fn handle_transcript_enter(&mut self) { - if !self.state.selected_cell_is_thinking() { - self.state.toggle_selected_cell_expanded(); - } - } - fn active_session_matches(&self, session_id: &str) -> bool { self.state.conversation.active_session_id.as_deref() == Some(session_id) } @@ -811,12 +837,68 @@ fn filter_resume_sessions( items } +fn slash_candidates_with_local_commands( + candidates: &[astrcode_client::AstrcodeConversationSlashCandidateDto], + query: &str, +) -> Vec { + let mut merged = candidates.to_vec(); + let model_candidate = astrcode_client::AstrcodeConversationSlashCandidateDto { + id: "model".to_string(), + title: "/model".to_string(), + description: "选择当前已配置的模型".to_string(), + keywords: vec!["model".to_string(), "profile".to_string()], + action_kind: astrcode_client::AstrcodeConversationSlashActionKindDto::ExecuteCommand, + action_value: "/model".to_string(), + }; + + if !merged + .iter() + .any(|candidate| candidate.id == model_candidate.id) + && fuzzy_contains( + query, + [ + model_candidate.id.clone(), + model_candidate.title.clone(), + model_candidate.description.clone(), + ], + ) + { + merged.push(model_candidate); + } + + merged +} + +fn filter_model_options( + options: &[AstrcodeModelOptionDto], + query: &str, +) -> Vec { + let mut items = options + .iter() + .filter(|option| { + fuzzy_contains( + query, + [ + option.model.clone(), + option.profile_name.clone(), + option.provider_kind.clone(), + ], + ) + }) + .cloned() + .collect::>(); + items.sort_by(|left, right| left.model.cmp(&right.model)); + items +} + fn slash_query_from_input(input: &str) -> String { let trimmed = input.trim(); - if let Some(query) = trimmed.strip_prefix("/skill") { - return query.trim().to_string(); - } - trimmed.trim_start_matches('/').trim().to_string() + let command = trimmed.trim_start_matches('/'); + command + .split_whitespace() + .next() + .unwrap_or_default() + .to_string() } fn resume_query_from_input(input: &str) -> String { @@ -828,6 +910,15 @@ fn resume_query_from_input(input: &str) -> String { .to_string() } +fn model_query_from_input(input: &str) -> String { + input + .trim() + .strip_prefix("/model") + .map(str::trim) + .unwrap_or_default() + .to_string() +} + #[cfg(test)] mod tests { use std::{ @@ -878,6 +969,18 @@ mod tests { } } + #[test] + fn model_query_from_input_extracts_optional_filter() { + assert_eq!(super::model_query_from_input("/model"), ""); + assert_eq!(super::model_query_from_input("/model claude"), "claude"); + } + + #[test] + fn slash_candidates_with_local_commands_includes_model_entry() { + let items = super::slash_candidates_with_local_commands(&[], "model"); + assert!(items.iter().any(|item| item.id == "model")); + } + #[derive(Debug)] enum MockCall { Request { @@ -1221,19 +1324,72 @@ mod tests { AppControllerChannels::new(actions_tx, actions_rx), ); controller.state.conversation.active_session_id = Some("session-1".to_string()); - controller.state.scroll_up_by(6); controller.state.replace_input("hello"); controller.submit_current_input().await; - assert_eq!(controller.state.interaction.scroll_anchor, 0); - assert!(controller.state.interaction.follow_transcript_tail); - handle_next_action(&mut controller).await; - assert_eq!( - controller.state.interaction.status.message, - "prompt accepted: turn turn-1" + assert_eq!(controller.state.interaction.status.message, "ready"); + transport.assert_consumed(); + } + + #[tokio::test] + async fn submitting_skill_slash_sends_structured_skill_invocation() { + let transport = MockTransport::default(); + transport.push(MockCall::Request { + expected: AstrcodeTransportRequest { + method: AstrcodeTransportMethod::Post, + url: "http://localhost:5529/api/sessions/session-1/prompts".to_string(), + auth_token: Some("session-token".to_string()), + query: Vec::new(), + json_body: Some(json!({ + "text": "修复失败测试", + "skillInvocation": { + "skillId": "review", + "userPrompt": "修复失败测试" + } + })), + }, + result: Ok(AstrcodeTransportResponse { + status: 202, + body: json!({ + "sessionId": "session-1", + "accepted": true, + "turnId": "turn-2" + }) + .to_string(), + }), + }); + + let (actions_tx, actions_rx) = mpsc::unbounded_channel(); + let mut controller = AppController::new( + client_with_transport(transport.clone()), + CliState::new( + "http://localhost:5529".to_string(), + Some(PathBuf::from("D:/repo-a")), + ascii_capabilities(), + ), + None, + AppControllerChannels::new(actions_tx, actions_rx), ); + controller.state.conversation.active_session_id = Some("session-1".to_string()); + controller.state.conversation.slash_candidates = + vec![astrcode_client::AstrcodeConversationSlashCandidateDto { + id: "review".to_string(), + title: "Review".to_string(), + description: "review skill".to_string(), + keywords: vec!["review".to_string()], + action_kind: astrcode_client::AstrcodeConversationSlashActionKindDto::InsertText, + action_value: "/review".to_string(), + }]; + controller + .state + .replace_input("/review 修复失败测试".to_string()); + + controller.submit_current_input().await; + + handle_next_action(&mut controller).await; + assert_eq!(controller.state.interaction.status.message, "ready"); transport.assert_consumed(); } @@ -1279,12 +1435,12 @@ mod tests { status: 200, body: json!({ "items": [{ - "id": "skill-review", + "id": "review", "title": "Review skill", "description": "插入 review skill", "keywords": ["review"], "actionKind": "insert_text", - "actionValue": "/skill review" + "actionValue": "/review" }] }) .to_string(), @@ -1378,11 +1534,8 @@ mod tests { "session one should hydrate one transcript block" ); - controller - .execute_command(Command::Skill { - query: Some("review".to_string()), - }) - .await; + controller.state.replace_input("/review".to_string()); + controller.open_slash_palette("review".to_string()).await; handle_next_action(&mut controller).await; let PaletteState::Slash(palette) = &controller.state.interaction.palette else { panic!("skill command should open slash palette"); diff --git a/crates/cli/src/bottom_pane/layout.rs b/crates/cli/src/bottom_pane/layout.rs new file mode 100644 index 00000000..88ab07e4 --- /dev/null +++ b/crates/cli/src/bottom_pane/layout.rs @@ -0,0 +1,68 @@ +use ratatui::layout::{Rect, Size}; + +use super::BottomPaneState; +use crate::state::ActiveOverlay; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct SurfaceLayout { + pub screen: Rect, + pub viewport: Rect, + pub overlay: Option, +} + +impl SurfaceLayout { + pub fn new(size: Size, overlay: ActiveOverlay, pane: &BottomPaneState) -> Self { + let screen = Rect::new(0, 0, size.width, size.height); + if overlay.is_open() { + return Self { + screen, + viewport: screen, + overlay: Some(screen), + }; + } + + let viewport_height = pane.desired_height(size.height); + let viewport = Rect::new( + 0, + size.height.saturating_sub(viewport_height), + size.width, + viewport_height, + ); + Self { + screen, + viewport, + overlay: None, + } + } + + pub fn viewport_height(self) -> u16 { + self.viewport.height + } +} + +#[cfg(test)] +mod tests { + use ratatui::layout::Size; + + use super::SurfaceLayout; + use crate::{bottom_pane::BottomPaneState, state::ActiveOverlay}; + + #[test] + fn overlay_uses_full_screen_viewport() { + let layout = SurfaceLayout::new( + Size::new(80, 24), + ActiveOverlay::Browser, + &BottomPaneState::default(), + ); + assert_eq!(layout.viewport.height, 24); + assert_eq!(layout.overlay, Some(layout.screen)); + } + + #[test] + fn inline_layout_anchors_viewport_to_bottom() { + let pane = BottomPaneState::default(); + let layout = SurfaceLayout::new(Size::new(80, 24), ActiveOverlay::None, &pane); + assert_eq!(layout.viewport.bottom(), 24); + assert!(layout.viewport.height >= 4); + } +} diff --git a/crates/cli/src/bottom_pane/mod.rs b/crates/cli/src/bottom_pane/mod.rs new file mode 100644 index 00000000..4e4a442d --- /dev/null +++ b/crates/cli/src/bottom_pane/mod.rs @@ -0,0 +1,7 @@ +mod layout; +mod model; +mod render; + +pub use layout::SurfaceLayout; +pub use model::BottomPaneState; +pub use render::render_bottom_pane; diff --git a/crates/cli/src/bottom_pane/model.rs b/crates/cli/src/bottom_pane/model.rs new file mode 100644 index 00000000..ebe4a678 --- /dev/null +++ b/crates/cli/src/bottom_pane/model.rs @@ -0,0 +1,223 @@ +use ratatui::text::Line; + +use crate::{ + chat::ChatSurfaceFrame, + state::{CliState, PaletteState}, + ui::{CodexTheme, line_to_ratatui, palette_lines}, +}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum BottomPaneMode { + EmptySessionMinimal { + welcome_lines: Vec>, + }, + ActiveSession { + status_line: Option>, + detail_lines: Vec>, + preview_lines: Vec>, + }, +} + +impl Default for BottomPaneMode { + fn default() -> Self { + Self::ActiveSession { + status_line: None, + detail_lines: Vec::new(), + preview_lines: Vec::new(), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct BottomPaneState { + pub mode: BottomPaneMode, + pub composer_line_count: usize, + pub palette_title: Option, + pub palette_lines: Vec>, +} + +impl BottomPaneState { + pub fn from_cli( + state: &CliState, + chat: &ChatSurfaceFrame, + theme: &CodexTheme, + width: u16, + ) -> Self { + let palette_title = palette_title(&state.interaction.palette); + let palette_lines = if palette_title.is_some() { + palette_lines( + &state.interaction.palette, + usize::from(width.saturating_sub(4).max(1)), + theme, + ) + .into_iter() + .map(|line| line_to_ratatui(&line, theme)) + .collect() + } else { + Vec::new() + }; + + Self { + mode: if should_show_empty_session_minimal(state, chat) { + BottomPaneMode::EmptySessionMinimal { + welcome_lines: build_welcome_lines(state), + } + } else { + BottomPaneMode::ActiveSession { + status_line: active_status_line(state, chat), + detail_lines: chat.detail_lines.clone(), + preview_lines: chat.preview_lines.clone(), + } + }, + composer_line_count: state.interaction.composer.line_count(), + palette_title, + palette_lines, + } + } + + pub fn desired_height(&self, total_height: u16) -> u16 { + let palette_height = self.palette_height(); + let composer_height = composer_height(total_height, self.composer_line_count); + let content_height = match &self.mode { + BottomPaneMode::EmptySessionMinimal { welcome_lines } => { + (welcome_lines.len() as u16 + 2).clamp(5, 6) + }, + BottomPaneMode::ActiveSession { + status_line, + detail_lines, + preview_lines, + } => (status_line.is_some() as u16) + .saturating_add(detail_lines.len().min(2) as u16) + .saturating_add(preview_lines.len().min(3) as u16), + }; + + content_height + .saturating_add(palette_height) + .saturating_add(composer_height) + .clamp(bottom_pane_min_height(total_height), total_height.max(1)) + } + + pub fn palette_height(&self) -> u16 { + if self.palette_title.is_some() { + (self.palette_lines.len() as u16 + 2).clamp(3, 7) + } else { + 0 + } + } +} + +pub fn composer_height(total_height: u16, line_count: usize) -> u16 { + let preferred = line_count.clamp(1, 3) as u16; + if total_height <= 4 { 1 } else { preferred } +} + +fn bottom_pane_min_height(total_height: u16) -> u16 { + if total_height <= 8 { 3 } else { 4 } +} + +fn should_show_empty_session_minimal(state: &CliState, chat: &ChatSurfaceFrame) -> bool { + state.conversation.transcript.is_empty() + && chat.status_line.is_none() + && chat.detail_lines.is_empty() + && chat.preview_lines.is_empty() + && !state.interaction.browser.open +} + +fn build_welcome_lines(state: &CliState) -> Vec> { + let model = state + .shell + .current_model + .as_ref() + .map(|model| model.model.clone()) + .unwrap_or_else(|| "loading".to_string()); + let directory = state + .shell + .working_dir + .as_ref() + .and_then(|path| path.file_name()) + .map(|name| name.to_string_lossy().to_string()) + .unwrap_or_else(|| "~".to_string()); + + vec![ + Line::from(">_ Astrcode CLI"), + Line::from(format!("model: {model}")), + Line::from(format!("directory: {directory}")), + ] +} + +fn active_status_line(state: &CliState, chat: &ChatSurfaceFrame) -> Option> { + if state.interaction.status.is_error { + return Some(Line::from(format!( + "• {}", + state.interaction.status.message + ))); + } + let trimmed = state.interaction.status.message.trim(); + if !trimmed.is_empty() && trimmed != "ready" { + return Some(Line::from(format!("• {trimmed}"))); + } + chat.status_line.clone() +} + +fn palette_title(palette: &PaletteState) -> Option { + match palette { + PaletteState::Slash(_) => Some("/ commands".to_string()), + PaletteState::Resume(_) => Some("/resume".to_string()), + PaletteState::Model(_) => Some("/model".to_string()), + PaletteState::Closed => None, + } +} + +#[cfg(test)] +mod tests { + use astrcode_client::AstrcodeCurrentModelInfoDto; + + use super::{ + BottomPaneMode, BottomPaneState, composer_height, should_show_empty_session_minimal, + }; + use crate::{ + capability::{ColorLevel, GlyphMode, TerminalCapabilities}, + chat::ChatSurfaceFrame, + state::CliState, + ui::CodexTheme, + }; + + fn capabilities() -> TerminalCapabilities { + TerminalCapabilities { + color: ColorLevel::Ansi16, + glyphs: GlyphMode::Unicode, + alt_screen: false, + mouse: false, + bracketed_paste: false, + } + } + + #[test] + fn empty_session_uses_minimal_mode() { + let mut state = CliState::new("http://127.0.0.1:5529".to_string(), None, capabilities()); + state.shell.current_model = Some(AstrcodeCurrentModelInfoDto { + profile_name: "default".to_string(), + model: "glm-5.1".to_string(), + provider_kind: "glm".to_string(), + }); + let chat = ChatSurfaceFrame::default(); + assert!(should_show_empty_session_minimal(&state, &chat)); + + let theme = CodexTheme::new(state.shell.capabilities); + let pane = BottomPaneState::from_cli(&state, &chat, &theme, 80); + match &pane.mode { + BottomPaneMode::EmptySessionMinimal { welcome_lines } => { + assert_eq!(welcome_lines.len(), 3); + assert!(welcome_lines[0].to_string().contains("Astrcode CLI")); + }, + BottomPaneMode::ActiveSession { .. } => panic!("empty session should use minimal mode"), + } + assert!((6..=8).contains(&pane.desired_height(24))); + } + + #[test] + fn composer_height_stays_single_line_by_default() { + assert_eq!(composer_height(24, 1), 1); + assert_eq!(composer_height(24, 4), 3); + } +} diff --git a/crates/cli/src/bottom_pane/render.rs b/crates/cli/src/bottom_pane/render.rs new file mode 100644 index 00000000..a0a20f0a --- /dev/null +++ b/crates/cli/src/bottom_pane/render.rs @@ -0,0 +1,178 @@ +use ratatui::{ + layout::{Constraint, Direction, Layout, Rect}, + widgets::{Block, Borders, Clear, Paragraph}, +}; + +use super::{ + BottomPaneState, + model::{BottomPaneMode, composer_height}, +}; +use crate::{ + state::{CliState, PaneFocus}, + ui::{CodexTheme, ThemePalette, composer::render_composer, custom_terminal::Frame}, +}; + +pub fn render_bottom_pane( + frame: &mut Frame<'_>, + area: Rect, + state: &CliState, + pane: &BottomPaneState, + theme: &CodexTheme, +) { + let popup_height = pane.palette_height(); + let composer_height = composer_height(area.height, pane.composer_line_count); + + let body_height = area + .height + .saturating_sub(popup_height) + .saturating_sub(composer_height); + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(body_height), + Constraint::Length(popup_height), + Constraint::Length(composer_height), + ]) + .split(area); + + render_body(frame, chunks[0], pane, theme); + render_palette(frame, chunks[1], pane, theme); + render_composer_area(frame, chunks[2], state, theme); +} + +fn render_body(frame: &mut Frame<'_>, area: Rect, pane: &BottomPaneState, theme: &CodexTheme) { + match &pane.mode { + BottomPaneMode::EmptySessionMinimal { welcome_lines } => { + if area.height == 0 { + return; + } + let card_height = (welcome_lines.len() as u16 + 2).min(area.height); + let card_area = Rect::new(area.x, area.y, area.width.min(48), card_height); + let block = Block::default() + .borders(Borders::ALL) + .border_style(theme.menu_block_style()); + let inner = block.inner(card_area); + frame.render_widget(block, card_area); + frame.render_widget(Paragraph::new(welcome_lines.clone()), inner); + }, + BottomPaneMode::ActiveSession { + status_line, + detail_lines, + preview_lines, + } => { + let status_height = status_line.is_some() as u16; + let detail_height = detail_lines.len().min(2) as u16; + let preview_height = preview_lines.len().min(3) as u16; + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(status_height), + Constraint::Length(detail_height), + Constraint::Length(preview_height), + Constraint::Min(0), + ]) + .split(area); + + if let Some(line) = status_line { + frame.render_widget(Paragraph::new(vec![line.clone()]), chunks[0]); + } + if detail_height > 0 { + frame.render_widget( + Paragraph::new( + detail_lines + .iter() + .take(detail_height as usize) + .cloned() + .collect::>(), + ), + chunks[1], + ); + } + if preview_height > 0 { + let visible_preview = preview_tail(preview_lines, preview_height as usize); + frame.render_widget(Paragraph::new(visible_preview), chunks[2]); + } + }, + } +} + +fn render_palette(frame: &mut Frame<'_>, area: Rect, pane: &BottomPaneState, theme: &CodexTheme) { + if area.height == 0 { + return; + } + if let Some(title) = &pane.palette_title { + frame.render_widget(Clear, area); + let block = Block::default() + .borders(Borders::ALL) + .border_style(theme.menu_block_style()) + .title(title.clone()); + let inner = block.inner(area); + frame.render_widget(block, area); + frame.render_widget(Paragraph::new(pane.palette_lines.clone()), inner); + } +} + +fn render_composer_area(frame: &mut Frame<'_>, area: Rect, state: &CliState, theme: &CodexTheme) { + if area.height == 0 { + return; + } + let focused = matches!( + state.interaction.pane_focus, + PaneFocus::Composer | PaneFocus::Palette + ); + let composer = render_composer( + &state.interaction.composer, + area.width.saturating_sub(2), + area.height, + focused, + ); + let prompt = theme.glyph("›", ">"); + let composer_lines = composer + .lines + .into_iter() + .enumerate() + .map(|(index, line)| { + if index == 0 { + ratatui::text::Line::from(format!("{prompt} {}", line)) + } else { + ratatui::text::Line::from(format!(" {}", line)) + } + }) + .collect::>(); + frame.render_widget(Paragraph::new(composer_lines), area); + if let Some((cursor_x, cursor_y)) = composer.cursor { + frame.set_cursor_position((area.x + cursor_x + 2, area.y + cursor_y)); + } +} + +fn preview_tail( + lines: &[ratatui::text::Line<'static>], + visible_lines: usize, +) -> Vec> { + let skip = lines.len().saturating_sub(visible_lines); + lines.iter().skip(skip).cloned().collect() +} + +#[cfg(test)] +mod tests { + use ratatui::text::Line; + + use super::preview_tail; + + #[test] + fn preview_tail_prefers_latest_lines() { + let lines = vec![ + Line::from("line-1"), + Line::from("line-2"), + Line::from("line-3"), + Line::from("line-4"), + ]; + + let visible = preview_tail(&lines, 2) + .into_iter() + .map(|line| line.to_string()) + .collect::>(); + + assert_eq!(visible, vec!["line-3".to_string(), "line-4".to_string()]); + } +} diff --git a/crates/cli/src/chat/mod.rs b/crates/cli/src/chat/mod.rs new file mode 100644 index 00000000..0fe714d1 --- /dev/null +++ b/crates/cli/src/chat/mod.rs @@ -0,0 +1,3 @@ +mod surface; + +pub use surface::{ChatSurfaceFrame, ChatSurfaceState}; diff --git a/crates/cli/src/chat/surface.rs b/crates/cli/src/chat/surface.rs new file mode 100644 index 00000000..bc6dd877 --- /dev/null +++ b/crates/cli/src/chat/surface.rs @@ -0,0 +1,293 @@ +use std::collections::HashMap; + +use ratatui::text::Line; + +use crate::{ + state::{CliState, TranscriptCell, TranscriptCellKind, TranscriptCellStatus}, + ui::{ + CodexTheme, + cells::{RenderableCell, TranscriptCellView}, + line_to_ratatui, + }, +}; + +const STREAMING_ASSISTANT_TAIL_BUDGET: usize = 8; + +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct ChatSurfaceFrame { + pub history_lines: Vec>, + pub status_line: Option>, + pub detail_lines: Vec>, + pub preview_lines: Vec>, +} + +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct ChatSurfaceState { + committed_line_counts: HashMap, +} + +impl ChatSurfaceState { + pub fn reset(&mut self) { + self.committed_line_counts.clear(); + } + + pub fn build_frame( + &mut self, + state: &CliState, + theme: &CodexTheme, + width: u16, + ) -> ChatSurfaceFrame { + let mut frame = ChatSurfaceFrame::default(); + let content_width = usize::from(width.max(28)); + + for cell in state.transcript_cells() { + if cell_is_streaming(&cell) { + self.apply_active_cell(&cell, state, theme, content_width, &mut frame); + continue; + } + self.commit_completed_cell(&cell, state, theme, content_width, &mut frame); + } + + if let Some(banner) = &state.conversation.banner { + frame.status_line = Some(Line::from(format!("• {}", banner.error.message))); + frame.detail_lines.insert( + 0, + Line::from(" 当前流需要重新同步,建议等待自动恢复或重新加载快照。"), + ); + } + + frame + } + + fn apply_active_cell( + &mut self, + cell: &TranscriptCell, + state: &CliState, + theme: &CodexTheme, + width: usize, + frame: &mut ChatSurfaceFrame, + ) { + let rendered = trim_trailing_blank_lines(render_cell_lines(cell, state, theme, width)); + + match &cell.kind { + TranscriptCellKind::Assistant { .. } => { + let committed_count = *self + .committed_line_counts + .get(cell.id.as_str()) + .unwrap_or(&0); + let (new_history, preview, stable_count) = + split_streaming_assistant_lines(rendered, committed_count); + if !new_history.is_empty() { + frame.history_lines.extend(new_history); + } + self.committed_line_counts + .insert(cell.id.clone(), stable_count); + frame.status_line = Some(Line::from("• 正在生成回复")); + frame.preview_lines = preview; + }, + TranscriptCellKind::Thinking { .. } => { + frame.status_line = Some(Line::from("• 正在思考")); + frame.detail_lines = rendered; + }, + TranscriptCellKind::ToolCall { tool_name, .. } => { + frame.status_line = Some(Line::from(format!("• 正在运行 {tool_name}"))); + frame.detail_lines = rendered; + }, + _ => {}, + } + } + + fn commit_completed_cell( + &mut self, + cell: &TranscriptCell, + state: &CliState, + theme: &CodexTheme, + width: usize, + frame: &mut ChatSurfaceFrame, + ) { + let rendered = render_cell_lines(cell, state, theme, width); + let committed_count = *self + .committed_line_counts + .get(cell.id.as_str()) + .unwrap_or(&0); + if committed_count < rendered.len() { + frame + .history_lines + .extend(rendered.iter().skip(committed_count).cloned()); + self.committed_line_counts + .insert(cell.id.clone(), rendered.len()); + } + } +} + +fn cell_is_streaming(cell: &TranscriptCell) -> bool { + match &cell.kind { + TranscriptCellKind::Assistant { status, .. } + | TranscriptCellKind::Thinking { status, .. } + | TranscriptCellKind::ToolCall { status, .. } => { + matches!(status, TranscriptCellStatus::Streaming) + }, + _ => false, + } +} + +fn render_cell_lines( + cell: &TranscriptCell, + state: &CliState, + theme: &CodexTheme, + width: usize, +) -> Vec> { + let view = TranscriptCellView { + selected: false, + expanded: state.is_cell_expanded(cell.id.as_str()) || cell.expanded, + thinking: thinking_state_for_cell(cell, state), + }; + cell.render_lines(width, state.shell.capabilities, theme, &view) + .into_iter() + .map(|line| line_to_ratatui(&line, theme)) + .collect() +} + +fn trim_trailing_blank_lines(mut lines: Vec>) -> Vec> { + while lines + .last() + .is_some_and(|line| line.spans.is_empty() || line.to_string().is_empty()) + { + lines.pop(); + } + lines +} + +fn split_streaming_assistant_lines( + lines: Vec>, + committed_count: usize, +) -> (Vec>, Vec>, usize) { + let stable_count = lines.len().saturating_sub(STREAMING_ASSISTANT_TAIL_BUDGET); + let new_history = if stable_count > committed_count { + lines[committed_count..stable_count].to_vec() + } else { + Vec::new() + }; + let preview = lines[stable_count.min(lines.len())..].to_vec(); + (new_history, preview, stable_count) +} + +fn thinking_state_for_cell( + cell: &TranscriptCell, + state: &CliState, +) -> Option { + let TranscriptCellKind::Thinking { body, status } = &cell.kind else { + return None; + }; + Some(state.thinking_playback.present( + &state.thinking_pool, + cell.id.as_str(), + body.as_str(), + *status, + state.is_cell_expanded(cell.id.as_str()) || cell.expanded, + )) +} + +#[cfg(test)] +mod tests { + use astrcode_client::{ + AstrcodeConversationAssistantBlockDto, AstrcodeConversationBlockDto, + AstrcodeConversationBlockStatusDto, + }; + use ratatui::text::Line; + + use super::ChatSurfaceState; + use crate::{ + capability::{ColorLevel, GlyphMode, TerminalCapabilities}, + state::CliState, + ui::CodexTheme, + }; + + fn capabilities() -> TerminalCapabilities { + TerminalCapabilities { + color: ColorLevel::Ansi16, + glyphs: GlyphMode::Unicode, + alt_screen: false, + mouse: false, + bracketed_paste: false, + } + } + + fn assistant_block( + id: &str, + status: AstrcodeConversationBlockStatusDto, + markdown: &str, + ) -> AstrcodeConversationBlockDto { + AstrcodeConversationBlockDto::Assistant(AstrcodeConversationAssistantBlockDto { + id: id.to_string(), + turn_id: Some("turn-1".to_string()), + status, + markdown: markdown.to_string(), + }) + } + + fn line_texts(lines: &[Line<'static>]) -> Vec { + lines.iter().map(|line| line.to_string()).collect() + } + + #[test] + fn streaming_assistant_progressively_commits_history_and_keeps_tail_in_preview() { + let mut state = CliState::new("http://127.0.0.1:5529".to_string(), None, capabilities()); + state.conversation.transcript = vec![assistant_block( + "assistant-1", + AstrcodeConversationBlockStatusDto::Streaming, + "- 第1项:这是一个足够长的列表项,用来制造稳定折行。\n- \ + 第2项:这是一个足够长的列表项,用来制造稳定折行。\n- \ + 第3项:这是一个足够长的列表项,用来制造稳定折行。\n- \ + 第4项:这是一个足够长的列表项,用来制造稳定折行。\n- \ + 第5项:这是一个足够长的列表项,用来制造稳定折行。\n- \ + 第6项:这是一个足够长的列表项,用来制造稳定折行。", + )]; + let theme = CodexTheme::new(state.shell.capabilities); + let mut surface = ChatSurfaceState::default(); + + let frame = surface.build_frame(&state, &theme, 28); + let history = line_texts(&frame.history_lines); + let preview = line_texts(&frame.preview_lines); + + assert!(history.iter().any(|line| line.contains("第1项"))); + assert!(history.iter().any(|line| line.contains("第2项"))); + assert!(!preview.iter().any(|line| line.contains("第1项"))); + assert!(preview.iter().any(|line| line.contains("第6项"))); + + let second = surface.build_frame(&state, &theme, 28); + assert!(second.history_lines.is_empty()); + assert_eq!(line_texts(&second.preview_lines), preview); + } + + #[test] + fn completing_streaming_assistant_only_commits_remaining_tail_once() { + let mut state = CliState::new("http://127.0.0.1:5529".to_string(), None, capabilities()); + state.conversation.transcript = vec![assistant_block( + "assistant-1", + AstrcodeConversationBlockStatusDto::Streaming, + "前言\n\n- 第一项\n- 第二项\n第5行\n第6行\n第7行\n第8行\n第9行\n第10行", + )]; + let theme = CodexTheme::new(state.shell.capabilities); + let mut surface = ChatSurfaceState::default(); + + let _ = surface.build_frame(&state, &theme, 80); + + state.conversation.transcript = vec![assistant_block( + "assistant-1", + AstrcodeConversationBlockStatusDto::Complete, + "前言\n\n- 第一项\n- 第二项\n第5行\n第6行\n第7行\n第8行\n第9行\n第10行", + )]; + + let completed = surface.build_frame(&state, &theme, 80); + let history = line_texts(&completed.history_lines); + + assert!(history.iter().any(|line| line.contains("- 第一项"))); + assert!(history.iter().any(|line| line.contains("- 第二项"))); + assert!(history.iter().any(|line| line.contains("第10行"))); + assert!(completed.preview_lines.is_empty()); + + let repeated = surface.build_frame(&state, &theme, 80); + assert!(repeated.history_lines.is_empty()); + } +} diff --git a/crates/cli/src/command/mod.rs b/crates/cli/src/command/mod.rs index ede2e798..c86df892 100644 --- a/crates/cli/src/command/mod.rs +++ b/crates/cli/src/command/mod.rs @@ -14,20 +14,34 @@ pub enum InputAction { #[derive(Debug, Clone, PartialEq, Eq)] pub enum Command { New, - Resume { query: Option }, + Resume { + query: Option, + }, + Model { + query: Option, + }, Compact, - Skill { query: Option }, - Unknown { raw: String }, + SkillInvoke { + skill_id: String, + prompt: Option, + }, + Unknown { + raw: String, + }, } #[derive(Debug, Clone, PartialEq, Eq)] pub enum PaletteAction { SwitchSession { session_id: String }, ReplaceInput { text: String }, + SelectModel { profile_name: String, model: String }, RunCommand(Command), } -pub fn classify_input(input: String) -> InputAction { +pub fn classify_input( + input: String, + slash_candidates: &[AstrcodeConversationSlashCandidateDto], +) -> InputAction { let trimmed = input.trim(); if trimmed.is_empty() { return InputAction::Empty; @@ -39,7 +53,7 @@ pub fn classify_input(input: String) -> InputAction { }; } - InputAction::RunCommand(parse_command(trimmed)) + InputAction::RunCommand(parse_command(trimmed, slash_candidates)) } pub fn fuzzy_contains(query: &str, fields: impl IntoIterator) -> bool { @@ -55,18 +69,25 @@ pub fn fuzzy_contains(query: &str, fields: impl IntoIterator) -> pub fn palette_action(selection: PaletteSelection) -> PaletteAction { match selection { PaletteSelection::ResumeSession(session_id) => PaletteAction::SwitchSession { session_id }, + PaletteSelection::ModelOption(option) => PaletteAction::SelectModel { + profile_name: option.profile_name, + model: option.model, + }, PaletteSelection::SlashCandidate(candidate) => match candidate.action_kind { AstrcodeConversationSlashActionKindDto::InsertText => PaletteAction::ReplaceInput { text: candidate.action_value, }, AstrcodeConversationSlashActionKindDto::ExecuteCommand => { - PaletteAction::RunCommand(parse_command(candidate.action_value.as_str())) + PaletteAction::RunCommand(parse_command(candidate.action_value.as_str(), &[])) }, }, } } -pub fn parse_command(command: &str) -> Command { +pub fn parse_command( + command: &str, + slash_candidates: &[AstrcodeConversationSlashCandidateDto], +) -> Command { let trimmed = command.trim(); let mut parts = trimmed.splitn(2, char::is_whitespace); let head = parts.next().unwrap_or_default(); @@ -79,8 +100,24 @@ pub fn parse_command(command: &str) -> Command { match head { "/new" => Command::New, "/resume" => Command::Resume { query: tail }, + "/model" => Command::Model { query: tail }, "/compact" => Command::Compact, - "/skill" => Command::Skill { query: tail }, + _ if head.starts_with('/') => { + let skill_id = head.trim_start_matches('/'); + if slash_candidates.iter().any(|candidate| { + candidate.action_kind == AstrcodeConversationSlashActionKindDto::InsertText + && candidate.action_value == format!("/{skill_id}") + }) { + Command::SkillInvoke { + skill_id: skill_id.to_string(), + prompt: tail, + } + } else { + Command::Unknown { + raw: trimmed.to_string(), + } + } + }, _ => Command::Unknown { raw: trimmed.to_string(), }, @@ -112,17 +149,34 @@ mod tests { #[test] fn parses_built_in_commands() { - assert_eq!(parse_command("/new"), Command::New); + assert_eq!(parse_command("/new", &[]), Command::New); assert_eq!( - parse_command("/resume terminal"), + parse_command("/resume terminal", &[]), Command::Resume { query: Some("terminal".to_string()) } ); assert_eq!( - parse_command("/skill review"), - Command::Skill { - query: Some("review".to_string()) + parse_command("/model claude", &[]), + Command::Model { + query: Some("claude".to_string()) + } + ); + assert_eq!( + parse_command( + "/review 修复失败测试", + &[AstrcodeConversationSlashCandidateDto { + id: "review".to_string(), + title: "Review".to_string(), + description: "Review current changes".to_string(), + keywords: vec!["review".to_string()], + action_kind: AstrcodeConversationSlashActionKindDto::InsertText, + action_value: "/review".to_string(), + }] + ), + Command::SkillInvoke { + skill_id: "review".to_string(), + prompt: Some("修复失败测试".to_string()) } ); } @@ -130,10 +184,20 @@ mod tests { #[test] fn classifies_plain_prompt_without_command_semantics() { assert_eq!( - classify_input("实现 terminal v1".to_string()), + classify_input("实现 terminal v1".to_string(), &[]), InputAction::SubmitPrompt { text: "实现 terminal v1".to_string() } ); } + + #[test] + fn unknown_slash_command_stays_unknown_when_skill_is_not_visible() { + assert_eq!( + parse_command("/review 修复失败测试", &[]), + Command::Unknown { + raw: "/review 修复失败测试".to_string() + } + ); + } } diff --git a/crates/cli/src/lib.rs b/crates/cli/src/lib.rs index 7a3b7883..b3dc5df9 100644 --- a/crates/cli/src/lib.rs +++ b/crates/cli/src/lib.rs @@ -1,7 +1,11 @@ pub mod app; +pub mod bottom_pane; pub mod capability; +pub mod chat; pub mod command; pub mod launcher; +pub mod model; pub mod render; pub mod state; +pub mod tui; pub mod ui; diff --git a/crates/cli/src/model/events.rs b/crates/cli/src/model/events.rs new file mode 100644 index 00000000..fc24ebb9 --- /dev/null +++ b/crates/cli/src/model/events.rs @@ -0,0 +1,55 @@ +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct EventLog { + entries: Vec, +} + +impl EventLog { + pub fn new(entries: Vec) -> Self { + Self { entries } + } + + pub fn entries(&self) -> &[Event] { + &self.entries + } + + pub fn replace(&mut self, entries: Vec) { + self.entries = entries; + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Event { + UserTurn { + id: String, + text: String, + }, + AssistantBlock { + id: String, + text: String, + streaming: bool, + }, + Thinking { + id: String, + summary: String, + preview: String, + }, + ToolStatus { + id: String, + tool_name: String, + summary: String, + }, + ToolSummary { + id: String, + tool_name: String, + summary: String, + artifact_path: Option, + }, + SystemNote { + id: String, + text: String, + }, + Error { + id: String, + text: String, + }, +} diff --git a/crates/cli/src/model/mod.rs b/crates/cli/src/model/mod.rs new file mode 100644 index 00000000..bdff38af --- /dev/null +++ b/crates/cli/src/model/mod.rs @@ -0,0 +1,2 @@ +pub mod events; +pub mod reducer; diff --git a/crates/cli/src/model/reducer.rs b/crates/cli/src/model/reducer.rs new file mode 100644 index 00000000..02949e0b --- /dev/null +++ b/crates/cli/src/model/reducer.rs @@ -0,0 +1,564 @@ +use std::{collections::BTreeSet, sync::Arc, time::Duration}; + +use ratatui::text::Line; + +use super::events::Event; +use crate::render::wrap::wrap_plain_text; + +const STREAM_TAIL_BUDGET: usize = 8; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CommitKind { + UserTurn, + AssistantBlock, + ToolSummary, + SystemNote, + Error, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CommittedSlice { + pub kind: CommitKind, + pub height: u16, + pub lines: Arc<[Line<'static>]>, +} + +impl CommittedSlice { + pub fn plain(lines: impl IntoIterator>) -> Self { + let lines = lines + .into_iter() + .map(|line| Line::from(line.as_ref().to_string())) + .collect::>(); + Self { + kind: CommitKind::SystemNote, + height: lines.len().max(1) as u16, + lines: lines.into(), + } + } + + fn new(kind: CommitKind, lines: Vec>, wrap_width: usize) -> Self { + let lines = with_block_spacing(lines); + Self { + kind, + height: rendered_height(&lines, wrap_width), + lines: lines.into(), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct HudState { + pub status_line: Option>, + pub detail_lines: Vec>, + pub live_preview_lines: Vec>, + pub queued_lines: Vec>, +} + +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct OverlayState { + pub browser_open: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct UiProjection { + pub commit_queue: Vec, + pub hud: HudState, + pub overlay: OverlayState, +} + +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct ProjectionReducer { + committed_once: BTreeSet, +} + +impl ProjectionReducer { + pub fn reset(&mut self) { + self.committed_once.clear(); + } + + pub fn reduce_events( + &mut self, + events: &[Event], + width: u16, + hud_height: u16, + stream_age: Duration, + ) -> UiProjection { + let wrap_width = usize::from(width.max(24)); + let content_width = wrap_width.saturating_sub(2); + let stream_tail_budget = if hud_height <= 10 { + 6 + } else { + STREAM_TAIL_BUDGET + }; + let preview_budget = if hud_height <= 10 { 4 } else { 6 }; + let mut projection = UiProjection::default(); + + let mut live_details = Vec::new(); + let mut live_preview_lines = Vec::new(); + let mut streaming_assistant_text = None; + let mut tool_chain_active = false; + for event in events { + match event { + Event::UserTurn { id, text } => { + if self.committed_once.insert(id.clone()) { + projection.commit_queue.push(CommittedSlice::new( + CommitKind::UserTurn, + render_prefixed_block("› ", " ", text, content_width), + wrap_width, + )); + } + }, + Event::AssistantBlock { + id, + text, + streaming, + } => { + if *streaming { + streaming_assistant_text = Some(text.clone()); + projection.hud.status_line = Some(Line::from("• Responding")); + } else if self.committed_once.insert(id.clone()) { + projection.commit_queue.push(CommittedSlice::new( + CommitKind::AssistantBlock, + render_prefixed_block("• ", " ", text.as_str(), content_width), + wrap_width, + )); + } + }, + Event::Thinking { + summary, preview, .. + } => { + if live_details.is_empty() { + live_details = vec![ + Line::from(format!(" └ {summary}")), + Line::from(format!(" {preview}")), + ]; + trim_tail(&mut live_details, stream_tail_budget); + } + tool_chain_active = true; + projection.hud.status_line = Some(Line::from("• Thinking")); + }, + Event::ToolStatus { + tool_name, summary, .. + } => { + live_details = render_detail_block( + "└ ", + " ", + format!("{tool_name} · {summary}").as_str(), + content_width, + ); + trim_tail(&mut live_details, 3); + tool_chain_active = true; + projection.hud.status_line = Some(Line::from(format!("• Running {tool_name}"))); + }, + Event::ToolSummary { + id, + tool_name, + summary, + artifact_path, + } => { + tool_chain_active = true; + if self.committed_once.insert(id.clone()) { + let mut lines = render_prefixed_block( + "↳ ", + " ", + format!("{tool_name} · {summary}").as_str(), + content_width, + ); + if let Some(path) = artifact_path { + lines.push(Line::from(format!(" {path}"))); + } + if lines.len() > 2 { + lines = vec![lines[0].clone(), lines[lines.len() - 1].clone()]; + } + projection.commit_queue.push(CommittedSlice::new( + CommitKind::ToolSummary, + lines, + wrap_width, + )); + } + }, + Event::SystemNote { id, text } => { + if self.committed_once.insert(id.clone()) { + projection.commit_queue.push(CommittedSlice::new( + CommitKind::SystemNote, + render_prefixed_block("· ", " ", text, content_width), + wrap_width, + )); + } + }, + Event::Error { id, text } => { + if self.committed_once.insert(id.clone()) { + projection.commit_queue.push(CommittedSlice::new( + CommitKind::Error, + render_prefixed_block("! ", " ", text, content_width), + wrap_width, + )); + } + projection.hud.status_line = Some(Line::from(format!("• {text}"))); + }, + } + } + + if let Some(text) = streaming_assistant_text { + let preview_triggered = assistant_preview_triggered( + text.as_str(), + content_width, + tool_chain_active, + stream_age, + ); + if preview_triggered { + live_preview_lines = render_preview_block(text.as_str(), content_width); + trim_tail(&mut live_preview_lines, preview_budget); + } else if live_details.is_empty() { + live_details = vec![Line::from(" └ 正在生成回复")]; + } + } + + projection.hud.detail_lines = live_details; + projection.hud.live_preview_lines = live_preview_lines; + projection + } +} + +fn render_prefixed_block( + first_prefix: &str, + subsequent_prefix: &str, + text: &str, + width: usize, +) -> Vec> { + let prefix_width = first_prefix + .chars() + .count() + .max(subsequent_prefix.chars().count()); + let wrapped = wrap_plain_text(text, width.saturating_sub(prefix_width).max(1)); + wrapped + .into_iter() + .enumerate() + .map(|(index, line)| { + if index == 0 { + Line::from(format!("{first_prefix}{line}")) + } else { + Line::from(format!("{subsequent_prefix}{line}")) + } + }) + .collect() +} + +fn with_block_spacing(mut lines: Vec>) -> Vec> { + if !lines + .last() + .is_some_and(|line| line.to_string().trim().is_empty()) + { + lines.push(Line::from(String::new())); + } + lines +} + +fn trim_tail(lines: &mut Vec>, limit: usize) { + if lines.len() > limit { + let keep_from = lines.len() - limit; + lines.drain(0..keep_from); + } +} + +fn render_detail_block( + first_prefix: &str, + subsequent_prefix: &str, + text: &str, + width: usize, +) -> Vec> { + let first = format!(" {first_prefix}"); + let subsequent = format!(" {subsequent_prefix}"); + render_prefixed_block(first.as_str(), subsequent.as_str(), text, width) +} + +fn render_preview_block(text: &str, width: usize) -> Vec> { + wrap_plain_text(text, width.max(1)) + .into_iter() + .map(Line::from) + .collect() +} + +fn assistant_preview_triggered( + text: &str, + width: usize, + tool_chain_active: bool, + stream_age: Duration, +) -> bool { + let rendered_rows = wrap_plain_text(text, width.max(1)).len(); + rendered_rows > 6 || tool_chain_active || stream_age >= Duration::from_millis(800) +} + +fn rendered_height(lines: &[Line<'static>], wrap_width: usize) -> u16 { + let wrap_width = wrap_width.max(1); + lines + .iter() + .map(|line| line.width().max(1).div_ceil(wrap_width)) + .sum::() + .max(1) as u16 +} + +#[cfg_attr(not(test), allow(dead_code))] +fn split_semantic_blocks(text: &str) -> Vec { + if text.trim().is_empty() { + return vec![String::new()]; + } + + let mut blocks = Vec::new(); + let mut current = Vec::new(); + let mut in_code_block = false; + + for raw_line in text.lines() { + let trimmed = raw_line.trim_end(); + let is_fence = trimmed.starts_with("```"); + let is_list_item = is_list_item(trimmed); + let is_heading = is_heading(trimmed); + let is_blockquote = is_blockquote(trimmed); + + if is_fence { + current.push(trimmed.to_string()); + if in_code_block { + blocks.push(current.join("\n")); + current.clear(); + } + in_code_block = !in_code_block; + continue; + } + + if in_code_block { + current.push(trimmed.to_string()); + continue; + } + + if trimmed.is_empty() { + if !current.is_empty() { + blocks.push(current.join("\n")); + current.clear(); + } + continue; + } + + if is_blockquote { + if !current.is_empty() { + blocks.push(current.join("\n")); + current.clear(); + } + blocks.push(trimmed.to_string()); + continue; + } + + if is_heading { + if !current.is_empty() { + blocks.push(current.join("\n")); + current.clear(); + } + current.push(trimmed.to_string()); + continue; + } + + if is_list_item { + if !current.is_empty() { + blocks.push(current.join("\n")); + current.clear(); + } + blocks.push(trimmed.to_string()); + continue; + } + + current.push(trimmed.to_string()); + } + + if !current.is_empty() { + blocks.push(current.join("\n")); + } + + if blocks.is_empty() { + blocks.push(text.to_string()); + } + blocks +} + +fn is_list_item(line: &str) -> bool { + let trimmed = line.trim_start(); + trimmed.starts_with("- ") + || trimmed.starts_with("* ") + || trimmed.starts_with("+ ") + || trimmed + .chars() + .enumerate() + .take_while(|(_, ch)| ch.is_ascii_digit()) + .last() + .is_some_and(|(index, _)| trimmed[index + 1..].starts_with(". ")) +} + +fn is_heading(line: &str) -> bool { + let trimmed = line.trim_start(); + let hashes = trimmed.chars().take_while(|ch| *ch == '#').count(); + hashes > 0 && trimmed[hashes..].starts_with(' ') +} + +fn is_blockquote(line: &str) -> bool { + line.trim_start().starts_with("> ") +} + +#[cfg(test)] +mod tests { + use std::time::Duration; + + use ratatui::text::Line; + + use super::{CommitKind, CommittedSlice, ProjectionReducer}; + use crate::model::events::Event; + + #[test] + fn reducer_keeps_only_tail_of_streaming_assistant_in_hud() { + let mut reducer = ProjectionReducer::default(); + let events = vec![ + Event::UserTurn { + id: "user-1".to_string(), + text: "你好".to_string(), + }, + Event::AssistantBlock { + id: "assistant-1".to_string(), + text: "第一段\n\n第二段\n\n第三段".to_string(), + streaming: true, + }, + ]; + let projection = reducer.reduce_events(&events, 68, 12, Duration::ZERO); + assert_eq!(projection.commit_queue.len(), 1); + assert!(matches!( + projection.commit_queue[0].kind, + CommitKind::UserTurn + )); + assert!(!projection.hud.detail_lines.is_empty()); + assert!( + projection + .hud + .detail_lines + .iter() + .any(|line| line.to_string().contains("正在生成回复")) + ); + } + + #[test] + fn reducer_promotes_long_streaming_assistant_to_live_preview() { + let mut reducer = ProjectionReducer::default(); + let events = vec![Event::AssistantBlock { + id: "assistant-1".to_string(), + text: "第一行\n第二行\n第三行\n第四行\n第五行\n第六行\n第七行".to_string(), + streaming: true, + }]; + let projection = reducer.reduce_events(&events, 36, 12, Duration::ZERO); + assert_eq!(projection.hud.status_line, Some(Line::from("• Responding"))); + assert!(!projection.hud.live_preview_lines.is_empty()); + assert!( + projection + .hud + .live_preview_lines + .iter() + .any(|line| line.to_string().contains("第七行")) + ); + } + + #[test] + fn completed_user_and_assistant_blocks_are_committed_in_order() { + let mut reducer = ProjectionReducer::default(); + let projection = reducer.reduce_events( + &[ + Event::UserTurn { + id: "user-1".to_string(), + text: "hello".to_string(), + }, + Event::AssistantBlock { + id: "assistant-1".to_string(), + text: "world".to_string(), + streaming: false, + }, + ], + 72, + 12, + Duration::ZERO, + ); + assert_eq!(projection.commit_queue.len(), 2); + assert!(matches!( + projection.commit_queue[0].kind, + CommitKind::UserTurn + )); + assert!(matches!( + projection.commit_queue[1].kind, + CommitKind::AssistantBlock + )); + assert!( + projection.commit_queue[0] + .lines + .last() + .is_some_and(|line| line.to_string().is_empty()) + ); + assert!( + projection.commit_queue[1] + .lines + .last() + .is_some_and(|line| line.to_string().is_empty()) + ); + } + + #[test] + fn committed_slice_height_counts_wrapped_wide_characters() { + let slice = + CommittedSlice::new(CommitKind::SystemNote, vec![Line::from("你好你好你好")], 4); + assert_eq!(slice.height, 4); + } + + #[test] + fn split_semantic_blocks_keeps_heading_with_following_paragraph() { + let blocks = super::split_semantic_blocks("前言\n\n## 标题\n正文第一行\n正文第二行"); + assert_eq!(blocks, vec!["前言", "## 标题\n正文第一行\n正文第二行"]); + } + + #[test] + fn split_semantic_blocks_treats_blockquote_as_its_own_block() { + let blocks = super::split_semantic_blocks("第一段\n> 引用\n第二段"); + assert_eq!(blocks, vec!["第一段", "> 引用", "第二段"]); + } + + #[test] + fn tool_summary_commits_only_two_lines_with_artifact_reference() { + let mut reducer = ProjectionReducer::default(); + let projection = reducer.reduce_events( + &[Event::ToolSummary { + id: "tool-1".to_string(), + tool_name: "readFile".to_string(), + summary: "line1 line1 line1 line1\nline2 line2 line2 line2\nline3".to_string(), + artifact_path: Some("/tmp/result.txt".to_string()), + }], + 30, + 12, + Duration::ZERO, + ); + assert_eq!(projection.commit_queue.len(), 1); + assert!(projection.commit_queue[0].lines.len() <= 3); + assert!( + projection.commit_queue[0] + .lines + .iter() + .any(|line| line.to_string().contains("/tmp/result.txt")) + ); + } + + #[test] + fn reset_clears_committed_once_and_frontier_progress() { + let mut reducer = ProjectionReducer::default(); + let events = [Event::AssistantBlock { + id: "assistant-1".to_string(), + text: "第一段\n\n第二段".to_string(), + streaming: true, + }]; + let projection = reducer.reduce_events(&events, 72, 12, Duration::ZERO); + assert!(projection.commit_queue.is_empty()); + + reducer.reset(); + + let projection = reducer.reduce_events(&events, 72, 12, Duration::ZERO); + assert!(projection.commit_queue.is_empty()); + } +} diff --git a/crates/cli/src/render/commit.rs b/crates/cli/src/render/commit.rs new file mode 100644 index 00000000..5c6016f6 --- /dev/null +++ b/crates/cli/src/render/commit.rs @@ -0,0 +1,2 @@ +#[cfg(test)] +mod tests {} diff --git a/crates/cli/src/render/live.rs b/crates/cli/src/render/live.rs new file mode 100644 index 00000000..5c6016f6 --- /dev/null +++ b/crates/cli/src/render/live.rs @@ -0,0 +1,2 @@ +#[cfg(test)] +mod tests {} diff --git a/crates/cli/src/render/mod.rs b/crates/cli/src/render/mod.rs index 46892b49..1d786404 100644 --- a/crates/cli/src/render/mod.rs +++ b/crates/cli/src/render/mod.rs @@ -1,453 +1,3 @@ -use ratatui::{ - Frame, - layout::{Constraint, Direction, Layout, Rect}, - widgets::{Block, Clear, Paragraph, Wrap}, -}; - -use crate::{ - state::{CliState, PaneFocus}, - ui::{self, CodexTheme, ThemePalette}, -}; - -const FOOTER_HEIGHT: u16 = 5; - -pub fn render(frame: &mut Frame<'_>, state: &mut CliState) { - state.set_viewport_size(frame.area().width, frame.area().height); - let theme = CodexTheme::new(state.shell.capabilities); - frame.render_widget(Block::default().style(theme.app_background()), frame.area()); - - let footer_area = Rect { - x: frame.area().x, - y: frame.area().bottom().saturating_sub(FOOTER_HEIGHT), - width: frame.area().width, - height: FOOTER_HEIGHT, - }; - let transcript_height = frame.area().height.saturating_sub(footer_area.height); - let transcript_area = Rect { - x: frame.area().x, - y: frame.area().y, - width: frame.area().width, - height: transcript_height, - }; - - refresh_caches(state, transcript_area, footer_area, &theme); - render_transcript(frame, state, transcript_area, &theme); - render_footer(frame, state, footer_area, &theme); - - if ui::palette_visible(&state.interaction.palette) { - render_palette(frame, state, transcript_area, footer_area, &theme); - } -} - -fn render_transcript(frame: &mut Frame<'_>, state: &mut CliState, area: Rect, theme: &CodexTheme) { - let transcript = &state.render.transcript_cache; - let viewport_height = area.height; - let scroll = transcript_scroll_offset( - transcript.lines.len(), - viewport_height, - state.interaction.scroll_anchor, - state.interaction.follow_transcript_tail, - transcript.selected_line_range, - matches!(state.interaction.pane_focus, PaneFocus::Transcript) - && state.interaction.selection_drives_scroll, - ); - frame.render_widget( - Paragraph::new( - transcript - .lines - .iter() - .map(|line| ui::line_to_ratatui(line, theme)) - .collect::>(), - ) - .scroll((scroll, 0)), - area, - ); -} - -fn render_footer(frame: &mut Frame<'_>, state: &CliState, area: Rect, theme: &CodexTheme) { - let footer = &state.render.footer_cache; - let layout = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Length(1), - Constraint::Length(1), - Constraint::Length(1), - Constraint::Length(1), - Constraint::Length(1), - ]) - .split(area); - - frame.render_widget( - Paragraph::new(vec![ui::line_to_ratatui(&footer.lines[0], theme)]), - layout[0], - ); - frame.render_widget( - Paragraph::new(theme.divider().repeat(usize::from(area.width))) - .style(theme.line_style(crate::state::WrappedLineStyle::Divider)), - layout[1], - ); - frame.render_widget( - Paragraph::new(vec![ui::line_to_ratatui(&footer.lines[1], theme)]), - layout[2], - ); - frame.render_widget( - Paragraph::new(theme.divider().repeat(usize::from(area.width))) - .style(theme.line_style(crate::state::WrappedLineStyle::Divider)), - layout[3], - ); - frame.render_widget( - Paragraph::new(vec![ui::line_to_ratatui(&footer.lines[2], theme)]), - layout[4], - ); - - if matches!( - state.interaction.pane_focus, - PaneFocus::Composer | PaneFocus::Palette - ) { - frame.set_cursor_position((area.x.saturating_add(footer.cursor_col), layout[2].y)); - } -} - -fn render_palette( - frame: &mut Frame<'_>, - state: &CliState, - transcript_area: Rect, - footer_area: Rect, - theme: &CodexTheme, -) { - let menu_lines = &state.render.palette_cache.lines; - let menu_height = menu_lines.len().clamp(1, 5) as u16; - let menu_width = footer_area.width.saturating_sub(2); - let menu_area = Rect { - x: transcript_area.x.saturating_add(1), - y: footer_area - .y - .saturating_sub(menu_height) - .max(transcript_area.y), - width: menu_width, - height: menu_height, - }; - frame.render_widget(Clear, menu_area); - frame.render_widget( - Paragraph::new( - menu_lines - .iter() - .map(|line| ui::line_to_ratatui(line, theme)) - .collect::>(), - ) - .style(theme.menu_block_style()) - .wrap(Wrap { trim: false }), - menu_area, - ); -} - -fn transcript_scroll_offset( - total_lines: usize, - viewport_height: u16, - anchor_from_bottom: u16, - follow_tail: bool, - selected_line_range: Option<(usize, usize)>, - selection_drives_scroll: bool, -) -> u16 { - let max_scroll = total_lines.saturating_sub(usize::from(viewport_height)); - let mut top_offset = if follow_tail { - max_scroll - } else { - max_scroll.saturating_sub(usize::from(anchor_from_bottom)) - }; - if selection_drives_scroll { - if let Some((selected_start, selected_end)) = selected_line_range { - let viewport_height = usize::from(viewport_height); - if selected_start < top_offset { - top_offset = selected_start; - } else if selected_end >= top_offset.saturating_add(viewport_height) { - top_offset = selected_end - .saturating_add(1) - .saturating_sub(viewport_height); - } - } - } - top_offset = top_offset.min(max_scroll); - top_offset.try_into().unwrap_or(u16::MAX) -} - -pub fn refresh_caches( - state: &mut CliState, - transcript_area: Rect, - footer_area: Rect, - theme: &CodexTheme, -) { - let transcript_width = transcript_area.width.saturating_sub(2); - let transcript_cache_valid = state.render.transcript_cache.width == transcript_width - && state.render.transcript_cache.revision == state.render.transcript_revision - && !state.render.transcript_cache.lines.is_empty(); - if state.render.dirty.transcript || !transcript_cache_valid { - let transcript = ui::transcript_lines(state, transcript_width, theme); - state.update_transcript_cache( - transcript_width, - transcript.lines, - transcript.selected_line_range, - ); - } - - let footer_width = footer_area.width.saturating_sub(2); - let footer_cache_valid = state.render.footer_cache.width == footer_width - && state.render.footer_cache.lines.len() == 3; - if state.render.dirty.footer || !footer_cache_valid { - let footer = ui::footer_lines(state, footer_width, theme); - state - .render - .update_footer_cache(footer_width, footer.lines, footer.cursor_col); - } - - let palette_width = footer_area.width.saturating_sub(2); - let palette_should_show = ui::palette_visible(&state.interaction.palette); - if !palette_should_show { - if !state.render.palette_cache.lines.is_empty() { - state.render.update_palette_cache(palette_width, Vec::new()); - } - state.render.dirty.palette = false; - return; - } - - let palette_cache_valid = state.render.palette_cache.width == palette_width; - if state.render.dirty.palette || !palette_cache_valid { - let menu_lines = ui::palette_lines( - &state.interaction.palette, - usize::from(footer_area.width.saturating_sub(4)), - theme, - ); - state.render.update_palette_cache(palette_width, menu_lines); - } -} - -#[cfg(test)] -mod tests { - use astrcode_client::{ - AstrcodeConversationControlStateDto, AstrcodeConversationCursorDto, - AstrcodeConversationSlashActionKindDto, AstrcodeConversationSlashCandidateDto, - }; - use ratatui::{Terminal, backend::TestBackend}; - - use super::render; - use crate::{ - capability::{ColorLevel, GlyphMode, TerminalCapabilities}, - state::{CliState, PaneFocus}, - }; - - fn capabilities(glyphs: GlyphMode) -> TerminalCapabilities { - TerminalCapabilities { - color: ColorLevel::Ansi16, - glyphs, - alt_screen: false, - mouse: false, - bracketed_paste: false, - } - } - - #[test] - fn renders_minimal_layout() { - let backend = TestBackend::new(100, 28); - let mut terminal = Terminal::new(backend).expect("terminal"); - let mut state = CliState::new( - "http://127.0.0.1:5529".to_string(), - None, - capabilities(GlyphMode::Unicode), - ); - - terminal - .draw(|frame| render(frame, &mut state)) - .expect("draw"); - let text = terminal - .backend() - .buffer() - .content - .iter() - .map(|cell| cell.symbol()) - .collect::(); - assert!(text.contains("Astrcode")); - assert!(text.contains("commands")); - assert!(!text.contains("Navigation")); - } - - #[test] - fn renders_ascii_fallback_symbols() { - let backend = TestBackend::new(80, 24); - let mut terminal = Terminal::new(backend).expect("terminal"); - let mut state = CliState::new( - "http://127.0.0.1:5529".to_string(), - None, - capabilities(GlyphMode::Ascii), - ); - - terminal - .draw(|frame| render(frame, &mut state)) - .expect("draw"); - let text = terminal - .backend() - .buffer() - .content - .iter() - .map(|cell| cell.symbol()) - .collect::(); - assert!(text.contains(">")); - assert!(text.contains("-")); - } - - #[test] - fn renders_inline_slash_menu() { - let backend = TestBackend::new(110, 28); - let mut terminal = Terminal::new(backend).expect("terminal"); - let mut state = CliState::new( - "http://127.0.0.1:5529".to_string(), - None, - capabilities(GlyphMode::Unicode), - ); - state.set_slash_query( - "review", - vec![AstrcodeConversationSlashCandidateDto { - id: "review".to_string(), - title: "Review current changes".to_string(), - description: "对当前工作区变更运行 review".to_string(), - keywords: vec!["review".to_string()], - action_kind: AstrcodeConversationSlashActionKindDto::ExecuteCommand, - action_value: "/review".to_string(), - }], - ); - terminal - .draw(|frame| render(frame, &mut state)) - .expect("draw"); - let text = terminal - .backend() - .buffer() - .content - .iter() - .map(|cell| cell.symbol()) - .collect::(); - assert!(text.contains("/ commands")); - assert!(text.contains("Review current changes")); - } - - #[test] - fn renders_thinking_cell_with_preview() { - let backend = TestBackend::new(100, 28); - let mut terminal = Terminal::new(backend).expect("terminal"); - let mut state = CliState::new( - "http://127.0.0.1:5529".to_string(), - None, - capabilities(GlyphMode::Unicode), - ); - state.conversation.control = Some(AstrcodeConversationControlStateDto { - phase: astrcode_client::AstrcodePhaseDto::Thinking, - can_submit_prompt: true, - can_request_compact: true, - compact_pending: false, - compacting: false, - active_turn_id: Some("turn-1".to_string()), - last_compact_meta: None, - }); - state.conversation.cursor = Some(AstrcodeConversationCursorDto("1.0".to_string())); - state.interaction.set_focus(PaneFocus::Transcript); - - terminal - .draw(|frame| render(frame, &mut state)) - .expect("draw"); - let text = terminal - .backend() - .buffer() - .content - .iter() - .map(|cell| cell.symbol()) - .collect::(); - assert!(text.contains("Ctrl+O")); - } - - #[test] - fn transcript_scroll_offset_keeps_selected_range_visible() { - assert_eq!( - super::transcript_scroll_offset(64, 10, 0, false, Some((20, 24)), true), - 20 - ); - assert_eq!( - super::transcript_scroll_offset(64, 10, 0, false, Some((3, 5)), true), - 3 - ); - } - - #[test] - fn transcript_render_uses_prewrapped_cache_without_extra_paragraph_wrapping() { - use crate::state::{WrappedLine, WrappedLineStyle}; - - let backend = TestBackend::new(40, 10); - let mut terminal = Terminal::new(backend).expect("terminal"); - let mut state = CliState::new( - "http://127.0.0.1:5529".to_string(), - None, - capabilities(GlyphMode::Unicode), - ); - state.set_viewport_size(40, 10); - state.update_transcript_cache( - 38, - vec![ - WrappedLine { - style: WrappedLineStyle::Plain, - content: "这一行故意非常非常非常长,只有在 Paragraph 再次 wrap \ - 时才会额外占用多行。" - .to_string(), - }, - WrappedLine { - style: WrappedLineStyle::Plain, - content: "第二行".to_string(), - }, - WrappedLine { - style: WrappedLineStyle::Plain, - content: "第三行".to_string(), - }, - WrappedLine { - style: WrappedLineStyle::Plain, - content: "第四行".to_string(), - }, - WrappedLine { - style: WrappedLineStyle::Plain, - content: "目标尾行".to_string(), - }, - ], - None, - ); - - terminal - .draw(|frame| render(frame, &mut state)) - .expect("draw"); - let text = terminal - .backend() - .buffer() - .content - .iter() - .map(|cell| cell.symbol()) - .collect::(); - let buffer = terminal.backend().buffer(); - let rendered_rows = (0..buffer.area.height) - .map(|row| { - let start = usize::from(row) * usize::from(buffer.area.width); - let end = start + usize::from(buffer.area.width); - buffer.content[start..end] - .iter() - .map(|cell| cell.symbol()) - .collect::() - .replace(' ', "") - }) - .collect::>(); - - assert!( - rendered_rows - .first() - .is_some_and(|row| row.starts_with("这一行故意非常非常非常长")), - "the first cached line should remain visible when the transcript exactly fits" - ); - assert!( - rendered_rows.iter().any(|row| row.contains("目标尾行")), - "tail-follow transcript should keep the final cached line visible" - ); - assert!(text.contains("第")); - } -} +pub mod commit; +pub mod live; +pub mod wrap; diff --git a/crates/cli/src/render/wrap.rs b/crates/cli/src/render/wrap.rs new file mode 100644 index 00000000..9f2cecbf --- /dev/null +++ b/crates/cli/src/render/wrap.rs @@ -0,0 +1,106 @@ +use std::borrow::Cow; + +use textwrap::{Options, WordSeparator, wrap}; +use unicode_segmentation::UnicodeSegmentation; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ContentKind { + Prose, + CodeBlock, + UrlOrPath, + Table, + ToolLog, + Whitespace, +} + +pub fn wrap_plain_text(text: &str, width: usize) -> Vec { + wrap_content(ContentKind::Prose, text, width) +} + +pub fn wrap_content(kind: ContentKind, text: &str, width: usize) -> Vec { + let width = width.max(1); + let mut lines = Vec::new(); + for raw_line in text.split('\n') { + if raw_line.trim().is_empty() { + lines.push(String::new()); + continue; + } + + let options = match classify_line(kind, raw_line) { + ContentKind::CodeBlock | ContentKind::ToolLog => base_options(width).break_words(true), + ContentKind::UrlOrPath => base_options(width).break_words(true), + ContentKind::Table => base_options(width).break_words(true), + ContentKind::Whitespace => { + lines.push(String::new()); + continue; + }, + ContentKind::Prose => base_options(width), + }; + + let wrapped = wrap(raw_line, options); + if wrapped.is_empty() { + lines.push(String::new()); + } else { + lines.extend(wrapped.into_iter().map(|line| normalize_line(line))); + } + } + + if lines.is_empty() { + lines.push(String::new()); + } + lines +} + +fn base_options(width: usize) -> Options<'static> { + Options::new(width) + .word_separator(WordSeparator::AsciiSpace) + .break_words(false) +} + +fn classify_line(kind: ContentKind, line: &str) -> ContentKind { + if line.trim().is_empty() { + return ContentKind::Whitespace; + } + if matches!( + kind, + ContentKind::CodeBlock | ContentKind::ToolLog | ContentKind::Table + ) { + return kind; + } + if looks_like_path_or_url(line) { + return ContentKind::UrlOrPath; + } + kind +} + +fn looks_like_path_or_url(line: &str) -> bool { + line.contains("://") + || line.contains('\\') + || line.matches('/').count() >= 2 + || line + .split_word_bounds() + .any(|segment| segment.contains('.') && segment.len() > 12) +} + +fn normalize_line(line: Cow<'_, str>) -> String { + line.trim_end_matches([' ', '\t']).to_string() +} + +#[cfg(test)] +mod tests { + use super::wrap_plain_text; + + #[test] + fn wraps_chinese_and_keeps_whitespace_only_line_as_single_row() { + let wrapped = wrap_plain_text("第一行\n\n这一段非常长,需要正确折行。", 8); + assert!(wrapped.len() >= 3); + assert!(wrapped.iter().any(|line| line.is_empty())); + } + + #[test] + fn wraps_long_path_without_dropping_tail() { + let wrapped = wrap_plain_text("C:/very/long/path/to/a/really-large-file-name.txt", 12); + assert!(wrapped.len() >= 2); + assert!(wrapped.last().is_some_and(|line| line.contains("txt"))); + } +} diff --git a/crates/cli/src/state/conversation.rs b/crates/cli/src/state/conversation.rs index f216df3d..0157de7b 100644 --- a/crates/cli/src/state/conversation.rs +++ b/crates/cli/src/state/conversation.rs @@ -44,7 +44,7 @@ impl ConversationState { self.child_summaries = snapshot.child_summaries; self.slash_candidates = snapshot.slash_candidates; self.banner = snapshot.banner; - render.invalidate_transcript_cache(); + render.mark_dirty(); } pub fn apply_stream_envelope( @@ -82,14 +82,16 @@ impl ConversationState { self.transcript_index .insert(block_id_of(block).to_string(), self.transcript.len() - 1); } - render.invalidate_transcript_cache(); + render.mark_dirty(); false }, AstrcodeConversationDeltaDto::PatchBlock { block_id, patch } => { if let Some((index, block)) = self.find_block_mut(block_id.as_str()) { - apply_block_patch(block, patch); + let changed = apply_block_patch(block, patch); let _ = index; - render.invalidate_transcript_cache(); + if changed { + render.mark_dirty(); + } } else { debug_missing_block("patch", block_id.as_str()); } @@ -97,17 +99,21 @@ impl ConversationState { }, AstrcodeConversationDeltaDto::CompleteBlock { block_id, status } => { if let Some((index, block)) = self.find_block_mut(block_id.as_str()) { - set_block_status(block, status); + let changed = set_block_status(block, status); let _ = index; - render.invalidate_transcript_cache(); + if changed { + render.mark_dirty(); + } } else { debug_missing_block("complete", block_id.as_str()); } false }, AstrcodeConversationDeltaDto::UpdateControlState { control } => { - self.control = Some(control); - render.invalidate_transcript_cache(); + if self.control.as_ref() != Some(&control) { + self.control = Some(control); + render.mark_dirty(); + } false }, AstrcodeConversationDeltaDto::UpsertChildSummary { child } => { @@ -132,13 +138,16 @@ impl ConversationState { true }, AstrcodeConversationDeltaDto::SetBanner { banner } => { - self.banner = Some(banner); - render.invalidate_transcript_cache(); + if self.banner.as_ref() != Some(&banner) { + self.banner = Some(banner); + render.mark_dirty(); + } false }, AstrcodeConversationDeltaDto::ClearBanner => { - self.banner = None; - render.invalidate_transcript_cache(); + if self.banner.take().is_some() { + render.mark_dirty(); + } false }, AstrcodeConversationDeltaDto::RehydrateRequired { error } => { @@ -198,69 +207,153 @@ fn block_id_of(block: &AstrcodeConversationBlockDto) -> &str { fn apply_block_patch( block: &mut AstrcodeConversationBlockDto, patch: AstrcodeConversationBlockPatchDto, -) { +) -> bool { match patch { AstrcodeConversationBlockPatchDto::AppendMarkdown { markdown } => match block { - AstrcodeConversationBlockDto::Assistant(block) => block.markdown.push_str(&markdown), - AstrcodeConversationBlockDto::Thinking(block) => block.markdown.push_str(&markdown), - AstrcodeConversationBlockDto::SystemNote(block) => block.markdown.push_str(&markdown), - AstrcodeConversationBlockDto::User(block) => block.markdown.push_str(&markdown), + AstrcodeConversationBlockDto::Assistant(block) => { + normalize_markdown_append(&mut block.markdown, &markdown) + }, + AstrcodeConversationBlockDto::Thinking(block) => { + normalize_markdown_append(&mut block.markdown, &markdown) + }, + AstrcodeConversationBlockDto::SystemNote(block) => { + normalize_markdown_append(&mut block.markdown, &markdown) + }, + AstrcodeConversationBlockDto::User(block) => { + normalize_markdown_append(&mut block.markdown, &markdown) + }, AstrcodeConversationBlockDto::ToolCall(_) | AstrcodeConversationBlockDto::Error(_) - | AstrcodeConversationBlockDto::ChildHandoff(_) => {}, + | AstrcodeConversationBlockDto::ChildHandoff(_) => false, }, AstrcodeConversationBlockPatchDto::ReplaceMarkdown { markdown } => match block { - AstrcodeConversationBlockDto::Assistant(block) => block.markdown = markdown, - AstrcodeConversationBlockDto::Thinking(block) => block.markdown = markdown, - AstrcodeConversationBlockDto::SystemNote(block) => block.markdown = markdown, - AstrcodeConversationBlockDto::User(block) => block.markdown = markdown, + AstrcodeConversationBlockDto::Assistant(block) => { + replace_if_changed(&mut block.markdown, markdown) + }, + AstrcodeConversationBlockDto::Thinking(block) => { + replace_if_changed(&mut block.markdown, markdown) + }, + AstrcodeConversationBlockDto::SystemNote(block) => { + replace_if_changed(&mut block.markdown, markdown) + }, + AstrcodeConversationBlockDto::User(block) => { + replace_if_changed(&mut block.markdown, markdown) + }, AstrcodeConversationBlockDto::ToolCall(_) | AstrcodeConversationBlockDto::Error(_) - | AstrcodeConversationBlockDto::ChildHandoff(_) => {}, + | AstrcodeConversationBlockDto::ChildHandoff(_) => false, }, AstrcodeConversationBlockPatchDto::AppendToolStream { stream, chunk } => { if let AstrcodeConversationBlockDto::ToolCall(block) = block { if enum_wire_name(&stream).as_deref() == Some("stderr") { + if chunk.is_empty() { + return false; + } block.streams.stderr.push_str(&chunk); } else { + if chunk.is_empty() { + return false; + } block.streams.stdout.push_str(&chunk); } + true + } else { + false } }, AstrcodeConversationBlockPatchDto::ReplaceSummary { summary } => { if let AstrcodeConversationBlockDto::ToolCall(block) = block { - block.summary = Some(summary); + replace_option_if_changed(&mut block.summary, summary) + } else { + false } }, AstrcodeConversationBlockPatchDto::ReplaceMetadata { metadata } => { if let AstrcodeConversationBlockDto::ToolCall(block) = block { - block.metadata = Some(metadata); + replace_option_if_changed(&mut block.metadata, metadata) + } else { + false } }, AstrcodeConversationBlockPatchDto::ReplaceError { error } => { if let AstrcodeConversationBlockDto::ToolCall(block) = block { - block.error = error; + replace_if_changed(&mut block.error, error) + } else { + false } }, AstrcodeConversationBlockPatchDto::ReplaceDuration { duration_ms } => { if let AstrcodeConversationBlockDto::ToolCall(block) = block { - block.duration_ms = Some(duration_ms); + replace_option_if_changed(&mut block.duration_ms, duration_ms) + } else { + false } }, AstrcodeConversationBlockPatchDto::ReplaceChildRef { child_ref } => { if let AstrcodeConversationBlockDto::ToolCall(block) = block { - block.child_ref = Some(child_ref); + replace_option_if_changed(&mut block.child_ref, child_ref) + } else { + false } }, AstrcodeConversationBlockPatchDto::SetTruncated { truncated } => { if let AstrcodeConversationBlockDto::ToolCall(block) = block { - block.truncated = truncated; + if block.truncated != truncated { + block.truncated = truncated; + true + } else { + false + } + } else { + false } }, AstrcodeConversationBlockPatchDto::SetStatus { status } => set_block_status(block, status), } } +fn normalize_markdown_append(current: &mut String, incoming: &str) -> bool { + if incoming.is_empty() { + return false; + } + + if current.is_empty() { + current.push_str(incoming); + return true; + } + + if incoming.starts_with(current.as_str()) { + if current != incoming { + *current = incoming.to_string(); + return true; + } + return false; + } + + if current.ends_with(incoming) { + return false; + } + + if let Some(overlap) = longest_suffix_prefix_overlap(current.as_str(), incoming) { + current.push_str(&incoming[overlap..]); + return overlap < incoming.len(); + } + + current.push_str(incoming); + true +} + +fn longest_suffix_prefix_overlap(current: &str, incoming: &str) -> Option { + let max_overlap = current.len().min(incoming.len()); + incoming + .char_indices() + .map(|(index, _)| index) + .chain(std::iter::once(incoming.len())) + .filter(|index| *index > 0 && *index <= max_overlap) + .rev() + .find(|index| current.ends_with(&incoming[..*index])) +} + fn enum_wire_name(value: &T) -> Option where T: serde::Serialize, @@ -279,14 +372,114 @@ fn debug_missing_block(operation: &str, block_id: &str) { fn set_block_status( block: &mut AstrcodeConversationBlockDto, status: AstrcodeConversationBlockStatusDto, -) { +) -> bool { match block { - AstrcodeConversationBlockDto::Assistant(block) => block.status = status, - AstrcodeConversationBlockDto::Thinking(block) => block.status = status, - AstrcodeConversationBlockDto::ToolCall(block) => block.status = status, + AstrcodeConversationBlockDto::Assistant(block) => { + replace_if_changed(&mut block.status, status) + }, + AstrcodeConversationBlockDto::Thinking(block) => { + replace_if_changed(&mut block.status, status) + }, + AstrcodeConversationBlockDto::ToolCall(block) => { + replace_if_changed(&mut block.status, status) + }, AstrcodeConversationBlockDto::User(_) | AstrcodeConversationBlockDto::Error(_) | AstrcodeConversationBlockDto::SystemNote(_) - | AstrcodeConversationBlockDto::ChildHandoff(_) => {}, + | AstrcodeConversationBlockDto::ChildHandoff(_) => false, + } +} + +fn replace_if_changed(slot: &mut T, next: T) -> bool { + if *slot == next { + false + } else { + *slot = next; + true + } +} + +fn replace_option_if_changed(slot: &mut Option, next: T) -> bool { + if slot.as_ref() == Some(&next) { + false + } else { + *slot = Some(next); + true + } +} + +#[cfg(test)] +mod tests { + use astrcode_client::{ + AstrcodeConversationAssistantBlockDto, AstrcodeConversationBlockDto, + AstrcodeConversationBlockPatchDto, AstrcodeConversationBlockStatusDto, + AstrcodeConversationCursorDto, AstrcodeConversationDeltaDto, + AstrcodeConversationStreamEnvelopeDto, + }; + + use super::{ConversationState, normalize_markdown_append}; + use crate::state::RenderState; + + #[test] + fn append_markdown_replaces_with_cumulative_body() { + let mut current = "你好".to_string(); + normalize_markdown_append(&mut current, "你好,世界"); + assert_eq!(current, "你好,世界"); + } + + #[test] + fn append_markdown_ignores_replayed_suffix() { + let mut current = "你好,世界".to_string(); + normalize_markdown_append(&mut current, "世界"); + assert_eq!(current, "你好,世界"); + } + + #[test] + fn append_markdown_appends_only_non_overlapping_suffix() { + let mut current = "你好,世".to_string(); + normalize_markdown_append(&mut current, "世界"); + assert_eq!(current, "你好,世界"); + } + + #[test] + fn append_markdown_keeps_true_incremental_append() { + let mut current = "你好".to_string(); + normalize_markdown_append(&mut current, ",世界"); + assert_eq!(current, "你好,世界"); + } + + #[test] + fn duplicate_markdown_replay_does_not_mark_surface_dirty() { + let mut conversation = ConversationState { + transcript: vec![AstrcodeConversationBlockDto::Assistant( + AstrcodeConversationAssistantBlockDto { + id: "assistant-1".to_string(), + turn_id: Some("turn-1".to_string()), + status: AstrcodeConversationBlockStatusDto::Streaming, + markdown: "你好,世界".to_string(), + }, + )], + transcript_index: [("assistant-1".to_string(), 0)].into_iter().collect(), + ..Default::default() + }; + let mut render = RenderState::default(); + render.take_frame_dirty(); + + conversation.apply_stream_envelope( + AstrcodeConversationStreamEnvelopeDto { + session_id: "session-1".to_string(), + cursor: AstrcodeConversationCursorDto("1.1".to_string()), + delta: AstrcodeConversationDeltaDto::PatchBlock { + block_id: "assistant-1".to_string(), + patch: AstrcodeConversationBlockPatchDto::AppendMarkdown { + markdown: "世界".to_string(), + }, + }, + }, + &mut render, + &Default::default(), + ); + + assert!(!render.take_frame_dirty()); } } diff --git a/crates/cli/src/state/interaction.rs b/crates/cli/src/state/interaction.rs index cadc2074..9a867d49 100644 --- a/crates/cli/src/state/interaction.rs +++ b/crates/cli/src/state/interaction.rs @@ -1,13 +1,15 @@ use std::collections::BTreeSet; -use astrcode_client::{AstrcodeConversationSlashCandidateDto, AstrcodeSessionListItem}; +use astrcode_client::{ + AstrcodeConversationSlashCandidateDto, AstrcodeModelOptionDto, AstrcodeSessionListItem, +}; #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum PaneFocus { - Transcript, #[default] Composer, Palette, + Browser, } #[derive(Debug, Clone, PartialEq, Eq, Default)] @@ -132,18 +134,27 @@ pub struct ResumePaletteState { pub selected: usize, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ModelPaletteState { + pub query: String, + pub items: Vec, + pub selected: usize, +} + #[derive(Debug, Clone, PartialEq, Eq, Default)] pub enum PaletteState { #[default] Closed, Slash(SlashPaletteState), Resume(ResumePaletteState), + Model(ModelPaletteState), } #[derive(Debug, Clone, PartialEq, Eq)] pub enum PaletteSelection { ResumeSession(String), SlashCandidate(AstrcodeConversationSlashCandidateDto), + ModelOption(AstrcodeModelOptionDto), } #[derive(Debug, Clone, PartialEq, Eq)] @@ -167,33 +178,22 @@ pub struct TranscriptState { pub expanded_cells: BTreeSet, } -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct BrowserState { + pub open: bool, + pub selected_cell: usize, + pub last_seen_cell_count: usize, +} + +#[derive(Debug, Clone, PartialEq, Eq, Default)] pub struct InteractionState { pub status: StatusLine, - pub scroll_anchor: u16, - pub follow_transcript_tail: bool, - pub selection_drives_scroll: bool, pub pane_focus: PaneFocus, pub last_non_palette_focus: PaneFocus, pub composer: ComposerState, pub palette: PaletteState, pub transcript: TranscriptState, -} - -impl Default for InteractionState { - fn default() -> Self { - Self { - status: StatusLine::default(), - scroll_anchor: 0, - follow_transcript_tail: true, - selection_drives_scroll: true, - pane_focus: PaneFocus::default(), - last_non_palette_focus: PaneFocus::default(), - composer: ComposerState::default(), - palette: PaletteState::default(), - transcript: TranscriptState::default(), - } - } + pub browser: BrowserState, } impl InteractionState { @@ -259,51 +259,25 @@ impl InteractionState { self.composer.take() } - pub fn scroll_up(&mut self) { - self.scroll_up_by(1); - } - - pub fn scroll_up_by(&mut self, lines: u16) { - self.follow_transcript_tail = false; - self.selection_drives_scroll = false; - self.scroll_anchor = self.scroll_anchor.saturating_add(lines.max(1)); - } - - pub fn scroll_down(&mut self) { - self.scroll_down_by(1); - } - - pub fn scroll_down_by(&mut self, lines: u16) { - self.scroll_anchor = self.scroll_anchor.saturating_sub(lines.max(1)); - self.follow_transcript_tail = self.scroll_anchor == 0; - self.selection_drives_scroll = self.follow_transcript_tail; - } - - pub fn reset_scroll(&mut self) { - self.scroll_anchor = 0; - self.follow_transcript_tail = true; - self.selection_drives_scroll = true; - } - pub fn cycle_focus_forward(&mut self) { self.set_focus(match self.pane_focus { - PaneFocus::Transcript => PaneFocus::Composer, - PaneFocus::Composer => PaneFocus::Transcript, + PaneFocus::Composer => PaneFocus::Composer, PaneFocus::Palette => PaneFocus::Palette, + PaneFocus::Browser => PaneFocus::Browser, }); } pub fn cycle_focus_backward(&mut self) { self.set_focus(match self.pane_focus { - PaneFocus::Transcript => PaneFocus::Composer, - PaneFocus::Composer => PaneFocus::Transcript, + PaneFocus::Composer => PaneFocus::Composer, PaneFocus::Palette => PaneFocus::Palette, + PaneFocus::Browser => PaneFocus::Browser, }); } pub fn set_focus(&mut self, focus: PaneFocus) { self.pane_focus = focus; - if !matches!(focus, PaneFocus::Palette) { + if !matches!(focus, PaneFocus::Palette | PaneFocus::Browser) { self.last_non_palette_focus = focus; } } @@ -312,21 +286,15 @@ impl InteractionState { if cell_count == 0 { return; } - self.set_focus(PaneFocus::Transcript); self.transcript.selected_cell = (self.transcript.selected_cell + 1) % cell_count; - self.follow_transcript_tail = false; - self.selection_drives_scroll = true; } pub fn transcript_prev(&mut self, cell_count: usize) { if cell_count == 0 { return; } - self.set_focus(PaneFocus::Transcript); self.transcript.selected_cell = (self.transcript.selected_cell + cell_count - 1) % cell_count; - self.follow_transcript_tail = false; - self.selection_drives_scroll = true; } pub fn sync_transcript_cells(&mut self, cell_count: usize) { @@ -351,12 +319,70 @@ impl InteractionState { } pub fn reset_for_snapshot(&mut self) { - self.reset_scroll(); self.palette = PaletteState::Closed; self.transcript = TranscriptState::default(); + self.browser = BrowserState::default(); + self.set_focus(PaneFocus::Composer); + } + + pub fn open_browser(&mut self, cell_count: usize) { + self.browser.open = true; + if cell_count == 0 { + self.browser.selected_cell = 0; + } else if self.browser.selected_cell >= cell_count { + self.browser.selected_cell = cell_count - 1; + } + self.browser.last_seen_cell_count = cell_count; + self.transcript.selected_cell = self.browser.selected_cell; + self.pane_focus = PaneFocus::Browser; + } + + pub fn close_browser(&mut self) { + self.browser.open = false; self.set_focus(PaneFocus::Composer); } + pub fn browser_next(&mut self, cell_count: usize, page_size: usize) { + if cell_count == 0 { + return; + } + self.open_browser(cell_count); + self.browser.selected_cell = + (self.browser.selected_cell + page_size.max(1)).min(cell_count - 1); + if self.browser.selected_cell == cell_count - 1 { + self.browser.last_seen_cell_count = cell_count; + } + self.transcript.selected_cell = self.browser.selected_cell; + } + + pub fn browser_prev(&mut self, cell_count: usize, page_size: usize) { + if cell_count == 0 { + return; + } + self.open_browser(cell_count); + self.browser.selected_cell = self.browser.selected_cell.saturating_sub(page_size.max(1)); + self.transcript.selected_cell = self.browser.selected_cell; + } + + pub fn browser_first(&mut self, cell_count: usize) { + if cell_count == 0 { + return; + } + self.open_browser(cell_count); + self.browser.selected_cell = 0; + self.transcript.selected_cell = 0; + } + + pub fn browser_last(&mut self, cell_count: usize) { + if cell_count == 0 { + return; + } + self.open_browser(cell_count); + self.browser.selected_cell = cell_count - 1; + self.browser.last_seen_cell_count = cell_count; + self.transcript.selected_cell = self.browser.selected_cell; + } + pub fn set_resume_palette( &mut self, query: impl Into, @@ -392,6 +418,28 @@ impl InteractionState { self.pane_focus = PaneFocus::Palette; } + pub fn set_model_palette( + &mut self, + query: impl Into, + items: Vec, + ) { + self.palette = PaletteState::Model(ModelPaletteState { + query: query.into(), + items, + selected: 0, + }); + self.pane_focus = PaneFocus::Palette; + } + + pub fn sync_model_items(&mut self, items: Vec) { + if let PaletteState::Model(palette) = &mut self.palette { + palette.items = items; + if palette.selected >= palette.items.len() { + palette.selected = 0; + } + } + } + pub fn sync_slash_items(&mut self, items: Vec) { if let PaletteState::Slash(palette) = &mut self.palette { palette.items = items; @@ -418,7 +466,13 @@ impl InteractionState { PaletteState::Slash(palette) if !palette.items.is_empty() => { palette.selected = (palette.selected + 1) % palette.items.len(); }, - PaletteState::Closed | PaletteState::Resume(_) | PaletteState::Slash(_) => {}, + PaletteState::Model(palette) if !palette.items.is_empty() => { + palette.selected = (palette.selected + 1) % palette.items.len(); + }, + PaletteState::Closed + | PaletteState::Resume(_) + | PaletteState::Slash(_) + | PaletteState::Model(_) => {}, } } @@ -432,7 +486,14 @@ impl InteractionState { palette.selected = (palette.selected + palette.items.len().saturating_sub(1)) % palette.items.len(); }, - PaletteState::Closed | PaletteState::Resume(_) | PaletteState::Slash(_) => {}, + PaletteState::Model(palette) if !palette.items.is_empty() => { + palette.selected = (palette.selected + palette.items.len().saturating_sub(1)) + % palette.items.len(); + }, + PaletteState::Closed + | PaletteState::Resume(_) + | PaletteState::Slash(_) + | PaletteState::Model(_) => {}, } } @@ -447,20 +508,23 @@ impl InteractionState { .get(palette.selected) .cloned() .map(PaletteSelection::SlashCandidate), + PaletteState::Model(palette) => palette + .items + .get(palette.selected) + .cloned() + .map(PaletteSelection::ModelOption), PaletteState::Closed => None, } } pub fn clear_surface_state(&mut self) { match self.pane_focus { - PaneFocus::Transcript => { - self.reset_scroll(); - self.transcript.expanded_cells.clear(); - }, PaneFocus::Composer => { self.status = StatusLine::default(); + self.transcript.expanded_cells.clear(); }, PaneFocus::Palette => self.close_palette(), + PaneFocus::Browser => self.close_browser(), } } } @@ -472,21 +536,19 @@ mod tests { #[test] fn tab_flow_cycles_two_surfaces() { let mut state = InteractionState::default(); - state.set_focus(PaneFocus::Transcript); state.cycle_focus_forward(); assert_eq!(state.pane_focus, PaneFocus::Composer); state.cycle_focus_forward(); - assert_eq!(state.pane_focus, PaneFocus::Transcript); + assert_eq!(state.pane_focus, PaneFocus::Composer); } #[test] fn close_palette_restores_previous_focus() { let mut state = InteractionState::default(); - state.set_focus(PaneFocus::Transcript); state.set_slash_palette("", Vec::new()); assert_eq!(state.pane_focus, PaneFocus::Palette); state.close_palette(); - assert_eq!(state.pane_focus, PaneFocus::Transcript); + assert_eq!(state.pane_focus, PaneFocus::Composer); } #[test] @@ -498,6 +560,29 @@ mod tests { assert!(!state.is_cell_expanded("assistant-1")); } + #[test] + fn browser_open_close_and_navigation_track_selected_cell() { + let mut state = InteractionState::default(); + state.open_browser(4); + assert!(state.browser.open); + assert_eq!(state.pane_focus, PaneFocus::Browser); + + state.browser_next(4, 1); + assert_eq!(state.browser.selected_cell, 1); + state.browser_next(4, 5); + assert_eq!(state.browser.selected_cell, 3); + state.browser_prev(4, 2); + assert_eq!(state.browser.selected_cell, 1); + state.browser_first(4); + assert_eq!(state.browser.selected_cell, 0); + state.browser_last(4); + assert_eq!(state.browser.selected_cell, 3); + + state.close_browser(); + assert!(!state.browser.open); + assert_eq!(state.pane_focus, PaneFocus::Composer); + } + #[test] fn composer_backspace_respects_cursor_position() { let mut state = InteractionState::default(); @@ -521,11 +606,11 @@ mod tests { } #[test] - fn manual_scroll_disables_selection_driven_scroll() { + fn transcript_navigation_updates_selected_cell() { let mut state = InteractionState::default(); state.transcript_next(4); - assert!(state.selection_drives_scroll); - state.scroll_up(); - assert!(!state.selection_drives_scroll); + assert_eq!(state.transcript.selected_cell, 1); + state.transcript_prev(4); + assert_eq!(state.transcript.selected_cell, 0); } } diff --git a/crates/cli/src/state/mod.rs b/crates/cli/src/state/mod.rs index 53f50247..0e60475a 100644 --- a/crates/cli/src/state/mod.rs +++ b/crates/cli/src/state/mod.rs @@ -11,7 +11,7 @@ use std::{path::PathBuf, time::Duration}; use astrcode_client::{ AstrcodeConversationErrorEnvelopeDto, AstrcodeConversationSlashCandidateDto, AstrcodeConversationSnapshotResponseDto, AstrcodeConversationStreamEnvelopeDto, - AstrcodePhaseDto, AstrcodeSessionListItem, + AstrcodeCurrentModelInfoDto, AstrcodeModelOptionDto, AstrcodePhaseDto, AstrcodeSessionListItem, }; pub use conversation::ConversationState; pub use debug::DebugChannelState; @@ -19,9 +19,7 @@ pub use interaction::{ ComposerState, InteractionState, PaletteSelection, PaletteState, PaneFocus, ResumePaletteState, SlashPaletteState, StatusLine, }; -pub use render::{ - RenderState, StreamViewState, TranscriptRenderCache, WrappedLine, WrappedLineStyle, -}; +pub use render::{ActiveOverlay, RenderState, StreamViewState, WrappedLine, WrappedLineStyle}; pub use shell::ShellState; pub use thinking::{ThinkingPlaybackDriver, ThinkingPresentationState, ThinkingSnippetPool}; pub use transcript_cell::{TranscriptCell, TranscriptCellKind, TranscriptCellStatus}; @@ -63,12 +61,12 @@ impl CliState { pub fn set_status(&mut self, message: impl Into) { self.interaction.set_status(message); - self.render.mark_footer_dirty(); + self.render.mark_dirty(); } pub fn set_error_status(&mut self, message: impl Into) { self.interaction.set_error_status(message); - self.render.mark_footer_dirty(); + self.render.mark_dirty(); } pub fn set_stream_mode( @@ -79,131 +77,89 @@ impl CliState { ) -> bool { let changed = self.stream_view.mode != mode || self.stream_view.pending_chunks != pending; self.stream_view.update(mode, pending, oldest); - if changed { - self.render.mark_footer_dirty(); - } changed } - pub fn set_viewport_size(&mut self, width: u16, height: u16) { - self.render.set_viewport_size(width, height); - } - - pub fn update_transcript_cache( - &mut self, - width: u16, - lines: Vec, - selected_line_range: Option<(usize, usize)>, - ) { - self.render - .update_transcript_cache(width, lines, selected_line_range); + pub fn note_terminal_resize(&mut self, width: u16, height: u16) { + self.render.note_terminal_resize(width, height); } pub fn push_input(&mut self, ch: char) { self.interaction.push_input(ch); - self.render.mark_footer_dirty(); + self.render.mark_dirty(); } pub fn append_input(&mut self, value: &str) { self.interaction.append_input(value); - self.render.mark_footer_dirty(); + self.render.mark_dirty(); } pub fn insert_newline(&mut self) { self.interaction.insert_newline(); - self.render.mark_footer_dirty(); + self.render.mark_dirty(); } pub fn pop_input(&mut self) { self.interaction.pop_input(); - self.render.mark_footer_dirty(); + self.render.mark_dirty(); } pub fn delete_input(&mut self) { self.interaction.delete_input(); - self.render.mark_footer_dirty(); + self.render.mark_dirty(); } pub fn move_cursor_left(&mut self) { self.interaction.move_cursor_left(); - self.render.mark_footer_dirty(); + self.render.mark_dirty(); } pub fn move_cursor_right(&mut self) { self.interaction.move_cursor_right(); - self.render.mark_footer_dirty(); + self.render.mark_dirty(); } pub fn move_cursor_home(&mut self) { self.interaction.move_cursor_home(); - self.render.mark_footer_dirty(); + self.render.mark_dirty(); } pub fn move_cursor_end(&mut self) { self.interaction.move_cursor_end(); - self.render.mark_footer_dirty(); + self.render.mark_dirty(); } pub fn replace_input(&mut self, input: impl Into) { self.interaction.replace_input(input); - self.render.mark_footer_dirty(); + self.render.mark_dirty(); } pub fn take_input(&mut self) -> String { let input = self.interaction.take_input(); - self.render.mark_footer_dirty(); + self.render.mark_dirty(); input } - pub fn scroll_up(&mut self) { - self.interaction.scroll_up(); - self.render.mark_transcript_dirty(); - } - - pub fn scroll_down(&mut self) { - self.interaction.scroll_down(); - self.render.mark_transcript_dirty(); - } - - pub fn scroll_up_by(&mut self, lines: u16) { - self.interaction.scroll_up_by(lines); - self.render.mark_transcript_dirty(); - } - - pub fn scroll_down_by(&mut self, lines: u16) { - self.interaction.scroll_down_by(lines); - self.render.mark_transcript_dirty(); - } - - pub fn resume_transcript_tail(&mut self) { - self.interaction.reset_scroll(); - self.render.invalidate_transcript_cache(); - } - pub fn cycle_focus_forward(&mut self) { self.interaction.cycle_focus_forward(); - self.render.invalidate_transcript_cache(); - self.render.mark_footer_dirty(); - self.render.mark_palette_dirty(); + self.render.mark_dirty(); } pub fn cycle_focus_backward(&mut self) { self.interaction.cycle_focus_backward(); - self.render.invalidate_transcript_cache(); - self.render.mark_footer_dirty(); - self.render.mark_palette_dirty(); + self.render.mark_dirty(); } pub fn transcript_next(&mut self) { self.interaction .transcript_next(self.conversation.transcript.len()); - self.render.invalidate_transcript_cache(); + self.render.mark_dirty(); } pub fn transcript_prev(&mut self) { self.interaction .transcript_prev(self.conversation.transcript.len()); - self.render.invalidate_transcript_cache(); + self.render.mark_dirty(); } pub fn transcript_cells(&self) -> Vec { @@ -211,6 +167,13 @@ impl CliState { .project_transcript_cells(&self.interaction.transcript.expanded_cells) } + pub fn browser_transcript_cells(&self) -> Vec { + self.transcript_cells() + .into_iter() + .filter(transcript_cell_visible_in_browser) + .collect() + } + pub fn selected_transcript_cell(&self) -> Option { self.conversation.project_transcript_cell( self.interaction.transcript.selected_cell, @@ -218,6 +181,12 @@ impl CliState { ) } + pub fn selected_browser_cell(&self) -> Option { + self.browser_transcript_cells() + .into_iter() + .nth(self.interaction.browser.selected_cell) + } + pub fn is_cell_expanded(&self, cell_id: &str) -> bool { self.interaction.is_cell_expanded(cell_id) } @@ -228,26 +197,43 @@ impl CliState { } pub fn toggle_selected_cell_expanded(&mut self) { - if let Some(cell_id) = self.selected_transcript_cell().map(|cell| cell.id.clone()) { + let selected = if self.interaction.browser.open { + self.selected_browser_cell() + } else { + self.selected_transcript_cell() + }; + if let Some(cell_id) = selected.map(|cell| cell.id.clone()) { self.interaction.toggle_cell_expanded(cell_id.as_str()); - self.render.invalidate_transcript_cache(); + self.render.mark_dirty(); } } pub fn clear_surface_state(&mut self) { - let invalidate = matches!(self.interaction.pane_focus, PaneFocus::Transcript); self.interaction.clear_surface_state(); - if invalidate { - self.render.invalidate_transcript_cache(); - } + self.sync_overlay_state(); + self.render.mark_dirty(); } pub fn update_sessions(&mut self, sessions: Vec) { self.conversation.update_sessions(sessions); self.interaction .sync_resume_items(self.conversation.sessions.clone()); - self.render.invalidate_transcript_cache(); - self.render.mark_palette_dirty(); + self.render.mark_dirty(); + } + + pub fn update_current_model(&mut self, current_model: AstrcodeCurrentModelInfoDto) { + if self.shell.current_model.as_ref() != Some(¤t_model) { + self.shell.current_model = Some(current_model); + self.render.mark_dirty(); + } + } + + pub fn update_model_options(&mut self, model_options: Vec) { + if self.shell.model_options != model_options { + self.shell.model_options = model_options.clone(); + self.interaction.sync_model_items(model_options); + self.render.mark_dirty(); + } } pub fn set_resume_query( @@ -256,8 +242,7 @@ impl CliState { items: Vec, ) { self.interaction.set_resume_palette(query, items); - self.render.invalidate_footer_cache(); - self.render.invalidate_palette_cache(); + self.render.mark_dirty(); } pub fn set_slash_query( @@ -266,26 +251,66 @@ impl CliState { items: Vec, ) { self.interaction.set_slash_palette(query, items); - self.render.invalidate_footer_cache(); - self.render.invalidate_palette_cache(); + self.render.mark_dirty(); + } + + pub fn set_model_query( + &mut self, + query: impl Into, + items: Vec, + ) { + self.interaction.set_model_palette(query, items); + self.render.mark_dirty(); } pub fn close_palette(&mut self) { self.interaction.close_palette(); - self.render.invalidate_footer_cache(); - self.render.invalidate_palette_cache(); + self.render.mark_dirty(); + } + + pub fn toggle_browser(&mut self) { + if self.interaction.browser.open { + self.interaction.close_browser(); + } else { + self.interaction + .open_browser(self.browser_transcript_cells().len()); + } + self.sync_overlay_state(); + self.render.mark_dirty(); + } + + pub fn browser_next(&mut self, page_size: usize) { + self.interaction + .browser_next(self.browser_transcript_cells().len(), page_size); + self.render.mark_dirty(); + } + + pub fn browser_prev(&mut self, page_size: usize) { + self.interaction + .browser_prev(self.browser_transcript_cells().len(), page_size); + self.render.mark_dirty(); + } + + pub fn browser_first(&mut self) { + self.interaction + .browser_first(self.browser_transcript_cells().len()); + self.render.mark_dirty(); + } + + pub fn browser_last(&mut self) { + self.interaction + .browser_last(self.browser_transcript_cells().len()); + self.render.mark_dirty(); } pub fn palette_next(&mut self) { self.interaction.palette_next(); - self.render.mark_footer_dirty(); - self.render.mark_palette_dirty(); + self.render.mark_dirty(); } pub fn palette_prev(&mut self) { self.interaction.palette_prev(); - self.render.mark_footer_dirty(); - self.render.mark_palette_dirty(); + self.render.mark_dirty(); } pub fn selected_palette(&self) -> Option { @@ -300,9 +325,8 @@ impl CliState { .sync_transcript_cells(self.conversation.transcript.len()); self.thinking_playback .sync_session(self.conversation.active_session_id.as_deref()); - self.render.invalidate_transcript_cache(); - self.render.invalidate_footer_cache(); - self.render.invalidate_palette_cache(); + self.sync_overlay_state(); + self.render.mark_dirty(); } pub fn apply_stream_envelope(&mut self, envelope: AstrcodeConversationStreamEnvelopeDto) { @@ -316,23 +340,18 @@ impl CliState { self.interaction .sync_slash_items(self.conversation.slash_candidates.clone()); } - self.render.invalidate_transcript_cache(); - self.render.mark_footer_dirty(); - if slash_candidates_changed { - self.render.mark_palette_dirty(); - } + self.render.mark_dirty(); } pub fn set_banner_error(&mut self, error: AstrcodeConversationErrorEnvelopeDto) { self.conversation.set_banner_error(error); self.interaction.set_focus(PaneFocus::Composer); - self.render.invalidate_transcript_cache(); - self.render.mark_footer_dirty(); + self.render.mark_dirty(); } pub fn clear_banner(&mut self) { self.conversation.clear_banner(); - self.render.invalidate_transcript_cache(); + self.render.mark_dirty(); } pub fn active_phase(&self) -> Option { @@ -346,12 +365,21 @@ impl CliState { pub fn advance_thinking_playback(&mut self) -> bool { if self.should_animate_thinking_playback() { self.thinking_playback.advance(); - self.render.invalidate_transcript_cache(); + self.render.mark_dirty(); return true; } false } + fn sync_overlay_state(&mut self) { + let overlay = if self.interaction.browser.open { + ActiveOverlay::Browser + } else { + ActiveOverlay::None + }; + self.render.set_active_overlay(overlay); + } + fn should_animate_thinking_playback(&self) -> bool { if self.transcript_cells().iter().any(|cell| { matches!( @@ -398,6 +426,20 @@ impl CliState { } } +fn transcript_cell_visible_in_browser(cell: &TranscriptCell) -> bool { + match &cell.kind { + TranscriptCellKind::Assistant { status, .. } + | TranscriptCellKind::Thinking { status, .. } + | TranscriptCellKind::ToolCall { status, .. } => { + !matches!(status, TranscriptCellStatus::Streaming) + }, + TranscriptCellKind::User { .. } + | TranscriptCellKind::Error { .. } + | TranscriptCellKind::SystemNote { .. } + | TranscriptCellKind::ChildHandoff { .. } => true, + } +} + #[cfg(test)] mod tests { use astrcode_client::{ @@ -435,12 +477,12 @@ mod tests { )], child_summaries: Vec::new(), slash_candidates: vec![AstrcodeConversationSlashCandidateDto { - id: "skill-review".to_string(), + id: "review".to_string(), title: "Review".to_string(), description: "review skill".to_string(), keywords: vec!["review".to_string()], action_kind: AstrcodeConversationSlashActionKindDto::InsertText, - action_value: "/skill review".to_string(), + action_value: "/review".to_string(), }], banner: None, } @@ -514,12 +556,12 @@ mod tests { state.set_slash_query( "review", vec![AstrcodeConversationSlashCandidateDto { - id: "skill-review".to_string(), + id: "review".to_string(), title: "Review".to_string(), description: "review skill".to_string(), keywords: vec!["review".to_string()], action_kind: AstrcodeConversationSlashActionKindDto::InsertText, - action_value: "/skill review".to_string(), + action_value: "/review".to_string(), }], ); @@ -534,49 +576,29 @@ mod tests { #[test] fn resize_invalidates_wrap_cache() { let mut state = CliState::new("http://127.0.0.1:5529".to_string(), None, capabilities()); - state.update_transcript_cache( - 80, - vec![WrappedLine { - style: WrappedLineStyle::Plain, - content: "cached".to_string(), - }], - None, - ); - state.scroll_up(); - state.set_viewport_size(100, 40); + state.note_terminal_resize(100, 40); - assert_eq!(state.render.transcript_cache.lines.len(), 0); - assert_eq!(state.interaction.scroll_anchor, 1); - assert!(!state.interaction.follow_transcript_tail); + assert!(state.render.frame_dirty); } #[test] - fn manual_scroll_disables_follow_until_returning_to_tail() { + fn viewport_resize_marks_surface_dirty() { let mut state = CliState::new("http://127.0.0.1:5529".to_string(), None, capabilities()); - - state.scroll_up(); - assert_eq!(state.interaction.scroll_anchor, 1); - assert!(!state.interaction.follow_transcript_tail); - - state.scroll_down(); - assert_eq!(state.interaction.scroll_anchor, 0); - assert!(state.interaction.follow_transcript_tail); + state.render.take_frame_dirty(); + state.note_terminal_resize(80, 6); + assert!(state.render.take_frame_dirty()); } #[test] - fn resume_transcript_tail_restores_follow_mode_after_manual_scroll() { + fn browser_toggle_marks_surface_dirty() { let mut state = CliState::new("http://127.0.0.1:5529".to_string(), None, capabilities()); + state.activate_snapshot(sample_snapshot()); + state.render.take_frame_dirty(); - state.scroll_up_by(4); - assert_eq!(state.interaction.scroll_anchor, 4); - assert!(!state.interaction.follow_transcript_tail); - - state.resume_transcript_tail(); + state.toggle_browser(); - assert_eq!(state.interaction.scroll_anchor, 0); - assert!(state.interaction.follow_transcript_tail); - assert!(state.interaction.selection_drives_scroll); - assert!(state.render.dirty.transcript); + assert!(state.interaction.browser.open); + assert!(state.render.take_frame_dirty()); } #[test] @@ -593,6 +615,29 @@ mod tests { }); let frame = state.thinking_playback.frame; state.advance_thinking_playback(); - assert!(state.thinking_playback.frame > frame); + assert_eq!(state.thinking_playback.frame, frame.wrapping_add(1)); + } + + #[test] + fn browser_filters_out_streaming_cells() { + let mut state = CliState::new("http://127.0.0.1:5529".to_string(), None, capabilities()); + state.conversation.transcript = vec![ + AstrcodeConversationBlockDto::Assistant(AstrcodeConversationAssistantBlockDto { + id: "assistant-streaming".to_string(), + turn_id: Some("turn-1".to_string()), + status: AstrcodeConversationBlockStatusDto::Streaming, + markdown: "draft".to_string(), + }), + AstrcodeConversationBlockDto::Assistant(AstrcodeConversationAssistantBlockDto { + id: "assistant-complete".to_string(), + turn_id: Some("turn-1".to_string()), + status: AstrcodeConversationBlockStatusDto::Complete, + markdown: "done".to_string(), + }), + ]; + + let browser_cells = state.browser_transcript_cells(); + assert_eq!(browser_cells.len(), 1); + assert_eq!(browser_cells[0].id, "assistant-complete"); } } diff --git a/crates/cli/src/state/render.rs b/crates/cli/src/state/render.rs index 4fe5412c..7162e3d3 100644 --- a/crates/cli/src/state/render.rs +++ b/crates/cli/src/state/render.rs @@ -12,12 +12,6 @@ pub struct WrappedLine { pub enum WrappedLineStyle { Plain, Muted, - Divider, - HeroBorder, - HeroTitle, - HeroBody, - HeroMuted, - HeroFeedTitle, Selection, PromptEcho, ThinkingLabel, @@ -27,138 +21,51 @@ pub enum WrappedLineStyle { ToolBody, Notice, ErrorText, - FooterInput, - FooterStatus, - FooterHint, - FooterKey, PaletteItem, PaletteSelected, } -#[derive(Debug, Clone, PartialEq, Eq, Default)] -pub struct TranscriptRenderCache { - pub width: u16, - pub revision: u64, - pub lines: Vec, - pub selected_line_range: Option<(usize, usize)>, -} - -#[derive(Debug, Clone, PartialEq, Eq, Default)] -pub struct FooterRenderCache { - pub width: u16, - pub lines: Vec, - pub cursor_col: u16, -} - -#[derive(Debug, Clone, PartialEq, Eq, Default)] -pub struct PaletteRenderCache { - pub width: u16, - pub lines: Vec, +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum ActiveOverlay { + #[default] + None, + Browser, } -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -pub struct DirtyRegions { - pub transcript: bool, - pub footer: bool, - pub palette: bool, +impl ActiveOverlay { + pub fn is_open(self) -> bool { + !matches!(self, Self::None) + } } #[derive(Debug, Clone, PartialEq, Eq, Default)] pub struct RenderState { - pub viewport_width: u16, - pub viewport_height: u16, - pub transcript_revision: u64, - pub transcript_cache: TranscriptRenderCache, - pub footer_cache: FooterRenderCache, - pub palette_cache: PaletteRenderCache, - pub dirty: DirtyRegions, pub frame_dirty: bool, + pub active_overlay: ActiveOverlay, + pub last_known_terminal_size: Option<(u16, u16)>, } impl RenderState { - pub fn set_viewport_size(&mut self, width: u16, height: u16) -> bool { - if self.viewport_width == width && self.viewport_height == height { - return false; + pub fn note_terminal_resize(&mut self, width: u16, height: u16) -> bool { + let next = Some((width, height)); + let changed = self.last_known_terminal_size != next; + self.last_known_terminal_size = next; + if changed { + self.frame_dirty = true; } - self.viewport_width = width; - self.viewport_height = height; - self.transcript_cache = TranscriptRenderCache::default(); - self.footer_cache = FooterRenderCache::default(); - self.palette_cache = PaletteRenderCache::default(); - self.dirty = DirtyRegions { - transcript: true, - footer: true, - palette: true, - }; - self.frame_dirty = true; - true + changed } - pub fn update_transcript_cache( - &mut self, - width: u16, - lines: Vec, - selected_line_range: Option<(usize, usize)>, - ) { - self.transcript_cache = TranscriptRenderCache { - width, - revision: self.transcript_revision, - lines, - selected_line_range, - }; - self.dirty.transcript = false; - } - - pub fn invalidate_transcript_cache(&mut self) { - self.transcript_revision = self.transcript_revision.saturating_add(1); - self.transcript_cache = TranscriptRenderCache::default(); - self.mark_transcript_dirty(); - } - - pub fn update_footer_cache(&mut self, width: u16, lines: Vec, cursor_col: u16) { - self.footer_cache = FooterRenderCache { - width, - lines, - cursor_col, - }; - self.dirty.footer = false; - } - - pub fn invalidate_footer_cache(&mut self) { - self.footer_cache = FooterRenderCache::default(); - self.mark_footer_dirty(); - } - - pub fn update_palette_cache(&mut self, width: u16, lines: Vec) { - self.palette_cache = PaletteRenderCache { width, lines }; - self.dirty.palette = false; - } - - pub fn invalidate_palette_cache(&mut self) { - self.palette_cache = PaletteRenderCache::default(); - self.mark_palette_dirty(); - } - - pub fn mark_transcript_dirty(&mut self) { - self.dirty.transcript = true; - self.frame_dirty = true; - } - - pub fn mark_footer_dirty(&mut self) { - self.dirty.footer = true; + pub fn set_active_overlay(&mut self, overlay: ActiveOverlay) -> bool { + if self.active_overlay == overlay { + return false; + } + self.active_overlay = overlay; self.frame_dirty = true; + true } - pub fn mark_palette_dirty(&mut self) { - self.dirty.palette = true; - self.frame_dirty = true; - } - pub fn mark_all_dirty(&mut self) { - self.dirty = DirtyRegions { - transcript: true, - footer: true, - palette: true, - }; + pub fn mark_dirty(&mut self) { self.frame_dirty = true; } diff --git a/crates/cli/src/state/shell.rs b/crates/cli/src/state/shell.rs index 486392d9..eafc6310 100644 --- a/crates/cli/src/state/shell.rs +++ b/crates/cli/src/state/shell.rs @@ -1,5 +1,7 @@ use std::path::PathBuf; +use astrcode_client::{AstrcodeCurrentModelInfoDto, AstrcodeModelOptionDto}; + use crate::capability::TerminalCapabilities; #[derive(Debug, Clone, PartialEq, Eq)] @@ -7,6 +9,8 @@ pub struct ShellState { pub connection_origin: String, pub working_dir: Option, pub capabilities: TerminalCapabilities, + pub current_model: Option, + pub model_options: Vec, } impl Default for ShellState { @@ -21,6 +25,8 @@ impl Default for ShellState { mouse: false, bracketed_paste: false, }, + current_model: None, + model_options: Vec::new(), } } } @@ -35,6 +41,8 @@ impl ShellState { connection_origin, working_dir, capabilities, + current_model: None, + model_options: Vec::new(), } } } diff --git a/crates/cli/src/tui/mod.rs b/crates/cli/src/tui/mod.rs new file mode 100644 index 00000000..aa6a94ae --- /dev/null +++ b/crates/cli/src/tui/mod.rs @@ -0,0 +1,3 @@ +pub mod runtime; + +pub use runtime::TuiRuntime; diff --git a/crates/cli/src/tui/runtime.rs b/crates/cli/src/tui/runtime.rs new file mode 100644 index 00000000..6457a56e --- /dev/null +++ b/crates/cli/src/tui/runtime.rs @@ -0,0 +1,141 @@ +use std::{io, io::Write}; + +use ratatui::{ + backend::Backend, + layout::{Offset, Rect, Size}, + text::Line, +}; + +use crate::ui::{ + custom_terminal::{Frame, Terminal}, + insert_history::insert_history_lines, +}; + +#[derive(Debug)] +pub struct TuiRuntime +where + B: Backend + Write, +{ + terminal: Terminal, + pending_history_lines: Vec>, + deferred_history_lines: Vec>, + overlay_open: bool, +} + +impl TuiRuntime +where + B: Backend + Write, +{ + pub fn with_backend(backend: B) -> io::Result { + let terminal = Terminal::with_options(backend)?; + Ok(Self { + terminal, + pending_history_lines: Vec::new(), + deferred_history_lines: Vec::new(), + overlay_open: false, + }) + } + + pub fn terminal(&self) -> &Terminal { + &self.terminal + } + + pub fn terminal_mut(&mut self) -> &mut Terminal { + &mut self.terminal + } + + pub fn screen_size(&self) -> io::Result { + self.terminal.size() + } + + pub fn stage_history_lines(&mut self, lines: I) + where + I: IntoIterator>, + { + self.pending_history_lines.extend(lines); + } + + pub fn draw(&mut self, viewport_height: u16, overlay_open: bool, render: F) -> io::Result<()> + where + F: FnOnce(&mut Frame<'_>, Rect), + { + if let Some(area) = self.pending_viewport_area()? { + self.terminal.set_viewport_area(area); + self.terminal.clear()?; + } + + let mut needs_full_repaint = self.update_inline_viewport(viewport_height)?; + + if overlay_open { + if !self.pending_history_lines.is_empty() { + self.deferred_history_lines + .append(&mut self.pending_history_lines); + } + } else { + if self.overlay_open && !self.deferred_history_lines.is_empty() { + self.pending_history_lines + .append(&mut self.deferred_history_lines); + } + needs_full_repaint |= self.flush_pending_history_lines()?; + } + self.overlay_open = overlay_open; + + if needs_full_repaint { + self.terminal.invalidate_viewport(); + } + + self.terminal.draw(|frame| { + let area = frame.area(); + render(frame, area); + }) + } + + fn flush_pending_history_lines(&mut self) -> io::Result { + if self.pending_history_lines.is_empty() { + return Ok(false); + } + let lines = std::mem::take(&mut self.pending_history_lines); + insert_history_lines(&mut self.terminal, lines)?; + Ok(true) + } + + fn update_inline_viewport(&mut self, height: u16) -> io::Result { + let size = self.terminal.size()?; + let mut area = self.terminal.viewport_area; + area.height = height.min(size.height).max(1); + area.width = size.width; + + if area.bottom() > size.height { + let scroll_by = area.bottom() - size.height; + self.terminal + .backend_mut() + .scroll_region_up(0..area.top(), scroll_by)?; + area.y = size.height.saturating_sub(area.height); + } + + if area != self.terminal.viewport_area { + self.terminal.clear()?; + self.terminal.set_viewport_area(area); + return Ok(true); + } + + Ok(false) + } + + fn pending_viewport_area(&mut self) -> io::Result> { + let screen_size = self.terminal.size()?; + let last_known_screen_size = self.terminal.last_known_screen_size; + if screen_size != last_known_screen_size { + let cursor_pos = self.terminal.get_cursor_position()?; + let last_known_cursor_pos = self.terminal.last_known_cursor_pos; + if cursor_pos.y != last_known_cursor_pos.y { + let offset = Offset { + x: 0, + y: cursor_pos.y as i32 - last_known_cursor_pos.y as i32, + }; + return Ok(Some(self.terminal.viewport_area.offset(offset))); + } + } + Ok(None) + } +} diff --git a/crates/cli/src/ui/cells.rs b/crates/cli/src/ui/cells.rs index 27a1f33a..05d7352d 100644 --- a/crates/cli/src/ui/cells.rs +++ b/crates/cli/src/ui/cells.rs @@ -86,6 +86,7 @@ impl RenderableCell for TranscriptCell { theme, view, WrappedLineStyle::ErrorText, + MarkdownRenderMode::Literal, ), TranscriptCellKind::SystemNote { markdown, .. } => render_secondary_line( markdown, @@ -94,6 +95,7 @@ impl RenderableCell for TranscriptCell { theme, view, WrappedLineStyle::Notice, + MarkdownRenderMode::Display, ), TranscriptCellKind::ChildHandoff { title, message, .. } => render_secondary_line( &format!("{title} · {message}"), @@ -102,6 +104,7 @@ impl RenderableCell for TranscriptCell { theme, view, WrappedLineStyle::Notice, + MarkdownRenderMode::Literal, ), } } @@ -117,6 +120,12 @@ impl TranscriptCellView { } } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum MarkdownRenderMode { + Literal, + Display, +} + fn render_message( body: &str, width: usize, @@ -134,11 +143,19 @@ fn render_message( } ); let subsequent_prefix = " ".repeat(display_width(first_prefix.as_str())); - let wrapped = wrap_text( - body, - width.saturating_sub(display_width(first_prefix.as_str())), - capabilities, - ); + let wrapped = if is_user { + wrap_literal_text( + body, + width.saturating_sub(display_width(first_prefix.as_str())), + capabilities, + ) + } else { + wrap_text( + body, + width.saturating_sub(display_width(first_prefix.as_str())), + capabilities, + ) + }; let style = view.resolve_style(if is_user { WrappedLineStyle::PromptEcho } else { @@ -336,9 +353,10 @@ fn render_secondary_line( theme: &dyn ThemePalette, view: &TranscriptCellView, style: WrappedLineStyle, + render_mode: MarkdownRenderMode, ) -> Vec { let mut lines = Vec::new(); - for line in wrap_text(body, width.saturating_sub(2), capabilities) { + for line in wrap_text_with_mode(body, width.saturating_sub(2), capabilities, render_mode) { lines.push(WrappedLine { style: view.resolve_style(style), content: format!("{} {line}", secondary_marker(theme)), @@ -376,31 +394,6 @@ fn tool_block_marker(theme: &dyn ThemePalette) -> &'static str { theme.glyph("│", "|") } -pub(crate) fn synthetic_thinking_lines( - theme: &dyn ThemePalette, - presentation: &ThinkingPresentationState, -) -> Vec { - vec![ - WrappedLine { - style: WrappedLineStyle::ThinkingLabel, - content: format!("{} {}", thinking_marker(theme), presentation.summary), - }, - WrappedLine { - style: WrappedLineStyle::ThinkingPreview, - content: format!( - " {} {}", - thinking_preview_prefix(theme), - presentation.preview - ), - }, - WrappedLine { - style: WrappedLineStyle::ThinkingPreview, - content: format!(" {}", presentation.hint), - }, - blank_line(), - ] -} - fn blank_line() -> WrappedLine { WrappedLine { style: WrappedLineStyle::Plain, @@ -418,6 +411,19 @@ fn status_suffix(status: TranscriptCellStatus) -> &'static str { } pub fn wrap_text(text: &str, width: usize, capabilities: TerminalCapabilities) -> Vec { + wrap_text_with_mode(text, width, capabilities, MarkdownRenderMode::Display) +} + +fn wrap_literal_text(text: &str, width: usize, capabilities: TerminalCapabilities) -> Vec { + wrap_text_with_mode(text, width, capabilities, MarkdownRenderMode::Literal) +} + +fn wrap_text_with_mode( + text: &str, + width: usize, + capabilities: TerminalCapabilities, + render_mode: MarkdownRenderMode, +) -> Vec { if width == 0 { return vec![String::new()]; } @@ -437,6 +443,21 @@ pub fn wrap_text(text: &str, width: usize, capabilities: TerminalCapabilities) - continue; } + if matches!(render_mode, MarkdownRenderMode::Display) { + if is_horizontal_rule(trimmed) { + output.push(render_horizontal_rule(width, capabilities)); + index += 1; + continue; + } + + if let Some((level, heading)) = parse_heading(trimmed) { + let heading = normalize_inline_markdown(heading, render_mode); + output.extend(render_heading(level, heading.as_str(), width, capabilities)); + index += 1; + continue; + } + } + if let Some(marker) = fence_delimiter(trimmed) { in_fence = !in_fence; fence_marker = if in_fence { marker } else { "" }; @@ -461,13 +482,14 @@ pub fn wrap_text(text: &str, width: usize, capabilities: TerminalCapabilities) - block.push(source_lines[index].trim_end()); index += 1; } - output.extend(render_table_block(&block, width, capabilities)); + output.extend(render_table_block(&block, width, capabilities, render_mode)); continue; } if let Some((prefix, body)) = parse_list_prefix(trimmed) { + let body = normalize_inline_markdown(body, render_mode); output.extend(wrap_with_prefix( - body, + body.as_str(), width, capabilities, &prefix, @@ -478,8 +500,9 @@ pub fn wrap_text(text: &str, width: usize, capabilities: TerminalCapabilities) - } if let Some((prefix, body)) = parse_quote_prefix(trimmed) { + let body = normalize_inline_markdown(body, render_mode); output.extend(wrap_with_prefix( - body, + body.as_str(), width, capabilities, &prefix, @@ -511,11 +534,8 @@ pub fn wrap_text(text: &str, width: usize, capabilities: TerminalCapabilities) - paragraph.push(next.trim()); index += 1; } - output.extend(wrap_paragraph( - paragraph.join(" ").as_str(), - width, - capabilities, - )); + let paragraph = normalize_inline_markdown(paragraph.join(" ").as_str(), render_mode); + output.extend(wrap_paragraph(paragraph.as_str(), width, capabilities)); } if output.is_empty() { @@ -584,6 +604,279 @@ fn parse_quote_prefix(line: &str) -> Option<(String, &str)> { .map(|rest| (format!("{}> ", " ".repeat(indent_width)), rest.trim_start())) } +fn parse_heading(line: &str) -> Option<(usize, &str)> { + let trimmed = line.trim(); + let hashes = trimmed.bytes().take_while(|byte| *byte == b'#').count(); + if hashes == 0 || hashes > 6 { + return None; + } + let body = trimmed.get(hashes..)?.strip_prefix(' ')?; + Some((hashes, body.trim_end().trim_end_matches('#').trim_end())) +} + +fn is_horizontal_rule(line: &str) -> bool { + let compact = line + .chars() + .filter(|ch| !ch.is_whitespace()) + .collect::(); + if compact.len() < 3 { + return false; + } + let mut chars = compact.chars(); + let Some(first) = chars.next() else { + return false; + }; + matches!(first, '-' | '*' | '_') && chars.all(|ch| ch == first) +} + +fn render_horizontal_rule(width: usize, capabilities: TerminalCapabilities) -> String { + let glyph = if capabilities.ascii_only() { + "-" + } else { + "─" + }; + glyph.repeat(width.clamp(3, 48)) +} + +fn render_heading( + level: usize, + body: &str, + width: usize, + capabilities: TerminalCapabilities, +) -> Vec { + let normalized = body.trim(); + if normalized.is_empty() { + return vec![String::new()]; + } + + let underline_glyph = match (level, capabilities.ascii_only()) { + (1, false) => "═", + (1, true) => "=", + (_, false) => "─", + (_, true) => "-", + }; + let heading_width = display_width(normalized).clamp(3, width.max(3).min(48)); + let mut lines = wrap_paragraph(normalized, width, capabilities); + if level <= 2 { + lines.push(underline_glyph.repeat(heading_width)); + } + lines +} + +fn normalize_inline_markdown(text: &str, render_mode: MarkdownRenderMode) -> String { + match render_mode { + MarkdownRenderMode::Literal => text.to_string(), + MarkdownRenderMode::Display => render_inline_markdown(text), + } +} + +fn render_inline_markdown(text: &str) -> String { + let chars = text.chars().collect::>(); + let mut output = String::new(); + let mut index = 0; + + while index < chars.len() { + if chars[index] == '\\' { + if let Some(next) = chars.get(index + 1) { + output.push(*next); + index += 2; + } else { + index += 1; + } + continue; + } + + if let Some((rendered, next)) = parse_inline_code(&chars, index) { + output.push_str(rendered.as_str()); + index = next; + continue; + } + + if let Some((rendered, next)) = parse_link_or_image(&chars, index) { + output.push_str(rendered.as_str()); + index = next; + continue; + } + + if let Some((rendered, next)) = parse_autolink(&chars, index) { + output.push_str(rendered.as_str()); + index = next; + continue; + } + + if let Some((rendered, next)) = parse_delimited_span(&chars, index, "**") { + output.push_str(rendered.as_str()); + index = next; + continue; + } + + if let Some((rendered, next)) = parse_delimited_span(&chars, index, "~~") { + output.push_str(rendered.as_str()); + index = next; + continue; + } + + if let Some((rendered, next)) = parse_delimited_span(&chars, index, "*") { + output.push_str(rendered.as_str()); + index = next; + continue; + } + + output.push(chars[index]); + index += 1; + } + + output +} + +fn parse_inline_code(chars: &[char], index: usize) -> Option<(String, usize)> { + if chars.get(index) != Some(&'`') { + return None; + } + let closing = chars[index + 1..] + .iter() + .position(|ch| *ch == '`') + .map(|offset| index + 1 + offset)?; + Some(( + chars[index + 1..closing].iter().collect::(), + closing + 1, + )) +} + +fn parse_link_or_image(chars: &[char], index: usize) -> Option<(String, usize)> { + let is_image = chars.get(index) == Some(&'!') && chars.get(index + 1) == Some(&'['); + let label_start = if is_image { + index + 1 + } else if chars.get(index) == Some(&'[') { + index + } else { + return None; + }; + + let (label, next_index) = parse_bracketed(chars, label_start, '[', ']')?; + if chars.get(next_index) != Some(&'(') { + return None; + } + let (destination, end_index) = parse_bracketed(chars, next_index, '(', ')')?; + + let label = render_inline_markdown(label.as_str()); + let destination = destination.trim().to_string(); + + let rendered = if is_image { + if label.is_empty() { + "[image]".to_string() + } else { + label + } + } else if destination.is_empty() || label.is_empty() || label == destination { + label + } else { + format!("{label} ({destination})") + }; + + Some((rendered, end_index)) +} + +fn parse_autolink(chars: &[char], index: usize) -> Option<(String, usize)> { + if chars.get(index) != Some(&'<') { + return None; + } + let closing = chars[index + 1..] + .iter() + .position(|ch| *ch == '>') + .map(|offset| index + 1 + offset)?; + let body = chars[index + 1..closing].iter().collect::(); + if body.contains("://") || body.contains('@') { + Some((body, closing + 1)) + } else { + None + } +} + +fn parse_delimited_span(chars: &[char], index: usize, marker: &str) -> Option<(String, usize)> { + if !matches_marker(chars, index, marker) || !emphasis_can_open(chars, index, marker) { + return None; + } + let marker_width = marker.chars().count(); + let mut cursor = index + marker_width; + while cursor < chars.len() { + if matches_marker(chars, cursor, marker) && emphasis_can_close(chars, cursor, marker) { + let inner = chars[index + marker_width..cursor] + .iter() + .collect::(); + return Some(( + render_inline_markdown(inner.as_str()), + cursor + marker_width, + )); + } + cursor += 1; + } + None +} + +fn matches_marker(chars: &[char], index: usize, marker: &str) -> bool { + for (offset, expected) in marker.chars().enumerate() { + if chars.get(index + offset) != Some(&expected) { + return false; + } + } + true +} + +fn emphasis_can_open(chars: &[char], index: usize, marker: &str) -> bool { + let marker_width = marker.chars().count(); + let prev = index + .checked_sub(1) + .and_then(|position| chars.get(position)); + let next = chars.get(index + marker_width); + next.is_some_and(|ch| !ch.is_whitespace()) && prev.is_none_or(|ch| emphasis_boundary(*ch)) +} + +fn emphasis_can_close(chars: &[char], index: usize, marker: &str) -> bool { + let marker_width = marker.chars().count(); + let prev = index + .checked_sub(1) + .and_then(|position| chars.get(position)); + let next = chars.get(index + marker_width); + prev.is_some_and(|ch| !ch.is_whitespace()) && next.is_none_or(|ch| emphasis_boundary(*ch)) +} + +fn emphasis_boundary(ch: char) -> bool { + !ch.is_alphanumeric() +} + +fn parse_bracketed( + chars: &[char], + index: usize, + open: char, + close: char, +) -> Option<(String, usize)> { + if chars.get(index) != Some(&open) { + return None; + } + + let mut cursor = index + 1; + let mut body = String::new(); + while cursor < chars.len() { + match chars[cursor] { + '\\' => { + if let Some(next) = chars.get(cursor + 1) { + body.push(*next); + cursor += 2; + } else { + cursor += 1; + } + }, + ch if ch == close => return Some((body, cursor + 1)), + ch => { + body.push(ch); + cursor += 1; + }, + } + } + None +} + fn indent_like(prefix: &str) -> String { " ".repeat(display_width(prefix)) } @@ -633,7 +926,7 @@ fn wrap_with_prefix( current_available = subsequent_available; } - if current_width > 0 && current_width == display_width(current_prefix) { + if current_width == display_width(current_prefix) { current.push_str(chunk.as_ref()); current_width += chunk_width; } @@ -684,7 +977,12 @@ fn render_preformatted_block( block.push(source_lines[index].trim_end()); index += 1; } - lines.extend(render_table_block(&block, width, capabilities)); + lines.extend(render_table_block( + &block, + width, + capabilities, + MarkdownRenderMode::Literal, + )); continue; } lines.extend(wrap_preformatted_line(line, width, capabilities)); @@ -700,10 +998,11 @@ fn render_table_block( lines: &[&str], width: usize, capabilities: TerminalCapabilities, + render_mode: MarkdownRenderMode, ) -> Vec { let rows = lines .iter() - .map(|line| parse_table_row(line)) + .map(|line| parse_table_row(line, render_mode)) .collect::>(); let col_count = rows.iter().map(Vec::len).max().unwrap_or(0); if col_count == 0 { @@ -742,23 +1041,28 @@ fn render_table_block( col_widths[index] = col_widths[index].saturating_sub(1); } - rows.iter() - .enumerate() - .map(|(row_index, row)| { - if separator_rows[row_index] { - render_table_separator(&col_widths) - } else { - render_table_row(row, &col_widths) - } - }) - .collect() + if matches!(render_mode, MarkdownRenderMode::Literal) { + return rows + .iter() + .enumerate() + .map(|(row_index, row)| { + if separator_rows[row_index] { + render_plain_table_separator(&col_widths) + } else { + render_plain_table_row(row, &col_widths) + } + }) + .collect(); + } + + render_boxed_table(rows, separator_rows, col_widths, capabilities) } -fn parse_table_row(line: &str) -> Vec { +fn parse_table_row(line: &str, render_mode: MarkdownRenderMode) -> Vec { line.trim() .trim_matches('|') .split('|') - .map(|cell| cell.trim().to_string()) + .map(|cell| normalize_inline_markdown(cell.trim(), render_mode)) .collect() } @@ -770,7 +1074,7 @@ fn is_table_separator(cell: &str) -> bool { .all(|ch| ch == '-' || ch == ':' || ch.is_whitespace()) } -fn render_table_separator(col_widths: &[usize]) -> String { +fn render_plain_table_separator(col_widths: &[usize]) -> String { let mut line = String::from("|"); for width in col_widths { line.push_str(&"-".repeat(width.saturating_add(2))); @@ -779,7 +1083,7 @@ fn render_table_separator(col_widths: &[usize]) -> String { line } -fn render_table_row(row: &[String], col_widths: &[usize]) -> String { +fn render_plain_table_row(row: &[String], col_widths: &[usize]) -> String { let mut line = String::from("|"); for (index, width) in col_widths.iter().enumerate() { let cell = row.get(index).map(String::as_str).unwrap_or(""); @@ -791,6 +1095,168 @@ fn render_table_row(row: &[String], col_widths: &[usize]) -> String { line } +fn render_boxed_table( + rows: Vec>, + separator_rows: Vec, + col_widths: Vec, + capabilities: TerminalCapabilities, +) -> Vec { + let chars = table_chars(capabilities); + let mut rendered = Vec::new(); + let mut emitted_top = false; + let mut saw_header_separator = false; + let has_separator_row = separator_rows.iter().any(|is_separator| *is_separator); + + for (row_index, row) in rows.iter().enumerate() { + if separator_rows[row_index] { + if !emitted_top { + rendered.push(render_table_border( + &col_widths, + chars.top_left, + chars.top_mid, + chars.top_right, + chars.horizontal, + )); + emitted_top = true; + } + rendered.push(render_table_border( + &col_widths, + chars.mid_left, + chars.mid_mid, + chars.mid_right, + chars.horizontal, + )); + saw_header_separator = true; + continue; + } + + if !emitted_top { + rendered.push(render_table_border( + &col_widths, + chars.top_left, + chars.top_mid, + chars.top_right, + chars.horizontal, + )); + emitted_top = true; + } + + rendered.push(render_boxed_table_row(row, &col_widths, chars.vertical)); + + if !has_separator_row && row_index + 1 < rows.len() { + rendered.push(render_table_border( + &col_widths, + chars.mid_left, + chars.mid_mid, + chars.mid_right, + chars.horizontal, + )); + } else if has_separator_row + && saw_header_separator + && row_index + 1 < rows.len() + && separator_rows.get(row_index + 1).copied().unwrap_or(false) + { + rendered.push(render_table_border( + &col_widths, + chars.mid_left, + chars.mid_mid, + chars.mid_right, + chars.horizontal, + )); + } + } + + if emitted_top { + rendered.push(render_table_border( + &col_widths, + chars.bottom_left, + chars.bottom_mid, + chars.bottom_right, + chars.horizontal, + )); + } + + rendered +} + +fn render_table_border( + col_widths: &[usize], + left: &str, + middle: &str, + right: &str, + horizontal: &str, +) -> String { + let mut line = String::from(left); + for (index, width) in col_widths.iter().enumerate() { + line.push_str(&horizontal.repeat(width.saturating_add(2))); + if index + 1 == col_widths.len() { + line.push_str(right); + } else { + line.push_str(middle); + } + } + line +} + +fn render_boxed_table_row(row: &[String], col_widths: &[usize], vertical: &str) -> String { + let mut line = String::from(vertical); + for (index, width) in col_widths.iter().enumerate() { + let cell = row.get(index).map(String::as_str).unwrap_or(""); + line.push(' '); + line.push_str(pad_to_width(truncate_to_width(cell, *width).as_str(), *width).as_str()); + line.push(' '); + line.push_str(vertical); + } + line +} + +#[derive(Debug, Clone, Copy)] +struct TableChars<'a> { + top_left: &'a str, + top_mid: &'a str, + top_right: &'a str, + mid_left: &'a str, + mid_mid: &'a str, + mid_right: &'a str, + bottom_left: &'a str, + bottom_mid: &'a str, + bottom_right: &'a str, + horizontal: &'a str, + vertical: &'a str, +} + +fn table_chars(capabilities: TerminalCapabilities) -> TableChars<'static> { + if capabilities.ascii_only() { + TableChars { + top_left: "+", + top_mid: "+", + top_right: "+", + mid_left: "+", + mid_mid: "+", + mid_right: "+", + bottom_left: "+", + bottom_mid: "+", + bottom_right: "+", + horizontal: "-", + vertical: "|", + } + } else { + TableChars { + top_left: "┌", + top_mid: "┬", + top_right: "┐", + mid_left: "├", + mid_mid: "┼", + mid_right: "┤", + bottom_left: "└", + bottom_mid: "┴", + bottom_right: "┘", + horizontal: "─", + vertical: "│", + } + } +} + fn split_token_by_width<'a>( token: &'a str, width: usize, @@ -846,7 +1312,7 @@ fn display_width(text: &str) -> usize { mod tests { use super::{ RenderableCell, TranscriptCellView, assistant_marker, secondary_marker, thinking_marker, - tool_marker, wrap_text, + tool_marker, wrap_literal_text, wrap_text, }; use crate::{ capability::{ColorLevel, GlyphMode, TerminalCapabilities}, @@ -896,15 +1362,85 @@ mod tests { assert!(lines.iter().all(|line| !line.is_empty())); } + #[test] + fn wrap_text_keeps_last_token_after_soft_wrap() { + let lines = wrap_text( + "查看 readFile (https://example.com/read-file) 与 writeFile。", + 48, + unicode_capabilities(), + ); + let joined = lines.join("\n"); + assert!(joined.contains("writeFile。")); + } + #[test] fn wrap_text_formats_markdown_tables() { let lines = wrap_text( - "| 工具 | 说明 |\n| --- | --- |\n| reviewnow | 代码审查 |\n| git-commit | 自动提交 |", + "| 工具 | 说明 |\n| --- | --- |\n| **reviewnow** | 代码审查 |\n| `git-commit` | \ + 自动提交 |", 32, unicode_capabilities(), ); - assert!(lines.iter().any(|line| line.contains("| 工具"))); - assert!(lines.iter().any(|line| line.contains("---"))); + assert!(lines.iter().any(|line| line.contains("┌"))); + assert!(lines.iter().any(|line| line.contains("│ 工具"))); + assert!(lines.iter().any(|line| line.contains("reviewnow"))); + assert!(lines.iter().any(|line| line.contains("git-commit"))); + assert!(lines.iter().all(|line| !line.contains("**reviewnow**"))); + assert!(lines.iter().all(|line| !line.contains("`git-commit`"))); + assert!(lines.iter().any(|line| line.contains("└"))); + } + + #[test] + fn wrap_text_normalizes_headings_and_links() { + let lines = wrap_text( + "## 文件操作\n\n查看 [readFile](https://example.com/read-file) 与 **writeFile**。", + 48, + unicode_capabilities(), + ); + let joined = lines.join("\n"); + assert!(joined.contains("文件操作")); + assert!(joined.contains("────")); + assert!(joined.contains("readFile")); + assert!(joined.contains("https://example.com/read-file")); + assert!(joined.contains("writeFile")); + assert!(!joined.contains("## 文件操作")); + assert!(!joined.contains("**writeFile**")); + assert!(!joined.contains("[readFile]")); + } + + #[test] + fn inline_markdown_keeps_emphasis_body_before_cjk_punctuation() { + assert_eq!( + super::render_inline_markdown("**writeFile**。"), + "writeFile。" + ); + } + + #[test] + fn wrap_literal_text_preserves_user_markdown_markers() { + let lines = wrap_literal_text( + "## 用户原文\n请保留 **readFile** 和 [link](https://example.com)。", + 120, + unicode_capabilities(), + ); + let joined = lines.join("\n"); + assert!(joined.contains("## 用户原文")); + assert!(joined.contains("**readFile**")); + assert!(joined.contains("[link](https://example.com)")); + } + + #[test] + fn wrap_literal_text_keeps_plain_markdown_table_shape() { + let lines = wrap_literal_text( + "| 工具 | 说明 |\n| --- | --- |\n| readFile | 读取文件 |", + 48, + unicode_capabilities(), + ); + let joined = lines.join("\n"); + assert!(joined.contains("| 工具")); + assert!(joined.contains("---")); + assert!(joined.contains("readFile")); + assert!(!joined.contains("┌")); } #[test] @@ -965,4 +1501,38 @@ mod tests { assert!(lines.iter().any(|line| line.content.contains("- 第一项"))); assert!(lines.iter().any(|line| line.content.contains("- 第二项"))); } + + #[test] + fn assistant_rendering_strips_markdown_syntax_markers() { + let theme = CodexTheme::new(unicode_capabilities()); + let cell = TranscriptCell { + id: "assistant-3".to_string(), + expanded: false, + kind: TranscriptCellKind::Assistant { + body: "## 文件操作\n\n| 工具 | 说明 |\n| --- | --- |\n| **readFile** | 读取 \ + `README.md` |" + .to_string(), + status: TranscriptCellStatus::Complete, + }, + }; + + let lines = cell.render_lines( + 48, + unicode_capabilities(), + &theme, + &TranscriptCellView::default(), + ); + let rendered = lines + .iter() + .map(|line| line.content.as_str()) + .collect::>() + .join("\n"); + + assert!(rendered.contains("文件操作")); + assert!(rendered.contains("readFile")); + assert!(rendered.contains("README.md")); + assert!(!rendered.contains("## 文件操作")); + assert!(!rendered.contains("**readFile**")); + assert!(!rendered.contains("`README.md`")); + } } diff --git a/crates/cli/src/ui/composer.rs b/crates/cli/src/ui/composer.rs new file mode 100644 index 00000000..416356c2 --- /dev/null +++ b/crates/cli/src/ui/composer.rs @@ -0,0 +1,68 @@ +use ratatui::text::Line; +use unicode_width::UnicodeWidthStr; + +use crate::{render::wrap::wrap_plain_text, state::ComposerState}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ComposerRenderState { + pub lines: Vec>, + pub cursor: Option<(u16, u16)>, +} + +pub fn render_composer( + composer: &ComposerState, + width: u16, + height: u16, + focused: bool, +) -> ComposerRenderState { + let width = usize::from(width.max(1)); + let height = usize::from(height.max(1)); + if composer.as_str().is_empty() { + return ComposerRenderState { + lines: vec![Line::from("")], + cursor: focused.then_some((0, 0)), + }; + } + + let wrapped_all = wrap_plain_text(composer.as_str(), width); + let prefix = &composer.as_str()[..composer.cursor.min(composer.as_str().len())]; + let wrapped_prefix = wrap_plain_text(prefix, width); + let cursor_row = wrapped_prefix.len().saturating_sub(1); + let cursor_col = wrapped_prefix + .last() + .map(|line| UnicodeWidthStr::width(line.as_str()) as u16) + .unwrap_or(0); + let visible_start = cursor_row.saturating_add(1).saturating_sub(height); + let visible_lines = wrapped_all + .iter() + .skip(visible_start) + .take(height) + .cloned() + .map(Line::from) + .collect::>(); + let cursor = focused.then_some((cursor_col, cursor_row.saturating_sub(visible_start) as u16)); + + ComposerRenderState { + lines: if visible_lines.is_empty() { + vec![Line::from("")] + } else { + visible_lines + }, + cursor, + } +} + +#[cfg(test)] +mod tests { + use super::render_composer; + use crate::state::ComposerState; + + #[test] + fn empty_focused_composer_keeps_blank_canvas() { + let composer = ComposerState::default(); + let rendered = render_composer(&composer, 24, 2, true); + assert_eq!(rendered.lines.len(), 1); + assert!(rendered.lines[0].to_string().is_empty()); + assert_eq!(rendered.cursor, Some((0, 0))); + } +} diff --git a/crates/cli/src/ui/custom_terminal.rs b/crates/cli/src/ui/custom_terminal.rs new file mode 100644 index 00000000..8e5cef21 --- /dev/null +++ b/crates/cli/src/ui/custom_terminal.rs @@ -0,0 +1,458 @@ +use std::{io, io::Write}; + +use crossterm::{ + cursor::MoveTo, + queue, + style::{Colors, Print, SetAttribute, SetBackgroundColor, SetColors, SetForegroundColor}, + terminal::Clear, +}; +use ratatui::{ + backend::{Backend, ClearType}, + buffer::Buffer, + layout::{Position, Rect, Size}, + style::{Color, Modifier}, + widgets::Widget, +}; +use unicode_width::UnicodeWidthStr; + +fn display_width(s: &str) -> usize { + if !s.contains('\x1B') { + return s.width(); + } + + let mut visible = String::with_capacity(s.len()); + let mut chars = s.chars(); + while let Some(ch) = chars.next() { + if ch == '\x1B' && chars.clone().next() == Some(']') { + chars.next(); + for c in chars.by_ref() { + if c == '\x07' { + break; + } + } + continue; + } + visible.push(ch); + } + visible.width() +} + +#[derive(Debug)] +pub struct Frame<'a> { + pub(crate) cursor_position: Option, + pub(crate) viewport_area: Rect, + pub(crate) buffer: &'a mut Buffer, +} + +impl Frame<'_> { + pub const fn area(&self) -> Rect { + self.viewport_area + } + + pub fn set_cursor_position>(&mut self, position: P) { + self.cursor_position = Some(position.into()); + } + + pub fn buffer_mut(&mut self) -> &mut Buffer { + self.buffer + } + + pub fn render_widget(&mut self, widget: W, area: Rect) { + widget.render(area, self.buffer); + } +} + +#[derive(Debug)] +pub struct Terminal +where + B: Backend + Write, +{ + backend: B, + buffers: [Buffer; 2], + current: usize, + hidden_cursor: bool, + pub viewport_area: Rect, + pub last_known_screen_size: Size, + pub last_known_cursor_pos: Position, + visible_history_rows: u16, +} + +impl Drop for Terminal +where + B: Backend + Write, +{ + fn drop(&mut self) { + let _ = self.show_cursor(); + } +} + +impl Terminal +where + B: Backend + Write, +{ + pub fn with_options(mut backend: B) -> io::Result { + let screen_size = backend.size()?; + let cursor_pos = backend + .get_cursor_position() + .unwrap_or(Position { x: 0, y: 0 }); + Ok(Self { + backend, + buffers: [Buffer::empty(Rect::ZERO), Buffer::empty(Rect::ZERO)], + current: 0, + hidden_cursor: false, + viewport_area: Rect::new(0, cursor_pos.y, 0, 0), + last_known_screen_size: screen_size, + last_known_cursor_pos: cursor_pos, + visible_history_rows: 0, + }) + } + + pub fn backend(&self) -> &B { + &self.backend + } + + pub fn backend_mut(&mut self) -> &mut B { + &mut self.backend + } + + pub fn size(&self) -> io::Result { + self.backend.size() + } + + pub fn get_cursor_position(&mut self) -> io::Result { + self.backend.get_cursor_position() + } + + pub fn set_cursor_position>(&mut self, position: P) -> io::Result<()> { + let position = position.into(); + self.backend.set_cursor_position(position)?; + self.last_known_cursor_pos = position; + Ok(()) + } + + pub fn hide_cursor(&mut self) -> io::Result<()> { + self.backend.hide_cursor()?; + self.hidden_cursor = true; + Ok(()) + } + + pub fn show_cursor(&mut self) -> io::Result<()> { + self.backend.show_cursor()?; + self.hidden_cursor = false; + Ok(()) + } + + pub fn set_viewport_area(&mut self, area: Rect) { + self.current_buffer_mut().resize(area); + self.previous_buffer_mut().resize(area); + self.viewport_area = area; + self.visible_history_rows = self.visible_history_rows.min(area.top()); + } + + pub fn clear(&mut self) -> io::Result<()> { + if self.viewport_area.is_empty() { + return Ok(()); + } + self.backend + .set_cursor_position(self.viewport_area.as_position())?; + self.backend.clear_region(ClearType::AfterCursor)?; + self.previous_buffer_mut().reset(); + Ok(()) + } + + pub fn invalidate_viewport(&mut self) { + self.previous_buffer_mut().reset(); + } + + pub fn visible_history_rows(&self) -> u16 { + self.visible_history_rows + } + + pub fn note_history_rows_inserted(&mut self, inserted_rows: u16) { + self.visible_history_rows = self + .visible_history_rows + .saturating_add(inserted_rows) + .min(self.viewport_area.top()); + } + + pub fn autoresize(&mut self) -> io::Result<()> { + let screen_size = self.size()?; + if screen_size != self.last_known_screen_size { + self.last_known_screen_size = screen_size; + } + Ok(()) + } + + pub fn draw(&mut self, render_callback: F) -> io::Result<()> + where + F: FnOnce(&mut Frame<'_>), + { + self.autoresize()?; + + let mut frame = Frame { + cursor_position: None, + viewport_area: self.viewport_area, + buffer: self.current_buffer_mut(), + }; + render_callback(&mut frame); + let cursor_position = frame.cursor_position; + + self.flush()?; + match cursor_position { + None => self.hide_cursor()?, + Some(position) => { + self.show_cursor()?; + self.set_cursor_position(position)?; + }, + } + self.swap_buffers(); + Backend::flush(&mut self.backend)?; + Ok(()) + } + + fn current_buffer(&self) -> &Buffer { + &self.buffers[self.current] + } + + fn current_buffer_mut(&mut self) -> &mut Buffer { + &mut self.buffers[self.current] + } + + fn previous_buffer(&self) -> &Buffer { + &self.buffers[1 - self.current] + } + + fn previous_buffer_mut(&mut self) -> &mut Buffer { + &mut self.buffers[1 - self.current] + } + + fn flush(&mut self) -> io::Result<()> { + let updates = diff_buffers(self.previous_buffer(), self.current_buffer()); + let mut last_put = None; + for command in &updates { + if let DrawCommand::Put { x, y, .. } = command { + last_put = Some(Position { x: *x, y: *y }); + } + } + if let Some(position) = last_put { + self.last_known_cursor_pos = position; + } + draw_commands(&mut self.backend, updates.into_iter()) + } + + fn swap_buffers(&mut self) { + self.previous_buffer_mut().reset(); + self.current = 1 - self.current; + } +} + +#[derive(Debug)] +enum DrawCommand { + Put { + x: u16, + y: u16, + cell: ratatui::buffer::Cell, + }, + ClearToEnd { + x: u16, + y: u16, + bg: Color, + }, +} + +fn diff_buffers(a: &Buffer, b: &Buffer) -> Vec { + let previous_buffer = &a.content; + let next_buffer = &b.content; + + let mut updates = Vec::new(); + let mut last_nonblank_columns = vec![0; a.area.height as usize]; + for y in 0..a.area.height { + let row_start = y as usize * a.area.width as usize; + let row_end = row_start + a.area.width as usize; + let row = &next_buffer[row_start..row_end]; + let bg = row.last().map(|cell| cell.bg).unwrap_or(Color::Reset); + + let mut last_nonblank_column = 0usize; + let mut column = 0usize; + while column < row.len() { + let cell = &row[column]; + let width = display_width(cell.symbol()); + if cell.symbol() != " " || cell.bg != bg || cell.modifier != Modifier::empty() { + last_nonblank_column = column + width.saturating_sub(1); + } + column += width.max(1); + } + + if last_nonblank_column + 1 < row.len() { + let (x, y) = a.pos_of(row_start + last_nonblank_column + 1); + updates.push(DrawCommand::ClearToEnd { x, y, bg }); + } + last_nonblank_columns[y as usize] = last_nonblank_column as u16; + } + + let mut invalidated = 0usize; + let mut to_skip = 0usize; + for (index, (current, previous)) in next_buffer.iter().zip(previous_buffer.iter()).enumerate() { + if !current.skip && (current != previous || invalidated > 0) && to_skip == 0 { + let (x, y) = a.pos_of(index); + let row = index / a.area.width as usize; + if x <= last_nonblank_columns[row] { + updates.push(DrawCommand::Put { + x, + y, + cell: current.clone(), + }); + } + } + to_skip = display_width(current.symbol()).saturating_sub(1); + let affected_width = display_width(current.symbol()).max(display_width(previous.symbol())); + invalidated = affected_width.max(invalidated).saturating_sub(1); + } + updates +} + +fn draw_commands(writer: &mut impl Write, commands: I) -> io::Result<()> +where + I: Iterator, +{ + let mut fg = Color::Reset; + let mut bg = Color::Reset; + let mut modifier = Modifier::empty(); + let mut last_pos: Option = None; + + for command in commands { + let (x, y) = match &command { + DrawCommand::Put { x, y, .. } | DrawCommand::ClearToEnd { x, y, .. } => (*x, *y), + }; + if !matches!(last_pos, Some(p) if x == p.x + 1 && y == p.y) { + queue!(writer, MoveTo(x, y))?; + } + last_pos = Some(Position { x, y }); + match command { + DrawCommand::Put { cell, .. } => { + if cell.modifier != modifier { + let diff = ModifierDiff { + from: modifier, + to: cell.modifier, + }; + diff.queue(writer)?; + modifier = cell.modifier; + } + if cell.fg != fg || cell.bg != bg { + queue!( + writer, + SetColors(Colors::new( + to_crossterm_color(cell.fg), + to_crossterm_color(cell.bg) + )) + )?; + fg = cell.fg; + bg = cell.bg; + } + queue!(writer, Print(cell.symbol()))?; + }, + DrawCommand::ClearToEnd { bg: clear_bg, .. } => { + queue!(writer, SetAttribute(crossterm::style::Attribute::Reset))?; + modifier = Modifier::empty(); + queue!(writer, SetBackgroundColor(to_crossterm_color(clear_bg)))?; + bg = clear_bg; + queue!(writer, Clear(crossterm::terminal::ClearType::UntilNewLine))?; + }, + } + } + + queue!( + writer, + SetForegroundColor(crossterm::style::Color::Reset), + SetBackgroundColor(crossterm::style::Color::Reset), + SetAttribute(crossterm::style::Attribute::Reset), + )?; + Ok(()) +} + +fn to_crossterm_color(color: Color) -> crossterm::style::Color { + match color { + Color::Reset => crossterm::style::Color::Reset, + Color::Black => crossterm::style::Color::Black, + Color::Red => crossterm::style::Color::DarkRed, + Color::Green => crossterm::style::Color::DarkGreen, + Color::Yellow => crossterm::style::Color::DarkYellow, + Color::Blue => crossterm::style::Color::DarkBlue, + Color::Magenta => crossterm::style::Color::DarkMagenta, + Color::Cyan => crossterm::style::Color::DarkCyan, + Color::Gray => crossterm::style::Color::Grey, + Color::DarkGray => crossterm::style::Color::DarkGrey, + Color::LightRed => crossterm::style::Color::Red, + Color::LightGreen => crossterm::style::Color::Green, + Color::LightYellow => crossterm::style::Color::Yellow, + Color::LightBlue => crossterm::style::Color::Blue, + Color::LightMagenta => crossterm::style::Color::Magenta, + Color::LightCyan => crossterm::style::Color::Cyan, + Color::White => crossterm::style::Color::White, + Color::Rgb(r, g, b) => crossterm::style::Color::Rgb { r, g, b }, + Color::Indexed(index) => crossterm::style::Color::AnsiValue(index), + } +} + +struct ModifierDiff { + from: Modifier, + to: Modifier, +} + +impl ModifierDiff { + fn queue(self, writer: &mut W) -> io::Result<()> { + use crossterm::style::Attribute as CAttribute; + let removed = self.from - self.to; + if removed.contains(Modifier::REVERSED) { + queue!(writer, SetAttribute(CAttribute::NoReverse))?; + } + if removed.contains(Modifier::BOLD) { + queue!(writer, SetAttribute(CAttribute::NormalIntensity))?; + if self.to.contains(Modifier::DIM) { + queue!(writer, SetAttribute(CAttribute::Dim))?; + } + } + if removed.contains(Modifier::ITALIC) { + queue!(writer, SetAttribute(CAttribute::NoItalic))?; + } + if removed.contains(Modifier::UNDERLINED) { + queue!(writer, SetAttribute(CAttribute::NoUnderline))?; + } + if removed.contains(Modifier::DIM) { + queue!(writer, SetAttribute(CAttribute::NormalIntensity))?; + } + if removed.contains(Modifier::CROSSED_OUT) { + queue!(writer, SetAttribute(CAttribute::NotCrossedOut))?; + } + if removed.contains(Modifier::SLOW_BLINK) || removed.contains(Modifier::RAPID_BLINK) { + queue!(writer, SetAttribute(CAttribute::NoBlink))?; + } + + let added = self.to - self.from; + if added.contains(Modifier::REVERSED) { + queue!(writer, SetAttribute(CAttribute::Reverse))?; + } + if added.contains(Modifier::BOLD) { + queue!(writer, SetAttribute(CAttribute::Bold))?; + } + if added.contains(Modifier::ITALIC) { + queue!(writer, SetAttribute(CAttribute::Italic))?; + } + if added.contains(Modifier::UNDERLINED) { + queue!(writer, SetAttribute(CAttribute::Underlined))?; + } + if added.contains(Modifier::DIM) { + queue!(writer, SetAttribute(CAttribute::Dim))?; + } + if added.contains(Modifier::CROSSED_OUT) { + queue!(writer, SetAttribute(CAttribute::CrossedOut))?; + } + if added.contains(Modifier::SLOW_BLINK) { + queue!(writer, SetAttribute(CAttribute::SlowBlink))?; + } + if added.contains(Modifier::RAPID_BLINK) { + queue!(writer, SetAttribute(CAttribute::RapidBlink))?; + } + Ok(()) + } +} diff --git a/crates/cli/src/ui/footer.rs b/crates/cli/src/ui/footer.rs deleted file mode 100644 index ef40b312..00000000 --- a/crates/cli/src/ui/footer.rs +++ /dev/null @@ -1,168 +0,0 @@ -use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; - -use super::{ThemePalette, truncate_to_width}; -use crate::state::{ - CliState, ComposerState, PaletteState, PaneFocus, WrappedLine, WrappedLineStyle, -}; - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct FooterRenderOutput { - pub lines: Vec, - pub cursor_col: u16, -} - -pub fn footer_lines(state: &CliState, width: u16, theme: &dyn ThemePalette) -> FooterRenderOutput { - let width = usize::from(width.max(24)); - let prompt_state = visible_input_state(&state.interaction.composer, width.saturating_sub(4)); - let input_focused = matches!( - state.interaction.pane_focus, - PaneFocus::Composer | PaneFocus::Palette - ); - let prompt = if state.interaction.composer.is_empty() { - if input_focused { - String::new() - } else { - "在这里输入,或键入 /".to_string() - } - } else { - prompt_state.visible - }; - - FooterRenderOutput { - lines: vec![ - WrappedLine { - style: if state.interaction.status.is_error { - WrappedLineStyle::ErrorText - } else { - WrappedLineStyle::FooterStatus - }, - content: truncate_to_width(status_line(state).as_str(), width), - }, - WrappedLine { - style: if state.interaction.composer.is_empty() { - if input_focused { - WrappedLineStyle::FooterInput - } else { - WrappedLineStyle::Muted - } - } else { - WrappedLineStyle::FooterInput - }, - content: format!("{} {}", theme.glyph("›", ">"), prompt), - }, - WrappedLine { - style: WrappedLineStyle::FooterHint, - content: truncate_to_width(footer_hint(state).as_str(), width), - }, - ], - cursor_col: (2 + prompt_state.cursor_columns.min(width.saturating_sub(3))) as u16, - } -} - -fn status_line(state: &CliState) -> String { - if state.interaction.status.is_error { - return state.interaction.status.message.clone(); - } - - match &state.interaction.palette { - PaletteState::Slash(palette) => format!( - "/ commands · {} 条候选 · ↑↓ 选择 · Enter 执行 · Esc 关闭", - palette.items.len() - ), - PaletteState::Resume(resume) => format!( - "/resume · {} 条会话 · ↑↓ 选择 · Enter 切换 · Esc 关闭", - resume.items.len() - ), - PaletteState::Closed => { - let status = state.interaction.status.message.trim(); - if status.is_empty() || status == "ready" { - String::new() - } else { - status.to_string() - } - }, - } -} - -fn footer_hint(state: &CliState) -> String { - if !matches!(state.interaction.palette, PaletteState::Closed) { - return "Tab 切换焦点 · Esc 关闭 palette · Ctrl+O thinking".to_string(); - } - - let session = state - .conversation - .active_session_title - .as_deref() - .filter(|title| !title.trim().is_empty()) - .unwrap_or("新会话"); - let phase = state - .active_phase() - .map(|phase| format!("{phase:?}").to_lowercase()) - .unwrap_or_else(|| "idle".to_string()); - - if state.interaction.composer.line_count() > 1 { - format!( - "{session} · {phase} · {} 行输入 · Shift+Enter 换行 · Ctrl+O thinking", - state.interaction.composer.line_count() - ) - } else { - format!("{session} · {phase} · Enter 发送 · / commands · Ctrl+O thinking") - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -struct VisibleInputState { - visible: String, - cursor_columns: usize, -} - -fn visible_input_state(composer: &ComposerState, width: usize) -> VisibleInputState { - let input = composer.as_str(); - let cursor = composer.cursor.min(input.len()); - let line_start = input - .get(..cursor) - .and_then(|value| value.rfind('\n').map(|index| index + 1)) - .unwrap_or(0); - let line_end = input - .get(cursor..) - .and_then(|value| value.find('\n').map(|index| cursor + index)) - .unwrap_or(input.len()); - let line = &input[line_start..line_end]; - let cursor_in_line = cursor.saturating_sub(line_start); - let before_cursor = &line[..cursor_in_line]; - - if UnicodeWidthStr::width(line) <= width { - return VisibleInputState { - visible: line.to_string(), - cursor_columns: UnicodeWidthStr::width(before_cursor), - }; - } - - let left_context_budget = width.saturating_mul(2) / 3; - let mut visible_before = String::new(); - let mut visible_before_width = 0; - for ch in before_cursor.chars().rev() { - let ch_width = UnicodeWidthChar::width(ch).unwrap_or(0); - if visible_before_width + ch_width > left_context_budget && !visible_before.is_empty() { - break; - } - visible_before.insert(0, ch); - visible_before_width += ch_width; - } - - let mut visible = visible_before.clone(); - let mut visible_width = visible_before_width; - for ch in line[cursor_in_line..].chars() { - let ch_width = UnicodeWidthChar::width(ch).unwrap_or(0); - if visible_width + ch_width > width { - break; - } - visible.push(ch); - visible_width += ch_width; - } - - VisibleInputState { - cursor_columns: visible_before_width, - visible, - } -} diff --git a/crates/cli/src/ui/hero.rs b/crates/cli/src/ui/hero.rs deleted file mode 100644 index a26c5cbc..00000000 --- a/crates/cli/src/ui/hero.rs +++ /dev/null @@ -1,332 +0,0 @@ -use unicode_width::UnicodeWidthStr; - -use super::{ThemePalette, cells::wrap_text, truncate_to_width}; -use crate::{ - capability::TerminalCapabilities, - state::{CliState, WrappedLine, WrappedLineStyle}, -}; - -const MIN_CARD_WIDTH: usize = 44; -const HORIZONTAL_LAYOUT_WIDTH: usize = 70; - -pub fn hero_lines(state: &CliState, width: u16, theme: &dyn ThemePalette) -> Vec { - let width = usize::from(width.max(MIN_CARD_WIDTH as u16)); - let session_title = state - .conversation - .active_session_title - .as_deref() - .filter(|title| !title.trim().is_empty()) - .unwrap_or("Astrcode workspace"); - let working_dir = state - .shell - .working_dir - .as_ref() - .map(|path| path.display().to_string()) - .unwrap_or_else(|| "未附加工作目录".to_string()); - let phase = state - .active_phase() - .map(|phase| format!("{phase:?}").to_lowercase()) - .unwrap_or_else(|| "idle".to_string()); - let recent_sessions = state - .conversation - .sessions - .iter() - .filter(|session| { - Some(session.session_id.as_str()) != state.conversation.active_session_id.as_deref() - }) - .take(3) - .map(|session| { - if session.title.trim().is_empty() { - session.display_name.clone() - } else { - session.title.clone() - } - }) - .collect::>(); - - let title = format!(" Astrcode v{} ", env!("CARGO_PKG_VERSION")); - let context = HeroContext { - width, - title: title.as_str(), - session_title, - working_dir: working_dir.as_str(), - phase: phase.as_str(), - recent_sessions: &recent_sessions, - capabilities: state.shell.capabilities, - theme, - }; - let mut lines = if width >= HORIZONTAL_LAYOUT_WIDTH { - horizontal_card(&context) - } else { - compact_card(&context) - }; - lines.push(WrappedLine { - style: WrappedLineStyle::Plain, - content: String::new(), - }); - lines -} - -struct HeroContext<'a> { - width: usize, - title: &'a str, - session_title: &'a str, - working_dir: &'a str, - phase: &'a str, - recent_sessions: &'a [String], - capabilities: TerminalCapabilities, - theme: &'a dyn ThemePalette, -} - -fn horizontal_card(context: &HeroContext<'_>) -> Vec { - let inner_width = context.width.saturating_sub(2).max(MIN_CARD_WIDTH - 2); - let left_width = (inner_width / 2).clamp(24, 38); - let right_width = inner_width.saturating_sub(left_width + 1); - - let mut rows = Vec::new(); - push_two_col_block( - &mut rows, - &[String::from("Welcome back!")], - &[String::from("使用提示")], - left_width, - right_width, - context.theme, - WrappedLineStyle::HeroTitle, - ); - push_two_col_block( - &mut rows, - &wrap_text(context.session_title, left_width, context.capabilities), - &wrap_text("输入 / 打开 commands", right_width, context.capabilities), - left_width, - right_width, - context.theme, - WrappedLineStyle::HeroBody, - ); - push_two_col_block( - &mut rows, - &[String::from(" /\\_/\\\\")], - &wrap_text( - "Tab 在 transcript / composer 间切换", - right_width, - context.capabilities, - ), - left_width, - right_width, - context.theme, - WrappedLineStyle::HeroBody, - ); - push_two_col_block( - &mut rows, - &[String::from(" .-\" \"-.")], - &wrap_text( - "Ctrl+O 展开或收起 thinking", - right_width, - context.capabilities, - ), - left_width, - right_width, - context.theme, - WrappedLineStyle::HeroBody, - ); - push_two_col_block( - &mut rows, - &[format!("phase · {}", context.phase)], - &[String::from("最近活动")], - left_width, - right_width, - context.theme, - WrappedLineStyle::HeroFeedTitle, - ); - - let cwd_lines = wrap_text(context.working_dir, left_width, context.capabilities); - let activity_lines = if context.recent_sessions.is_empty() { - wrap_text("暂无最近会话", right_width, context.capabilities) - } else { - context - .recent_sessions - .iter() - .flat_map(|item| wrap_text(item, right_width, context.capabilities)) - .collect::>() - }; - push_two_col_block( - &mut rows, - &cwd_lines, - &activity_lines, - left_width, - right_width, - context.theme, - WrappedLineStyle::HeroMuted, - ); - push_two_col_block( - &mut rows, - &[String::new()], - &wrap_text("/resume 查看更多", right_width, context.capabilities), - left_width, - right_width, - context.theme, - WrappedLineStyle::HeroMuted, - ); - - framed_rows(rows, context.width, context.title, context.theme) -} - -fn compact_card(context: &HeroContext<'_>) -> Vec { - let inner_width = context.width.saturating_sub(2).max(MIN_CARD_WIDTH - 2); - let mut rows = vec![ - WrappedLine { - style: WrappedLineStyle::HeroTitle, - content: pad_to_width("Welcome back!", inner_width), - }, - WrappedLine { - style: WrappedLineStyle::HeroBody, - content: pad_to_width(context.session_title, inner_width), - }, - WrappedLine { - style: WrappedLineStyle::HeroBody, - content: pad_to_width(format!("phase · {}", context.phase).as_str(), inner_width), - }, - ]; - - for line in wrap_text(context.working_dir, inner_width, context.capabilities) { - rows.push(WrappedLine { - style: WrappedLineStyle::HeroMuted, - content: pad_to_width(line.as_str(), inner_width), - }); - } - - rows.push(WrappedLine { - style: WrappedLineStyle::HeroFeedTitle, - content: pad_to_width("使用提示", inner_width), - }); - for tip in [ - "输入 / 打开 commands", - "Ctrl+O 展开或收起 thinking", - "Tab 在 transcript / composer 间切换", - ] { - rows.push(WrappedLine { - style: WrappedLineStyle::HeroBody, - content: pad_to_width(tip, inner_width), - }); - } - rows.push(WrappedLine { - style: WrappedLineStyle::HeroFeedTitle, - content: pad_to_width("最近活动", inner_width), - }); - if context.recent_sessions.is_empty() { - rows.push(WrappedLine { - style: WrappedLineStyle::HeroMuted, - content: pad_to_width("暂无最近会话", inner_width), - }); - } else { - for item in context.recent_sessions.iter().take(3) { - rows.push(WrappedLine { - style: WrappedLineStyle::HeroMuted, - content: pad_to_width(item.as_str(), inner_width), - }); - } - } - rows.push(WrappedLine { - style: WrappedLineStyle::HeroMuted, - content: pad_to_width("/resume 查看更多", inner_width), - }); - - framed_rows(rows, context.width, context.title, context.theme) -} - -fn framed_rows( - rows: Vec, - width: usize, - title: &str, - theme: &dyn ThemePalette, -) -> Vec { - let mut lines = vec![WrappedLine { - style: WrappedLineStyle::HeroBorder, - content: frame_top(width, title, theme), - }]; - let vertical = theme.glyph("│", "|"); - for row in rows { - lines.push(WrappedLine { - style: row.style, - content: format!("{vertical}{}{vertical}", row.content), - }); - } - lines.push(WrappedLine { - style: WrappedLineStyle::HeroBorder, - content: frame_bottom(width, theme), - }); - lines -} - -fn two_col_row( - left: &str, - right: &str, - left_width: usize, - right_width: usize, - theme: &dyn ThemePalette, - style: WrappedLineStyle, -) -> WrappedLine { - let divider = theme.glyph("│", "|"); - WrappedLine { - style, - content: format!( - "{}{}{}", - pad_to_width(left, left_width), - divider, - pad_to_width(right, right_width) - ), - } -} - -fn push_two_col_block( - rows: &mut Vec, - left_lines: &[String], - right_lines: &[String], - left_width: usize, - right_width: usize, - theme: &dyn ThemePalette, - style: WrappedLineStyle, -) { - let line_count = left_lines.len().max(right_lines.len()).max(1); - for index in 0..line_count { - rows.push(two_col_row( - left_lines.get(index).map(String::as_str).unwrap_or(""), - right_lines.get(index).map(String::as_str).unwrap_or(""), - left_width, - right_width, - theme, - style, - )); - } -} - -fn frame_top(width: usize, title: &str, theme: &dyn ThemePalette) -> String { - let left = theme.glyph("╭", "+"); - let right = theme.glyph("╮", "+"); - let horizontal = theme.glyph("─", "-"); - let inner_width = width.saturating_sub(2); - let title_width = UnicodeWidthStr::width(title); - if title_width >= inner_width { - return format!("{left}{}{right}", truncate_to_width(title, inner_width)); - } - let remaining = inner_width.saturating_sub(title_width); - format!("{left}{title}{}{right}", horizontal.repeat(remaining)) -} - -fn frame_bottom(width: usize, theme: &dyn ThemePalette) -> String { - let left = theme.glyph("╰", "+"); - let right = theme.glyph("╯", "+"); - let horizontal = theme.glyph("─", "-"); - format!( - "{left}{}{right}", - horizontal.repeat(width.saturating_sub(2)) - ) -} - -fn pad_to_width(text: &str, width: usize) -> String { - let value = truncate_to_width(text, width); - let current_width = UnicodeWidthStr::width(value.as_str()); - if current_width >= width { - return value; - } - format!("{value}{}", " ".repeat(width - current_width)) -} diff --git a/crates/cli/src/ui/host.rs b/crates/cli/src/ui/host.rs new file mode 100644 index 00000000..24ee9f8e --- /dev/null +++ b/crates/cli/src/ui/host.rs @@ -0,0 +1,149 @@ +use std::{io, io::Write}; + +use ratatui::{backend::Backend, layout::Rect, text::Line}; + +use super::{ + custom_terminal::{Frame, Terminal}, + insert_history::insert_history_lines, +}; +use crate::model::reducer::CommittedSlice; + +const MAX_BOTTOM_PANE_HEIGHT: u16 = 6; + +pub fn bottom_pane_height_for_terminal(total_height: u16) -> u16 { + if total_height <= 8 { + 4 + } else if total_height <= 12 { + 5 + } else { + MAX_BOTTOM_PANE_HEIGHT + } +} + +#[derive(Debug)] +pub struct TerminalHost +where + B: Backend + Write, +{ + terminal: Terminal, + pending_history_lines: Vec>, + deferred_history_lines: Vec>, + last_known_size: Rect, + overlay_open: bool, +} + +impl TerminalHost +where + B: Backend + Write, +{ + pub fn with_backend(backend: B) -> io::Result { + let terminal = Terminal::with_options(backend)?; + Ok(Self::new(terminal)) + } + + pub fn new(terminal: Terminal) -> Self { + let size = terminal.size().expect("terminal size should be readable"); + Self { + terminal, + pending_history_lines: Vec::new(), + deferred_history_lines: Vec::new(), + last_known_size: Rect::new(0, 0, size.width, size.height), + overlay_open: false, + } + } + + pub fn terminal(&self) -> &Terminal { + &self.terminal + } + + pub fn terminal_mut(&mut self) -> &mut Terminal { + &mut self.terminal + } + + pub fn on_new_commits(&mut self, commits: Vec) -> bool { + if commits.is_empty() { + return false; + } + for slice in commits { + self.pending_history_lines + .extend(slice.lines.iter().cloned()); + } + true + } + + pub fn on_resize(&mut self, width: u16, height: u16) -> io::Result { + let next_size = Rect::new(0, 0, width, height); + if self.last_known_size == next_size { + return Ok(false); + } + self.last_known_size = next_size; + self.terminal.autoresize()?; + Ok(true) + } + + pub fn draw_frame( + &mut self, + viewport_height: u16, + overlay_open: bool, + render: F, + ) -> io::Result<()> + where + F: FnOnce(&mut Frame<'_>, Rect), + { + self.update_inline_viewport(viewport_height)?; + + if overlay_open { + if !self.pending_history_lines.is_empty() { + self.deferred_history_lines + .append(&mut self.pending_history_lines); + } + } else { + if self.overlay_open && !self.deferred_history_lines.is_empty() { + self.pending_history_lines + .append(&mut self.deferred_history_lines); + } + self.flush_pending_history()?; + } + self.overlay_open = overlay_open; + + self.terminal.draw(|frame| { + let area = frame.area(); + render(frame, area); + }) + } + + fn update_inline_viewport(&mut self, height: u16) -> io::Result<()> { + let size = self.terminal.size()?; + let mut area = self.terminal.viewport_area; + area.height = height.min(size.height).max(1); + area.width = size.width; + area.y = size.height.saturating_sub(area.height); + if area != self.terminal.viewport_area { + self.terminal.clear()?; + self.terminal.set_viewport_area(area); + } + Ok(()) + } + + fn flush_pending_history(&mut self) -> io::Result<()> { + if self.pending_history_lines.is_empty() { + return Ok(()); + } + let lines = std::mem::take(&mut self.pending_history_lines); + insert_history_lines(&mut self.terminal, lines)?; + self.terminal.invalidate_viewport(); + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::bottom_pane_height_for_terminal; + + #[test] + fn bottom_pane_height_stays_small() { + assert_eq!(bottom_pane_height_for_terminal(8), 4); + assert_eq!(bottom_pane_height_for_terminal(12), 5); + assert_eq!(bottom_pane_height_for_terminal(24), 6); + } +} diff --git a/crates/cli/src/ui/hud.rs b/crates/cli/src/ui/hud.rs new file mode 100644 index 00000000..171d4c61 --- /dev/null +++ b/crates/cli/src/ui/hud.rs @@ -0,0 +1,102 @@ +use ratatui::layout::Rect; +#[cfg(test)] +use unicode_width::UnicodeWidthStr; + +#[cfg(test)] +use crate::ui::truncate_to_width; +use crate::{ + bottom_pane::{BottomPaneState, render_bottom_pane}, + chat::ChatSurfaceState, + state::CliState, + ui::{CodexTheme, custom_terminal::Frame, overlay::render_browser_overlay}, +}; + +pub fn render_hud(frame: &mut Frame<'_>, state: &CliState, theme: &CodexTheme) { + render_hud_in_area(frame, frame.area(), state, theme) +} + +pub fn render_hud_in_area(frame: &mut Frame<'_>, area: Rect, state: &CliState, theme: &CodexTheme) { + if state.interaction.browser.open { + render_browser_overlay(frame, state, theme); + return; + } + + let mut chat = ChatSurfaceState::default(); + let chat_frame = chat.build_frame(state, theme, area.width); + let pane = BottomPaneState::from_cli(state, &chat_frame, theme, area.width); + render_bottom_pane(frame, area, state, &pane, theme); +} + +pub fn desired_viewport_height(state: &CliState, total_height: u16) -> u16 { + if state.interaction.browser.open { + return total_height.max(1); + } + let theme = CodexTheme::new(state.shell.capabilities); + let mut chat = ChatSurfaceState::default(); + let chat_frame = chat.build_frame(state, &theme, 80); + BottomPaneState::from_cli(state, &chat_frame, &theme, 80).desired_height(total_height) +} + +#[cfg(test)] +fn align_left_right(left: &str, right: &str, width: usize) -> String { + if width == 0 { + return String::new(); + } + let left = truncate_to_width(left, width); + let right = truncate_to_width(right, width); + let left_width = UnicodeWidthStr::width(left.as_str()); + let right_width = UnicodeWidthStr::width(right.as_str()); + if left_width + right_width + 1 > width { + return truncate_to_width(format!("{left} · {right}").as_str(), width); + } + format!( + "{left}{}{right}", + " ".repeat(width - left_width - right_width) + ) +} + +#[cfg(test)] +mod tests { + use astrcode_client::{ + AstrcodeConversationAssistantBlockDto, AstrcodeConversationBlockDto, + AstrcodeConversationBlockStatusDto, + }; + + use super::{align_left_right, desired_viewport_height}; + use crate::{ + capability::{ColorLevel, GlyphMode, TerminalCapabilities}, + state::CliState, + }; + + fn capabilities() -> TerminalCapabilities { + TerminalCapabilities { + color: ColorLevel::Ansi16, + glyphs: GlyphMode::Unicode, + alt_screen: false, + mouse: false, + bracketed_paste: false, + } + } + + #[test] + fn desired_viewport_height_stays_small() { + let mut state = CliState::new("http://127.0.0.1:5529".to_string(), None, capabilities()); + assert!((6..=8).contains(&desired_viewport_height(&state, 20))); + + state.conversation.transcript = vec![AstrcodeConversationBlockDto::Assistant( + AstrcodeConversationAssistantBlockDto { + id: "assistant-streaming".to_string(), + turn_id: Some("turn-1".to_string()), + status: AstrcodeConversationBlockStatusDto::Streaming, + markdown: "这是一个比较长的流式响应,用来验证底部面板会扩展。".to_string(), + }, + )]; + assert!((2..=5).contains(&desired_viewport_height(&state, 20))); + } + + #[test] + fn align_left_right_preserves_right_hint() { + let line = align_left_right("Esc close", "glm-5.1 · idle · Astrcode", 40); + assert!(line.contains("glm-5.1")); + } +} diff --git a/crates/cli/src/ui/insert_history.rs b/crates/cli/src/ui/insert_history.rs new file mode 100644 index 00000000..913c40c6 --- /dev/null +++ b/crates/cli/src/ui/insert_history.rs @@ -0,0 +1,307 @@ +use std::{fmt, io, io::Write}; + +use crossterm::{ + Command, + cursor::{MoveTo, MoveToColumn, RestorePosition, SavePosition}, + queue, + style::{ + Color as CColor, Colors, Print, SetAttribute, SetBackgroundColor, SetColors, + SetForegroundColor, + }, + terminal::{Clear, ClearType}, +}; +use ratatui::{ + backend::Backend, + layout::Size, + style::{Color, Modifier}, + text::{Line, Span}, +}; + +use super::custom_terminal::Terminal; +use crate::render::wrap::wrap_plain_text; + +pub fn insert_history_lines( + terminal: &mut Terminal, + lines: Vec>, +) -> io::Result<()> +where + B: Backend + Write, +{ + let screen_size = terminal.backend().size().unwrap_or(Size::new(0, 0)); + let mut area = terminal.viewport_area; + let last_cursor_pos = terminal.last_known_cursor_pos; + let mut should_update_area = false; + let writer = terminal.backend_mut(); + + let wrap_width = area.width.max(1) as usize; + let mut wrapped = Vec::new(); + let mut wrapped_rows = 0usize; + for line in &lines { + let parts = wrap_line(line, wrap_width); + wrapped_rows += parts + .iter() + .map(|wrapped_line| wrapped_line.width().max(1).div_ceil(wrap_width)) + .sum::(); + wrapped.extend(parts); + } + let wrapped_lines = wrapped_rows as u16; + + let cursor_top = if area.bottom() < screen_size.height { + let scroll_amount = wrapped_lines.min(screen_size.height - area.bottom()); + let top_1based = area.top() + 1; + queue!(writer, SetScrollRegion(top_1based..screen_size.height))?; + queue!(writer, MoveTo(0, area.top()))?; + for _ in 0..scroll_amount { + queue!(writer, Print("\x1bM"))?; + } + queue!(writer, ResetScrollRegion)?; + + let cursor_top = area.top().saturating_sub(1); + area.y += scroll_amount; + should_update_area = true; + cursor_top + } else { + area.top().saturating_sub(1) + }; + + queue!(writer, SetScrollRegion(1..area.top()))?; + queue!(writer, MoveTo(0, cursor_top))?; + for line in &wrapped { + queue!(writer, Print("\r\n"))?; + write_history_line(writer, line, wrap_width)?; + } + queue!(writer, ResetScrollRegion)?; + queue!(writer, MoveTo(last_cursor_pos.x, last_cursor_pos.y))?; + let _ = writer; + if should_update_area { + terminal.set_viewport_area(area); + } + + if wrapped_lines > 0 { + terminal.note_history_rows_inserted(wrapped_lines); + } + Ok(()) +} + +fn wrap_line(line: &Line<'static>, width: usize) -> Vec> { + let content = line.to_string(); + let wrapped = wrap_plain_text(content.as_str(), width.max(1)); + wrapped + .into_iter() + .map(|item| Line::from(item).style(line.style)) + .collect() +} + +fn write_history_line( + writer: &mut W, + line: &Line<'static>, + wrap_width: usize, +) -> io::Result<()> { + let physical_rows = line.width().max(1).div_ceil(wrap_width) as u16; + if physical_rows > 1 { + queue!(writer, SavePosition)?; + for _ in 1..physical_rows { + queue!(writer, crossterm::cursor::MoveDown(1), MoveToColumn(0))?; + queue!(writer, Clear(ClearType::UntilNewLine))?; + } + queue!(writer, RestorePosition)?; + } + queue!( + writer, + SetColors(Colors::new( + line.style + .fg + .map(to_crossterm_color) + .unwrap_or(CColor::Reset), + line.style + .bg + .map(to_crossterm_color) + .unwrap_or(CColor::Reset) + )) + )?; + queue!(writer, Clear(ClearType::UntilNewLine))?; + let merged_spans: Vec> = line + .spans + .iter() + .map(|span| Span { + style: span.style.patch(line.style), + content: span.content.clone(), + }) + .collect(); + write_spans(writer, merged_spans.iter()) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct SetScrollRegion(pub std::ops::Range); + +impl Command for SetScrollRegion { + fn write_ansi(&self, f: &mut impl fmt::Write) -> fmt::Result { + write!(f, "\x1b[{};{}r", self.0.start, self.0.end) + } + + #[cfg(windows)] + fn execute_winapi(&self) -> io::Result<()> { + Err(io::Error::other( + "tried to execute SetScrollRegion using WinAPI; use ANSI instead", + )) + } + + #[cfg(windows)] + fn is_ansi_code_supported(&self) -> bool { + true + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct ResetScrollRegion; + +impl Command for ResetScrollRegion { + fn write_ansi(&self, f: &mut impl fmt::Write) -> fmt::Result { + write!(f, "\x1b[r") + } + + #[cfg(windows)] + fn execute_winapi(&self) -> io::Result<()> { + Err(io::Error::other( + "tried to execute ResetScrollRegion using WinAPI; use ANSI instead", + )) + } + + #[cfg(windows)] + fn is_ansi_code_supported(&self) -> bool { + true + } +} + +struct ModifierDiff { + from: Modifier, + to: Modifier, +} + +impl ModifierDiff { + fn queue(self, writer: &mut W) -> io::Result<()> { + use crossterm::style::Attribute as CAttribute; + let removed = self.from - self.to; + if removed.contains(Modifier::REVERSED) { + queue!(writer, SetAttribute(CAttribute::NoReverse))?; + } + if removed.contains(Modifier::BOLD) { + queue!(writer, SetAttribute(CAttribute::NormalIntensity))?; + if self.to.contains(Modifier::DIM) { + queue!(writer, SetAttribute(CAttribute::Dim))?; + } + } + if removed.contains(Modifier::ITALIC) { + queue!(writer, SetAttribute(CAttribute::NoItalic))?; + } + if removed.contains(Modifier::UNDERLINED) { + queue!(writer, SetAttribute(CAttribute::NoUnderline))?; + } + if removed.contains(Modifier::DIM) { + queue!(writer, SetAttribute(CAttribute::NormalIntensity))?; + } + if removed.contains(Modifier::CROSSED_OUT) { + queue!(writer, SetAttribute(CAttribute::NotCrossedOut))?; + } + if removed.contains(Modifier::SLOW_BLINK) || removed.contains(Modifier::RAPID_BLINK) { + queue!(writer, SetAttribute(CAttribute::NoBlink))?; + } + + let added = self.to - self.from; + if added.contains(Modifier::REVERSED) { + queue!(writer, SetAttribute(CAttribute::Reverse))?; + } + if added.contains(Modifier::BOLD) { + queue!(writer, SetAttribute(CAttribute::Bold))?; + } + if added.contains(Modifier::ITALIC) { + queue!(writer, SetAttribute(CAttribute::Italic))?; + } + if added.contains(Modifier::UNDERLINED) { + queue!(writer, SetAttribute(CAttribute::Underlined))?; + } + if added.contains(Modifier::DIM) { + queue!(writer, SetAttribute(CAttribute::Dim))?; + } + if added.contains(Modifier::CROSSED_OUT) { + queue!(writer, SetAttribute(CAttribute::CrossedOut))?; + } + if added.contains(Modifier::SLOW_BLINK) { + queue!(writer, SetAttribute(CAttribute::SlowBlink))?; + } + if added.contains(Modifier::RAPID_BLINK) { + queue!(writer, SetAttribute(CAttribute::RapidBlink))?; + } + Ok(()) + } +} + +fn write_spans<'a, I>(writer: &mut impl Write, content: I) -> io::Result<()> +where + I: IntoIterator>, +{ + let mut fg = Color::Reset; + let mut bg = Color::Reset; + let mut modifier = Modifier::empty(); + + for span in content { + let mut next_modifier = Modifier::empty(); + next_modifier.insert(span.style.add_modifier); + next_modifier.remove(span.style.sub_modifier); + if next_modifier != modifier { + let diff = ModifierDiff { + from: modifier, + to: next_modifier, + }; + diff.queue(writer)?; + modifier = next_modifier; + } + + let next_fg = span.style.fg.unwrap_or(Color::Reset); + let next_bg = span.style.bg.unwrap_or(Color::Reset); + if next_fg != fg || next_bg != bg { + queue!( + writer, + SetColors(Colors::new( + to_crossterm_color(next_fg), + to_crossterm_color(next_bg) + )) + )?; + fg = next_fg; + bg = next_bg; + } + + queue!(writer, Print(span.content.clone()))?; + } + + queue!( + writer, + SetForegroundColor(CColor::Reset), + SetBackgroundColor(CColor::Reset), + SetAttribute(crossterm::style::Attribute::Reset), + ) +} + +fn to_crossterm_color(color: Color) -> CColor { + match color { + Color::Reset => CColor::Reset, + Color::Black => CColor::Black, + Color::Red => CColor::DarkRed, + Color::Green => CColor::DarkGreen, + Color::Yellow => CColor::DarkYellow, + Color::Blue => CColor::DarkBlue, + Color::Magenta => CColor::DarkMagenta, + Color::Cyan => CColor::DarkCyan, + Color::Gray => CColor::Grey, + Color::DarkGray => CColor::DarkGrey, + Color::LightRed => CColor::Red, + Color::LightGreen => CColor::Green, + Color::LightYellow => CColor::Yellow, + Color::LightBlue => CColor::Blue, + Color::LightMagenta => CColor::Magenta, + Color::LightCyan => CColor::Cyan, + Color::White => CColor::White, + Color::Rgb(r, g, b) => CColor::Rgb { r, g, b }, + Color::Indexed(index) => CColor::AnsiValue(index), + } +} diff --git a/crates/cli/src/ui/mod.rs b/crates/cli/src/ui/mod.rs index 80006e1f..eae08e77 100644 --- a/crates/cli/src/ui/mod.rs +++ b/crates/cli/src/ui/mod.rs @@ -1,18 +1,18 @@ pub mod cells; -mod footer; -mod hero; +pub mod composer; +pub mod custom_terminal; +pub mod host; +pub mod hud; +pub mod insert_history; +pub mod overlay; mod palette; mod text; mod theme; -mod transcript; -pub use footer::{FooterRenderOutput, footer_lines}; -pub use hero::hero_lines; pub use palette::{palette_lines, palette_visible}; use ratatui::text::{Line, Span}; pub use text::truncate_to_width; pub use theme::{CodexTheme, ThemePalette}; -pub use transcript::transcript_lines; use crate::state::WrappedLine; diff --git a/crates/cli/src/ui/overlay.rs b/crates/cli/src/ui/overlay.rs new file mode 100644 index 00000000..62b7cf01 --- /dev/null +++ b/crates/cli/src/ui/overlay.rs @@ -0,0 +1,178 @@ +use ratatui::{ + layout::{Constraint, Direction, Layout, Rect}, + widgets::{Block, Borders, Clear, Paragraph}, +}; + +use crate::{ + state::{CliState, WrappedLine}, + ui::{ + CodexTheme, + cells::{RenderableCell, TranscriptCellView}, + custom_terminal::Frame, + }, +}; + +pub fn render_browser_overlay(frame: &mut Frame<'_>, state: &CliState, theme: &CodexTheme) { + let area = centered_rect(frame.area()); + frame.render_widget(Clear, area); + + let title = state + .conversation + .active_session_title + .clone() + .map(|title| format!("History · {title}")) + .unwrap_or_else(|| "History".to_string()); + let block = Block::default().borders(Borders::ALL).title(title); + let inner = block.inner(area); + frame.render_widget(block, area); + + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Min(1), Constraint::Length(1)]) + .split(inner); + + let rendered = browser_lines(state, chunks[0].width, theme); + let content_height = usize::from(chunks[0].height.max(1)); + let scroll = overlay_scroll_offset( + rendered.lines.len(), + content_height, + rendered.selected_line_range, + ); + + frame.render_widget( + Paragraph::new( + rendered + .lines + .iter() + .map(|line| super::line_to_ratatui(line, theme)) + .collect::>(), + ) + .scroll((scroll as u16, 0)), + chunks[0], + ); + + frame.render_widget( + Paragraph::new(browser_footer(state, rendered.total_cells)), + chunks[1], + ); +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct BrowserRenderOutput { + lines: Vec, + selected_line_range: Option<(usize, usize)>, + total_cells: usize, +} + +fn browser_lines(state: &CliState, width: u16, theme: &CodexTheme) -> BrowserRenderOutput { + let width = usize::from(width.max(28)); + let mut lines = Vec::new(); + let mut selected_line_range = None; + let transcript_cells = state.browser_transcript_cells(); + + if let Some(banner) = &state.conversation.banner { + lines.push(WrappedLine { + style: crate::state::WrappedLineStyle::ErrorText, + content: format!("! {}", banner.error.message), + }); + lines.push(WrappedLine { + style: crate::state::WrappedLineStyle::Muted, + content: " stream 需要重新同步,继续操作前建议等待恢复。".to_string(), + }); + lines.push(WrappedLine { + style: crate::state::WrappedLineStyle::Plain, + content: String::new(), + }); + } + + for (index, cell) in transcript_cells.iter().enumerate() { + let line_start = lines.len(); + let view = TranscriptCellView { + selected: state.interaction.browser.selected_cell == index, + expanded: state.is_cell_expanded(cell.id.as_str()) || cell.expanded, + thinking: match &cell.kind { + crate::state::TranscriptCellKind::Thinking { body, status } => { + Some(state.thinking_playback.present( + &state.thinking_pool, + cell.id.as_str(), + body.as_str(), + *status, + state.is_cell_expanded(cell.id.as_str()) || cell.expanded, + )) + }, + _ => None, + }, + }; + let rendered = cell.render_lines(width, state.shell.capabilities, theme, &view); + lines.extend(rendered); + if view.selected { + selected_line_range = Some((line_start, lines.len().saturating_sub(1))); + } + } + + BrowserRenderOutput { + lines, + selected_line_range, + total_cells: transcript_cells.len(), + } +} + +fn overlay_scroll_offset( + total_lines: usize, + viewport_height: usize, + selected_line_range: Option<(usize, usize)>, +) -> usize { + let max_scroll = total_lines.saturating_sub(viewport_height); + let Some((selected_start, selected_end)) = selected_line_range else { + return max_scroll; + }; + if selected_end < viewport_height { + 0 + } else { + selected_end + .saturating_add(1) + .saturating_sub(viewport_height) + .min(max_scroll) + .min(selected_start) + } +} + +fn browser_footer(state: &CliState, total_cells: usize) -> String { + let unseen = total_cells > state.interaction.browser.last_seen_cell_count + && state.interaction.browser.selected_cell + 1 < total_cells; + if unseen { + "Esc close · ↑↓ browse · Home/End jump · PgUp/PgDn page · End 查看新消息".to_string() + } else { + "Esc close · ↑↓ browse · Home/End jump · PgUp/PgDn page · Enter expand".to_string() + } +} + +fn centered_rect(area: Rect) -> Rect { + let horizontal_margin = area.width.saturating_div(12).max(1); + let vertical_margin = area.height.saturating_div(10).max(1); + Rect { + x: area.x.saturating_add(horizontal_margin), + y: area.y.saturating_add(vertical_margin), + width: area + .width + .saturating_sub(horizontal_margin.saturating_mul(2)), + height: area + .height + .saturating_sub(vertical_margin.saturating_mul(2)), + } +} + +#[cfg(test)] +mod tests { + use super::overlay_scroll_offset; + + #[test] + fn overlay_scroll_defaults_to_bottom_when_nothing_selected() { + assert_eq!(overlay_scroll_offset(20, 5, None), 15); + } + + #[test] + fn overlay_scroll_keeps_selected_range_visible() { + assert_eq!(overlay_scroll_offset(30, 6, Some((20, 22))), 17); + } +} diff --git a/crates/cli/src/ui/palette.rs b/crates/cli/src/ui/palette.rs index 2f84eec6..0b5da995 100644 --- a/crates/cli/src/ui/palette.rs +++ b/crates/cli/src/ui/palette.rs @@ -16,7 +16,12 @@ pub fn palette_lines( width, theme, " 没有匹配的命令", - |item| (item.title.as_str(), item.description.as_str()), + |item| { + ( + item.title.clone(), + compact_slash_subtitle(item.description.as_str()).to_string(), + ) + }, ), PaletteState::Resume(resume) => render_palette_items( &resume.items, @@ -24,7 +29,20 @@ pub fn palette_lines( width, theme, " 没有匹配的会话", - |item| (item.title.as_str(), item.working_dir.as_str()), + |item| (item.title.clone(), item.working_dir.clone()), + ), + PaletteState::Model(model) => render_palette_items( + &model.items, + model.selected, + width, + theme, + " 没有匹配的模型", + |item| { + ( + item.model.clone(), + format!("{} · {}", item.profile_name, item.provider_kind), + ) + }, ), } } @@ -71,6 +89,30 @@ fn candidate_line(prefix: &str, title: &str, meta: &str, width: usize) -> String ) } +fn compact_slash_subtitle(description: &str) -> &str { + let trimmed = description.trim(); + if trimmed.is_empty() { + return trimmed; + } + + for marker in [" Use when ", " Trigger when ", " 适用场景", " TRIGGER when"] { + if let Some((head, _)) = trimmed.split_once(marker) { + let compact = head + .trim() + .trim_end_matches(['.', '。', ';', ';', ':', ':']); + if !compact.is_empty() { + return compact; + } + } + } + + trimmed + .split(['.', '。']) + .map(str::trim) + .find(|segment| !segment.is_empty()) + .unwrap_or(trimmed) +} + fn render_palette_items( items: &[T], selected: usize, @@ -80,7 +122,7 @@ fn render_palette_items( meta: F, ) -> Vec where - F: Fn(&T) -> (&str, &str), + F: Fn(&T) -> (String, String), { if items.is_empty() { return vec![WrappedLine { @@ -105,8 +147,8 @@ where } else { " " }, - title, - details, + title.as_str(), + details.as_str(), width, ), } @@ -116,7 +158,7 @@ where #[cfg(test)] mod tests { - use super::{candidate_line, visible_window}; + use super::{candidate_line, compact_slash_subtitle, visible_window}; #[test] fn visible_window_tracks_selected_item() { @@ -140,4 +182,13 @@ mod tests { assert!(!line.contains('\n')); assert!(line.contains("Issue Fixer")); } + + #[test] + fn compact_slash_subtitle_drops_use_when_tail() { + let subtitle = compact_slash_subtitle( + "Fast-forward through OpenSpec artifact creation. Use when the user wants to quickly \ + create all artifacts needed for implementation.", + ); + assert_eq!(subtitle, "Fast-forward through OpenSpec artifact creation"); + } } diff --git a/crates/cli/src/ui/theme.rs b/crates/cli/src/ui/theme.rs index cc106d32..d5df3953 100644 --- a/crates/cli/src/ui/theme.rs +++ b/crates/cli/src/ui/theme.rs @@ -22,19 +22,11 @@ impl CodexTheme { } pub fn app_background(&self) -> Style { - Style::default().bg(self.bg()) + Style::default() } pub fn menu_block_style(&self) -> Style { - Style::default().bg(self.bg()).fg(self.text_primary()) - } - - fn bg(&self) -> Color { - match self.capabilities.color { - ColorLevel::TrueColor => Color::Rgb(26, 24, 22), - ColorLevel::Ansi16 => Color::Black, - ColorLevel::None => Color::Reset, - } + Style::default().fg(self.text_primary()) } fn surface_alt(&self) -> Color { @@ -109,46 +101,28 @@ impl ThemePalette for CodexTheme { if matches!(self.capabilities.color, ColorLevel::None) { return match style { WrappedLineStyle::Plain - | WrappedLineStyle::HeroBorder - | WrappedLineStyle::HeroBody - | WrappedLineStyle::HeroFeedTitle | WrappedLineStyle::ThinkingBody | WrappedLineStyle::ToolBody | WrappedLineStyle::Notice | WrappedLineStyle::PaletteItem => base, WrappedLineStyle::Selection - | WrappedLineStyle::HeroTitle | WrappedLineStyle::PromptEcho | WrappedLineStyle::ToolLabel | WrappedLineStyle::ErrorText - | WrappedLineStyle::FooterInput - | WrappedLineStyle::FooterKey | WrappedLineStyle::PaletteSelected => base.add_modifier(Modifier::BOLD), WrappedLineStyle::ThinkingLabel => { base.add_modifier(Modifier::BOLD | Modifier::ITALIC) }, - WrappedLineStyle::Muted - | WrappedLineStyle::Divider - | WrappedLineStyle::FooterStatus - | WrappedLineStyle::FooterHint - | WrappedLineStyle::HeroMuted - | WrappedLineStyle::ThinkingPreview => base.add_modifier(Modifier::DIM), + WrappedLineStyle::Muted | WrappedLineStyle::ThinkingPreview => { + base.add_modifier(Modifier::DIM) + }, }; } match style { WrappedLineStyle::Plain => base.fg(self.text_primary()), - WrappedLineStyle::Muted - | WrappedLineStyle::Divider - | WrappedLineStyle::FooterStatus - | WrappedLineStyle::FooterHint - | WrappedLineStyle::HeroMuted - | WrappedLineStyle::ThinkingPreview => base.fg(self.text_muted()), - WrappedLineStyle::HeroTitle => base.fg(self.accent()).add_modifier(Modifier::BOLD), - WrappedLineStyle::HeroBorder => base.fg(self.accent_soft()), - WrappedLineStyle::HeroBody => base.fg(self.text_primary()), - WrappedLineStyle::HeroFeedTitle => { - base.fg(self.accent_soft()).add_modifier(Modifier::BOLD) + WrappedLineStyle::Muted | WrappedLineStyle::ThinkingPreview => { + base.fg(self.text_muted()) }, WrappedLineStyle::Selection => base .fg(self.text_primary()) @@ -166,10 +140,6 @@ impl ThemePalette for CodexTheme { WrappedLineStyle::ToolBody => base.fg(self.text_secondary()), WrappedLineStyle::Notice => base.fg(self.text_secondary()), WrappedLineStyle::ErrorText => base.fg(self.error()).add_modifier(Modifier::BOLD), - WrappedLineStyle::FooterInput => { - base.fg(self.text_primary()).add_modifier(Modifier::BOLD) - }, - WrappedLineStyle::FooterKey => base.fg(self.accent_soft()).add_modifier(Modifier::BOLD), WrappedLineStyle::PaletteItem => base.fg(self.text_secondary()), WrappedLineStyle::PaletteSelected => { base.fg(self.accent()).add_modifier(Modifier::BOLD) diff --git a/crates/cli/src/ui/transcript.rs b/crates/cli/src/ui/transcript.rs deleted file mode 100644 index 7a88cd56..00000000 --- a/crates/cli/src/ui/transcript.rs +++ /dev/null @@ -1,124 +0,0 @@ -use astrcode_client::AstrcodePhaseDto; - -use super::{ - ThemePalette, - cells::{RenderableCell, TranscriptCellView, synthetic_thinking_lines}, -}; -use crate::state::{CliState, TranscriptCellStatus, WrappedLine, WrappedLineStyle}; - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct TranscriptRenderOutput { - pub lines: Vec, - pub selected_line_range: Option<(usize, usize)>, -} - -pub fn transcript_lines( - state: &CliState, - width: u16, - theme: &dyn ThemePalette, -) -> TranscriptRenderOutput { - let width = usize::from(width.max(28)); - let mut lines = Vec::new(); - let mut selected_line_range = None; - let transcript_cells = state.transcript_cells(); - if let Some(banner) = &state.conversation.banner { - lines.push(WrappedLine { - style: WrappedLineStyle::ErrorText, - content: format!("{} {}", theme.glyph("!", "!"), banner.error.message), - }); - lines.push(WrappedLine { - style: WrappedLineStyle::Muted, - content: " stream 需要重新同步,继续操作前建议等待恢复。".to_string(), - }); - lines.push(WrappedLine { - style: WrappedLineStyle::Plain, - content: String::new(), - }); - } - - lines.extend(super::hero_lines(state, width as u16, theme)); - - for (index, cell) in transcript_cells.iter().enumerate() { - let line_start = lines.len(); - let view = TranscriptCellView { - selected: matches!( - state.interaction.pane_focus, - crate::state::PaneFocus::Transcript - ) && state.interaction.transcript.selected_cell == index, - expanded: state.is_cell_expanded(cell.id.as_str()) || cell.expanded, - thinking: match &cell.kind { - crate::state::TranscriptCellKind::Thinking { body, status } => { - Some(state.thinking_playback.present( - &state.thinking_pool, - cell.id.as_str(), - body.as_str(), - *status, - state.is_cell_expanded(cell.id.as_str()) || cell.expanded, - )) - }, - _ => None, - }, - }; - let rendered = cell.render_lines(width, state.shell.capabilities, theme, &view); - lines.extend(rendered); - if view.selected { - let line_end = lines.len().saturating_sub(1); - selected_line_range = Some((line_start, line_end)); - } - } - - if should_render_synthetic_thinking(state) { - let presentation = state.thinking_playback.present( - &state.thinking_pool, - state - .conversation - .control - .as_ref() - .and_then(|control| control.active_turn_id.as_deref()) - .unwrap_or("active-thinking"), - "", - TranscriptCellStatus::Streaming, - false, - ); - lines.extend(synthetic_thinking_lines(theme, &presentation)); - } - - TranscriptRenderOutput { - lines, - selected_line_range, - } -} - -fn should_render_synthetic_thinking(state: &CliState) -> bool { - let Some(control) = &state.conversation.control else { - return false; - }; - if control.active_turn_id.is_none() { - return false; - } - if !matches!( - control.phase, - AstrcodePhaseDto::Thinking | AstrcodePhaseDto::CallingTool | AstrcodePhaseDto::Streaming - ) { - return false; - } - - !state - .transcript_cells() - .iter() - .any(|cell| match &cell.kind { - crate::state::TranscriptCellKind::Thinking { status, .. } => { - matches!( - status, - TranscriptCellStatus::Streaming | TranscriptCellStatus::Complete - ) - }, - crate::state::TranscriptCellKind::Assistant { status, body } => { - matches!(status, TranscriptCellStatus::Streaming) && !body.trim().is_empty() - }, - crate::state::TranscriptCellKind::ToolCall { status, .. } => { - matches!(status, TranscriptCellStatus::Streaming) - }, - _ => false, - }) -} diff --git a/crates/client/src/lib.rs b/crates/client/src/lib.rs index bfd0dd6f..7d7484ca 100644 --- a/crates/client/src/lib.rs +++ b/crates/client/src/lib.rs @@ -5,8 +5,8 @@ use std::sync::Arc; use astrcode_protocol::http::{ AuthExchangeRequest, AuthExchangeResponse, CompactSessionRequest, CompactSessionResponse, - CreateSessionRequest, ExecutionControlDto, PromptAcceptedResponse, PromptRequest, - SessionListItem, + CreateSessionRequest, CurrentModelInfoDto, ExecutionControlDto, ModelOptionDto, + PromptAcceptedResponse, PromptRequest, SaveActiveSelectionRequest, SessionListItem, conversation::v1::{ ConversationCursorDto, ConversationDeltaDto, ConversationErrorEnvelopeDto, ConversationSlashCandidatesResponseDto, ConversationSnapshotResponseDto, @@ -35,9 +35,12 @@ pub use astrcode_protocol::http::{ CompactSessionRequest as AstrcodeCompactSessionRequest, CompactSessionResponse as AstrcodeCompactSessionResponse, CreateSessionRequest as AstrcodeCreateSessionRequest, - ExecutionControlDto as AstrcodeExecutionControlDto, PhaseDto as AstrcodePhaseDto, - PromptAcceptedResponse as AstrcodePromptAcceptedResponse, - PromptRequest as AstrcodePromptRequest, SessionListItem as AstrcodeSessionListItem, + CurrentModelInfoDto as AstrcodeCurrentModelInfoDto, + ExecutionControlDto as AstrcodeExecutionControlDto, ModelOptionDto as AstrcodeModelOptionDto, + PhaseDto as AstrcodePhaseDto, PromptAcceptedResponse as AstrcodePromptAcceptedResponse, + PromptRequest as AstrcodePromptRequest, PromptSkillInvocation as AstrcodePromptSkillInvocation, + SaveActiveSelectionRequest as AstrcodeSaveActiveSelectionRequest, + SessionListItem as AstrcodeSessionListItem, conversation::v1::{ ConversationAssistantBlockDto as AstrcodeConversationAssistantBlockDto, ConversationBannerDto as AstrcodeConversationBannerDto, @@ -249,6 +252,62 @@ where .await } + pub async fn get_current_model(&self) -> Result { + self.send_json::( + TransportMethod::Get, + "/api/models/current", + Vec::new(), + None, + true, + ) + .await + } + + pub async fn list_models(&self) -> Result, ClientError> { + self.send_json::, Value>( + TransportMethod::Get, + "/api/models", + Vec::new(), + None, + true, + ) + .await + } + + pub async fn save_active_selection( + &self, + request: SaveActiveSelectionRequest, + ) -> Result<(), ClientError> { + let response = self + .transport + .execute(TransportRequest { + method: TransportMethod::Post, + url: self.url("/api/config/active-selection"), + auth_token: Some(self.require_api_token().await?), + query: Vec::new(), + json_body: Some(serde_json::to_value(request).map_err(|error| { + ClientError::new( + ClientErrorKind::UnexpectedResponse, + format!("serialize request body failed: {error}"), + ) + })?), + }) + .await + .map_err(ClientError::from_transport)?; + if response.status == 204 { + Ok(()) + } else { + Err(ClientError::new( + ClientErrorKind::UnexpectedResponse, + format!( + "save active selection expected 204 response, got {}", + response.status + ), + ) + .with_status(response.status)) + } + } + pub async fn request_compact( &self, session_id: &str, @@ -515,8 +574,8 @@ mod tests { use astrcode_protocol::http::{ CompactSessionRequest, CompactSessionResponse, ConversationSlashActionKindDto, - ConversationSlashCandidateDto, ConversationSlashCandidatesResponseDto, ExecutionControlDto, - PhaseDto, + ConversationSlashCandidateDto, ConversationSlashCandidatesResponseDto, CurrentModelInfoDto, + ExecutionControlDto, PhaseDto, SaveActiveSelectionRequest, }; use async_trait::async_trait; use serde_json::json; @@ -795,12 +854,12 @@ mod tests { status: 200, body: json!({ "items": [{ - "id": "skill-review", + "id": "review", "title": "Review skill", "description": "插入 review skill", "keywords": ["skill", "review"], "actionKind": "insert_text", - "actionValue": "/skill review" + "actionValue": "/review" }] }) .to_string(), @@ -823,12 +882,12 @@ mod tests { candidates, ConversationSlashCandidatesResponseDto { items: vec![ConversationSlashCandidateDto { - id: "skill-review".to_string(), + id: "review".to_string(), title: "Review skill".to_string(), description: "插入 review skill".to_string(), keywords: vec!["skill".to_string(), "review".to_string()], action_kind: ConversationSlashActionKindDto::InsertText, - action_value: "/skill review".to_string(), + action_value: "/review".to_string(), }] } ); @@ -1045,4 +1104,88 @@ mod tests { .expect("compact should succeed"); assert!(response.deferred); } + + #[tokio::test] + async fn save_active_selection_accepts_no_content_response() { + let transport = MockTransport::default(); + transport.push(MockCall::Request { + expected: TransportRequest { + method: TransportMethod::Post, + url: "http://localhost:5529/api/config/active-selection".to_string(), + auth_token: Some("session-token".to_string()), + query: Vec::new(), + json_body: Some(json!({ + "activeProfile": "anthropic", + "activeModel": "claude-sonnet" + })), + }, + result: Ok(TransportResponse { + status: 204, + body: String::new(), + }), + }); + + let client = AstrcodeClient::with_transport( + ClientConfig { + origin: "http://localhost:5529".to_string(), + api_token: Some("session-token".to_string()), + api_token_expires_at_ms: Some(super::current_timestamp_ms() + 30_000), + stream_buffer: 8, + }, + transport, + ); + + client + .save_active_selection(SaveActiveSelectionRequest { + active_profile: "anthropic".to_string(), + active_model: "claude-sonnet".to_string(), + }) + .await + .expect("save active selection should succeed"); + } + + #[tokio::test] + async fn get_current_model_decodes_model_summary() { + let transport = MockTransport::default(); + transport.push(MockCall::Request { + expected: TransportRequest { + method: TransportMethod::Get, + url: "http://localhost:5529/api/models/current".to_string(), + auth_token: Some("session-token".to_string()), + query: Vec::new(), + json_body: None, + }, + result: Ok(TransportResponse { + status: 200, + body: json!({ + "profileName": "anthropic", + "model": "claude-sonnet", + "providerKind": "anthropic" + }) + .to_string(), + }), + }); + + let client = AstrcodeClient::with_transport( + ClientConfig { + origin: "http://localhost:5529".to_string(), + api_token: Some("session-token".to_string()), + api_token_expires_at_ms: Some(super::current_timestamp_ms() + 30_000), + stream_buffer: 8, + }, + transport, + ); + + assert_eq!( + client + .get_current_model() + .await + .expect("current model should decode"), + CurrentModelInfoDto { + profile_name: "anthropic".to_string(), + model: "claude-sonnet".to_string(), + provider_kind: "anthropic".to_string(), + } + ); + } } diff --git a/crates/protocol/src/http/mod.rs b/crates/protocol/src/http/mod.rs index 910f3d56..b1579da5 100644 --- a/crates/protocol/src/http/mod.rs +++ b/crates/protocol/src/http/mod.rs @@ -71,7 +71,7 @@ pub use runtime::{ }; pub use session::{ CompactSessionRequest, CompactSessionResponse, CreateSessionRequest, DeleteProjectResultDto, - PromptAcceptedResponse, PromptRequest, SessionListItem, + PromptAcceptedResponse, PromptRequest, PromptSkillInvocation, SessionListItem, }; pub use session_event::{SessionCatalogEventEnvelope, SessionCatalogEventPayload}; pub use terminal::v1::{ diff --git a/crates/protocol/src/http/session.rs b/crates/protocol/src/http/session.rs index 32253f86..b522ae38 100644 --- a/crates/protocol/src/http/session.rs +++ b/crates/protocol/src/http/session.rs @@ -47,12 +47,26 @@ pub struct SessionListItem { pub phase: PhaseDto, } +/// `POST /api/sessions/:id/prompt` 请求体——向会话提交用户提示词。 +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct PromptSkillInvocation { + /// 用户显式选择的 skill id(kebab-case)。 + pub skill_id: String, + /// slash 命令头之后剩余的用户提示词。 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub user_prompt: Option, +} + /// `POST /api/sessions/:id/prompt` 请求体——向会话提交用户提示词。 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct PromptRequest { /// 用户输入的文本内容 pub text: String, + /// 用户通过一级 slash 命令显式点名的 skill。 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub skill_invocation: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub control: Option, } diff --git a/crates/protocol/tests/fixtures/terminal/v1/snapshot.json b/crates/protocol/tests/fixtures/terminal/v1/snapshot.json index 194ac0b8..e24d28c3 100644 --- a/crates/protocol/tests/fixtures/terminal/v1/snapshot.json +++ b/crates/protocol/tests/fixtures/terminal/v1/snapshot.json @@ -120,15 +120,15 @@ "actionValue": "new_session" }, { - "id": "slash-skill", - "title": "/skill", - "description": "插入 skill 引用", + "id": "review", + "title": "/review", + "description": "调用 review skill", "keywords": [ "skill", - "insert" + "review" ], "actionKind": "insert_text", - "actionValue": "/skill " + "actionValue": "/review" } ], "banner": { diff --git a/crates/protocol/tests/terminal_conformance.rs b/crates/protocol/tests/terminal_conformance.rs index 6ba3c57e..8d4807f2 100644 --- a/crates/protocol/tests/terminal_conformance.rs +++ b/crates/protocol/tests/terminal_conformance.rs @@ -131,12 +131,12 @@ fn terminal_snapshot_fixture_freezes_v1_hydration_shape() { action_value: "new_session".to_string(), }, TerminalSlashCandidateDto { - id: "slash-skill".to_string(), - title: "/skill".to_string(), - description: "插入 skill 引用".to_string(), - keywords: vec!["skill".to_string(), "insert".to_string()], + id: "review".to_string(), + title: "/review".to_string(), + description: "调用 review skill".to_string(), + keywords: vec!["skill".to_string(), "review".to_string()], action_kind: TerminalSlashActionKindDto::InsertText, - action_value: "/skill ".to_string(), + action_value: "/review".to_string(), }, ], banner: Some(TerminalBannerDto { diff --git a/crates/server/src/bootstrap/composer_skills.rs b/crates/server/src/bootstrap/composer_skills.rs index 76a91f69..63bff0be 100644 --- a/crates/server/src/bootstrap/composer_skills.rs +++ b/crates/server/src/bootstrap/composer_skills.rs @@ -1,7 +1,7 @@ use std::{path::Path, sync::Arc}; use astrcode_adapter_skills::SkillCatalog; -use astrcode_application::{ComposerSkillPort, ComposerSkillSummary}; +use astrcode_application::{ComposerResolvedSkill, ComposerSkillPort, ComposerSkillSummary}; #[derive(Clone)] pub(crate) struct RuntimeComposerSkillPort { @@ -29,4 +29,16 @@ impl ComposerSkillPort for RuntimeComposerSkillPort { .map(|skill| ComposerSkillSummary::new(skill.id, skill.description)) .collect() } + + fn resolve_skill(&self, working_dir: &Path, skill_id: &str) -> Option { + self.skill_catalog + .resolve_for_working_dir(&working_dir.to_string_lossy()) + .into_iter() + .find(|skill| skill.matches_requested_name(skill_id)) + .map(|skill| ComposerResolvedSkill { + id: skill.id, + description: skill.description, + guide: skill.guide, + }) + } } diff --git a/crates/server/src/http/routes/sessions/mutation.rs b/crates/server/src/http/routes/sessions/mutation.rs index d1a73786..b34fe158 100644 --- a/crates/server/src/http/routes/sessions/mutation.rs +++ b/crates/server/src/http/routes/sessions/mutation.rs @@ -46,7 +46,17 @@ pub(crate) async fn submit_prompt( let session_id = validate_session_path_id(&session_id)?; let summary = state .app - .submit_prompt_summary(&session_id, request.text, request.control) + .submit_prompt_summary( + &session_id, + request.text, + request.control, + request.skill_invocation.map(|invocation| { + astrcode_application::PromptSkillInvocation { + skill_id: invocation.skill_id, + user_prompt: invocation.user_prompt, + } + }), + ) .await .map_err(ApiError::from)?; Ok(( diff --git a/crates/server/src/tests/config_routes_tests.rs b/crates/server/src/tests/config_routes_tests.rs index 8f243646..1764eda9 100644 --- a/crates/server/src/tests/config_routes_tests.rs +++ b/crates/server/src/tests/config_routes_tests.rs @@ -188,6 +188,89 @@ async fn prompt_route_roundtrips_accepted_execution_control() { assert_eq!(accepted_control.manual_compact, None); } +#[tokio::test] +async fn prompt_route_accepts_structured_skill_invocation() { + let (state, _guard) = test_state(None).await; + let session = state + .app + .create_session( + tempfile::tempdir() + .expect("tempdir") + .path() + .display() + .to_string(), + ) + .await + .expect("session should be created"); + let app = build_api_router().with_state(state.clone()); + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri(format!("/api/sessions/{}/prompts", session.session_id)) + .header(AUTH_HEADER_NAME, "browser-token") + .header("content-type", "application/json") + .body(Body::from( + serde_json::json!({ + "text": "提交当前修改", + "skillInvocation": { + "skillId": "git-commit", + "userPrompt": "提交当前修改" + } + }) + .to_string(), + )) + .expect("request should be valid"), + ) + .await + .expect("response should be returned"); + + assert_eq!(response.status(), StatusCode::ACCEPTED); + let payload: PromptAcceptedResponse = json_body(response).await; + assert!(!payload.turn_id.is_empty()); +} + +#[tokio::test] +async fn prompt_route_rejects_unknown_skill_invocation() { + let (state, _guard) = test_state(None).await; + let session = state + .app + .create_session( + tempfile::tempdir() + .expect("tempdir") + .path() + .display() + .to_string(), + ) + .await + .expect("session should be created"); + let app = build_api_router().with_state(state); + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri(format!("/api/sessions/{}/prompts", session.session_id)) + .header(AUTH_HEADER_NAME, "browser-token") + .header("content-type", "application/json") + .body(Body::from( + serde_json::json!({ + "text": "", + "skillInvocation": { + "skillId": "missing-skill" + } + }) + .to_string(), + )) + .expect("request should be valid"), + ) + .await + .expect("response should be returned"); + + assert_eq!(response.status(), StatusCode::BAD_REQUEST); +} + #[tokio::test] async fn prompt_submission_registers_session_root_agent_context() { let (state, _guard) = test_state(None).await; diff --git a/openspec/archive/2026-04-13-complete-turn-orchestration/.openspec.yaml b/openspec/changes/archive/2026-04-13-complete-turn-orchestration/.openspec.yaml similarity index 100% rename from openspec/archive/2026-04-13-complete-turn-orchestration/.openspec.yaml rename to openspec/changes/archive/2026-04-13-complete-turn-orchestration/.openspec.yaml diff --git a/openspec/archive/2026-04-13-complete-turn-orchestration/design.md b/openspec/changes/archive/2026-04-13-complete-turn-orchestration/design.md similarity index 100% rename from openspec/archive/2026-04-13-complete-turn-orchestration/design.md rename to openspec/changes/archive/2026-04-13-complete-turn-orchestration/design.md diff --git a/openspec/archive/2026-04-13-complete-turn-orchestration/proposal.md b/openspec/changes/archive/2026-04-13-complete-turn-orchestration/proposal.md similarity index 100% rename from openspec/archive/2026-04-13-complete-turn-orchestration/proposal.md rename to openspec/changes/archive/2026-04-13-complete-turn-orchestration/proposal.md diff --git a/openspec/archive/2026-04-13-complete-turn-orchestration/specs/application-use-cases/spec.md b/openspec/changes/archive/2026-04-13-complete-turn-orchestration/specs/application-use-cases/spec.md similarity index 100% rename from openspec/archive/2026-04-13-complete-turn-orchestration/specs/application-use-cases/spec.md rename to openspec/changes/archive/2026-04-13-complete-turn-orchestration/specs/application-use-cases/spec.md diff --git a/openspec/archive/2026-04-13-complete-turn-orchestration/specs/session-runtime/spec.md b/openspec/changes/archive/2026-04-13-complete-turn-orchestration/specs/session-runtime/spec.md similarity index 100% rename from openspec/archive/2026-04-13-complete-turn-orchestration/specs/session-runtime/spec.md rename to openspec/changes/archive/2026-04-13-complete-turn-orchestration/specs/session-runtime/spec.md diff --git a/openspec/archive/2026-04-13-complete-turn-orchestration/specs/turn-budget-governance/spec.md b/openspec/changes/archive/2026-04-13-complete-turn-orchestration/specs/turn-budget-governance/spec.md similarity index 100% rename from openspec/archive/2026-04-13-complete-turn-orchestration/specs/turn-budget-governance/spec.md rename to openspec/changes/archive/2026-04-13-complete-turn-orchestration/specs/turn-budget-governance/spec.md diff --git a/openspec/archive/2026-04-13-complete-turn-orchestration/specs/turn-observability/spec.md b/openspec/changes/archive/2026-04-13-complete-turn-orchestration/specs/turn-observability/spec.md similarity index 100% rename from openspec/archive/2026-04-13-complete-turn-orchestration/specs/turn-observability/spec.md rename to openspec/changes/archive/2026-04-13-complete-turn-orchestration/specs/turn-observability/spec.md diff --git a/openspec/archive/2026-04-13-complete-turn-orchestration/tasks.md b/openspec/changes/archive/2026-04-13-complete-turn-orchestration/tasks.md similarity index 100% rename from openspec/archive/2026-04-13-complete-turn-orchestration/tasks.md rename to openspec/changes/archive/2026-04-13-complete-turn-orchestration/tasks.md diff --git a/openspec/archive/2026-04-13-expose-agent-control-contracts/.openspec.yaml b/openspec/changes/archive/2026-04-13-expose-agent-control-contracts/.openspec.yaml similarity index 100% rename from openspec/archive/2026-04-13-expose-agent-control-contracts/.openspec.yaml rename to openspec/changes/archive/2026-04-13-expose-agent-control-contracts/.openspec.yaml diff --git a/openspec/archive/2026-04-13-expose-agent-control-contracts/design.md b/openspec/changes/archive/2026-04-13-expose-agent-control-contracts/design.md similarity index 100% rename from openspec/archive/2026-04-13-expose-agent-control-contracts/design.md rename to openspec/changes/archive/2026-04-13-expose-agent-control-contracts/design.md diff --git a/openspec/archive/2026-04-13-expose-agent-control-contracts/proposal.md b/openspec/changes/archive/2026-04-13-expose-agent-control-contracts/proposal.md similarity index 100% rename from openspec/archive/2026-04-13-expose-agent-control-contracts/proposal.md rename to openspec/changes/archive/2026-04-13-expose-agent-control-contracts/proposal.md diff --git a/openspec/archive/2026-04-13-expose-agent-control-contracts/specs/agent-delivery-contracts/spec.md b/openspec/changes/archive/2026-04-13-expose-agent-control-contracts/specs/agent-delivery-contracts/spec.md similarity index 100% rename from openspec/archive/2026-04-13-expose-agent-control-contracts/specs/agent-delivery-contracts/spec.md rename to openspec/changes/archive/2026-04-13-expose-agent-control-contracts/specs/agent-delivery-contracts/spec.md diff --git a/openspec/archive/2026-04-13-expose-agent-control-contracts/specs/application-use-cases/spec.md b/openspec/changes/archive/2026-04-13-expose-agent-control-contracts/specs/application-use-cases/spec.md similarity index 100% rename from openspec/archive/2026-04-13-expose-agent-control-contracts/specs/application-use-cases/spec.md rename to openspec/changes/archive/2026-04-13-expose-agent-control-contracts/specs/application-use-cases/spec.md diff --git a/openspec/archive/2026-04-13-expose-agent-control-contracts/specs/kernel/spec.md b/openspec/changes/archive/2026-04-13-expose-agent-control-contracts/specs/kernel/spec.md similarity index 100% rename from openspec/archive/2026-04-13-expose-agent-control-contracts/specs/kernel/spec.md rename to openspec/changes/archive/2026-04-13-expose-agent-control-contracts/specs/kernel/spec.md diff --git a/openspec/archive/2026-04-13-expose-agent-control-contracts/specs/subrun-status-contracts/spec.md b/openspec/changes/archive/2026-04-13-expose-agent-control-contracts/specs/subrun-status-contracts/spec.md similarity index 100% rename from openspec/archive/2026-04-13-expose-agent-control-contracts/specs/subrun-status-contracts/spec.md rename to openspec/changes/archive/2026-04-13-expose-agent-control-contracts/specs/subrun-status-contracts/spec.md diff --git a/openspec/archive/2026-04-13-expose-agent-control-contracts/tasks.md b/openspec/changes/archive/2026-04-13-expose-agent-control-contracts/tasks.md similarity index 100% rename from openspec/archive/2026-04-13-expose-agent-control-contracts/tasks.md rename to openspec/changes/archive/2026-04-13-expose-agent-control-contracts/tasks.md diff --git a/openspec/archive/2026-04-13-implement-agent-execution-use-cases/.openspec.yaml b/openspec/changes/archive/2026-04-13-implement-agent-execution-use-cases/.openspec.yaml similarity index 100% rename from openspec/archive/2026-04-13-implement-agent-execution-use-cases/.openspec.yaml rename to openspec/changes/archive/2026-04-13-implement-agent-execution-use-cases/.openspec.yaml diff --git a/openspec/archive/2026-04-13-implement-agent-execution-use-cases/design.md b/openspec/changes/archive/2026-04-13-implement-agent-execution-use-cases/design.md similarity index 100% rename from openspec/archive/2026-04-13-implement-agent-execution-use-cases/design.md rename to openspec/changes/archive/2026-04-13-implement-agent-execution-use-cases/design.md diff --git a/openspec/archive/2026-04-13-implement-agent-execution-use-cases/proposal.md b/openspec/changes/archive/2026-04-13-implement-agent-execution-use-cases/proposal.md similarity index 100% rename from openspec/archive/2026-04-13-implement-agent-execution-use-cases/proposal.md rename to openspec/changes/archive/2026-04-13-implement-agent-execution-use-cases/proposal.md diff --git a/openspec/archive/2026-04-13-implement-agent-execution-use-cases/specs/agent-profile-resolution/spec.md b/openspec/changes/archive/2026-04-13-implement-agent-execution-use-cases/specs/agent-profile-resolution/spec.md similarity index 100% rename from openspec/archive/2026-04-13-implement-agent-execution-use-cases/specs/agent-profile-resolution/spec.md rename to openspec/changes/archive/2026-04-13-implement-agent-execution-use-cases/specs/agent-profile-resolution/spec.md diff --git a/openspec/archive/2026-04-13-implement-agent-execution-use-cases/specs/application-use-cases/spec.md b/openspec/changes/archive/2026-04-13-implement-agent-execution-use-cases/specs/application-use-cases/spec.md similarity index 100% rename from openspec/archive/2026-04-13-implement-agent-execution-use-cases/specs/application-use-cases/spec.md rename to openspec/changes/archive/2026-04-13-implement-agent-execution-use-cases/specs/application-use-cases/spec.md diff --git a/openspec/archive/2026-04-13-implement-agent-execution-use-cases/specs/kernel/spec.md b/openspec/changes/archive/2026-04-13-implement-agent-execution-use-cases/specs/kernel/spec.md similarity index 100% rename from openspec/archive/2026-04-13-implement-agent-execution-use-cases/specs/kernel/spec.md rename to openspec/changes/archive/2026-04-13-implement-agent-execution-use-cases/specs/kernel/spec.md diff --git a/openspec/archive/2026-04-13-implement-agent-execution-use-cases/specs/root-agent-execution/spec.md b/openspec/changes/archive/2026-04-13-implement-agent-execution-use-cases/specs/root-agent-execution/spec.md similarity index 100% rename from openspec/archive/2026-04-13-implement-agent-execution-use-cases/specs/root-agent-execution/spec.md rename to openspec/changes/archive/2026-04-13-implement-agent-execution-use-cases/specs/root-agent-execution/spec.md diff --git a/openspec/archive/2026-04-13-implement-agent-execution-use-cases/specs/subagent-execution/spec.md b/openspec/changes/archive/2026-04-13-implement-agent-execution-use-cases/specs/subagent-execution/spec.md similarity index 100% rename from openspec/archive/2026-04-13-implement-agent-execution-use-cases/specs/subagent-execution/spec.md rename to openspec/changes/archive/2026-04-13-implement-agent-execution-use-cases/specs/subagent-execution/spec.md diff --git a/openspec/archive/2026-04-13-implement-agent-execution-use-cases/tasks.md b/openspec/changes/archive/2026-04-13-implement-agent-execution-use-cases/tasks.md similarity index 100% rename from openspec/archive/2026-04-13-implement-agent-execution-use-cases/tasks.md rename to openspec/changes/archive/2026-04-13-implement-agent-execution-use-cases/tasks.md diff --git a/openspec/archive/2026-04-13-integrate-plugin-capability-surface/.openspec.yaml b/openspec/changes/archive/2026-04-13-integrate-plugin-capability-surface/.openspec.yaml similarity index 100% rename from openspec/archive/2026-04-13-integrate-plugin-capability-surface/.openspec.yaml rename to openspec/changes/archive/2026-04-13-integrate-plugin-capability-surface/.openspec.yaml diff --git a/openspec/archive/2026-04-13-integrate-plugin-capability-surface/design.md b/openspec/changes/archive/2026-04-13-integrate-plugin-capability-surface/design.md similarity index 100% rename from openspec/archive/2026-04-13-integrate-plugin-capability-surface/design.md rename to openspec/changes/archive/2026-04-13-integrate-plugin-capability-surface/design.md diff --git a/openspec/archive/2026-04-13-integrate-plugin-capability-surface/proposal.md b/openspec/changes/archive/2026-04-13-integrate-plugin-capability-surface/proposal.md similarity index 100% rename from openspec/archive/2026-04-13-integrate-plugin-capability-surface/proposal.md rename to openspec/changes/archive/2026-04-13-integrate-plugin-capability-surface/proposal.md diff --git a/openspec/archive/2026-04-13-integrate-plugin-capability-surface/specs/application-use-cases/spec.md b/openspec/changes/archive/2026-04-13-integrate-plugin-capability-surface/specs/application-use-cases/spec.md similarity index 100% rename from openspec/archive/2026-04-13-integrate-plugin-capability-surface/specs/application-use-cases/spec.md rename to openspec/changes/archive/2026-04-13-integrate-plugin-capability-surface/specs/application-use-cases/spec.md diff --git a/openspec/archive/2026-04-13-integrate-plugin-capability-surface/specs/kernel/spec.md b/openspec/changes/archive/2026-04-13-integrate-plugin-capability-surface/specs/kernel/spec.md similarity index 100% rename from openspec/archive/2026-04-13-integrate-plugin-capability-surface/specs/kernel/spec.md rename to openspec/changes/archive/2026-04-13-integrate-plugin-capability-surface/specs/kernel/spec.md diff --git a/openspec/archive/2026-04-13-integrate-plugin-capability-surface/specs/plugin-capability-surface/spec.md b/openspec/changes/archive/2026-04-13-integrate-plugin-capability-surface/specs/plugin-capability-surface/spec.md similarity index 100% rename from openspec/archive/2026-04-13-integrate-plugin-capability-surface/specs/plugin-capability-surface/spec.md rename to openspec/changes/archive/2026-04-13-integrate-plugin-capability-surface/specs/plugin-capability-surface/spec.md diff --git a/openspec/archive/2026-04-13-integrate-plugin-capability-surface/specs/plugin-governance-lifecycle/spec.md b/openspec/changes/archive/2026-04-13-integrate-plugin-capability-surface/specs/plugin-governance-lifecycle/spec.md similarity index 100% rename from openspec/archive/2026-04-13-integrate-plugin-capability-surface/specs/plugin-governance-lifecycle/spec.md rename to openspec/changes/archive/2026-04-13-integrate-plugin-capability-surface/specs/plugin-governance-lifecycle/spec.md diff --git a/openspec/archive/2026-04-13-integrate-plugin-capability-surface/tasks.md b/openspec/changes/archive/2026-04-13-integrate-plugin-capability-surface/tasks.md similarity index 100% rename from openspec/archive/2026-04-13-integrate-plugin-capability-surface/tasks.md rename to openspec/changes/archive/2026-04-13-integrate-plugin-capability-surface/tasks.md diff --git a/openspec/archive/2026-04-13-modernize-composer-execution-controls/.openspec.yaml b/openspec/changes/archive/2026-04-13-modernize-composer-execution-controls/.openspec.yaml similarity index 100% rename from openspec/archive/2026-04-13-modernize-composer-execution-controls/.openspec.yaml rename to openspec/changes/archive/2026-04-13-modernize-composer-execution-controls/.openspec.yaml diff --git a/openspec/archive/2026-04-13-modernize-composer-execution-controls/design.md b/openspec/changes/archive/2026-04-13-modernize-composer-execution-controls/design.md similarity index 100% rename from openspec/archive/2026-04-13-modernize-composer-execution-controls/design.md rename to openspec/changes/archive/2026-04-13-modernize-composer-execution-controls/design.md diff --git a/openspec/archive/2026-04-13-modernize-composer-execution-controls/proposal.md b/openspec/changes/archive/2026-04-13-modernize-composer-execution-controls/proposal.md similarity index 100% rename from openspec/archive/2026-04-13-modernize-composer-execution-controls/proposal.md rename to openspec/changes/archive/2026-04-13-modernize-composer-execution-controls/proposal.md diff --git a/openspec/archive/2026-04-13-modernize-composer-execution-controls/specs/composer-execution-controls/spec.md b/openspec/changes/archive/2026-04-13-modernize-composer-execution-controls/specs/composer-execution-controls/spec.md similarity index 100% rename from openspec/archive/2026-04-13-modernize-composer-execution-controls/specs/composer-execution-controls/spec.md rename to openspec/changes/archive/2026-04-13-modernize-composer-execution-controls/specs/composer-execution-controls/spec.md diff --git a/openspec/archive/2026-04-13-modernize-composer-execution-controls/specs/root-agent-execution/spec.md b/openspec/changes/archive/2026-04-13-modernize-composer-execution-controls/specs/root-agent-execution/spec.md similarity index 100% rename from openspec/archive/2026-04-13-modernize-composer-execution-controls/specs/root-agent-execution/spec.md rename to openspec/changes/archive/2026-04-13-modernize-composer-execution-controls/specs/root-agent-execution/spec.md diff --git a/openspec/archive/2026-04-13-modernize-composer-execution-controls/specs/subagent-execution/spec.md b/openspec/changes/archive/2026-04-13-modernize-composer-execution-controls/specs/subagent-execution/spec.md similarity index 100% rename from openspec/archive/2026-04-13-modernize-composer-execution-controls/specs/subagent-execution/spec.md rename to openspec/changes/archive/2026-04-13-modernize-composer-execution-controls/specs/subagent-execution/spec.md diff --git a/openspec/archive/2026-04-13-modernize-composer-execution-controls/specs/turn-budget-governance/spec.md b/openspec/changes/archive/2026-04-13-modernize-composer-execution-controls/specs/turn-budget-governance/spec.md similarity index 100% rename from openspec/archive/2026-04-13-modernize-composer-execution-controls/specs/turn-budget-governance/spec.md rename to openspec/changes/archive/2026-04-13-modernize-composer-execution-controls/specs/turn-budget-governance/spec.md diff --git a/openspec/archive/2026-04-13-modernize-composer-execution-controls/tasks.md b/openspec/changes/archive/2026-04-13-modernize-composer-execution-controls/tasks.md similarity index 100% rename from openspec/archive/2026-04-13-modernize-composer-execution-controls/tasks.md rename to openspec/changes/archive/2026-04-13-modernize-composer-execution-controls/tasks.md diff --git a/openspec/archive/2026-04-13-project-architecture-refactor/.openspec.yaml b/openspec/changes/archive/2026-04-13-project-architecture-refactor/.openspec.yaml similarity index 100% rename from openspec/archive/2026-04-13-project-architecture-refactor/.openspec.yaml rename to openspec/changes/archive/2026-04-13-project-architecture-refactor/.openspec.yaml diff --git a/openspec/archive/2026-04-13-project-architecture-refactor/design.md b/openspec/changes/archive/2026-04-13-project-architecture-refactor/design.md similarity index 100% rename from openspec/archive/2026-04-13-project-architecture-refactor/design.md rename to openspec/changes/archive/2026-04-13-project-architecture-refactor/design.md diff --git a/openspec/archive/2026-04-13-project-architecture-refactor/proposal.md b/openspec/changes/archive/2026-04-13-project-architecture-refactor/proposal.md similarity index 100% rename from openspec/archive/2026-04-13-project-architecture-refactor/proposal.md rename to openspec/changes/archive/2026-04-13-project-architecture-refactor/proposal.md diff --git a/openspec/archive/2026-04-13-project-architecture-refactor/runtime-migration-baseline.md b/openspec/changes/archive/2026-04-13-project-architecture-refactor/runtime-migration-baseline.md similarity index 100% rename from openspec/archive/2026-04-13-project-architecture-refactor/runtime-migration-baseline.md rename to openspec/changes/archive/2026-04-13-project-architecture-refactor/runtime-migration-baseline.md diff --git a/openspec/archive/2026-04-13-project-architecture-refactor/specs/adapter-contracts/spec.md b/openspec/changes/archive/2026-04-13-project-architecture-refactor/specs/adapter-contracts/spec.md similarity index 100% rename from openspec/archive/2026-04-13-project-architecture-refactor/specs/adapter-contracts/spec.md rename to openspec/changes/archive/2026-04-13-project-architecture-refactor/specs/adapter-contracts/spec.md diff --git a/openspec/archive/2026-04-13-project-architecture-refactor/specs/application-use-cases/spec.md b/openspec/changes/archive/2026-04-13-project-architecture-refactor/specs/application-use-cases/spec.md similarity index 100% rename from openspec/archive/2026-04-13-project-architecture-refactor/specs/application-use-cases/spec.md rename to openspec/changes/archive/2026-04-13-project-architecture-refactor/specs/application-use-cases/spec.md diff --git a/openspec/archive/2026-04-13-project-architecture-refactor/specs/capability-semantic-model/spec.md b/openspec/changes/archive/2026-04-13-project-architecture-refactor/specs/capability-semantic-model/spec.md similarity index 100% rename from openspec/archive/2026-04-13-project-architecture-refactor/specs/capability-semantic-model/spec.md rename to openspec/changes/archive/2026-04-13-project-architecture-refactor/specs/capability-semantic-model/spec.md diff --git a/openspec/archive/2026-04-13-project-architecture-refactor/specs/kernel/spec.md b/openspec/changes/archive/2026-04-13-project-architecture-refactor/specs/kernel/spec.md similarity index 100% rename from openspec/archive/2026-04-13-project-architecture-refactor/specs/kernel/spec.md rename to openspec/changes/archive/2026-04-13-project-architecture-refactor/specs/kernel/spec.md diff --git a/openspec/archive/2026-04-13-project-architecture-refactor/specs/session-runtime/spec.md b/openspec/changes/archive/2026-04-13-project-architecture-refactor/specs/session-runtime/spec.md similarity index 100% rename from openspec/archive/2026-04-13-project-architecture-refactor/specs/session-runtime/spec.md rename to openspec/changes/archive/2026-04-13-project-architecture-refactor/specs/session-runtime/spec.md diff --git a/openspec/archive/2026-04-13-project-architecture-refactor/tasks.md b/openspec/changes/archive/2026-04-13-project-architecture-refactor/tasks.md similarity index 100% rename from openspec/archive/2026-04-13-project-architecture-refactor/tasks.md rename to openspec/changes/archive/2026-04-13-project-architecture-refactor/tasks.md diff --git a/openspec/archive/2026-04-13-rationalize-discovery-and-skill-surface/.openspec.yaml b/openspec/changes/archive/2026-04-13-rationalize-discovery-and-skill-surface/.openspec.yaml similarity index 100% rename from openspec/archive/2026-04-13-rationalize-discovery-and-skill-surface/.openspec.yaml rename to openspec/changes/archive/2026-04-13-rationalize-discovery-and-skill-surface/.openspec.yaml diff --git a/openspec/archive/2026-04-13-rationalize-discovery-and-skill-surface/design.md b/openspec/changes/archive/2026-04-13-rationalize-discovery-and-skill-surface/design.md similarity index 100% rename from openspec/archive/2026-04-13-rationalize-discovery-and-skill-surface/design.md rename to openspec/changes/archive/2026-04-13-rationalize-discovery-and-skill-surface/design.md diff --git a/openspec/archive/2026-04-13-rationalize-discovery-and-skill-surface/proposal.md b/openspec/changes/archive/2026-04-13-rationalize-discovery-and-skill-surface/proposal.md similarity index 100% rename from openspec/archive/2026-04-13-rationalize-discovery-and-skill-surface/proposal.md rename to openspec/changes/archive/2026-04-13-rationalize-discovery-and-skill-surface/proposal.md diff --git a/openspec/archive/2026-04-13-rationalize-discovery-and-skill-surface/specs/capability-semantic-model/spec.md b/openspec/changes/archive/2026-04-13-rationalize-discovery-and-skill-surface/specs/capability-semantic-model/spec.md similarity index 100% rename from openspec/archive/2026-04-13-rationalize-discovery-and-skill-surface/specs/capability-semantic-model/spec.md rename to openspec/changes/archive/2026-04-13-rationalize-discovery-and-skill-surface/specs/capability-semantic-model/spec.md diff --git a/openspec/archive/2026-04-13-rationalize-discovery-and-skill-surface/specs/tool-and-skill-discovery/spec.md b/openspec/changes/archive/2026-04-13-rationalize-discovery-and-skill-surface/specs/tool-and-skill-discovery/spec.md similarity index 100% rename from openspec/archive/2026-04-13-rationalize-discovery-and-skill-surface/specs/tool-and-skill-discovery/spec.md rename to openspec/changes/archive/2026-04-13-rationalize-discovery-and-skill-surface/specs/tool-and-skill-discovery/spec.md diff --git a/openspec/archive/2026-04-13-rationalize-discovery-and-skill-surface/tasks.md b/openspec/changes/archive/2026-04-13-rationalize-discovery-and-skill-surface/tasks.md similarity index 100% rename from openspec/archive/2026-04-13-rationalize-discovery-and-skill-surface/tasks.md rename to openspec/changes/archive/2026-04-13-rationalize-discovery-and-skill-surface/tasks.md diff --git a/openspec/archive/2026-04-13-reconcile-root-and-subagent-profile-driven-execution/.openspec.yaml b/openspec/changes/archive/2026-04-13-reconcile-root-and-subagent-profile-driven-execution/.openspec.yaml similarity index 100% rename from openspec/archive/2026-04-13-reconcile-root-and-subagent-profile-driven-execution/.openspec.yaml rename to openspec/changes/archive/2026-04-13-reconcile-root-and-subagent-profile-driven-execution/.openspec.yaml diff --git a/openspec/archive/2026-04-13-reconcile-root-and-subagent-profile-driven-execution/design.md b/openspec/changes/archive/2026-04-13-reconcile-root-and-subagent-profile-driven-execution/design.md similarity index 100% rename from openspec/archive/2026-04-13-reconcile-root-and-subagent-profile-driven-execution/design.md rename to openspec/changes/archive/2026-04-13-reconcile-root-and-subagent-profile-driven-execution/design.md diff --git a/openspec/archive/2026-04-13-reconcile-root-and-subagent-profile-driven-execution/proposal.md b/openspec/changes/archive/2026-04-13-reconcile-root-and-subagent-profile-driven-execution/proposal.md similarity index 100% rename from openspec/archive/2026-04-13-reconcile-root-and-subagent-profile-driven-execution/proposal.md rename to openspec/changes/archive/2026-04-13-reconcile-root-and-subagent-profile-driven-execution/proposal.md diff --git a/openspec/archive/2026-04-13-reconcile-root-and-subagent-profile-driven-execution/specs/agent-execution/spec.md b/openspec/changes/archive/2026-04-13-reconcile-root-and-subagent-profile-driven-execution/specs/agent-execution/spec.md similarity index 100% rename from openspec/archive/2026-04-13-reconcile-root-and-subagent-profile-driven-execution/specs/agent-execution/spec.md rename to openspec/changes/archive/2026-04-13-reconcile-root-and-subagent-profile-driven-execution/specs/agent-execution/spec.md diff --git a/openspec/archive/2026-04-13-reconcile-root-and-subagent-profile-driven-execution/specs/agent-profile-resolution/spec.md b/openspec/changes/archive/2026-04-13-reconcile-root-and-subagent-profile-driven-execution/specs/agent-profile-resolution/spec.md similarity index 100% rename from openspec/archive/2026-04-13-reconcile-root-and-subagent-profile-driven-execution/specs/agent-profile-resolution/spec.md rename to openspec/changes/archive/2026-04-13-reconcile-root-and-subagent-profile-driven-execution/specs/agent-profile-resolution/spec.md diff --git a/openspec/archive/2026-04-13-reconcile-root-and-subagent-profile-driven-execution/specs/root-agent-execution/spec.md b/openspec/changes/archive/2026-04-13-reconcile-root-and-subagent-profile-driven-execution/specs/root-agent-execution/spec.md similarity index 100% rename from openspec/archive/2026-04-13-reconcile-root-and-subagent-profile-driven-execution/specs/root-agent-execution/spec.md rename to openspec/changes/archive/2026-04-13-reconcile-root-and-subagent-profile-driven-execution/specs/root-agent-execution/spec.md diff --git a/openspec/archive/2026-04-13-reconcile-root-and-subagent-profile-driven-execution/specs/subagent-execution/spec.md b/openspec/changes/archive/2026-04-13-reconcile-root-and-subagent-profile-driven-execution/specs/subagent-execution/spec.md similarity index 100% rename from openspec/archive/2026-04-13-reconcile-root-and-subagent-profile-driven-execution/specs/subagent-execution/spec.md rename to openspec/changes/archive/2026-04-13-reconcile-root-and-subagent-profile-driven-execution/specs/subagent-execution/spec.md diff --git a/openspec/archive/2026-04-13-reconcile-root-and-subagent-profile-driven-execution/tasks.md b/openspec/changes/archive/2026-04-13-reconcile-root-and-subagent-profile-driven-execution/tasks.md similarity index 100% rename from openspec/archive/2026-04-13-reconcile-root-and-subagent-profile-driven-execution/tasks.md rename to openspec/changes/archive/2026-04-13-reconcile-root-and-subagent-profile-driven-execution/tasks.md diff --git a/openspec/archive/2026-04-13-restore-child-delivery-parent-wake-pipeline/.openspec.yaml b/openspec/changes/archive/2026-04-13-restore-child-delivery-parent-wake-pipeline/.openspec.yaml similarity index 100% rename from openspec/archive/2026-04-13-restore-child-delivery-parent-wake-pipeline/.openspec.yaml rename to openspec/changes/archive/2026-04-13-restore-child-delivery-parent-wake-pipeline/.openspec.yaml diff --git a/openspec/archive/2026-04-13-restore-child-delivery-parent-wake-pipeline/design.md b/openspec/changes/archive/2026-04-13-restore-child-delivery-parent-wake-pipeline/design.md similarity index 100% rename from openspec/archive/2026-04-13-restore-child-delivery-parent-wake-pipeline/design.md rename to openspec/changes/archive/2026-04-13-restore-child-delivery-parent-wake-pipeline/design.md diff --git a/openspec/archive/2026-04-13-restore-child-delivery-parent-wake-pipeline/proposal.md b/openspec/changes/archive/2026-04-13-restore-child-delivery-parent-wake-pipeline/proposal.md similarity index 100% rename from openspec/archive/2026-04-13-restore-child-delivery-parent-wake-pipeline/proposal.md rename to openspec/changes/archive/2026-04-13-restore-child-delivery-parent-wake-pipeline/proposal.md diff --git a/openspec/archive/2026-04-13-restore-child-delivery-parent-wake-pipeline/specs/agent-delivery-contracts/spec.md b/openspec/changes/archive/2026-04-13-restore-child-delivery-parent-wake-pipeline/specs/agent-delivery-contracts/spec.md similarity index 100% rename from openspec/archive/2026-04-13-restore-child-delivery-parent-wake-pipeline/specs/agent-delivery-contracts/spec.md rename to openspec/changes/archive/2026-04-13-restore-child-delivery-parent-wake-pipeline/specs/agent-delivery-contracts/spec.md diff --git a/openspec/archive/2026-04-13-restore-child-delivery-parent-wake-pipeline/specs/agent-execution/spec.md b/openspec/changes/archive/2026-04-13-restore-child-delivery-parent-wake-pipeline/specs/agent-execution/spec.md similarity index 100% rename from openspec/archive/2026-04-13-restore-child-delivery-parent-wake-pipeline/specs/agent-execution/spec.md rename to openspec/changes/archive/2026-04-13-restore-child-delivery-parent-wake-pipeline/specs/agent-execution/spec.md diff --git a/openspec/archive/2026-04-13-restore-child-delivery-parent-wake-pipeline/specs/agent-lifecycle/spec.md b/openspec/changes/archive/2026-04-13-restore-child-delivery-parent-wake-pipeline/specs/agent-lifecycle/spec.md similarity index 100% rename from openspec/archive/2026-04-13-restore-child-delivery-parent-wake-pipeline/specs/agent-lifecycle/spec.md rename to openspec/changes/archive/2026-04-13-restore-child-delivery-parent-wake-pipeline/specs/agent-lifecycle/spec.md diff --git a/openspec/archive/2026-04-13-restore-child-delivery-parent-wake-pipeline/specs/subagent-execution/spec.md b/openspec/changes/archive/2026-04-13-restore-child-delivery-parent-wake-pipeline/specs/subagent-execution/spec.md similarity index 100% rename from openspec/archive/2026-04-13-restore-child-delivery-parent-wake-pipeline/specs/subagent-execution/spec.md rename to openspec/changes/archive/2026-04-13-restore-child-delivery-parent-wake-pipeline/specs/subagent-execution/spec.md diff --git a/openspec/archive/2026-04-13-restore-child-delivery-parent-wake-pipeline/tasks.md b/openspec/changes/archive/2026-04-13-restore-child-delivery-parent-wake-pipeline/tasks.md similarity index 100% rename from openspec/archive/2026-04-13-restore-child-delivery-parent-wake-pipeline/tasks.md rename to openspec/changes/archive/2026-04-13-restore-child-delivery-parent-wake-pipeline/tasks.md diff --git a/openspec/archive/2026-04-13-restore-runtime-observability-pipeline/.openspec.yaml b/openspec/changes/archive/2026-04-13-restore-runtime-observability-pipeline/.openspec.yaml similarity index 100% rename from openspec/archive/2026-04-13-restore-runtime-observability-pipeline/.openspec.yaml rename to openspec/changes/archive/2026-04-13-restore-runtime-observability-pipeline/.openspec.yaml diff --git a/openspec/archive/2026-04-13-restore-runtime-observability-pipeline/design.md b/openspec/changes/archive/2026-04-13-restore-runtime-observability-pipeline/design.md similarity index 100% rename from openspec/archive/2026-04-13-restore-runtime-observability-pipeline/design.md rename to openspec/changes/archive/2026-04-13-restore-runtime-observability-pipeline/design.md diff --git a/openspec/archive/2026-04-13-restore-runtime-observability-pipeline/proposal.md b/openspec/changes/archive/2026-04-13-restore-runtime-observability-pipeline/proposal.md similarity index 100% rename from openspec/archive/2026-04-13-restore-runtime-observability-pipeline/proposal.md rename to openspec/changes/archive/2026-04-13-restore-runtime-observability-pipeline/proposal.md diff --git a/openspec/archive/2026-04-13-restore-runtime-observability-pipeline/specs/runtime-observability-pipeline/spec.md b/openspec/changes/archive/2026-04-13-restore-runtime-observability-pipeline/specs/runtime-observability-pipeline/spec.md similarity index 100% rename from openspec/archive/2026-04-13-restore-runtime-observability-pipeline/specs/runtime-observability-pipeline/spec.md rename to openspec/changes/archive/2026-04-13-restore-runtime-observability-pipeline/specs/runtime-observability-pipeline/spec.md diff --git a/openspec/archive/2026-04-13-restore-runtime-observability-pipeline/specs/turn-observability/spec.md b/openspec/changes/archive/2026-04-13-restore-runtime-observability-pipeline/specs/turn-observability/spec.md similarity index 100% rename from openspec/archive/2026-04-13-restore-runtime-observability-pipeline/specs/turn-observability/spec.md rename to openspec/changes/archive/2026-04-13-restore-runtime-observability-pipeline/specs/turn-observability/spec.md diff --git a/openspec/archive/2026-04-13-restore-runtime-observability-pipeline/tasks.md b/openspec/changes/archive/2026-04-13-restore-runtime-observability-pipeline/tasks.md similarity index 100% rename from openspec/archive/2026-04-13-restore-runtime-observability-pipeline/tasks.md rename to openspec/changes/archive/2026-04-13-restore-runtime-observability-pipeline/tasks.md diff --git a/openspec/archive/2026-04-13-runtime-migration-complete/.openspec.yaml b/openspec/changes/archive/2026-04-13-runtime-migration-complete/.openspec.yaml similarity index 100% rename from openspec/archive/2026-04-13-runtime-migration-complete/.openspec.yaml rename to openspec/changes/archive/2026-04-13-runtime-migration-complete/.openspec.yaml diff --git a/openspec/archive/2026-04-13-runtime-migration-complete/design.md b/openspec/changes/archive/2026-04-13-runtime-migration-complete/design.md similarity index 100% rename from openspec/archive/2026-04-13-runtime-migration-complete/design.md rename to openspec/changes/archive/2026-04-13-runtime-migration-complete/design.md diff --git a/openspec/archive/2026-04-13-runtime-migration-complete/proposal.md b/openspec/changes/archive/2026-04-13-runtime-migration-complete/proposal.md similarity index 100% rename from openspec/archive/2026-04-13-runtime-migration-complete/proposal.md rename to openspec/changes/archive/2026-04-13-runtime-migration-complete/proposal.md diff --git a/openspec/archive/2026-04-13-runtime-migration-complete/specs/agent-execution/spec.md b/openspec/changes/archive/2026-04-13-runtime-migration-complete/specs/agent-execution/spec.md similarity index 100% rename from openspec/archive/2026-04-13-runtime-migration-complete/specs/agent-execution/spec.md rename to openspec/changes/archive/2026-04-13-runtime-migration-complete/specs/agent-execution/spec.md diff --git a/openspec/archive/2026-04-13-runtime-migration-complete/specs/agent-lifecycle/spec.md b/openspec/changes/archive/2026-04-13-runtime-migration-complete/specs/agent-lifecycle/spec.md similarity index 100% rename from openspec/archive/2026-04-13-runtime-migration-complete/specs/agent-lifecycle/spec.md rename to openspec/changes/archive/2026-04-13-runtime-migration-complete/specs/agent-lifecycle/spec.md diff --git a/openspec/archive/2026-04-13-runtime-migration-complete/specs/auxiliary-features/spec.md b/openspec/changes/archive/2026-04-13-runtime-migration-complete/specs/auxiliary-features/spec.md similarity index 100% rename from openspec/archive/2026-04-13-runtime-migration-complete/specs/auxiliary-features/spec.md rename to openspec/changes/archive/2026-04-13-runtime-migration-complete/specs/auxiliary-features/spec.md diff --git a/openspec/archive/2026-04-13-runtime-migration-complete/specs/plugin-integration/spec.md b/openspec/changes/archive/2026-04-13-runtime-migration-complete/specs/plugin-integration/spec.md similarity index 100% rename from openspec/archive/2026-04-13-runtime-migration-complete/specs/plugin-integration/spec.md rename to openspec/changes/archive/2026-04-13-runtime-migration-complete/specs/plugin-integration/spec.md diff --git a/openspec/archive/2026-04-13-runtime-migration-complete/specs/session-persistence/spec.md b/openspec/changes/archive/2026-04-13-runtime-migration-complete/specs/session-persistence/spec.md similarity index 100% rename from openspec/archive/2026-04-13-runtime-migration-complete/specs/session-persistence/spec.md rename to openspec/changes/archive/2026-04-13-runtime-migration-complete/specs/session-persistence/spec.md diff --git a/openspec/archive/2026-04-13-runtime-migration-complete/specs/turn-orchestration/spec.md b/openspec/changes/archive/2026-04-13-runtime-migration-complete/specs/turn-orchestration/spec.md similarity index 100% rename from openspec/archive/2026-04-13-runtime-migration-complete/specs/turn-orchestration/spec.md rename to openspec/changes/archive/2026-04-13-runtime-migration-complete/specs/turn-orchestration/spec.md diff --git a/openspec/archive/2026-04-13-runtime-migration-complete/tasks.md b/openspec/changes/archive/2026-04-13-runtime-migration-complete/tasks.md similarity index 100% rename from openspec/archive/2026-04-13-runtime-migration-complete/tasks.md rename to openspec/changes/archive/2026-04-13-runtime-migration-complete/tasks.md diff --git a/openspec/archive/2026-04-13-unify-governance-and-reload-surface/.openspec.yaml b/openspec/changes/archive/2026-04-13-unify-governance-and-reload-surface/.openspec.yaml similarity index 100% rename from openspec/archive/2026-04-13-unify-governance-and-reload-surface/.openspec.yaml rename to openspec/changes/archive/2026-04-13-unify-governance-and-reload-surface/.openspec.yaml diff --git a/openspec/archive/2026-04-13-unify-governance-and-reload-surface/design.md b/openspec/changes/archive/2026-04-13-unify-governance-and-reload-surface/design.md similarity index 100% rename from openspec/archive/2026-04-13-unify-governance-and-reload-surface/design.md rename to openspec/changes/archive/2026-04-13-unify-governance-and-reload-surface/design.md diff --git a/openspec/archive/2026-04-13-unify-governance-and-reload-surface/proposal.md b/openspec/changes/archive/2026-04-13-unify-governance-and-reload-surface/proposal.md similarity index 100% rename from openspec/archive/2026-04-13-unify-governance-and-reload-surface/proposal.md rename to openspec/changes/archive/2026-04-13-unify-governance-and-reload-surface/proposal.md diff --git a/openspec/archive/2026-04-13-unify-governance-and-reload-surface/specs/application-use-cases/spec.md b/openspec/changes/archive/2026-04-13-unify-governance-and-reload-surface/specs/application-use-cases/spec.md similarity index 100% rename from openspec/archive/2026-04-13-unify-governance-and-reload-surface/specs/application-use-cases/spec.md rename to openspec/changes/archive/2026-04-13-unify-governance-and-reload-surface/specs/application-use-cases/spec.md diff --git a/openspec/archive/2026-04-13-unify-governance-and-reload-surface/specs/governance-reload-surface/spec.md b/openspec/changes/archive/2026-04-13-unify-governance-and-reload-surface/specs/governance-reload-surface/spec.md similarity index 100% rename from openspec/archive/2026-04-13-unify-governance-and-reload-surface/specs/governance-reload-surface/spec.md rename to openspec/changes/archive/2026-04-13-unify-governance-and-reload-surface/specs/governance-reload-surface/spec.md diff --git a/openspec/archive/2026-04-13-unify-governance-and-reload-surface/specs/plugin-capability-surface/spec.md b/openspec/changes/archive/2026-04-13-unify-governance-and-reload-surface/specs/plugin-capability-surface/spec.md similarity index 100% rename from openspec/archive/2026-04-13-unify-governance-and-reload-surface/specs/plugin-capability-surface/spec.md rename to openspec/changes/archive/2026-04-13-unify-governance-and-reload-surface/specs/plugin-capability-surface/spec.md diff --git a/openspec/archive/2026-04-13-unify-governance-and-reload-surface/specs/plugin-governance-lifecycle/spec.md b/openspec/changes/archive/2026-04-13-unify-governance-and-reload-surface/specs/plugin-governance-lifecycle/spec.md similarity index 100% rename from openspec/archive/2026-04-13-unify-governance-and-reload-surface/specs/plugin-governance-lifecycle/spec.md rename to openspec/changes/archive/2026-04-13-unify-governance-and-reload-surface/specs/plugin-governance-lifecycle/spec.md diff --git a/openspec/archive/2026-04-13-unify-governance-and-reload-surface/tasks.md b/openspec/changes/archive/2026-04-13-unify-governance-and-reload-surface/tasks.md similarity index 100% rename from openspec/archive/2026-04-13-unify-governance-and-reload-surface/tasks.md rename to openspec/changes/archive/2026-04-13-unify-governance-and-reload-surface/tasks.md diff --git a/openspec/archive/2026-04-13-wire-agent-profile-watch-invalidation/.openspec.yaml b/openspec/changes/archive/2026-04-13-wire-agent-profile-watch-invalidation/.openspec.yaml similarity index 100% rename from openspec/archive/2026-04-13-wire-agent-profile-watch-invalidation/.openspec.yaml rename to openspec/changes/archive/2026-04-13-wire-agent-profile-watch-invalidation/.openspec.yaml diff --git a/openspec/archive/2026-04-13-wire-agent-profile-watch-invalidation/design.md b/openspec/changes/archive/2026-04-13-wire-agent-profile-watch-invalidation/design.md similarity index 100% rename from openspec/archive/2026-04-13-wire-agent-profile-watch-invalidation/design.md rename to openspec/changes/archive/2026-04-13-wire-agent-profile-watch-invalidation/design.md diff --git a/openspec/archive/2026-04-13-wire-agent-profile-watch-invalidation/proposal.md b/openspec/changes/archive/2026-04-13-wire-agent-profile-watch-invalidation/proposal.md similarity index 100% rename from openspec/archive/2026-04-13-wire-agent-profile-watch-invalidation/proposal.md rename to openspec/changes/archive/2026-04-13-wire-agent-profile-watch-invalidation/proposal.md diff --git a/openspec/archive/2026-04-13-wire-agent-profile-watch-invalidation/specs/agent-profile-resolution/spec.md b/openspec/changes/archive/2026-04-13-wire-agent-profile-watch-invalidation/specs/agent-profile-resolution/spec.md similarity index 100% rename from openspec/archive/2026-04-13-wire-agent-profile-watch-invalidation/specs/agent-profile-resolution/spec.md rename to openspec/changes/archive/2026-04-13-wire-agent-profile-watch-invalidation/specs/agent-profile-resolution/spec.md diff --git a/openspec/archive/2026-04-13-wire-agent-profile-watch-invalidation/specs/agent-profile-watch-invalidation/spec.md b/openspec/changes/archive/2026-04-13-wire-agent-profile-watch-invalidation/specs/agent-profile-watch-invalidation/spec.md similarity index 100% rename from openspec/archive/2026-04-13-wire-agent-profile-watch-invalidation/specs/agent-profile-watch-invalidation/spec.md rename to openspec/changes/archive/2026-04-13-wire-agent-profile-watch-invalidation/specs/agent-profile-watch-invalidation/spec.md diff --git a/openspec/archive/2026-04-13-wire-agent-profile-watch-invalidation/tasks.md b/openspec/changes/archive/2026-04-13-wire-agent-profile-watch-invalidation/tasks.md similarity index 100% rename from openspec/archive/2026-04-13-wire-agent-profile-watch-invalidation/tasks.md rename to openspec/changes/archive/2026-04-13-wire-agent-profile-watch-invalidation/tasks.md diff --git a/openspec/archive/2026-04-14-add-step-local-tool-feedback-summaries/.openspec.yaml b/openspec/changes/archive/2026-04-14-add-step-local-tool-feedback-summaries/.openspec.yaml similarity index 100% rename from openspec/archive/2026-04-14-add-step-local-tool-feedback-summaries/.openspec.yaml rename to openspec/changes/archive/2026-04-14-add-step-local-tool-feedback-summaries/.openspec.yaml diff --git a/openspec/archive/2026-04-14-add-step-local-tool-feedback-summaries/design.md b/openspec/changes/archive/2026-04-14-add-step-local-tool-feedback-summaries/design.md similarity index 100% rename from openspec/archive/2026-04-14-add-step-local-tool-feedback-summaries/design.md rename to openspec/changes/archive/2026-04-14-add-step-local-tool-feedback-summaries/design.md diff --git a/openspec/archive/2026-04-14-add-step-local-tool-feedback-summaries/proposal.md b/openspec/changes/archive/2026-04-14-add-step-local-tool-feedback-summaries/proposal.md similarity index 100% rename from openspec/archive/2026-04-14-add-step-local-tool-feedback-summaries/proposal.md rename to openspec/changes/archive/2026-04-14-add-step-local-tool-feedback-summaries/proposal.md diff --git a/openspec/archive/2026-04-14-add-step-local-tool-feedback-summaries/specs/turn-budget-governance/spec.md b/openspec/changes/archive/2026-04-14-add-step-local-tool-feedback-summaries/specs/turn-budget-governance/spec.md similarity index 100% rename from openspec/archive/2026-04-14-add-step-local-tool-feedback-summaries/specs/turn-budget-governance/spec.md rename to openspec/changes/archive/2026-04-14-add-step-local-tool-feedback-summaries/specs/turn-budget-governance/spec.md diff --git a/openspec/archive/2026-04-14-add-step-local-tool-feedback-summaries/specs/turn-observability/spec.md b/openspec/changes/archive/2026-04-14-add-step-local-tool-feedback-summaries/specs/turn-observability/spec.md similarity index 100% rename from openspec/archive/2026-04-14-add-step-local-tool-feedback-summaries/specs/turn-observability/spec.md rename to openspec/changes/archive/2026-04-14-add-step-local-tool-feedback-summaries/specs/turn-observability/spec.md diff --git a/openspec/archive/2026-04-14-add-step-local-tool-feedback-summaries/specs/turn-orchestration/spec.md b/openspec/changes/archive/2026-04-14-add-step-local-tool-feedback-summaries/specs/turn-orchestration/spec.md similarity index 100% rename from openspec/archive/2026-04-14-add-step-local-tool-feedback-summaries/specs/turn-orchestration/spec.md rename to openspec/changes/archive/2026-04-14-add-step-local-tool-feedback-summaries/specs/turn-orchestration/spec.md diff --git a/openspec/archive/2026-04-14-add-step-local-tool-feedback-summaries/tasks.md b/openspec/changes/archive/2026-04-14-add-step-local-tool-feedback-summaries/tasks.md similarity index 100% rename from openspec/archive/2026-04-14-add-step-local-tool-feedback-summaries/tasks.md rename to openspec/changes/archive/2026-04-14-add-step-local-tool-feedback-summaries/tasks.md diff --git a/openspec/archive/2026-04-14-configurable-subagent-depth-limit/.openspec.yaml b/openspec/changes/archive/2026-04-14-configurable-subagent-depth-limit/.openspec.yaml similarity index 100% rename from openspec/archive/2026-04-14-configurable-subagent-depth-limit/.openspec.yaml rename to openspec/changes/archive/2026-04-14-configurable-subagent-depth-limit/.openspec.yaml diff --git a/openspec/archive/2026-04-14-configurable-subagent-depth-limit/proposal.md b/openspec/changes/archive/2026-04-14-configurable-subagent-depth-limit/proposal.md similarity index 100% rename from openspec/archive/2026-04-14-configurable-subagent-depth-limit/proposal.md rename to openspec/changes/archive/2026-04-14-configurable-subagent-depth-limit/proposal.md diff --git a/openspec/archive/2026-04-14-configurable-subagent-depth-limit/specs/subagent-execution/spec.md b/openspec/changes/archive/2026-04-14-configurable-subagent-depth-limit/specs/subagent-execution/spec.md similarity index 100% rename from openspec/archive/2026-04-14-configurable-subagent-depth-limit/specs/subagent-execution/spec.md rename to openspec/changes/archive/2026-04-14-configurable-subagent-depth-limit/specs/subagent-execution/spec.md diff --git a/openspec/archive/2026-04-14-configurable-subagent-depth-limit/tasks.md b/openspec/changes/archive/2026-04-14-configurable-subagent-depth-limit/tasks.md similarity index 100% rename from openspec/archive/2026-04-14-configurable-subagent-depth-limit/tasks.md rename to openspec/changes/archive/2026-04-14-configurable-subagent-depth-limit/tasks.md diff --git a/openspec/archive/2026-04-14-fix-nested-subagent-delivery-escalation/.openspec.yaml b/openspec/changes/archive/2026-04-14-fix-nested-subagent-delivery-escalation/.openspec.yaml similarity index 100% rename from openspec/archive/2026-04-14-fix-nested-subagent-delivery-escalation/.openspec.yaml rename to openspec/changes/archive/2026-04-14-fix-nested-subagent-delivery-escalation/.openspec.yaml diff --git a/openspec/archive/2026-04-14-fix-nested-subagent-delivery-escalation/design.md b/openspec/changes/archive/2026-04-14-fix-nested-subagent-delivery-escalation/design.md similarity index 100% rename from openspec/archive/2026-04-14-fix-nested-subagent-delivery-escalation/design.md rename to openspec/changes/archive/2026-04-14-fix-nested-subagent-delivery-escalation/design.md diff --git a/openspec/archive/2026-04-14-fix-nested-subagent-delivery-escalation/proposal.md b/openspec/changes/archive/2026-04-14-fix-nested-subagent-delivery-escalation/proposal.md similarity index 100% rename from openspec/archive/2026-04-14-fix-nested-subagent-delivery-escalation/proposal.md rename to openspec/changes/archive/2026-04-14-fix-nested-subagent-delivery-escalation/proposal.md diff --git a/openspec/archive/2026-04-14-fix-nested-subagent-delivery-escalation/specs/agent-delivery-contracts/spec.md b/openspec/changes/archive/2026-04-14-fix-nested-subagent-delivery-escalation/specs/agent-delivery-contracts/spec.md similarity index 100% rename from openspec/archive/2026-04-14-fix-nested-subagent-delivery-escalation/specs/agent-delivery-contracts/spec.md rename to openspec/changes/archive/2026-04-14-fix-nested-subagent-delivery-escalation/specs/agent-delivery-contracts/spec.md diff --git a/openspec/archive/2026-04-14-fix-nested-subagent-delivery-escalation/specs/subagent-execution/spec.md b/openspec/changes/archive/2026-04-14-fix-nested-subagent-delivery-escalation/specs/subagent-execution/spec.md similarity index 100% rename from openspec/archive/2026-04-14-fix-nested-subagent-delivery-escalation/specs/subagent-execution/spec.md rename to openspec/changes/archive/2026-04-14-fix-nested-subagent-delivery-escalation/specs/subagent-execution/spec.md diff --git a/openspec/archive/2026-04-14-fix-nested-subagent-delivery-escalation/tasks.md b/openspec/changes/archive/2026-04-14-fix-nested-subagent-delivery-escalation/tasks.md similarity index 100% rename from openspec/archive/2026-04-14-fix-nested-subagent-delivery-escalation/tasks.md rename to openspec/changes/archive/2026-04-14-fix-nested-subagent-delivery-escalation/tasks.md diff --git a/openspec/archive/2026-04-14-formalize-turn-loop-transitions/.openspec.yaml b/openspec/changes/archive/2026-04-14-formalize-turn-loop-transitions/.openspec.yaml similarity index 100% rename from openspec/archive/2026-04-14-formalize-turn-loop-transitions/.openspec.yaml rename to openspec/changes/archive/2026-04-14-formalize-turn-loop-transitions/.openspec.yaml diff --git a/openspec/archive/2026-04-14-formalize-turn-loop-transitions/design.md b/openspec/changes/archive/2026-04-14-formalize-turn-loop-transitions/design.md similarity index 100% rename from openspec/archive/2026-04-14-formalize-turn-loop-transitions/design.md rename to openspec/changes/archive/2026-04-14-formalize-turn-loop-transitions/design.md diff --git a/openspec/archive/2026-04-14-formalize-turn-loop-transitions/proposal.md b/openspec/changes/archive/2026-04-14-formalize-turn-loop-transitions/proposal.md similarity index 100% rename from openspec/archive/2026-04-14-formalize-turn-loop-transitions/proposal.md rename to openspec/changes/archive/2026-04-14-formalize-turn-loop-transitions/proposal.md diff --git a/openspec/archive/2026-04-14-formalize-turn-loop-transitions/specs/turn-budget-governance/spec.md b/openspec/changes/archive/2026-04-14-formalize-turn-loop-transitions/specs/turn-budget-governance/spec.md similarity index 100% rename from openspec/archive/2026-04-14-formalize-turn-loop-transitions/specs/turn-budget-governance/spec.md rename to openspec/changes/archive/2026-04-14-formalize-turn-loop-transitions/specs/turn-budget-governance/spec.md diff --git a/openspec/archive/2026-04-14-formalize-turn-loop-transitions/specs/turn-observability/spec.md b/openspec/changes/archive/2026-04-14-formalize-turn-loop-transitions/specs/turn-observability/spec.md similarity index 100% rename from openspec/archive/2026-04-14-formalize-turn-loop-transitions/specs/turn-observability/spec.md rename to openspec/changes/archive/2026-04-14-formalize-turn-loop-transitions/specs/turn-observability/spec.md diff --git a/openspec/archive/2026-04-14-formalize-turn-loop-transitions/specs/turn-orchestration/spec.md b/openspec/changes/archive/2026-04-14-formalize-turn-loop-transitions/specs/turn-orchestration/spec.md similarity index 100% rename from openspec/archive/2026-04-14-formalize-turn-loop-transitions/specs/turn-orchestration/spec.md rename to openspec/changes/archive/2026-04-14-formalize-turn-loop-transitions/specs/turn-orchestration/spec.md diff --git a/openspec/archive/2026-04-14-formalize-turn-loop-transitions/tasks.md b/openspec/changes/archive/2026-04-14-formalize-turn-loop-transitions/tasks.md similarity index 100% rename from openspec/archive/2026-04-14-formalize-turn-loop-transitions/tasks.md rename to openspec/changes/archive/2026-04-14-formalize-turn-loop-transitions/tasks.md diff --git a/openspec/archive/2026-04-14-govern-agent-tool-collaboration/.openspec.yaml b/openspec/changes/archive/2026-04-14-govern-agent-tool-collaboration/.openspec.yaml similarity index 100% rename from openspec/archive/2026-04-14-govern-agent-tool-collaboration/.openspec.yaml rename to openspec/changes/archive/2026-04-14-govern-agent-tool-collaboration/.openspec.yaml diff --git a/openspec/archive/2026-04-14-govern-agent-tool-collaboration/design.md b/openspec/changes/archive/2026-04-14-govern-agent-tool-collaboration/design.md similarity index 100% rename from openspec/archive/2026-04-14-govern-agent-tool-collaboration/design.md rename to openspec/changes/archive/2026-04-14-govern-agent-tool-collaboration/design.md diff --git a/openspec/archive/2026-04-14-govern-agent-tool-collaboration/proposal.md b/openspec/changes/archive/2026-04-14-govern-agent-tool-collaboration/proposal.md similarity index 100% rename from openspec/archive/2026-04-14-govern-agent-tool-collaboration/proposal.md rename to openspec/changes/archive/2026-04-14-govern-agent-tool-collaboration/proposal.md diff --git a/openspec/archive/2026-04-14-govern-agent-tool-collaboration/specs/agent-tool-governance/spec.md b/openspec/changes/archive/2026-04-14-govern-agent-tool-collaboration/specs/agent-tool-governance/spec.md similarity index 100% rename from openspec/archive/2026-04-14-govern-agent-tool-collaboration/specs/agent-tool-governance/spec.md rename to openspec/changes/archive/2026-04-14-govern-agent-tool-collaboration/specs/agent-tool-governance/spec.md diff --git a/openspec/archive/2026-04-14-govern-agent-tool-collaboration/specs/subagent-execution/spec.md b/openspec/changes/archive/2026-04-14-govern-agent-tool-collaboration/specs/subagent-execution/spec.md similarity index 100% rename from openspec/archive/2026-04-14-govern-agent-tool-collaboration/specs/subagent-execution/spec.md rename to openspec/changes/archive/2026-04-14-govern-agent-tool-collaboration/specs/subagent-execution/spec.md diff --git a/openspec/archive/2026-04-14-govern-agent-tool-collaboration/tasks.md b/openspec/changes/archive/2026-04-14-govern-agent-tool-collaboration/tasks.md similarity index 100% rename from openspec/archive/2026-04-14-govern-agent-tool-collaboration/tasks.md rename to openspec/changes/archive/2026-04-14-govern-agent-tool-collaboration/tasks.md diff --git a/openspec/archive/2026-04-14-independent-debug-workbench/.openspec.yaml b/openspec/changes/archive/2026-04-14-independent-debug-workbench/.openspec.yaml similarity index 100% rename from openspec/archive/2026-04-14-independent-debug-workbench/.openspec.yaml rename to openspec/changes/archive/2026-04-14-independent-debug-workbench/.openspec.yaml diff --git a/openspec/archive/2026-04-14-independent-debug-workbench/specs/runtime-observability-pipeline/spec.md b/openspec/changes/archive/2026-04-14-independent-debug-workbench/specs/runtime-observability-pipeline/spec.md similarity index 100% rename from openspec/archive/2026-04-14-independent-debug-workbench/specs/runtime-observability-pipeline/spec.md rename to openspec/changes/archive/2026-04-14-independent-debug-workbench/specs/runtime-observability-pipeline/spec.md diff --git a/openspec/archive/2026-04-14-measure-agent-tool-effectiveness/.openspec.yaml b/openspec/changes/archive/2026-04-14-measure-agent-tool-effectiveness/.openspec.yaml similarity index 100% rename from openspec/archive/2026-04-14-measure-agent-tool-effectiveness/.openspec.yaml rename to openspec/changes/archive/2026-04-14-measure-agent-tool-effectiveness/.openspec.yaml diff --git a/openspec/archive/2026-04-14-measure-agent-tool-effectiveness/design.md b/openspec/changes/archive/2026-04-14-measure-agent-tool-effectiveness/design.md similarity index 100% rename from openspec/archive/2026-04-14-measure-agent-tool-effectiveness/design.md rename to openspec/changes/archive/2026-04-14-measure-agent-tool-effectiveness/design.md diff --git a/openspec/archive/2026-04-14-measure-agent-tool-effectiveness/manual-checks.md b/openspec/changes/archive/2026-04-14-measure-agent-tool-effectiveness/manual-checks.md similarity index 100% rename from openspec/archive/2026-04-14-measure-agent-tool-effectiveness/manual-checks.md rename to openspec/changes/archive/2026-04-14-measure-agent-tool-effectiveness/manual-checks.md diff --git a/openspec/archive/2026-04-14-measure-agent-tool-effectiveness/proposal.md b/openspec/changes/archive/2026-04-14-measure-agent-tool-effectiveness/proposal.md similarity index 100% rename from openspec/archive/2026-04-14-measure-agent-tool-effectiveness/proposal.md rename to openspec/changes/archive/2026-04-14-measure-agent-tool-effectiveness/proposal.md diff --git a/openspec/archive/2026-04-14-measure-agent-tool-effectiveness/specs/agent-tool-evaluation/spec.md b/openspec/changes/archive/2026-04-14-measure-agent-tool-effectiveness/specs/agent-tool-evaluation/spec.md similarity index 100% rename from openspec/archive/2026-04-14-measure-agent-tool-effectiveness/specs/agent-tool-evaluation/spec.md rename to openspec/changes/archive/2026-04-14-measure-agent-tool-effectiveness/specs/agent-tool-evaluation/spec.md diff --git a/openspec/archive/2026-04-14-measure-agent-tool-effectiveness/specs/runtime-observability-pipeline/spec.md b/openspec/changes/archive/2026-04-14-measure-agent-tool-effectiveness/specs/runtime-observability-pipeline/spec.md similarity index 100% rename from openspec/archive/2026-04-14-measure-agent-tool-effectiveness/specs/runtime-observability-pipeline/spec.md rename to openspec/changes/archive/2026-04-14-measure-agent-tool-effectiveness/specs/runtime-observability-pipeline/spec.md diff --git a/openspec/archive/2026-04-14-measure-agent-tool-effectiveness/specs/turn-observability/spec.md b/openspec/changes/archive/2026-04-14-measure-agent-tool-effectiveness/specs/turn-observability/spec.md similarity index 100% rename from openspec/archive/2026-04-14-measure-agent-tool-effectiveness/specs/turn-observability/spec.md rename to openspec/changes/archive/2026-04-14-measure-agent-tool-effectiveness/specs/turn-observability/spec.md diff --git a/openspec/archive/2026-04-14-measure-agent-tool-effectiveness/tasks.md b/openspec/changes/archive/2026-04-14-measure-agent-tool-effectiveness/tasks.md similarity index 100% rename from openspec/archive/2026-04-14-measure-agent-tool-effectiveness/tasks.md rename to openspec/changes/archive/2026-04-14-measure-agent-tool-effectiveness/tasks.md diff --git a/openspec/archive/2026-04-14-recover-truncated-turn-output/.openspec.yaml b/openspec/changes/archive/2026-04-14-recover-truncated-turn-output/.openspec.yaml similarity index 100% rename from openspec/archive/2026-04-14-recover-truncated-turn-output/.openspec.yaml rename to openspec/changes/archive/2026-04-14-recover-truncated-turn-output/.openspec.yaml diff --git a/openspec/archive/2026-04-14-recover-truncated-turn-output/design.md b/openspec/changes/archive/2026-04-14-recover-truncated-turn-output/design.md similarity index 100% rename from openspec/archive/2026-04-14-recover-truncated-turn-output/design.md rename to openspec/changes/archive/2026-04-14-recover-truncated-turn-output/design.md diff --git a/openspec/archive/2026-04-14-recover-truncated-turn-output/proposal.md b/openspec/changes/archive/2026-04-14-recover-truncated-turn-output/proposal.md similarity index 100% rename from openspec/archive/2026-04-14-recover-truncated-turn-output/proposal.md rename to openspec/changes/archive/2026-04-14-recover-truncated-turn-output/proposal.md diff --git a/openspec/archive/2026-04-14-recover-truncated-turn-output/specs/turn-budget-governance/spec.md b/openspec/changes/archive/2026-04-14-recover-truncated-turn-output/specs/turn-budget-governance/spec.md similarity index 100% rename from openspec/archive/2026-04-14-recover-truncated-turn-output/specs/turn-budget-governance/spec.md rename to openspec/changes/archive/2026-04-14-recover-truncated-turn-output/specs/turn-budget-governance/spec.md diff --git a/openspec/archive/2026-04-14-recover-truncated-turn-output/specs/turn-observability/spec.md b/openspec/changes/archive/2026-04-14-recover-truncated-turn-output/specs/turn-observability/spec.md similarity index 100% rename from openspec/archive/2026-04-14-recover-truncated-turn-output/specs/turn-observability/spec.md rename to openspec/changes/archive/2026-04-14-recover-truncated-turn-output/specs/turn-observability/spec.md diff --git a/openspec/archive/2026-04-14-recover-truncated-turn-output/specs/turn-orchestration/spec.md b/openspec/changes/archive/2026-04-14-recover-truncated-turn-output/specs/turn-orchestration/spec.md similarity index 100% rename from openspec/archive/2026-04-14-recover-truncated-turn-output/specs/turn-orchestration/spec.md rename to openspec/changes/archive/2026-04-14-recover-truncated-turn-output/specs/turn-orchestration/spec.md diff --git a/openspec/archive/2026-04-14-recover-truncated-turn-output/tasks.md b/openspec/changes/archive/2026-04-14-recover-truncated-turn-output/tasks.md similarity index 100% rename from openspec/archive/2026-04-14-recover-truncated-turn-output/tasks.md rename to openspec/changes/archive/2026-04-14-recover-truncated-turn-output/tasks.md diff --git a/openspec/archive/2026-04-14-stabilize-persisted-tool-result-references/.openspec.yaml b/openspec/changes/archive/2026-04-14-stabilize-persisted-tool-result-references/.openspec.yaml similarity index 100% rename from openspec/archive/2026-04-14-stabilize-persisted-tool-result-references/.openspec.yaml rename to openspec/changes/archive/2026-04-14-stabilize-persisted-tool-result-references/.openspec.yaml diff --git a/openspec/archive/2026-04-14-stabilize-persisted-tool-result-references/design.md b/openspec/changes/archive/2026-04-14-stabilize-persisted-tool-result-references/design.md similarity index 100% rename from openspec/archive/2026-04-14-stabilize-persisted-tool-result-references/design.md rename to openspec/changes/archive/2026-04-14-stabilize-persisted-tool-result-references/design.md diff --git a/openspec/archive/2026-04-14-stabilize-persisted-tool-result-references/proposal.md b/openspec/changes/archive/2026-04-14-stabilize-persisted-tool-result-references/proposal.md similarity index 100% rename from openspec/archive/2026-04-14-stabilize-persisted-tool-result-references/proposal.md rename to openspec/changes/archive/2026-04-14-stabilize-persisted-tool-result-references/proposal.md diff --git a/openspec/archive/2026-04-14-stabilize-persisted-tool-result-references/specs/session-persistence/spec.md b/openspec/changes/archive/2026-04-14-stabilize-persisted-tool-result-references/specs/session-persistence/spec.md similarity index 100% rename from openspec/archive/2026-04-14-stabilize-persisted-tool-result-references/specs/session-persistence/spec.md rename to openspec/changes/archive/2026-04-14-stabilize-persisted-tool-result-references/specs/session-persistence/spec.md diff --git a/openspec/archive/2026-04-14-stabilize-persisted-tool-result-references/specs/turn-budget-governance/spec.md b/openspec/changes/archive/2026-04-14-stabilize-persisted-tool-result-references/specs/turn-budget-governance/spec.md similarity index 100% rename from openspec/archive/2026-04-14-stabilize-persisted-tool-result-references/specs/turn-budget-governance/spec.md rename to openspec/changes/archive/2026-04-14-stabilize-persisted-tool-result-references/specs/turn-budget-governance/spec.md diff --git a/openspec/archive/2026-04-14-stabilize-persisted-tool-result-references/specs/turn-observability/spec.md b/openspec/changes/archive/2026-04-14-stabilize-persisted-tool-result-references/specs/turn-observability/spec.md similarity index 100% rename from openspec/archive/2026-04-14-stabilize-persisted-tool-result-references/specs/turn-observability/spec.md rename to openspec/changes/archive/2026-04-14-stabilize-persisted-tool-result-references/specs/turn-observability/spec.md diff --git a/openspec/archive/2026-04-14-stabilize-persisted-tool-result-references/specs/turn-orchestration/spec.md b/openspec/changes/archive/2026-04-14-stabilize-persisted-tool-result-references/specs/turn-orchestration/spec.md similarity index 100% rename from openspec/archive/2026-04-14-stabilize-persisted-tool-result-references/specs/turn-orchestration/spec.md rename to openspec/changes/archive/2026-04-14-stabilize-persisted-tool-result-references/specs/turn-orchestration/spec.md diff --git a/openspec/archive/2026-04-14-stabilize-persisted-tool-result-references/tasks.md b/openspec/changes/archive/2026-04-14-stabilize-persisted-tool-result-references/tasks.md similarity index 100% rename from openspec/archive/2026-04-14-stabilize-persisted-tool-result-references/tasks.md rename to openspec/changes/archive/2026-04-14-stabilize-persisted-tool-result-references/tasks.md diff --git a/openspec/archive/2026-04-14-stream-tool-execution-from-llm-deltas/.openspec.yaml b/openspec/changes/archive/2026-04-14-stream-tool-execution-from-llm-deltas/.openspec.yaml similarity index 100% rename from openspec/archive/2026-04-14-stream-tool-execution-from-llm-deltas/.openspec.yaml rename to openspec/changes/archive/2026-04-14-stream-tool-execution-from-llm-deltas/.openspec.yaml diff --git a/openspec/archive/2026-04-14-stream-tool-execution-from-llm-deltas/design.md b/openspec/changes/archive/2026-04-14-stream-tool-execution-from-llm-deltas/design.md similarity index 100% rename from openspec/archive/2026-04-14-stream-tool-execution-from-llm-deltas/design.md rename to openspec/changes/archive/2026-04-14-stream-tool-execution-from-llm-deltas/design.md diff --git a/openspec/archive/2026-04-14-stream-tool-execution-from-llm-deltas/proposal.md b/openspec/changes/archive/2026-04-14-stream-tool-execution-from-llm-deltas/proposal.md similarity index 100% rename from openspec/archive/2026-04-14-stream-tool-execution-from-llm-deltas/proposal.md rename to openspec/changes/archive/2026-04-14-stream-tool-execution-from-llm-deltas/proposal.md diff --git a/openspec/archive/2026-04-14-stream-tool-execution-from-llm-deltas/specs/runtime-observability-pipeline/spec.md b/openspec/changes/archive/2026-04-14-stream-tool-execution-from-llm-deltas/specs/runtime-observability-pipeline/spec.md similarity index 100% rename from openspec/archive/2026-04-14-stream-tool-execution-from-llm-deltas/specs/runtime-observability-pipeline/spec.md rename to openspec/changes/archive/2026-04-14-stream-tool-execution-from-llm-deltas/specs/runtime-observability-pipeline/spec.md diff --git a/openspec/archive/2026-04-14-stream-tool-execution-from-llm-deltas/specs/turn-orchestration/spec.md b/openspec/changes/archive/2026-04-14-stream-tool-execution-from-llm-deltas/specs/turn-orchestration/spec.md similarity index 100% rename from openspec/archive/2026-04-14-stream-tool-execution-from-llm-deltas/specs/turn-orchestration/spec.md rename to openspec/changes/archive/2026-04-14-stream-tool-execution-from-llm-deltas/specs/turn-orchestration/spec.md diff --git a/openspec/archive/2026-04-14-stream-tool-execution-from-llm-deltas/tasks.md b/openspec/changes/archive/2026-04-14-stream-tool-execution-from-llm-deltas/tasks.md similarity index 100% rename from openspec/archive/2026-04-14-stream-tool-execution-from-llm-deltas/tasks.md rename to openspec/changes/archive/2026-04-14-stream-tool-execution-from-llm-deltas/tasks.md diff --git a/openspec/archive/2026-04-14-tighten-session-runtime-subdomain-boundaries/.openspec.yaml b/openspec/changes/archive/2026-04-14-tighten-session-runtime-subdomain-boundaries/.openspec.yaml similarity index 100% rename from openspec/archive/2026-04-14-tighten-session-runtime-subdomain-boundaries/.openspec.yaml rename to openspec/changes/archive/2026-04-14-tighten-session-runtime-subdomain-boundaries/.openspec.yaml diff --git a/openspec/archive/2026-04-14-tighten-session-runtime-subdomain-boundaries/design.md b/openspec/changes/archive/2026-04-14-tighten-session-runtime-subdomain-boundaries/design.md similarity index 100% rename from openspec/archive/2026-04-14-tighten-session-runtime-subdomain-boundaries/design.md rename to openspec/changes/archive/2026-04-14-tighten-session-runtime-subdomain-boundaries/design.md diff --git a/openspec/archive/2026-04-14-tighten-session-runtime-subdomain-boundaries/proposal.md b/openspec/changes/archive/2026-04-14-tighten-session-runtime-subdomain-boundaries/proposal.md similarity index 100% rename from openspec/archive/2026-04-14-tighten-session-runtime-subdomain-boundaries/proposal.md rename to openspec/changes/archive/2026-04-14-tighten-session-runtime-subdomain-boundaries/proposal.md diff --git a/openspec/archive/2026-04-14-tighten-session-runtime-subdomain-boundaries/specs/application-use-cases/spec.md b/openspec/changes/archive/2026-04-14-tighten-session-runtime-subdomain-boundaries/specs/application-use-cases/spec.md similarity index 100% rename from openspec/archive/2026-04-14-tighten-session-runtime-subdomain-boundaries/specs/application-use-cases/spec.md rename to openspec/changes/archive/2026-04-14-tighten-session-runtime-subdomain-boundaries/specs/application-use-cases/spec.md diff --git a/openspec/archive/2026-04-14-tighten-session-runtime-subdomain-boundaries/specs/session-runtime-subdomain-boundaries/spec.md b/openspec/changes/archive/2026-04-14-tighten-session-runtime-subdomain-boundaries/specs/session-runtime-subdomain-boundaries/spec.md similarity index 100% rename from openspec/archive/2026-04-14-tighten-session-runtime-subdomain-boundaries/specs/session-runtime-subdomain-boundaries/spec.md rename to openspec/changes/archive/2026-04-14-tighten-session-runtime-subdomain-boundaries/specs/session-runtime-subdomain-boundaries/spec.md diff --git a/openspec/archive/2026-04-14-tighten-session-runtime-subdomain-boundaries/specs/session-runtime/spec.md b/openspec/changes/archive/2026-04-14-tighten-session-runtime-subdomain-boundaries/specs/session-runtime/spec.md similarity index 100% rename from openspec/archive/2026-04-14-tighten-session-runtime-subdomain-boundaries/specs/session-runtime/spec.md rename to openspec/changes/archive/2026-04-14-tighten-session-runtime-subdomain-boundaries/specs/session-runtime/spec.md diff --git a/openspec/archive/2026-04-14-tighten-session-runtime-subdomain-boundaries/tasks.md b/openspec/changes/archive/2026-04-14-tighten-session-runtime-subdomain-boundaries/tasks.md similarity index 100% rename from openspec/archive/2026-04-14-tighten-session-runtime-subdomain-boundaries/tasks.md rename to openspec/changes/archive/2026-04-14-tighten-session-runtime-subdomain-boundaries/tasks.md diff --git a/openspec/archive/2026-04-15-enhance-agent-tool-experience/.openspec.yaml b/openspec/changes/archive/2026-04-15-enhance-agent-tool-experience/.openspec.yaml similarity index 100% rename from openspec/archive/2026-04-15-enhance-agent-tool-experience/.openspec.yaml rename to openspec/changes/archive/2026-04-15-enhance-agent-tool-experience/.openspec.yaml diff --git a/openspec/archive/2026-04-15-enhance-agent-tool-experience/design.md b/openspec/changes/archive/2026-04-15-enhance-agent-tool-experience/design.md similarity index 100% rename from openspec/archive/2026-04-15-enhance-agent-tool-experience/design.md rename to openspec/changes/archive/2026-04-15-enhance-agent-tool-experience/design.md diff --git a/openspec/archive/2026-04-15-enhance-agent-tool-experience/proposal.md b/openspec/changes/archive/2026-04-15-enhance-agent-tool-experience/proposal.md similarity index 100% rename from openspec/archive/2026-04-15-enhance-agent-tool-experience/proposal.md rename to openspec/changes/archive/2026-04-15-enhance-agent-tool-experience/proposal.md diff --git a/openspec/archive/2026-04-15-enhance-agent-tool-experience/specs/agent-delegation-surface/spec.md b/openspec/changes/archive/2026-04-15-enhance-agent-tool-experience/specs/agent-delegation-surface/spec.md similarity index 100% rename from openspec/archive/2026-04-15-enhance-agent-tool-experience/specs/agent-delegation-surface/spec.md rename to openspec/changes/archive/2026-04-15-enhance-agent-tool-experience/specs/agent-delegation-surface/spec.md diff --git a/openspec/archive/2026-04-15-enhance-agent-tool-experience/specs/agent-tool-governance/spec.md b/openspec/changes/archive/2026-04-15-enhance-agent-tool-experience/specs/agent-tool-governance/spec.md similarity index 100% rename from openspec/archive/2026-04-15-enhance-agent-tool-experience/specs/agent-tool-governance/spec.md rename to openspec/changes/archive/2026-04-15-enhance-agent-tool-experience/specs/agent-tool-governance/spec.md diff --git a/openspec/archive/2026-04-15-enhance-agent-tool-experience/specs/subagent-execution/spec.md b/openspec/changes/archive/2026-04-15-enhance-agent-tool-experience/specs/subagent-execution/spec.md similarity index 100% rename from openspec/archive/2026-04-15-enhance-agent-tool-experience/specs/subagent-execution/spec.md rename to openspec/changes/archive/2026-04-15-enhance-agent-tool-experience/specs/subagent-execution/spec.md diff --git a/openspec/archive/2026-04-15-enhance-agent-tool-experience/tasks.md b/openspec/changes/archive/2026-04-15-enhance-agent-tool-experience/tasks.md similarity index 100% rename from openspec/archive/2026-04-15-enhance-agent-tool-experience/tasks.md rename to openspec/changes/archive/2026-04-15-enhance-agent-tool-experience/tasks.md diff --git a/openspec/archive/2026-04-15-release-terminal-astrcode/.openspec.yaml b/openspec/changes/archive/2026-04-15-release-terminal-astrcode/.openspec.yaml similarity index 100% rename from openspec/archive/2026-04-15-release-terminal-astrcode/.openspec.yaml rename to openspec/changes/archive/2026-04-15-release-terminal-astrcode/.openspec.yaml diff --git a/openspec/archive/2026-04-15-release-terminal-astrcode/design.md b/openspec/changes/archive/2026-04-15-release-terminal-astrcode/design.md similarity index 100% rename from openspec/archive/2026-04-15-release-terminal-astrcode/design.md rename to openspec/changes/archive/2026-04-15-release-terminal-astrcode/design.md diff --git a/openspec/archive/2026-04-15-release-terminal-astrcode/proposal.md b/openspec/changes/archive/2026-04-15-release-terminal-astrcode/proposal.md similarity index 100% rename from openspec/archive/2026-04-15-release-terminal-astrcode/proposal.md rename to openspec/changes/archive/2026-04-15-release-terminal-astrcode/proposal.md diff --git a/openspec/archive/2026-04-15-release-terminal-astrcode/specs/composer-execution-controls/spec.md b/openspec/changes/archive/2026-04-15-release-terminal-astrcode/specs/composer-execution-controls/spec.md similarity index 100% rename from openspec/archive/2026-04-15-release-terminal-astrcode/specs/composer-execution-controls/spec.md rename to openspec/changes/archive/2026-04-15-release-terminal-astrcode/specs/composer-execution-controls/spec.md diff --git a/openspec/archive/2026-04-15-release-terminal-astrcode/specs/terminal-chat-read-model/spec.md b/openspec/changes/archive/2026-04-15-release-terminal-astrcode/specs/terminal-chat-read-model/spec.md similarity index 100% rename from openspec/archive/2026-04-15-release-terminal-astrcode/specs/terminal-chat-read-model/spec.md rename to openspec/changes/archive/2026-04-15-release-terminal-astrcode/specs/terminal-chat-read-model/spec.md diff --git a/openspec/archive/2026-04-15-release-terminal-astrcode/specs/terminal-chat-surface/spec.md b/openspec/changes/archive/2026-04-15-release-terminal-astrcode/specs/terminal-chat-surface/spec.md similarity index 100% rename from openspec/archive/2026-04-15-release-terminal-astrcode/specs/terminal-chat-surface/spec.md rename to openspec/changes/archive/2026-04-15-release-terminal-astrcode/specs/terminal-chat-surface/spec.md diff --git a/openspec/archive/2026-04-15-release-terminal-astrcode/specs/tool-and-skill-discovery/spec.md b/openspec/changes/archive/2026-04-15-release-terminal-astrcode/specs/tool-and-skill-discovery/spec.md similarity index 100% rename from openspec/archive/2026-04-15-release-terminal-astrcode/specs/tool-and-skill-discovery/spec.md rename to openspec/changes/archive/2026-04-15-release-terminal-astrcode/specs/tool-and-skill-discovery/spec.md diff --git a/openspec/archive/2026-04-15-release-terminal-astrcode/tasks.md b/openspec/changes/archive/2026-04-15-release-terminal-astrcode/tasks.md similarity index 100% rename from openspec/archive/2026-04-15-release-terminal-astrcode/tasks.md rename to openspec/changes/archive/2026-04-15-release-terminal-astrcode/tasks.md diff --git a/openspec/archive/2026-04-15-replace-summary-with-parent-delivery/.openspec.yaml b/openspec/changes/archive/2026-04-15-replace-summary-with-parent-delivery/.openspec.yaml similarity index 100% rename from openspec/archive/2026-04-15-replace-summary-with-parent-delivery/.openspec.yaml rename to openspec/changes/archive/2026-04-15-replace-summary-with-parent-delivery/.openspec.yaml diff --git a/openspec/archive/2026-04-15-replace-summary-with-parent-delivery/design.md b/openspec/changes/archive/2026-04-15-replace-summary-with-parent-delivery/design.md similarity index 100% rename from openspec/archive/2026-04-15-replace-summary-with-parent-delivery/design.md rename to openspec/changes/archive/2026-04-15-replace-summary-with-parent-delivery/design.md diff --git a/openspec/archive/2026-04-15-replace-summary-with-parent-delivery/proposal.md b/openspec/changes/archive/2026-04-15-replace-summary-with-parent-delivery/proposal.md similarity index 100% rename from openspec/archive/2026-04-15-replace-summary-with-parent-delivery/proposal.md rename to openspec/changes/archive/2026-04-15-replace-summary-with-parent-delivery/proposal.md diff --git a/openspec/archive/2026-04-15-replace-summary-with-parent-delivery/specs/agent-delivery-contracts/spec.md b/openspec/changes/archive/2026-04-15-replace-summary-with-parent-delivery/specs/agent-delivery-contracts/spec.md similarity index 100% rename from openspec/archive/2026-04-15-replace-summary-with-parent-delivery/specs/agent-delivery-contracts/spec.md rename to openspec/changes/archive/2026-04-15-replace-summary-with-parent-delivery/specs/agent-delivery-contracts/spec.md diff --git a/openspec/archive/2026-04-15-replace-summary-with-parent-delivery/specs/agent-tool-governance/spec.md b/openspec/changes/archive/2026-04-15-replace-summary-with-parent-delivery/specs/agent-tool-governance/spec.md similarity index 100% rename from openspec/archive/2026-04-15-replace-summary-with-parent-delivery/specs/agent-tool-governance/spec.md rename to openspec/changes/archive/2026-04-15-replace-summary-with-parent-delivery/specs/agent-tool-governance/spec.md diff --git a/openspec/archive/2026-04-15-replace-summary-with-parent-delivery/specs/subagent-execution/spec.md b/openspec/changes/archive/2026-04-15-replace-summary-with-parent-delivery/specs/subagent-execution/spec.md similarity index 100% rename from openspec/archive/2026-04-15-replace-summary-with-parent-delivery/specs/subagent-execution/spec.md rename to openspec/changes/archive/2026-04-15-replace-summary-with-parent-delivery/specs/subagent-execution/spec.md diff --git a/openspec/archive/2026-04-15-replace-summary-with-parent-delivery/tasks.md b/openspec/changes/archive/2026-04-15-replace-summary-with-parent-delivery/tasks.md similarity index 100% rename from openspec/archive/2026-04-15-replace-summary-with-parent-delivery/tasks.md rename to openspec/changes/archive/2026-04-15-replace-summary-with-parent-delivery/tasks.md diff --git a/openspec/archive/2026-04-15-subagent-effective-capability-profile/.openspec.yaml b/openspec/changes/archive/2026-04-15-subagent-effective-capability-profile/.openspec.yaml similarity index 100% rename from openspec/archive/2026-04-15-subagent-effective-capability-profile/.openspec.yaml rename to openspec/changes/archive/2026-04-15-subagent-effective-capability-profile/.openspec.yaml diff --git a/openspec/archive/2026-04-15-subagent-effective-capability-profile/design.md b/openspec/changes/archive/2026-04-15-subagent-effective-capability-profile/design.md similarity index 100% rename from openspec/archive/2026-04-15-subagent-effective-capability-profile/design.md rename to openspec/changes/archive/2026-04-15-subagent-effective-capability-profile/design.md diff --git a/openspec/archive/2026-04-15-subagent-effective-capability-profile/proposal.md b/openspec/changes/archive/2026-04-15-subagent-effective-capability-profile/proposal.md similarity index 100% rename from openspec/archive/2026-04-15-subagent-effective-capability-profile/proposal.md rename to openspec/changes/archive/2026-04-15-subagent-effective-capability-profile/proposal.md diff --git a/openspec/archive/2026-04-15-subagent-effective-capability-profile/specs/agent-tool-governance/spec.md b/openspec/changes/archive/2026-04-15-subagent-effective-capability-profile/specs/agent-tool-governance/spec.md similarity index 100% rename from openspec/archive/2026-04-15-subagent-effective-capability-profile/specs/agent-tool-governance/spec.md rename to openspec/changes/archive/2026-04-15-subagent-effective-capability-profile/specs/agent-tool-governance/spec.md diff --git a/openspec/archive/2026-04-15-subagent-effective-capability-profile/specs/capability-semantic-model/spec.md b/openspec/changes/archive/2026-04-15-subagent-effective-capability-profile/specs/capability-semantic-model/spec.md similarity index 100% rename from openspec/archive/2026-04-15-subagent-effective-capability-profile/specs/capability-semantic-model/spec.md rename to openspec/changes/archive/2026-04-15-subagent-effective-capability-profile/specs/capability-semantic-model/spec.md diff --git a/openspec/archive/2026-04-15-subagent-effective-capability-profile/specs/subagent-execution/spec.md b/openspec/changes/archive/2026-04-15-subagent-effective-capability-profile/specs/subagent-execution/spec.md similarity index 100% rename from openspec/archive/2026-04-15-subagent-effective-capability-profile/specs/subagent-execution/spec.md rename to openspec/changes/archive/2026-04-15-subagent-effective-capability-profile/specs/subagent-execution/spec.md diff --git a/openspec/archive/2026-04-15-subagent-effective-capability-profile/specs/subrun-status-contracts/spec.md b/openspec/changes/archive/2026-04-15-subagent-effective-capability-profile/specs/subrun-status-contracts/spec.md similarity index 100% rename from openspec/archive/2026-04-15-subagent-effective-capability-profile/specs/subrun-status-contracts/spec.md rename to openspec/changes/archive/2026-04-15-subagent-effective-capability-profile/specs/subrun-status-contracts/spec.md diff --git a/openspec/archive/2026-04-15-subagent-effective-capability-profile/tasks.md b/openspec/changes/archive/2026-04-15-subagent-effective-capability-profile/tasks.md similarity index 100% rename from openspec/archive/2026-04-15-subagent-effective-capability-profile/tasks.md rename to openspec/changes/archive/2026-04-15-subagent-effective-capability-profile/tasks.md From dfdc8dffcd3c97ca1beb717127db9dc101023dba Mon Sep 17 00:00:00 2001 From: whatevertogo Date: Sat, 18 Apr 2026 14:50:16 +0800 Subject: [PATCH 25/53] feat: enhance text rendering and history management in CLI - Introduced `HistoryLine` struct to manage wrapped lines with rewrap policies. - Updated `line_to_ratatui` to handle styled spans for better text rendering. - Added functions for materializing wrapped lines and history lines based on width and rewrap policies. - Refactored `render_browser_overlay` to utilize new materialization functions for rendering lines. - Improved palette handling by simplifying wrapped line creation. - Expanded theme capabilities to include span styles for enhanced text formatting. - Added architecture diagram to documentation for better understanding of system structure and data flow. --- AGENTS.md | 3 - CLAUDE.md | 124 +- Cargo.lock | 22 + crates/cli/Cargo.toml | 1 + crates/cli/src/app/mod.rs | 6 +- crates/cli/src/bottom_pane/model.rs | 34 +- crates/cli/src/chat/surface.rs | 109 +- crates/cli/src/render/commit.rs | 2 - crates/cli/src/render/live.rs | 2 - crates/cli/src/render/mod.rs | 2 - crates/cli/src/state/mod.rs | 5 +- crates/cli/src/state/render.rs | 86 ++ crates/cli/src/tui/runtime.rs | 8 +- crates/cli/src/ui/cells.rs | 1273 ++---------------- crates/cli/src/ui/host.rs | 149 --- crates/cli/src/ui/insert_history.rs | 88 +- crates/cli/src/ui/markdown.rs | 1879 +++++++++++++++++++++++++++ crates/cli/src/ui/mod.rs | 239 +++- crates/cli/src/ui/overlay.rs | 46 +- crates/cli/src/ui/palette.rs | 16 +- crates/cli/src/ui/theme.rs | 43 +- docs/architecture-diagram.md | 300 +++++ 22 files changed, 2935 insertions(+), 1502 deletions(-) delete mode 100644 crates/cli/src/render/commit.rs delete mode 100644 crates/cli/src/render/live.rs delete mode 100644 crates/cli/src/ui/host.rs create mode 100644 crates/cli/src/ui/markdown.rs create mode 100644 docs/architecture-diagram.md diff --git a/AGENTS.md b/AGENTS.md index f28af4e5..a57e8f8c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -65,10 +65,7 @@ node scripts/check-crate-boundaries.mjs --strict # 严格模式 ## Gotchas -- 前端css不允许出现webview相关内容这会导致应用端无法下滑窗口 - 文档必须使用中文 - 使用 `node scripts/check-crate-boundaries.mjs` 验证 crate 依赖规则没有被违反 - `src-tauri` 是 Tauri 薄壳,不含业务逻辑 - `server` 组合根在 `crates/server/src/bootstrap/runtime.rs` -- `astrcode-cli` 当前使用 `ratatui 0.30.0`;如果要引入第三方 textarea / TUI widget,先确认它不会再拉入另一套 ratatui 类型,否则会在 `Widget`、`Style`、`Frame` 上直接类型冲突 -- `Viewport::Inline + insert_before(...)` 只有在已提交历史把 inline viewport 之上的主屏空间挤满之后,`TestBackend::scrollback()` 才会真正出现对应行;少量 commit 可能仍停留在当前主屏 buffer,而不是 scrollback 断言里 diff --git a/CLAUDE.md b/CLAUDE.md index 06e39525..a57e8f8c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,91 +1,71 @@ -# AGENTS.md +# Repository Guidelines -你追求完美和优雅的最佳实践 +本项目不维护向后兼容,优先良好架构与干净代码。 -## 作用范围 +## 环境要求 -- 本文件定义仓库的全局默认规则。 -- 子目录中的 `AGENTS.md`、项目文档或更具体说明,优先级高于本文件。 -- 规则冲突时,按以下顺序决策: - 1. 安全与正确性 - 2. 项目约定 - 3. 可维护性 - 4. 局部偏好 +- Rust **nightly** 工具链(见 `rust-toolchain.toml`) +- Node.js 20+ +- 首次安装:`npm install && cd frontend && npm install` -## 工作原则 +## 常用命令 -- 修复根因,不修表象。 -- 修改前先理解上下文。 -- 遵循现有架构、约定与工具链。 -- 优先使用清晰命名,而不是依赖注释。 -- 注释只解释 **为什么**,不解释 **是什么**。 -- 优先选择正确、可维护的方案,不做临时性脏修复。 -- 改动应始终与当前任务强相关。 +```bash +# 开发 +cargo tauri dev # Tauri 桌面端开发 +cargo run -p astrcode-server # 只启动后端 +cd frontend && npm run dev # 只启动前端 -## 必须遵循的流程 +# 构建与检查 +cargo tauri build # Tauri 桌面端构建 +cargo check --workspace # 快速编译检查 +cargo test --workspace --exclude astrcode --lib # push 前快速测试 -### 1. 修改前 +# 完整 CI 检查 +cargo fmt --all -- --check +cargo clippy --all-targets --all-features -- -D warnings +cargo test --workspace --exclude astrcode +node scripts/check-crate-boundaries.mjs +cd frontend && npm run typecheck && npm run lint && npm run format:check -- 阅读相关代码、测试、配置和文档。 -- 理解调用链、数据流、约束条件和影响范围。 -- 不基于猜测实现功能或修复问题。 -- 能复用现有抽象时,优先复用。 +# 架构守卫 +node scripts/check-crate-boundaries.mjs # 检查 crate 依赖边界 +node scripts/check-crate-boundaries.mjs --strict # 严格模式 +``` -### 2. 修改中 +## 架构约束 -- 用**足够小但能完整解决问题**的改动完成任务。 -- 保持现有风格与结构,除非任务本身要求调整。 -- 避免无关重构或顺手大改。 -- 允许做小而明确的附带改进,但必须直接有益于当前任务,例如: - - 重命名含义不清的标识符 - - 删除明显死代码 - - 抽取重复且局部的简单逻辑 - - 补充或调整必要测试 +详见 `PROJECT_ARCHITECTURE.md`,以下为摘要: -### 3. 完成前 +- `server` 是唯一组合根,通过 `bootstrap_server_runtime()` 组装所有组件 +- `application` 不依赖任何 `adapter-*`,只依赖 `core` + `kernel` + `session-runtime` +- 治理层使用 `AppGovernance`(`astrcode-application`) +- 能力语义统一使用 `CapabilitySpec`(`astrcode-core`),传输层使用 `CapabilityWireDescriptor`(`astrcode-protocol`) -- 运行与改动最相关的最小充分验证,例如: - - 测试 - - lint - - typecheck - - build -- 检查 diff,移除无关修改。 -- 只有在实际运行过验证后,才声明“已验证”。 +## 代码规范 -## 以下操作必须先确认 +- 用中文注释,且注释尽量表明为什么和做了什么 +- 不需要向后兼容,优先良好架构,期望最佳实践而不是打补丁 +- Git 提交信息使用 emoji + type + scope 风格(如 `✨ feat(module): brief description`) -- 删除文件、目录、分支或大段代码 -- 执行破坏性 Git 操作,例如 `git reset --hard`、`git push --force` -- 修改数据库 Schema 或执行数据迁移 -- 修改公共 API、协议、持久化格式、权限逻辑或安全边界 -- 新增、移除或替换核心依赖 -- 超出当前任务范围的大型重构 +## 提交前验证 -## 禁止事项 +改了后端rust代码每次提交前按顺序执行: -- 不伪造结果、日志、测试结论或完成状态。 -- 不为了临时可用而绕过测试、校验或安全约束。 -- 不提交密钥、令牌或敏感配置。 -- 不手动修改 generated files。 -- 不把无关改动混入当前任务。 +1. `cargo fmt --all` — 格式化代码 +2. `cargo clippy --all-targets --all-features -- -D warnings` — 修复所有警告 +3. `cargo test --workspace` — 确保所有测试通过 +4. 确认变更内容后写出描述性提交信息 -## 沟通要求 +改了前端代码每次提交前按顺序执行: +1. `npm run format` — 格式化代码 +2. `npm run lint` — 修复所有 lint 错误 +3. `npm run typecheck` — 确保没有类型错误 +4. `npm run format:check` — 确保格式正确 -- 默认使用中文沟通,必要时保留英文技术术语。 -- 优先先查代码与文档;只有在无法安全推进时,才提出最小必要问题。 -- 最终总结必须包含: - 1. 改了什么 - 2. 为什么这样改 - 3. 做了哪些验证 - 4. 风险或注意事项 - 5. 下一步建议 +## Gotchas -## 目标 - -目标不是做最小改动。 -目标是以**正确、可维护、可验证**的方式完成任务。 - -## 项目提醒 - -- `astrcode-cli` 当前使用 `ratatui 0.30.0`;接第三方 textarea 或其他 TUI widget 之前,先确认它不会额外拉入另一套 ratatui 类型,否则会在 `Widget`、`Style`、`Frame` 上直接类型冲突 -- `Viewport::Inline + insert_before(...)` 在测试里并不等于“立刻进入 scrollback”;只有当提交内容把 inline viewport 上方空间顶满后,`TestBackend::scrollback()` 才会出现对应历史行,少量 commit 仍可能留在当前主屏 buffer +- 文档必须使用中文 +- 使用 `node scripts/check-crate-boundaries.mjs` 验证 crate 依赖规则没有被违反 +- `src-tauri` 是 Tauri 薄壳,不含业务逻辑 +- `server` 组合根在 `crates/server/src/bootstrap/runtime.rs` diff --git a/Cargo.lock b/Cargo.lock index fbaf1b76..fb510449 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -285,6 +285,7 @@ dependencies = [ "async-trait", "clap", "crossterm", + "pulldown-cmark", "ratatui", "reqwest", "serde", @@ -2142,6 +2143,15 @@ dependencies = [ "version_check", ] +[[package]] +name = "getopts" +version = "0.2.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df" +dependencies = [ + "unicode-width", +] + [[package]] name = "getrandom" version = "0.1.16" @@ -4143,6 +4153,18 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "pulldown-cmark" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57206b407293d2bcd3af849ce869d52068623f19e1b5ff8e8778e3309439682b" +dependencies = [ + "bitflags 2.11.0", + "getopts", + "memchr", + "unicase", +] + [[package]] name = "quick-xml" version = "0.38.4" diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index 81fa2120..ada8caef 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -12,6 +12,7 @@ anyhow.workspace = true async-trait.workspace = true clap = { version = "4.5", features = ["derive"] } crossterm = "0.29.0" +pulldown-cmark = "0.9.6" reqwest.workspace = true ratatui = { version = "0.30.0", features = ["scrolling-regions"] } serde.workspace = true diff --git a/crates/cli/src/app/mod.rs b/crates/cli/src/app/mod.rs index 414824ec..dc7e345d 100644 --- a/crates/cli/src/app/mod.rs +++ b/crates/cli/src/app/mod.rs @@ -218,7 +218,11 @@ fn redraw( let mut chat = controller .chat_surface .build_frame(&controller.state, &theme, size.width); - runtime.stage_history_lines(std::mem::take(&mut chat.history_lines)); + runtime.stage_history_lines( + std::mem::take(&mut chat.history_lines) + .into_iter() + .map(|line| crate::ui::history_line_to_ratatui(line, &theme)), + ); let pane = BottomPaneState::from_cli(&controller.state, &chat, &theme, size.width); let layout = SurfaceLayout::new(size, controller.state.render.active_overlay, &pane); runtime diff --git a/crates/cli/src/bottom_pane/model.rs b/crates/cli/src/bottom_pane/model.rs index ebe4a678..95cf9410 100644 --- a/crates/cli/src/bottom_pane/model.rs +++ b/crates/cli/src/bottom_pane/model.rs @@ -2,8 +2,8 @@ use ratatui::text::Line; use crate::{ chat::ChatSurfaceFrame, - state::{CliState, PaletteState}, - ui::{CodexTheme, line_to_ratatui, palette_lines}, + state::{CliState, PaletteState, WrappedLine, WrappedLineStyle}, + ui::{CodexTheme, line_to_ratatui, materialize_wrapped_lines, palette_lines}, }; #[derive(Debug, Clone, PartialEq, Eq)] @@ -64,9 +64,18 @@ impl BottomPaneState { } } else { BottomPaneMode::ActiveSession { - status_line: active_status_line(state, chat), - detail_lines: chat.detail_lines.clone(), - preview_lines: chat.preview_lines.clone(), + status_line: active_status_line(state, chat) + .map(|line| line_to_ratatui(&line, theme)), + detail_lines: materialize_wrapped_lines( + &chat.detail_lines, + usize::from(width.max(1)), + theme, + ), + preview_lines: materialize_wrapped_lines( + &chat.preview_lines, + usize::from(width.max(1)), + theme, + ), } }, composer_line_count: state.interaction.composer.line_count(), @@ -145,16 +154,19 @@ fn build_welcome_lines(state: &CliState) -> Vec> { ] } -fn active_status_line(state: &CliState, chat: &ChatSurfaceFrame) -> Option> { +fn active_status_line(state: &CliState, chat: &ChatSurfaceFrame) -> Option { if state.interaction.status.is_error { - return Some(Line::from(format!( - "• {}", - state.interaction.status.message - ))); + return Some(WrappedLine::plain( + WrappedLineStyle::Plain, + format!("• {}", state.interaction.status.message), + )); } let trimmed = state.interaction.status.message.trim(); if !trimmed.is_empty() && trimmed != "ready" { - return Some(Line::from(format!("• {trimmed}"))); + return Some(WrappedLine::plain( + WrappedLineStyle::Plain, + format!("• {trimmed}"), + )); } chat.status_line.clone() } diff --git a/crates/cli/src/chat/surface.rs b/crates/cli/src/chat/surface.rs index bc6dd877..6ac2af75 100644 --- a/crates/cli/src/chat/surface.rs +++ b/crates/cli/src/chat/surface.rs @@ -1,34 +1,32 @@ -use std::collections::HashMap; - -use ratatui::text::Line; +use std::collections::HashSet; use crate::{ - state::{CliState, TranscriptCell, TranscriptCellKind, TranscriptCellStatus}, + state::{ + CliState, TranscriptCell, TranscriptCellKind, TranscriptCellStatus, WrappedLine, + WrappedLineStyle, + }, ui::{ CodexTheme, cells::{RenderableCell, TranscriptCellView}, - line_to_ratatui, }, }; -const STREAMING_ASSISTANT_TAIL_BUDGET: usize = 8; - #[derive(Debug, Clone, PartialEq, Eq, Default)] pub struct ChatSurfaceFrame { - pub history_lines: Vec>, - pub status_line: Option>, - pub detail_lines: Vec>, - pub preview_lines: Vec>, + pub history_lines: Vec, + pub status_line: Option, + pub detail_lines: Vec, + pub preview_lines: Vec, } #[derive(Debug, Clone, PartialEq, Eq, Default)] pub struct ChatSurfaceState { - committed_line_counts: HashMap, + committed_cells: HashSet, } impl ChatSurfaceState { pub fn reset(&mut self) { - self.committed_line_counts.clear(); + self.committed_cells.clear(); } pub fn build_frame( @@ -49,10 +47,10 @@ impl ChatSurfaceState { } if let Some(banner) = &state.conversation.banner { - frame.status_line = Some(Line::from(format!("• {}", banner.error.message))); + frame.status_line = Some(status_line(format!("• {}", banner.error.message))); frame.detail_lines.insert( 0, - Line::from(" 当前流需要重新同步,建议等待自动恢复或重新加载快照。"), + plain_line(" 当前流需要重新同步,建议等待自动恢复或重新加载快照。"), ); } @@ -71,26 +69,15 @@ impl ChatSurfaceState { match &cell.kind { TranscriptCellKind::Assistant { .. } => { - let committed_count = *self - .committed_line_counts - .get(cell.id.as_str()) - .unwrap_or(&0); - let (new_history, preview, stable_count) = - split_streaming_assistant_lines(rendered, committed_count); - if !new_history.is_empty() { - frame.history_lines.extend(new_history); - } - self.committed_line_counts - .insert(cell.id.clone(), stable_count); - frame.status_line = Some(Line::from("• 正在生成回复")); - frame.preview_lines = preview; + frame.status_line = Some(status_line("• 正在生成回复")); + frame.preview_lines = rendered; }, TranscriptCellKind::Thinking { .. } => { - frame.status_line = Some(Line::from("• 正在思考")); + frame.status_line = Some(status_line("• 正在思考")); frame.detail_lines = rendered; }, TranscriptCellKind::ToolCall { tool_name, .. } => { - frame.status_line = Some(Line::from(format!("• 正在运行 {tool_name}"))); + frame.status_line = Some(status_line(format!("• 正在运行 {tool_name}"))); frame.detail_lines = rendered; }, _ => {}, @@ -105,21 +92,22 @@ impl ChatSurfaceState { width: usize, frame: &mut ChatSurfaceFrame, ) { - let rendered = render_cell_lines(cell, state, theme, width); - let committed_count = *self - .committed_line_counts - .get(cell.id.as_str()) - .unwrap_or(&0); - if committed_count < rendered.len() { - frame - .history_lines - .extend(rendered.iter().skip(committed_count).cloned()); - self.committed_line_counts - .insert(cell.id.clone(), rendered.len()); + if !self.committed_cells.insert(cell.id.clone()) { + return; } + let rendered = render_cell_lines(cell, state, theme, width); + frame.history_lines.extend(rendered); } } +fn plain_line(content: impl Into) -> WrappedLine { + WrappedLine::plain(WrappedLineStyle::Plain, content) +} + +fn status_line(content: impl Into) -> WrappedLine { + WrappedLine::plain(WrappedLineStyle::Plain, content) +} + fn cell_is_streaming(cell: &TranscriptCell) -> bool { match &cell.kind { TranscriptCellKind::Assistant { status, .. } @@ -136,42 +124,22 @@ fn render_cell_lines( state: &CliState, theme: &CodexTheme, width: usize, -) -> Vec> { +) -> Vec { let view = TranscriptCellView { selected: false, expanded: state.is_cell_expanded(cell.id.as_str()) || cell.expanded, thinking: thinking_state_for_cell(cell, state), }; cell.render_lines(width, state.shell.capabilities, theme, &view) - .into_iter() - .map(|line| line_to_ratatui(&line, theme)) - .collect() } -fn trim_trailing_blank_lines(mut lines: Vec>) -> Vec> { - while lines - .last() - .is_some_and(|line| line.spans.is_empty() || line.to_string().is_empty()) - { +fn trim_trailing_blank_lines(mut lines: Vec) -> Vec { + while lines.last().is_some_and(WrappedLine::is_blank) { lines.pop(); } lines } -fn split_streaming_assistant_lines( - lines: Vec>, - committed_count: usize, -) -> (Vec>, Vec>, usize) { - let stable_count = lines.len().saturating_sub(STREAMING_ASSISTANT_TAIL_BUDGET); - let new_history = if stable_count > committed_count { - lines[committed_count..stable_count].to_vec() - } else { - Vec::new() - }; - let preview = lines[stable_count.min(lines.len())..].to_vec(); - (new_history, preview, stable_count) -} - fn thinking_state_for_cell( cell: &TranscriptCell, state: &CliState, @@ -194,12 +162,11 @@ mod tests { AstrcodeConversationAssistantBlockDto, AstrcodeConversationBlockDto, AstrcodeConversationBlockStatusDto, }; - use ratatui::text::Line; use super::ChatSurfaceState; use crate::{ capability::{ColorLevel, GlyphMode, TerminalCapabilities}, - state::CliState, + state::{CliState, WrappedLine}, ui::CodexTheme, }; @@ -226,8 +193,8 @@ mod tests { }) } - fn line_texts(lines: &[Line<'static>]) -> Vec { - lines.iter().map(|line| line.to_string()).collect() + fn line_texts(lines: &[WrappedLine]) -> Vec { + lines.iter().map(WrappedLine::text).collect() } #[test] @@ -250,9 +217,8 @@ mod tests { let history = line_texts(&frame.history_lines); let preview = line_texts(&frame.preview_lines); - assert!(history.iter().any(|line| line.contains("第1项"))); - assert!(history.iter().any(|line| line.contains("第2项"))); - assert!(!preview.iter().any(|line| line.contains("第1项"))); + assert!(history.is_empty()); + assert!(preview.iter().any(|line| line.contains("第1项"))); assert!(preview.iter().any(|line| line.contains("第6项"))); let second = surface.build_frame(&state, &theme, 28); @@ -282,6 +248,7 @@ mod tests { let completed = surface.build_frame(&state, &theme, 80); let history = line_texts(&completed.history_lines); + assert!(history.iter().any(|line| line.contains("前言"))); assert!(history.iter().any(|line| line.contains("- 第一项"))); assert!(history.iter().any(|line| line.contains("- 第二项"))); assert!(history.iter().any(|line| line.contains("第10行"))); diff --git a/crates/cli/src/render/commit.rs b/crates/cli/src/render/commit.rs deleted file mode 100644 index 5c6016f6..00000000 --- a/crates/cli/src/render/commit.rs +++ /dev/null @@ -1,2 +0,0 @@ -#[cfg(test)] -mod tests {} diff --git a/crates/cli/src/render/live.rs b/crates/cli/src/render/live.rs deleted file mode 100644 index 5c6016f6..00000000 --- a/crates/cli/src/render/live.rs +++ /dev/null @@ -1,2 +0,0 @@ -#[cfg(test)] -mod tests {} diff --git a/crates/cli/src/render/mod.rs b/crates/cli/src/render/mod.rs index 1d786404..d425d5e4 100644 --- a/crates/cli/src/render/mod.rs +++ b/crates/cli/src/render/mod.rs @@ -1,3 +1 @@ -pub mod commit; -pub mod live; pub mod wrap; diff --git a/crates/cli/src/state/mod.rs b/crates/cli/src/state/mod.rs index 0e60475a..c6364d5e 100644 --- a/crates/cli/src/state/mod.rs +++ b/crates/cli/src/state/mod.rs @@ -19,7 +19,10 @@ pub use interaction::{ ComposerState, InteractionState, PaletteSelection, PaletteState, PaneFocus, ResumePaletteState, SlashPaletteState, StatusLine, }; -pub use render::{ActiveOverlay, RenderState, StreamViewState, WrappedLine, WrappedLineStyle}; +pub use render::{ + ActiveOverlay, RenderState, StreamViewState, WrappedLine, WrappedLineRewrapPolicy, + WrappedLineStyle, WrappedSpan, WrappedSpanStyle, +}; pub use shell::ShellState; pub use thinking::{ThinkingPlaybackDriver, ThinkingPresentationState, ThinkingSnippetPool}; pub use transcript_cell::{TranscriptCell, TranscriptCellKind, TranscriptCellStatus}; diff --git a/crates/cli/src/state/render.rs b/crates/cli/src/state/render.rs index 7162e3d3..150a92ca 100644 --- a/crates/cli/src/state/render.rs +++ b/crates/cli/src/state/render.rs @@ -5,9 +5,95 @@ use super::StreamRenderMode; #[derive(Debug, Clone, PartialEq, Eq)] pub struct WrappedLine { pub style: WrappedLineStyle, + pub rewrap_policy: WrappedLineRewrapPolicy, + pub spans: Vec, +} + +impl WrappedLine { + pub fn plain(style: WrappedLineStyle, content: impl Into) -> Self { + let content = content.into(); + let spans = if content.is_empty() { + Vec::new() + } else { + vec![WrappedSpan::plain(content)] + }; + Self { + style, + rewrap_policy: WrappedLineRewrapPolicy::Reflow, + spans, + } + } + + pub fn from_spans(style: WrappedLineStyle, spans: Vec) -> Self { + Self { + style, + rewrap_policy: WrappedLineRewrapPolicy::Reflow, + spans, + } + } + + pub fn with_rewrap_policy(mut self, rewrap_policy: WrappedLineRewrapPolicy) -> Self { + self.rewrap_policy = rewrap_policy; + self + } + + pub fn text(&self) -> String { + self.spans + .iter() + .map(|span| span.content.as_str()) + .collect::() + } + + pub fn is_blank(&self) -> bool { + self.spans.is_empty() || self.text().is_empty() + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WrappedLineRewrapPolicy { + Reflow, + PreserveAndCrop, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WrappedSpan { + pub style: Option, pub content: String, } +impl WrappedSpan { + pub fn plain(content: impl Into) -> Self { + Self { + style: None, + content: content.into(), + } + } + + pub fn styled(style: WrappedSpanStyle, content: impl Into) -> Self { + Self { + style: Some(style), + content: content.into(), + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WrappedSpanStyle { + Strong, + Emphasis, + Heading, + HeadingRule, + TableBorder, + TableHeader, + InlineCode, + Link, + ListMarker, + QuoteMarker, + CodeFence, + CodeText, + TextArt, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum WrappedLineStyle { Plain, diff --git a/crates/cli/src/tui/runtime.rs b/crates/cli/src/tui/runtime.rs index 6457a56e..861db289 100644 --- a/crates/cli/src/tui/runtime.rs +++ b/crates/cli/src/tui/runtime.rs @@ -3,10 +3,10 @@ use std::{io, io::Write}; use ratatui::{ backend::Backend, layout::{Offset, Rect, Size}, - text::Line, }; use crate::ui::{ + HistoryLine, custom_terminal::{Frame, Terminal}, insert_history::insert_history_lines, }; @@ -17,8 +17,8 @@ where B: Backend + Write, { terminal: Terminal, - pending_history_lines: Vec>, - deferred_history_lines: Vec>, + pending_history_lines: Vec, + deferred_history_lines: Vec, overlay_open: bool, } @@ -50,7 +50,7 @@ where pub fn stage_history_lines(&mut self, lines: I) where - I: IntoIterator>, + I: IntoIterator, { self.pending_history_lines.extend(lines); } diff --git a/crates/cli/src/ui/cells.rs b/crates/cli/src/ui/cells.rs index 05d7352d..7ba16905 100644 --- a/crates/cli/src/ui/cells.rs +++ b/crates/cli/src/ui/cells.rs @@ -1,13 +1,15 @@ -use std::borrow::Cow; +use unicode_width::UnicodeWidthStr; -use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; - -use super::{theme::ThemePalette, truncate_to_width}; +use super::{ + markdown::{render_literal_text, render_markdown_lines, render_preformatted_block}, + theme::ThemePalette, + truncate_to_width, +}; use crate::{ capability::TerminalCapabilities, state::{ ThinkingPresentationState, TranscriptCell, TranscriptCellKind, TranscriptCellStatus, - WrappedLine, WrappedLineStyle, + WrappedLine, WrappedLineStyle, WrappedSpan, }, }; @@ -134,47 +136,38 @@ fn render_message( view: &TranscriptCellView, is_user: bool, ) -> Vec { - let first_prefix = format!( - "{} ", - if is_user { - prompt_marker(theme) - } else { - assistant_marker(theme) - } - ); + let first_prefix = if is_user { + format!("{} ", prompt_marker(theme)) + } else { + format!("{} ", assistant_marker(theme)) + }; let subsequent_prefix = " ".repeat(display_width(first_prefix.as_str())); let wrapped = if is_user { - wrap_literal_text( + render_literal_lines( body, width.saturating_sub(display_width(first_prefix.as_str())), capabilities, + view.resolve_style(WrappedLineStyle::PromptEcho), ) } else { - wrap_text( + render_markdown_lines( body, width.saturating_sub(display_width(first_prefix.as_str())), capabilities, + view.resolve_style(WrappedLineStyle::Plain), ) }; - let style = view.resolve_style(if is_user { - WrappedLineStyle::PromptEcho - } else { - WrappedLineStyle::Plain - }); + let mut lines = Vec::new(); for (index, line) in wrapped.into_iter().enumerate() { - lines.push(WrappedLine { - style, - content: format!( - "{}{}", - if index == 0 { - first_prefix.as_str() - } else { - subsequent_prefix.as_str() - }, - line - ), - }); + lines.push(prepend_prefix( + line, + if index == 0 { + plain_prefix(first_prefix.as_str()) + } else { + plain_prefix(subsequent_prefix.as_str()) + }, + )); } lines.push(blank_line()); lines @@ -183,54 +176,53 @@ fn render_message( fn render_thinking_cell( width: usize, capabilities: TerminalCapabilities, - theme: &dyn ThemePalette, + _theme: &dyn ThemePalette, view: &TranscriptCellView, ) -> Vec { - let Some(thinking) = &view.thinking else { - return Vec::new(); + let Some(thinking) = view.thinking.as_ref() else { + return vec![blank_line()]; }; if !view.expanded { return vec![ - WrappedLine { - style: view.resolve_style(WrappedLineStyle::ThinkingLabel), - content: truncate_to_width( - format!("{} {}", thinking_marker(theme), thinking.summary).as_str(), + plain_line( + view.resolve_style(WrappedLineStyle::ThinkingLabel), + truncate_to_width( + format!("{} {}", thinking_marker(_theme), thinking.summary).as_str(), width, ), - }, - WrappedLine { - style: view.resolve_style(WrappedLineStyle::ThinkingPreview), - content: truncate_to_width( - format!(" {} {}", thinking_preview_prefix(theme), thinking.preview).as_str(), + ), + plain_line( + view.resolve_style(WrappedLineStyle::ThinkingPreview), + truncate_to_width( + format!(" {} {}", thinking_preview_prefix(_theme), thinking.preview).as_str(), width, ), - }, + ), blank_line(), ]; } - let mut lines = vec![WrappedLine { - style: view.resolve_style(WrappedLineStyle::ThinkingLabel), - content: format!("{} {}", thinking_marker(theme), thinking.summary), - }]; - lines.push(WrappedLine { - style: view.resolve_style(WrappedLineStyle::ThinkingPreview), - content: format!(" {}", thinking.hint), - }); - for line in wrap_text( + let mut lines = vec![plain_line( + view.resolve_style(WrappedLineStyle::ThinkingLabel), + format!("{} {}", thinking_marker(_theme), thinking.summary), + )]; + lines.push(plain_line( + view.resolve_style(WrappedLineStyle::ThinkingPreview), + format!(" {}", thinking.hint), + )); + for line in render_markdown_lines( thinking.expanded_body.as_str(), width.saturating_sub(2), capabilities, + view.resolve_style(WrappedLineStyle::ThinkingBody), ) { - lines.push(WrappedLine { - style: view.resolve_style(WrappedLineStyle::ThinkingBody), - content: format!(" {line}"), - }); + lines.push(prepend_prefix(line, plain_prefix(" "))); } lines.push(blank_line()); lines } +#[derive(Debug, Clone, Copy)] struct ToolCallView<'a> { tool_name: &'a str, summary: &'a str, @@ -250,68 +242,69 @@ fn render_tool_call_cell( theme: &dyn ThemePalette, view: &TranscriptCellView, ) -> Vec { - let mut lines = vec![WrappedLine { - style: view.resolve_style(WrappedLineStyle::ToolLabel), - content: truncate_to_width( + let mut lines = vec![plain_line( + view.resolve_style(WrappedLineStyle::ToolLabel), + truncate_to_width( format!( "{} tool {}{} · {}", tool_marker(theme), tool.tool_name, - status_suffix(tool.status), - tool.summary.trim() + if tool.truncated { " · truncated" } else { "" }, + tool.summary ) .as_str(), width, ), - }]; + )]; if view.expanded { let mut metadata = Vec::new(); + metadata.push(match tool.status { + TranscriptCellStatus::Streaming => "streaming".to_string(), + TranscriptCellStatus::Complete => "complete".to_string(), + TranscriptCellStatus::Failed => "failed".to_string(), + TranscriptCellStatus::Cancelled => "cancelled".to_string(), + }); if let Some(duration_ms) = tool.duration_ms { - metadata.push(format!("duration {duration_ms}ms")); - } - if tool.truncated { - metadata.push("output truncated".to_string()); + metadata.push(format!("{duration_ms}ms")); } - if let Some(child_session_id) = tool.child_session_id.filter(|value| !value.is_empty()) { + if let Some(child_session_id) = tool.child_session_id { metadata.push(format!("child session {child_session_id}")); } if !metadata.is_empty() { - lines.push(WrappedLine { - style: view.resolve_style(WrappedLineStyle::ToolBody), - content: format!(" meta {}", metadata.join(" · ")), - }); + lines.push(plain_line( + view.resolve_style(WrappedLineStyle::ToolBody), + format!(" meta {}", metadata.join(" · ")), + )); } if !tool.stdout.trim().is_empty() { append_preformatted_tool_section( &mut lines, "stdout", - tool.stdout.trim_end(), + tool.stdout, width, capabilities, theme, view, ); } - if !tool.stderr.trim().is_empty() { append_preformatted_tool_section( &mut lines, "stderr", - tool.stderr.trim_end(), + tool.stderr, width, capabilities, theme, view, ); } - - if let Some(error) = tool.error.filter(|value| !value.trim().is_empty()) { + if let Some(error) = tool.error { append_preformatted_tool_section( &mut lines, "error", - error.trim(), + error, width, capabilities, theme, @@ -334,15 +327,12 @@ fn append_preformatted_tool_section( view: &TranscriptCellView, ) { let section_style = view.resolve_style(WrappedLineStyle::ToolBody); - lines.push(WrappedLine { - style: section_style, - content: format!(" {label}"), - }); + lines.push(plain_line(section_style, format!(" {label}"))); for line in render_preformatted_block(body, width.saturating_sub(4), capabilities) { - lines.push(WrappedLine { - style: section_style, - content: format!(" {} {line}", tool_block_marker(theme)), - }); + lines.push(plain_line( + section_style, + format!(" {} {line}", tool_block_marker(theme)), + )); } } @@ -355,17 +345,60 @@ fn render_secondary_line( style: WrappedLineStyle, render_mode: MarkdownRenderMode, ) -> Vec { + let base_style = view.resolve_style(style); + let rendered = match render_mode { + MarkdownRenderMode::Literal => { + render_literal_lines(body, width.saturating_sub(2), capabilities, base_style) + }, + MarkdownRenderMode::Display => { + render_markdown_lines(body, width.saturating_sub(2), capabilities, base_style) + }, + }; + let mut lines = Vec::new(); - for line in wrap_text_with_mode(body, width.saturating_sub(2), capabilities, render_mode) { - lines.push(WrappedLine { - style: view.resolve_style(style), - content: format!("{} {line}", secondary_marker(theme)), - }); + for line in rendered { + lines.push(prepend_prefix( + line, + plain_prefix(format!("{} ", secondary_marker(theme)).as_str()), + )); } lines.push(blank_line()); lines } +fn render_literal_lines( + text: &str, + width: usize, + capabilities: TerminalCapabilities, + style: WrappedLineStyle, +) -> Vec { + render_literal_text(text, width, capabilities) + .into_iter() + .map(|line| plain_line(style, line)) + .collect() +} + +fn plain_line(style: WrappedLineStyle, content: impl Into) -> WrappedLine { + WrappedLine::plain(style, content) +} + +fn plain_prefix(prefix: &str) -> Vec { + if prefix.is_empty() { + Vec::new() + } else { + vec![WrappedSpan::plain(prefix.to_string())] + } +} + +fn prepend_prefix(mut line: WrappedLine, mut prefix: Vec) -> WrappedLine { + if prefix.is_empty() { + return line; + } + prefix.extend(line.spans); + line.spans = prefix; + line +} + fn prompt_marker(theme: &dyn ThemePalette) -> &'static str { theme.glyph("›", ">") } @@ -375,15 +408,15 @@ fn assistant_marker(theme: &dyn ThemePalette) -> &'static str { } fn thinking_marker(theme: &dyn ThemePalette) -> &'static str { - theme.glyph("∴", "~") + theme.glyph("◦", "o") } fn thinking_preview_prefix(theme: &dyn ThemePalette) -> &'static str { - theme.glyph("└", "|") + theme.glyph("↳", ">") } fn tool_marker(theme: &dyn ThemePalette) -> &'static str { - theme.glyph("↳", "=") + theme.glyph("◆", "+") } fn secondary_marker(theme: &dyn ThemePalette) -> &'static str { @@ -395,10 +428,7 @@ fn tool_block_marker(theme: &dyn ThemePalette) -> &'static str { } fn blank_line() -> WrappedLine { - WrappedLine { - style: WrappedLineStyle::Plain, - content: String::new(), - } + plain_line(WrappedLineStyle::Plain, String::new()) } fn status_suffix(status: TranscriptCellStatus) -> &'static str { @@ -410,900 +440,6 @@ fn status_suffix(status: TranscriptCellStatus) -> &'static str { } } -pub fn wrap_text(text: &str, width: usize, capabilities: TerminalCapabilities) -> Vec { - wrap_text_with_mode(text, width, capabilities, MarkdownRenderMode::Display) -} - -fn wrap_literal_text(text: &str, width: usize, capabilities: TerminalCapabilities) -> Vec { - wrap_text_with_mode(text, width, capabilities, MarkdownRenderMode::Literal) -} - -fn wrap_text_with_mode( - text: &str, - width: usize, - capabilities: TerminalCapabilities, - render_mode: MarkdownRenderMode, -) -> Vec { - if width == 0 { - return vec![String::new()]; - } - let mut output = Vec::new(); - let source_lines = text.lines().collect::>(); - let mut index = 0; - let mut in_fence = false; - let mut fence_marker = ""; - - while index < source_lines.len() { - let line = source_lines[index]; - let trimmed = line.trim_end(); - - if trimmed.is_empty() { - output.push(String::new()); - index += 1; - continue; - } - - if matches!(render_mode, MarkdownRenderMode::Display) { - if is_horizontal_rule(trimmed) { - output.push(render_horizontal_rule(width, capabilities)); - index += 1; - continue; - } - - if let Some((level, heading)) = parse_heading(trimmed) { - let heading = normalize_inline_markdown(heading, render_mode); - output.extend(render_heading(level, heading.as_str(), width, capabilities)); - index += 1; - continue; - } - } - - if let Some(marker) = fence_delimiter(trimmed) { - in_fence = !in_fence; - fence_marker = if in_fence { marker } else { "" }; - output.extend(wrap_preformatted_line(trimmed, width, capabilities)); - index += 1; - continue; - } - - if in_fence { - if !fence_marker.is_empty() && trimmed.trim_start().starts_with(fence_marker) { - in_fence = false; - fence_marker = ""; - } - output.extend(wrap_preformatted_line(trimmed, width, capabilities)); - index += 1; - continue; - } - - if is_table_line(trimmed) { - let mut block = Vec::new(); - while index < source_lines.len() && is_table_line(source_lines[index].trim_end()) { - block.push(source_lines[index].trim_end()); - index += 1; - } - output.extend(render_table_block(&block, width, capabilities, render_mode)); - continue; - } - - if let Some((prefix, body)) = parse_list_prefix(trimmed) { - let body = normalize_inline_markdown(body, render_mode); - output.extend(wrap_with_prefix( - body.as_str(), - width, - capabilities, - &prefix, - &indent_like(&prefix), - )); - index += 1; - continue; - } - - if let Some((prefix, body)) = parse_quote_prefix(trimmed) { - let body = normalize_inline_markdown(body, render_mode); - output.extend(wrap_with_prefix( - body.as_str(), - width, - capabilities, - &prefix, - &indent_like(&prefix), - )); - index += 1; - continue; - } - - if is_preformatted_line(line) { - output.extend(wrap_preformatted_line(trimmed, width, capabilities)); - index += 1; - continue; - } - - let mut paragraph = vec![trimmed.trim()]; - index += 1; - while index < source_lines.len() { - let next = source_lines[index].trim_end(); - if next.is_empty() - || fence_delimiter(next).is_some() - || is_table_line(next) - || parse_list_prefix(next).is_some() - || parse_quote_prefix(next).is_some() - || is_preformatted_line(source_lines[index]) - { - break; - } - paragraph.push(next.trim()); - index += 1; - } - let paragraph = normalize_inline_markdown(paragraph.join(" ").as_str(), render_mode); - output.extend(wrap_paragraph(paragraph.as_str(), width, capabilities)); - } - - if output.is_empty() { - output.push(String::new()); - } - output -} - -fn fence_delimiter(line: &str) -> Option<&'static str> { - let trimmed = line.trim_start(); - if trimmed.starts_with("```") { - Some("```") - } else if trimmed.starts_with("~~~") { - Some("~~~") - } else { - None - } -} - -fn is_table_line(line: &str) -> bool { - let trimmed = line.trim(); - trimmed.starts_with('|') && trimmed.ends_with('|') && trimmed.matches('|').count() >= 2 -} - -fn is_preformatted_line(line: &str) -> bool { - line.starts_with(" ") || line.starts_with('\t') -} - -fn parse_list_prefix(line: &str) -> Option<(String, &str)> { - let indent_width = line.chars().take_while(|ch| ch.is_whitespace()).count(); - let trimmed = line.trim_start(); - - for marker in ["- ", "* ", "+ "] { - if let Some(rest) = trimmed.strip_prefix(marker) { - return Some(( - format!("{}{}", " ".repeat(indent_width), marker), - rest.trim_start(), - )); - } - } - - let digits = trimmed - .chars() - .take_while(|ch| ch.is_ascii_digit()) - .collect::(); - if digits.is_empty() { - return None; - } - let remainder = &trimmed[digits.len()..]; - for punct in [". ", ") "] { - if let Some(rest) = remainder.strip_prefix(punct) { - return Some(( - format!("{}{}{}", " ".repeat(indent_width), digits, punct), - rest.trim_start(), - )); - } - } - None -} - -fn parse_quote_prefix(line: &str) -> Option<(String, &str)> { - let indent_width = line.chars().take_while(|ch| ch.is_whitespace()).count(); - let trimmed = line.trim_start(); - trimmed - .strip_prefix("> ") - .map(|rest| (format!("{}> ", " ".repeat(indent_width)), rest.trim_start())) -} - -fn parse_heading(line: &str) -> Option<(usize, &str)> { - let trimmed = line.trim(); - let hashes = trimmed.bytes().take_while(|byte| *byte == b'#').count(); - if hashes == 0 || hashes > 6 { - return None; - } - let body = trimmed.get(hashes..)?.strip_prefix(' ')?; - Some((hashes, body.trim_end().trim_end_matches('#').trim_end())) -} - -fn is_horizontal_rule(line: &str) -> bool { - let compact = line - .chars() - .filter(|ch| !ch.is_whitespace()) - .collect::(); - if compact.len() < 3 { - return false; - } - let mut chars = compact.chars(); - let Some(first) = chars.next() else { - return false; - }; - matches!(first, '-' | '*' | '_') && chars.all(|ch| ch == first) -} - -fn render_horizontal_rule(width: usize, capabilities: TerminalCapabilities) -> String { - let glyph = if capabilities.ascii_only() { - "-" - } else { - "─" - }; - glyph.repeat(width.clamp(3, 48)) -} - -fn render_heading( - level: usize, - body: &str, - width: usize, - capabilities: TerminalCapabilities, -) -> Vec { - let normalized = body.trim(); - if normalized.is_empty() { - return vec![String::new()]; - } - - let underline_glyph = match (level, capabilities.ascii_only()) { - (1, false) => "═", - (1, true) => "=", - (_, false) => "─", - (_, true) => "-", - }; - let heading_width = display_width(normalized).clamp(3, width.max(3).min(48)); - let mut lines = wrap_paragraph(normalized, width, capabilities); - if level <= 2 { - lines.push(underline_glyph.repeat(heading_width)); - } - lines -} - -fn normalize_inline_markdown(text: &str, render_mode: MarkdownRenderMode) -> String { - match render_mode { - MarkdownRenderMode::Literal => text.to_string(), - MarkdownRenderMode::Display => render_inline_markdown(text), - } -} - -fn render_inline_markdown(text: &str) -> String { - let chars = text.chars().collect::>(); - let mut output = String::new(); - let mut index = 0; - - while index < chars.len() { - if chars[index] == '\\' { - if let Some(next) = chars.get(index + 1) { - output.push(*next); - index += 2; - } else { - index += 1; - } - continue; - } - - if let Some((rendered, next)) = parse_inline_code(&chars, index) { - output.push_str(rendered.as_str()); - index = next; - continue; - } - - if let Some((rendered, next)) = parse_link_or_image(&chars, index) { - output.push_str(rendered.as_str()); - index = next; - continue; - } - - if let Some((rendered, next)) = parse_autolink(&chars, index) { - output.push_str(rendered.as_str()); - index = next; - continue; - } - - if let Some((rendered, next)) = parse_delimited_span(&chars, index, "**") { - output.push_str(rendered.as_str()); - index = next; - continue; - } - - if let Some((rendered, next)) = parse_delimited_span(&chars, index, "~~") { - output.push_str(rendered.as_str()); - index = next; - continue; - } - - if let Some((rendered, next)) = parse_delimited_span(&chars, index, "*") { - output.push_str(rendered.as_str()); - index = next; - continue; - } - - output.push(chars[index]); - index += 1; - } - - output -} - -fn parse_inline_code(chars: &[char], index: usize) -> Option<(String, usize)> { - if chars.get(index) != Some(&'`') { - return None; - } - let closing = chars[index + 1..] - .iter() - .position(|ch| *ch == '`') - .map(|offset| index + 1 + offset)?; - Some(( - chars[index + 1..closing].iter().collect::(), - closing + 1, - )) -} - -fn parse_link_or_image(chars: &[char], index: usize) -> Option<(String, usize)> { - let is_image = chars.get(index) == Some(&'!') && chars.get(index + 1) == Some(&'['); - let label_start = if is_image { - index + 1 - } else if chars.get(index) == Some(&'[') { - index - } else { - return None; - }; - - let (label, next_index) = parse_bracketed(chars, label_start, '[', ']')?; - if chars.get(next_index) != Some(&'(') { - return None; - } - let (destination, end_index) = parse_bracketed(chars, next_index, '(', ')')?; - - let label = render_inline_markdown(label.as_str()); - let destination = destination.trim().to_string(); - - let rendered = if is_image { - if label.is_empty() { - "[image]".to_string() - } else { - label - } - } else if destination.is_empty() || label.is_empty() || label == destination { - label - } else { - format!("{label} ({destination})") - }; - - Some((rendered, end_index)) -} - -fn parse_autolink(chars: &[char], index: usize) -> Option<(String, usize)> { - if chars.get(index) != Some(&'<') { - return None; - } - let closing = chars[index + 1..] - .iter() - .position(|ch| *ch == '>') - .map(|offset| index + 1 + offset)?; - let body = chars[index + 1..closing].iter().collect::(); - if body.contains("://") || body.contains('@') { - Some((body, closing + 1)) - } else { - None - } -} - -fn parse_delimited_span(chars: &[char], index: usize, marker: &str) -> Option<(String, usize)> { - if !matches_marker(chars, index, marker) || !emphasis_can_open(chars, index, marker) { - return None; - } - let marker_width = marker.chars().count(); - let mut cursor = index + marker_width; - while cursor < chars.len() { - if matches_marker(chars, cursor, marker) && emphasis_can_close(chars, cursor, marker) { - let inner = chars[index + marker_width..cursor] - .iter() - .collect::(); - return Some(( - render_inline_markdown(inner.as_str()), - cursor + marker_width, - )); - } - cursor += 1; - } - None -} - -fn matches_marker(chars: &[char], index: usize, marker: &str) -> bool { - for (offset, expected) in marker.chars().enumerate() { - if chars.get(index + offset) != Some(&expected) { - return false; - } - } - true -} - -fn emphasis_can_open(chars: &[char], index: usize, marker: &str) -> bool { - let marker_width = marker.chars().count(); - let prev = index - .checked_sub(1) - .and_then(|position| chars.get(position)); - let next = chars.get(index + marker_width); - next.is_some_and(|ch| !ch.is_whitespace()) && prev.is_none_or(|ch| emphasis_boundary(*ch)) -} - -fn emphasis_can_close(chars: &[char], index: usize, marker: &str) -> bool { - let marker_width = marker.chars().count(); - let prev = index - .checked_sub(1) - .and_then(|position| chars.get(position)); - let next = chars.get(index + marker_width); - prev.is_some_and(|ch| !ch.is_whitespace()) && next.is_none_or(|ch| emphasis_boundary(*ch)) -} - -fn emphasis_boundary(ch: char) -> bool { - !ch.is_alphanumeric() -} - -fn parse_bracketed( - chars: &[char], - index: usize, - open: char, - close: char, -) -> Option<(String, usize)> { - if chars.get(index) != Some(&open) { - return None; - } - - let mut cursor = index + 1; - let mut body = String::new(); - while cursor < chars.len() { - match chars[cursor] { - '\\' => { - if let Some(next) = chars.get(cursor + 1) { - body.push(*next); - cursor += 2; - } else { - cursor += 1; - } - }, - ch if ch == close => return Some((body, cursor + 1)), - ch => { - body.push(ch); - cursor += 1; - }, - } - } - None -} - -fn indent_like(prefix: &str) -> String { - " ".repeat(display_width(prefix)) -} - -fn wrap_paragraph(text: &str, width: usize, capabilities: TerminalCapabilities) -> Vec { - wrap_with_prefix(text, width, capabilities, "", "") -} - -fn wrap_with_prefix( - text: &str, - width: usize, - capabilities: TerminalCapabilities, - first_prefix: &str, - subsequent_prefix: &str, -) -> Vec { - let mut lines = Vec::new(); - let first_prefix_width = display_width(first_prefix); - let subsequent_prefix_width = display_width(subsequent_prefix); - let first_available = width.saturating_sub(first_prefix_width).max(1); - let subsequent_available = width.saturating_sub(subsequent_prefix_width).max(1); - - let mut current = first_prefix.to_string(); - let mut current_width = first_prefix_width; - let mut current_prefix = first_prefix; - let mut current_available = first_available; - - for token in text.split_whitespace() { - for chunk in split_token_by_width(token, current_available.max(1), capabilities) { - let chunk_width = display_width(chunk.as_ref()); - let needs_space = current_width > display_width(current_prefix); - let next_width = current_width + usize::from(needs_space) + chunk_width; - if next_width <= width { - if needs_space { - current.push(' '); - current_width += 1; - } - current.push_str(chunk.as_ref()); - current_width += chunk_width; - continue; - } - - if current_width > display_width(current_prefix) { - lines.push(current); - current = subsequent_prefix.to_string(); - current_width = subsequent_prefix_width; - current_prefix = subsequent_prefix; - current_available = subsequent_available; - } - - if current_width == display_width(current_prefix) { - current.push_str(chunk.as_ref()); - current_width += chunk_width; - } - } - } - - if current_width > display_width(current_prefix) || lines.is_empty() { - lines.push(current); - } - lines -} - -fn wrap_preformatted_line( - line: &str, - width: usize, - capabilities: TerminalCapabilities, -) -> Vec { - let indent = line - .chars() - .take_while(|ch| ch.is_whitespace()) - .collect::(); - let content = line[indent.len()..].trim_end(); - let prefix_width = display_width(indent.as_str()); - let available = width.saturating_sub(prefix_width).max(1); - let chunks = split_preserving_width(content, available, capabilities); - if chunks.is_empty() { - return vec![indent]; - } - chunks - .into_iter() - .map(|chunk| format!("{indent}{chunk}")) - .collect() -} - -fn render_preformatted_block( - body: &str, - width: usize, - capabilities: TerminalCapabilities, -) -> Vec { - let mut lines = Vec::new(); - let source_lines = body.lines().collect::>(); - let mut index = 0; - while index < source_lines.len() { - let line = source_lines[index].trim_end(); - if is_table_line(line) { - let mut block = Vec::new(); - while index < source_lines.len() && is_table_line(source_lines[index].trim_end()) { - block.push(source_lines[index].trim_end()); - index += 1; - } - lines.extend(render_table_block( - &block, - width, - capabilities, - MarkdownRenderMode::Literal, - )); - continue; - } - lines.extend(wrap_preformatted_line(line, width, capabilities)); - index += 1; - } - if lines.is_empty() { - lines.push(String::new()); - } - lines -} - -fn render_table_block( - lines: &[&str], - width: usize, - capabilities: TerminalCapabilities, - render_mode: MarkdownRenderMode, -) -> Vec { - let rows = lines - .iter() - .map(|line| parse_table_row(line, render_mode)) - .collect::>(); - let col_count = rows.iter().map(Vec::len).max().unwrap_or(0); - if col_count == 0 { - return lines - .iter() - .flat_map(|line| wrap_preformatted_line(line, width, capabilities)) - .collect(); - } - - let separator_rows = rows - .iter() - .map(|row| row.iter().all(|cell| is_table_separator(cell.as_str()))) - .collect::>(); - let mut col_widths = vec![3usize; col_count]; - for (row_index, row) in rows.iter().enumerate() { - if separator_rows[row_index] { - continue; - } - for (index, cell) in row.iter().enumerate() { - col_widths[index] = col_widths[index].max(display_width(cell.as_str()).min(40)); - } - } - - let min_widths = vec![3usize; col_count]; - let separator_width = col_count * 3 + 1; - let max_budget = width.saturating_sub(separator_width); - while col_widths.iter().sum::() > max_budget { - let Some((index, _)) = col_widths - .iter() - .enumerate() - .filter(|(index, value)| **value > min_widths[*index]) - .max_by_key(|(_, value)| **value) - else { - break; - }; - col_widths[index] = col_widths[index].saturating_sub(1); - } - - if matches!(render_mode, MarkdownRenderMode::Literal) { - return rows - .iter() - .enumerate() - .map(|(row_index, row)| { - if separator_rows[row_index] { - render_plain_table_separator(&col_widths) - } else { - render_plain_table_row(row, &col_widths) - } - }) - .collect(); - } - - render_boxed_table(rows, separator_rows, col_widths, capabilities) -} - -fn parse_table_row(line: &str, render_mode: MarkdownRenderMode) -> Vec { - line.trim() - .trim_matches('|') - .split('|') - .map(|cell| normalize_inline_markdown(cell.trim(), render_mode)) - .collect() -} - -fn is_table_separator(cell: &str) -> bool { - let trimmed = cell.trim(); - !trimmed.is_empty() - && trimmed - .chars() - .all(|ch| ch == '-' || ch == ':' || ch.is_whitespace()) -} - -fn render_plain_table_separator(col_widths: &[usize]) -> String { - let mut line = String::from("|"); - for width in col_widths { - line.push_str(&"-".repeat(width.saturating_add(2))); - line.push('|'); - } - line -} - -fn render_plain_table_row(row: &[String], col_widths: &[usize]) -> String { - let mut line = String::from("|"); - for (index, width) in col_widths.iter().enumerate() { - let cell = row.get(index).map(String::as_str).unwrap_or(""); - line.push(' '); - line.push_str(pad_to_width(truncate_to_width(cell, *width).as_str(), *width).as_str()); - line.push(' '); - line.push('|'); - } - line -} - -fn render_boxed_table( - rows: Vec>, - separator_rows: Vec, - col_widths: Vec, - capabilities: TerminalCapabilities, -) -> Vec { - let chars = table_chars(capabilities); - let mut rendered = Vec::new(); - let mut emitted_top = false; - let mut saw_header_separator = false; - let has_separator_row = separator_rows.iter().any(|is_separator| *is_separator); - - for (row_index, row) in rows.iter().enumerate() { - if separator_rows[row_index] { - if !emitted_top { - rendered.push(render_table_border( - &col_widths, - chars.top_left, - chars.top_mid, - chars.top_right, - chars.horizontal, - )); - emitted_top = true; - } - rendered.push(render_table_border( - &col_widths, - chars.mid_left, - chars.mid_mid, - chars.mid_right, - chars.horizontal, - )); - saw_header_separator = true; - continue; - } - - if !emitted_top { - rendered.push(render_table_border( - &col_widths, - chars.top_left, - chars.top_mid, - chars.top_right, - chars.horizontal, - )); - emitted_top = true; - } - - rendered.push(render_boxed_table_row(row, &col_widths, chars.vertical)); - - if !has_separator_row && row_index + 1 < rows.len() { - rendered.push(render_table_border( - &col_widths, - chars.mid_left, - chars.mid_mid, - chars.mid_right, - chars.horizontal, - )); - } else if has_separator_row - && saw_header_separator - && row_index + 1 < rows.len() - && separator_rows.get(row_index + 1).copied().unwrap_or(false) - { - rendered.push(render_table_border( - &col_widths, - chars.mid_left, - chars.mid_mid, - chars.mid_right, - chars.horizontal, - )); - } - } - - if emitted_top { - rendered.push(render_table_border( - &col_widths, - chars.bottom_left, - chars.bottom_mid, - chars.bottom_right, - chars.horizontal, - )); - } - - rendered -} - -fn render_table_border( - col_widths: &[usize], - left: &str, - middle: &str, - right: &str, - horizontal: &str, -) -> String { - let mut line = String::from(left); - for (index, width) in col_widths.iter().enumerate() { - line.push_str(&horizontal.repeat(width.saturating_add(2))); - if index + 1 == col_widths.len() { - line.push_str(right); - } else { - line.push_str(middle); - } - } - line -} - -fn render_boxed_table_row(row: &[String], col_widths: &[usize], vertical: &str) -> String { - let mut line = String::from(vertical); - for (index, width) in col_widths.iter().enumerate() { - let cell = row.get(index).map(String::as_str).unwrap_or(""); - line.push(' '); - line.push_str(pad_to_width(truncate_to_width(cell, *width).as_str(), *width).as_str()); - line.push(' '); - line.push_str(vertical); - } - line -} - -#[derive(Debug, Clone, Copy)] -struct TableChars<'a> { - top_left: &'a str, - top_mid: &'a str, - top_right: &'a str, - mid_left: &'a str, - mid_mid: &'a str, - mid_right: &'a str, - bottom_left: &'a str, - bottom_mid: &'a str, - bottom_right: &'a str, - horizontal: &'a str, - vertical: &'a str, -} - -fn table_chars(capabilities: TerminalCapabilities) -> TableChars<'static> { - if capabilities.ascii_only() { - TableChars { - top_left: "+", - top_mid: "+", - top_right: "+", - mid_left: "+", - mid_mid: "+", - mid_right: "+", - bottom_left: "+", - bottom_mid: "+", - bottom_right: "+", - horizontal: "-", - vertical: "|", - } - } else { - TableChars { - top_left: "┌", - top_mid: "┬", - top_right: "┐", - mid_left: "├", - mid_mid: "┼", - mid_right: "┤", - bottom_left: "└", - bottom_mid: "┴", - bottom_right: "┘", - horizontal: "─", - vertical: "│", - } - } -} - -fn split_token_by_width<'a>( - token: &'a str, - width: usize, - capabilities: TerminalCapabilities, -) -> Vec> { - let width = width.max(1); - if display_width(token) <= width { - return vec![Cow::Borrowed(token)]; - } - split_preserving_width(token, width, capabilities) -} - -fn split_preserving_width<'a>( - text: &'a str, - width: usize, - _capabilities: TerminalCapabilities, -) -> Vec> { - let width = width.max(1); - let mut chunks = Vec::new(); - let mut current = String::new(); - let mut current_width = 0; - - for ch in text.chars() { - let ch_width = UnicodeWidthChar::width(ch).unwrap_or(0).max(1); - if current_width + ch_width > width && !current.is_empty() { - chunks.push(Cow::Owned(current)); - current = String::new(); - current_width = 0; - } - current.push(ch); - current_width += ch_width; - } - - if !current.is_empty() { - chunks.push(Cow::Owned(current)); - } - chunks -} - -fn pad_to_width(text: &str, width: usize) -> String { - let current_width = display_width(text); - if current_width >= width { - return text.to_string(); - } - format!("{text}{}", " ".repeat(width - current_width)) -} - fn display_width(text: &str) -> usize { UnicodeWidthStr::width(text) } @@ -1312,7 +448,7 @@ fn display_width(text: &str) -> usize { mod tests { use super::{ RenderableCell, TranscriptCellView, assistant_marker, secondary_marker, thinking_marker, - tool_marker, wrap_literal_text, wrap_text, + tool_marker, }; use crate::{ capability::{ColorLevel, GlyphMode, TerminalCapabilities}, @@ -1340,109 +476,6 @@ mod tests { } } - #[test] - fn wrap_text_preserves_hanging_indent_for_lists() { - let lines = wrap_text( - "- 第一项需要被正确换行,并且后续行要和正文对齐", - 18, - unicode_capabilities(), - ); - assert!(lines[0].starts_with("- ")); - assert!(lines[1].starts_with(" ")); - } - - #[test] - fn wrap_text_breaks_cjk_without_spaces() { - let lines = wrap_text( - "这是一个没有空格但是需要自动换行的长句子", - 10, - unicode_capabilities(), - ); - assert!(lines.len() > 1); - assert!(lines.iter().all(|line| !line.is_empty())); - } - - #[test] - fn wrap_text_keeps_last_token_after_soft_wrap() { - let lines = wrap_text( - "查看 readFile (https://example.com/read-file) 与 writeFile。", - 48, - unicode_capabilities(), - ); - let joined = lines.join("\n"); - assert!(joined.contains("writeFile。")); - } - - #[test] - fn wrap_text_formats_markdown_tables() { - let lines = wrap_text( - "| 工具 | 说明 |\n| --- | --- |\n| **reviewnow** | 代码审查 |\n| `git-commit` | \ - 自动提交 |", - 32, - unicode_capabilities(), - ); - assert!(lines.iter().any(|line| line.contains("┌"))); - assert!(lines.iter().any(|line| line.contains("│ 工具"))); - assert!(lines.iter().any(|line| line.contains("reviewnow"))); - assert!(lines.iter().any(|line| line.contains("git-commit"))); - assert!(lines.iter().all(|line| !line.contains("**reviewnow**"))); - assert!(lines.iter().all(|line| !line.contains("`git-commit`"))); - assert!(lines.iter().any(|line| line.contains("└"))); - } - - #[test] - fn wrap_text_normalizes_headings_and_links() { - let lines = wrap_text( - "## 文件操作\n\n查看 [readFile](https://example.com/read-file) 与 **writeFile**。", - 48, - unicode_capabilities(), - ); - let joined = lines.join("\n"); - assert!(joined.contains("文件操作")); - assert!(joined.contains("────")); - assert!(joined.contains("readFile")); - assert!(joined.contains("https://example.com/read-file")); - assert!(joined.contains("writeFile")); - assert!(!joined.contains("## 文件操作")); - assert!(!joined.contains("**writeFile**")); - assert!(!joined.contains("[readFile]")); - } - - #[test] - fn inline_markdown_keeps_emphasis_body_before_cjk_punctuation() { - assert_eq!( - super::render_inline_markdown("**writeFile**。"), - "writeFile。" - ); - } - - #[test] - fn wrap_literal_text_preserves_user_markdown_markers() { - let lines = wrap_literal_text( - "## 用户原文\n请保留 **readFile** 和 [link](https://example.com)。", - 120, - unicode_capabilities(), - ); - let joined = lines.join("\n"); - assert!(joined.contains("## 用户原文")); - assert!(joined.contains("**readFile**")); - assert!(joined.contains("[link](https://example.com)")); - } - - #[test] - fn wrap_literal_text_keeps_plain_markdown_table_shape() { - let lines = wrap_literal_text( - "| 工具 | 说明 |\n| --- | --- |\n| readFile | 读取文件 |", - 48, - unicode_capabilities(), - ); - let joined = lines.join("\n"); - assert!(joined.contains("| 工具")); - assert!(joined.contains("---")); - assert!(joined.contains("readFile")); - assert!(!joined.contains("┌")); - } - #[test] fn ascii_markers_remain_distinct_by_cell_type() { let theme = CodexTheme::new(ascii_capabilities()); @@ -1473,9 +506,9 @@ mod tests { ); assert!(lines.len() >= 3); - assert!(lines[0].content.starts_with("• ")); - assert!(lines[1].content.starts_with(" ")); - assert!(!lines[1].content.starts_with(" ")); + assert!(lines[0].text().starts_with("• ")); + assert!(lines[1].text().starts_with(" ")); + assert!(!lines[1].text().starts_with(" ")); } #[test] @@ -1497,42 +530,8 @@ mod tests { &TranscriptCellView::default(), ); - assert!(lines.iter().any(|line| line.content == " ")); - assert!(lines.iter().any(|line| line.content.contains("- 第一项"))); - assert!(lines.iter().any(|line| line.content.contains("- 第二项"))); - } - - #[test] - fn assistant_rendering_strips_markdown_syntax_markers() { - let theme = CodexTheme::new(unicode_capabilities()); - let cell = TranscriptCell { - id: "assistant-3".to_string(), - expanded: false, - kind: TranscriptCellKind::Assistant { - body: "## 文件操作\n\n| 工具 | 说明 |\n| --- | --- |\n| **readFile** | 读取 \ - `README.md` |" - .to_string(), - status: TranscriptCellStatus::Complete, - }, - }; - - let lines = cell.render_lines( - 48, - unicode_capabilities(), - &theme, - &TranscriptCellView::default(), - ); - let rendered = lines - .iter() - .map(|line| line.content.as_str()) - .collect::>() - .join("\n"); - - assert!(rendered.contains("文件操作")); - assert!(rendered.contains("readFile")); - assert!(rendered.contains("README.md")); - assert!(!rendered.contains("## 文件操作")); - assert!(!rendered.contains("**readFile**")); - assert!(!rendered.contains("`README.md`")); + assert!(lines.iter().any(|line| line.text() == " ")); + assert!(lines.iter().any(|line| line.text().contains("- 第一项"))); + assert!(lines.iter().any(|line| line.text().contains("- 第二项"))); } } diff --git a/crates/cli/src/ui/host.rs b/crates/cli/src/ui/host.rs deleted file mode 100644 index 24ee9f8e..00000000 --- a/crates/cli/src/ui/host.rs +++ /dev/null @@ -1,149 +0,0 @@ -use std::{io, io::Write}; - -use ratatui::{backend::Backend, layout::Rect, text::Line}; - -use super::{ - custom_terminal::{Frame, Terminal}, - insert_history::insert_history_lines, -}; -use crate::model::reducer::CommittedSlice; - -const MAX_BOTTOM_PANE_HEIGHT: u16 = 6; - -pub fn bottom_pane_height_for_terminal(total_height: u16) -> u16 { - if total_height <= 8 { - 4 - } else if total_height <= 12 { - 5 - } else { - MAX_BOTTOM_PANE_HEIGHT - } -} - -#[derive(Debug)] -pub struct TerminalHost -where - B: Backend + Write, -{ - terminal: Terminal, - pending_history_lines: Vec>, - deferred_history_lines: Vec>, - last_known_size: Rect, - overlay_open: bool, -} - -impl TerminalHost -where - B: Backend + Write, -{ - pub fn with_backend(backend: B) -> io::Result { - let terminal = Terminal::with_options(backend)?; - Ok(Self::new(terminal)) - } - - pub fn new(terminal: Terminal) -> Self { - let size = terminal.size().expect("terminal size should be readable"); - Self { - terminal, - pending_history_lines: Vec::new(), - deferred_history_lines: Vec::new(), - last_known_size: Rect::new(0, 0, size.width, size.height), - overlay_open: false, - } - } - - pub fn terminal(&self) -> &Terminal { - &self.terminal - } - - pub fn terminal_mut(&mut self) -> &mut Terminal { - &mut self.terminal - } - - pub fn on_new_commits(&mut self, commits: Vec) -> bool { - if commits.is_empty() { - return false; - } - for slice in commits { - self.pending_history_lines - .extend(slice.lines.iter().cloned()); - } - true - } - - pub fn on_resize(&mut self, width: u16, height: u16) -> io::Result { - let next_size = Rect::new(0, 0, width, height); - if self.last_known_size == next_size { - return Ok(false); - } - self.last_known_size = next_size; - self.terminal.autoresize()?; - Ok(true) - } - - pub fn draw_frame( - &mut self, - viewport_height: u16, - overlay_open: bool, - render: F, - ) -> io::Result<()> - where - F: FnOnce(&mut Frame<'_>, Rect), - { - self.update_inline_viewport(viewport_height)?; - - if overlay_open { - if !self.pending_history_lines.is_empty() { - self.deferred_history_lines - .append(&mut self.pending_history_lines); - } - } else { - if self.overlay_open && !self.deferred_history_lines.is_empty() { - self.pending_history_lines - .append(&mut self.deferred_history_lines); - } - self.flush_pending_history()?; - } - self.overlay_open = overlay_open; - - self.terminal.draw(|frame| { - let area = frame.area(); - render(frame, area); - }) - } - - fn update_inline_viewport(&mut self, height: u16) -> io::Result<()> { - let size = self.terminal.size()?; - let mut area = self.terminal.viewport_area; - area.height = height.min(size.height).max(1); - area.width = size.width; - area.y = size.height.saturating_sub(area.height); - if area != self.terminal.viewport_area { - self.terminal.clear()?; - self.terminal.set_viewport_area(area); - } - Ok(()) - } - - fn flush_pending_history(&mut self) -> io::Result<()> { - if self.pending_history_lines.is_empty() { - return Ok(()); - } - let lines = std::mem::take(&mut self.pending_history_lines); - insert_history_lines(&mut self.terminal, lines)?; - self.terminal.invalidate_viewport(); - Ok(()) - } -} - -#[cfg(test)] -mod tests { - use super::bottom_pane_height_for_terminal; - - #[test] - fn bottom_pane_height_stays_small() { - assert_eq!(bottom_pane_height_for_terminal(8), 4); - assert_eq!(bottom_pane_height_for_terminal(12), 5); - assert_eq!(bottom_pane_height_for_terminal(24), 6); - } -} diff --git a/crates/cli/src/ui/insert_history.rs b/crates/cli/src/ui/insert_history.rs index 913c40c6..7912bd6b 100644 --- a/crates/cli/src/ui/insert_history.rs +++ b/crates/cli/src/ui/insert_history.rs @@ -18,11 +18,11 @@ use ratatui::{ }; use super::custom_terminal::Terminal; -use crate::render::wrap::wrap_plain_text; +use crate::ui::{HistoryLine, materialize_history_line}; pub fn insert_history_lines( terminal: &mut Terminal, - lines: Vec>, + lines: Vec, ) -> io::Result<()> where B: Backend + Write, @@ -83,13 +83,8 @@ where Ok(()) } -fn wrap_line(line: &Line<'static>, width: usize) -> Vec> { - let content = line.to_string(); - let wrapped = wrap_plain_text(content.as_str(), width.max(1)); - wrapped - .into_iter() - .map(|item| Line::from(item).style(line.style)) - .collect() +fn wrap_line(line: &HistoryLine, width: usize) -> Vec> { + materialize_history_line(line, width) } fn write_history_line( @@ -305,3 +300,78 @@ fn to_crossterm_color(color: Color) -> CColor { Color::Indexed(index) => CColor::AnsiValue(index), } } + +#[cfg(test)] +mod tests { + use ratatui::{ + style::{Color, Modifier, Style}, + text::{Line, Span}, + }; + + use super::wrap_line; + use crate::{state::WrappedLineRewrapPolicy, ui::HistoryLine}; + + #[test] + fn wrap_line_preserves_span_styles_after_rewrap() { + let link_style = Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::UNDERLINED); + let line = Line::from(vec![ + Span::raw("> "), + Span::styled("alpha beta", link_style), + ]); + + let wrapped = wrap_line( + &HistoryLine { + line, + rewrap_policy: WrappedLineRewrapPolicy::Reflow, + }, + 8, + ); + + assert_eq!(wrapped.len(), 2); + assert_eq!(wrapped[0].to_string(), "> alpha"); + assert_eq!(wrapped[1].to_string(), "beta"); + assert!(wrapped[0].spans.iter().any(|span| span.style == link_style)); + assert!(wrapped[1].spans.iter().any(|span| span.style == link_style)); + } + + #[test] + fn wrap_line_preserves_style_across_multiple_wrapped_rows() { + let code_style = Style::default().bg(Color::DarkGray); + let line = Line::from(vec![Span::styled("alpha beta gamma", code_style)]); + + let wrapped = wrap_line( + &HistoryLine { + line, + rewrap_policy: WrappedLineRewrapPolicy::Reflow, + }, + 5, + ); + + assert_eq!(wrapped.len(), 3); + assert_eq!(wrapped[0].to_string(), "alpha"); + assert_eq!(wrapped[1].to_string(), "beta"); + assert_eq!(wrapped[2].to_string(), "gamma"); + assert!( + wrapped + .iter() + .all(|line| line.spans.iter().all(|span| span.style == code_style)) + ); + } + + #[test] + fn preserve_and_crop_keeps_single_row() { + let line = Line::from("abcdefghijklmnopqrstuvwxyz"); + let wrapped = wrap_line( + &HistoryLine { + line, + rewrap_policy: WrappedLineRewrapPolicy::PreserveAndCrop, + }, + 8, + ); + + assert_eq!(wrapped.len(), 1); + assert_eq!(wrapped[0].to_string(), "abcdefg…"); + } +} diff --git a/crates/cli/src/ui/markdown.rs b/crates/cli/src/ui/markdown.rs new file mode 100644 index 00000000..e140c1b2 --- /dev/null +++ b/crates/cli/src/ui/markdown.rs @@ -0,0 +1,1879 @@ +use std::borrow::Cow; + +use pulldown_cmark::{CodeBlockKind, Event, HeadingLevel, Options, Parser, Tag}; +use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; + +use crate::{ + capability::TerminalCapabilities, + state::{ + WrappedLine, WrappedLineRewrapPolicy, WrappedLineStyle, WrappedSpan, WrappedSpanStyle, + }, +}; + +#[derive(Debug, Clone, PartialEq, Eq)] +enum InlineChunk { + Plain(String), + Styled(WrappedSpanStyle, String), +} + +impl InlineChunk { + fn plain(text: impl Into) -> Self { + Self::Plain(text.into()) + } + + fn styled(style: WrappedSpanStyle, text: impl Into) -> Self { + Self::Styled(style, text.into()) + } + + fn style(&self) -> Option { + match self { + Self::Plain(_) => None, + Self::Styled(style, _) => Some(*style), + } + } + + fn text(&self) -> &str { + match self { + Self::Plain(text) | Self::Styled(_, text) => text.as_str(), + } + } + + fn append_text(&mut self, text: &str) { + match self { + Self::Plain(current) | Self::Styled(_, current) => current.push_str(text), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum InlineNode { + Text(String), + Code(String), + SoftBreak, + HardBreak, + Styled(WrappedSpanStyle, Vec), + Link { + label: Vec, + destination: String, + }, + Image { + alt: Vec, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum BlockNode { + Paragraph(Vec), + Heading { + level: usize, + content: Vec, + }, + BlockQuote(Vec), + List { + start: Option, + items: Vec>, + }, + CodeBlock { + info: Option, + content: String, + }, + Table { + headers: Vec>, + rows: Vec>>, + }, + Rule, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum InlineTerminator { + Paragraph, + Heading, + Emphasis, + Strong, + Link, + Image, + TableCell, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum BlockTerminator { + BlockQuote, + Item, +} + +#[derive(Debug, Clone, Copy)] +struct TableChars<'a> { + top_left: &'a str, + top_mid: &'a str, + top_right: &'a str, + mid_left: &'a str, + mid_mid: &'a str, + mid_right: &'a str, + bottom_left: &'a str, + bottom_mid: &'a str, + bottom_right: &'a str, + horizontal: &'a str, + vertical: &'a str, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct RichTableRow { + cells: Vec>, + is_separator: bool, + is_header: bool, +} + +pub(crate) fn render_markdown_lines( + text: &str, + width: usize, + capabilities: TerminalCapabilities, + style: WrappedLineStyle, +) -> Vec { + if width == 0 { + return vec![WrappedLine::plain(style, String::new())]; + } + + let segments = split_markdown_segments(text); + let mut output = Vec::new(); + + for segment in segments { + match segment { + MarkdownSegment::Blank => output.push(WrappedLine::plain(style, String::new())), + MarkdownSegment::Text(block) => { + let nodes = parse_markdown_to_blocks(block.as_str()); + output.extend(layout_blocks(&nodes, width, capabilities, style)); + }, + MarkdownSegment::Preformatted(lines) => output.extend(render_preserved_lines( + &strip_pseudo_fence_lines(lines), + style, + WrappedSpanStyle::TextArt, + )), + } + } + + if output.is_empty() { + output.push(WrappedLine::plain(style, String::new())); + } + output +} + +#[cfg(test)] +pub(crate) fn wrap_text( + text: &str, + width: usize, + capabilities: TerminalCapabilities, +) -> Vec { + render_markdown_lines(text, width, capabilities, WrappedLineStyle::Plain) + .into_iter() + .map(|line| line.text()) + .collect() +} + +pub(crate) fn render_literal_text( + text: &str, + width: usize, + capabilities: TerminalCapabilities, +) -> Vec { + if width == 0 { + return vec![String::new()]; + } + + let mut output = Vec::new(); + for line in text.lines() { + let trimmed = line.trim_end(); + if trimmed.is_empty() { + output.push(String::new()); + continue; + } + output.extend(wrap_paragraph(trimmed, width, capabilities)); + } + + if output.is_empty() { + output.push(String::new()); + } + output +} + +pub(crate) fn render_preformatted_block( + body: &str, + _width: usize, + _capabilities: TerminalCapabilities, +) -> Vec { + let mut lines = body + .lines() + .map(|line| line.trim_end().to_string()) + .collect::>(); + if lines.is_empty() { + lines.push(String::new()); + } + lines +} + +#[cfg(test)] +pub(crate) fn flatten_inline_text(text: &str) -> String { + flatten_inline_markdown(text) +} + +fn render_blocks( + blocks: &[BlockNode], + width: usize, + capabilities: TerminalCapabilities, + style: WrappedLineStyle, +) -> Vec { + let mut output = Vec::new(); + for block in blocks { + output.extend(render_block(block, width, capabilities, style)); + } + output +} + +fn parse_markdown_to_blocks(text: &str) -> Vec { + parse_markdown_segment(text) +} + +fn layout_blocks( + blocks: &[BlockNode], + width: usize, + capabilities: TerminalCapabilities, + style: WrappedLineStyle, +) -> Vec { + render_blocks(blocks, width, capabilities, style) +} + +#[cfg(test)] +fn flatten_inline_markdown(text: &str) -> String { + flatten_inline_chunks(render_inline_chunks(&parse_inline_segment(text))) +} + +fn render_block( + block: &BlockNode, + width: usize, + capabilities: TerminalCapabilities, + style: WrappedLineStyle, +) -> Vec { + match block { + BlockNode::Paragraph(content) => render_inline_block(content, width, capabilities, style), + BlockNode::Heading { level, content } => { + render_heading_block(*level, content, width, capabilities, style) + }, + BlockNode::BlockQuote(blocks) => { + let quoted = render_blocks( + blocks, + width + .saturating_sub(display_width(quote_prefix(capabilities))) + .max(1), + capabilities, + style, + ); + prefix_lines( + quoted, + vec![InlineChunk::styled( + WrappedSpanStyle::QuoteMarker, + quote_prefix(capabilities), + )], + vec![InlineChunk::styled( + WrappedSpanStyle::QuoteMarker, + quote_prefix(capabilities), + )], + style, + ) + }, + BlockNode::List { start, items } => { + render_list_block(items, *start, width, capabilities, style) + }, + BlockNode::CodeBlock { info, content } => render_code_block( + info.as_deref(), + content.as_str(), + width, + capabilities, + style, + ), + BlockNode::Table { headers, rows } => { + render_table_from_nodes(headers, rows, width, capabilities, style) + }, + BlockNode::Rule => vec![ + rich_line( + style, + vec![InlineChunk::styled( + WrappedSpanStyle::HeadingRule, + render_horizontal_rule(width, capabilities), + )], + ) + .with_rewrap_policy(WrappedLineRewrapPolicy::PreserveAndCrop), + ], + } +} + +fn render_inline_block( + content: &[InlineNode], + width: usize, + capabilities: TerminalCapabilities, + style: WrappedLineStyle, +) -> Vec { + split_inline_sections(content) + .into_iter() + .flat_map(|section| { + wrap_inline_chunks(render_inline_chunks(§ion), width, capabilities) + .into_iter() + .map(|chunks| rich_line(style, chunks)) + .collect::>() + }) + .collect() +} + +fn render_heading_block( + level: usize, + content: &[InlineNode], + width: usize, + capabilities: TerminalCapabilities, + style: WrappedLineStyle, +) -> Vec { + let mut chunks = render_inline_chunks(content); + apply_style_to_plain_chunks(&mut chunks, WrappedSpanStyle::Heading); + let text = flatten_inline_chunks(chunks.clone()); + let mut lines = wrap_inline_chunks(chunks, width, capabilities) + .into_iter() + .map(|chunks| rich_line(style, chunks)) + .collect::>(); + if level <= 2 { + let underline = render_heading_rule(level, text.as_str(), width, capabilities); + lines.push(rich_line( + style, + vec![InlineChunk::styled( + WrappedSpanStyle::HeadingRule, + underline, + )], + )); + } + lines +} + +fn render_list_block( + items: &[Vec], + start: Option, + width: usize, + capabilities: TerminalCapabilities, + style: WrappedLineStyle, +) -> Vec { + let mut output = Vec::new(); + for (index, item) in items.iter().enumerate() { + let marker = match start { + Some(value) => format!("{}. ", value + index as u64), + None => "- ".to_string(), + }; + let indent = " ".repeat(display_width(marker.as_str())); + let rendered = render_blocks( + item, + width.saturating_sub(display_width(marker.as_str())).max(1), + capabilities, + style, + ); + output.extend(prefix_lines( + rendered, + vec![InlineChunk::styled( + WrappedSpanStyle::ListMarker, + marker.clone(), + )], + vec![InlineChunk::plain(indent)], + style, + )); + } + output +} + +fn render_code_block( + _info: Option<&str>, + content: &str, + _width: usize, + _capabilities: TerminalCapabilities, + style: WrappedLineStyle, +) -> Vec { + let lines = split_preserved_block_lines(content); + let text_art = should_use_preformatted_fallback(content); + render_preserved_lines( + &lines, + style, + if text_art { + WrappedSpanStyle::TextArt + } else { + WrappedSpanStyle::CodeText + }, + ) +} + +fn render_table_from_nodes( + headers: &[Vec], + rows: &[Vec>], + width: usize, + capabilities: TerminalCapabilities, + style: WrappedLineStyle, +) -> Vec { + render_rich_table_rows(headers, rows, width, capabilities, style) +} + +fn split_inline_sections(content: &[InlineNode]) -> Vec> { + let mut sections = Vec::new(); + let mut current = Vec::new(); + + for node in content { + if matches!(node, InlineNode::HardBreak) { + sections.push(std::mem::take(&mut current)); + continue; + } + current.push(node.clone()); + } + + if !current.is_empty() || sections.is_empty() { + sections.push(current); + } + sections +} + +fn render_inline_chunks(nodes: &[InlineNode]) -> Vec { + let mut output = Vec::new(); + for node in nodes { + match node { + InlineNode::Text(text) => push_chunk(&mut output, InlineChunk::plain(text.clone())), + InlineNode::Code(text) => push_chunk( + &mut output, + InlineChunk::styled(WrappedSpanStyle::InlineCode, text.clone()), + ), + InlineNode::SoftBreak => push_chunk(&mut output, InlineChunk::plain(" ")), + InlineNode::HardBreak => {}, + InlineNode::Styled(style, children) => { + let mut nested = render_inline_chunks(children); + apply_style_to_plain_chunks(&mut nested, *style); + output.extend(nested); + }, + InlineNode::Link { label, destination } => { + let label = flatten_inline_chunks(render_inline_chunks(label)); + let rendered = + if destination.is_empty() || label.is_empty() || label == *destination { + label + } else { + format!("{label} ({destination})") + }; + push_chunk( + &mut output, + InlineChunk::styled(WrappedSpanStyle::Link, rendered), + ); + }, + InlineNode::Image { alt } => { + let alt = flatten_inline_chunks(render_inline_chunks(alt)); + let rendered = if alt.is_empty() { + "[image]".to_string() + } else { + alt + }; + push_chunk(&mut output, InlineChunk::plain(rendered)); + }, + } + } + output +} + +fn prefix_lines( + lines: Vec, + first_prefix: Vec, + rest_prefix: Vec, + style: WrappedLineStyle, +) -> Vec { + if lines.is_empty() { + return vec![rich_line(style, first_prefix)]; + } + + lines + .into_iter() + .enumerate() + .map(|(index, line)| { + let prefix = if index == 0 { + first_prefix.clone() + } else { + rest_prefix.clone() + }; + prepend_inline_chunks(line, prefix) + }) + .collect() +} + +fn rich_line(style: WrappedLineStyle, chunks: Vec) -> WrappedLine { + WrappedLine::from_spans( + style, + chunks + .into_iter() + .filter_map(|chunk| match chunk { + InlineChunk::Plain(text) if text.is_empty() => None, + InlineChunk::Plain(text) => Some(WrappedSpan::plain(text)), + InlineChunk::Styled(_, text) if text.is_empty() => None, + InlineChunk::Styled(span_style, text) => { + Some(WrappedSpan::styled(span_style, text)) + }, + }) + .collect(), + ) +} + +fn prepend_inline_chunks(mut line: WrappedLine, prefix: Vec) -> WrappedLine { + if prefix.is_empty() { + return line; + } + let mut spans = prefix + .into_iter() + .filter_map(|chunk| match chunk { + InlineChunk::Plain(text) if text.is_empty() => None, + InlineChunk::Plain(text) => Some(WrappedSpan::plain(text)), + InlineChunk::Styled(_, text) if text.is_empty() => None, + InlineChunk::Styled(style, text) => Some(WrappedSpan::styled(style, text)), + }) + .collect::>(); + spans.extend(line.spans); + line.spans = spans; + line +} + +fn push_chunk(output: &mut Vec, chunk: InlineChunk) { + if chunk.text().is_empty() { + return; + } + if let Some(last) = output.last_mut() { + if last.style() == chunk.style() { + last.append_text(chunk.text()); + return; + } + } + output.push(chunk); +} + +fn apply_style_to_plain_chunks(chunks: &mut [InlineChunk], style: WrappedSpanStyle) { + for chunk in chunks { + if let InlineChunk::Plain(text) = chunk { + *chunk = InlineChunk::styled(style, text.clone()); + } + } +} + +fn render_heading_rule( + level: usize, + text: &str, + width: usize, + capabilities: TerminalCapabilities, +) -> String { + let glyph = match (level, capabilities.ascii_only()) { + (1, false) => "═", + (1, true) => "=", + (_, false) => "─", + (_, true) => "-", + }; + glyph.repeat(display_width(text).clamp(3, width.clamp(3, 48))) +} + +fn quote_prefix(capabilities: TerminalCapabilities) -> &'static str { + if capabilities.ascii_only() { + "| " + } else { + "│ " + } +} + +fn render_horizontal_rule(width: usize, capabilities: TerminalCapabilities) -> String { + let glyph = if capabilities.ascii_only() { + "-" + } else { + "─" + }; + glyph.repeat(width.clamp(3, 48)) +} + +fn parse_markdown_segment(text: &str) -> Vec { + let options = + Options::ENABLE_TABLES | Options::ENABLE_STRIKETHROUGH | Options::ENABLE_TASKLISTS; + let events = Parser::new_ext(text, options).collect::>(); + let mut index = 0; + parse_blocks_until(&events, &mut index, None) +} + +fn parse_blocks_until<'a>( + events: &[Event<'a>], + index: &mut usize, + terminator: Option, +) -> Vec { + let mut blocks = Vec::new(); + + while *index < events.len() { + match &events[*index] { + Event::End(tag) + if terminator.is_some_and(|term| matches_block_terminator(tag, term)) => + { + *index += 1; + break; + }, + Event::Start(Tag::Paragraph) => { + *index += 1; + blocks.push(BlockNode::Paragraph(parse_inlines_until( + events, + index, + InlineTerminator::Paragraph, + ))); + }, + Event::Start(Tag::Heading(level, ..)) => { + let level = heading_level(*level); + *index += 1; + blocks.push(BlockNode::Heading { + level, + content: parse_inlines_until(events, index, InlineTerminator::Heading), + }); + }, + Event::Start(Tag::BlockQuote) => { + *index += 1; + blocks.push(BlockNode::BlockQuote(parse_blocks_until( + events, + index, + Some(BlockTerminator::BlockQuote), + ))); + }, + Event::Start(Tag::List(start)) => { + let start = *start; + *index += 1; + let mut items = Vec::new(); + while *index < events.len() { + match &events[*index] { + Event::Start(Tag::Item) => { + *index += 1; + items.push(parse_blocks_until( + events, + index, + Some(BlockTerminator::Item), + )); + }, + Event::End(Tag::List(_)) => { + *index += 1; + break; + }, + _ => *index += 1, + } + } + blocks.push(BlockNode::List { start, items }); + }, + Event::Start(Tag::CodeBlock(kind)) => { + let info = match kind { + CodeBlockKind::Fenced(info) => Some(info.to_string()), + CodeBlockKind::Indented => None, + }; + *index += 1; + blocks.push(BlockNode::CodeBlock { + info, + content: parse_code_block(events, index), + }); + }, + Event::Start(Tag::Table(_)) => { + *index += 1; + blocks.push(parse_table(events, index)); + }, + Event::Rule => { + blocks.push(BlockNode::Rule); + *index += 1; + }, + Event::Html(html) | Event::Text(html) => { + let text = html.to_string(); + *index += 1; + if !text.trim().is_empty() { + blocks.push(BlockNode::Paragraph(vec![InlineNode::Text(text)])); + } + }, + Event::Code(_) + | Event::SoftBreak + | Event::HardBreak + | Event::TaskListMarker(_) + | Event::Start(Tag::Emphasis) + | Event::Start(Tag::Strong) + | Event::Start(Tag::Strikethrough) + | Event::Start(Tag::Link(..)) + | Event::Start(Tag::Image(..)) => blocks.push(BlockNode::Paragraph( + parse_inline_flow_until_block_boundary(events, index, terminator), + )), + _ => *index += 1, + } + } + + blocks +} + +fn parse_inlines_until<'a>( + events: &[Event<'a>], + index: &mut usize, + terminator: InlineTerminator, +) -> Vec { + let mut nodes = Vec::new(); + + while *index < events.len() { + match &events[*index] { + Event::End(tag) if matches_inline_terminator(tag, terminator) => { + *index += 1; + break; + }, + Event::Text(text) => { + push_inline_text(&mut nodes, text.as_ref()); + *index += 1; + }, + Event::Code(text) => { + nodes.push(InlineNode::Code(text.to_string())); + *index += 1; + }, + Event::SoftBreak => { + nodes.push(InlineNode::SoftBreak); + *index += 1; + }, + Event::HardBreak => { + nodes.push(InlineNode::HardBreak); + *index += 1; + }, + Event::TaskListMarker(checked) => { + push_inline_text(&mut nodes, if *checked { "[x] " } else { "[ ] " }); + *index += 1; + }, + Event::Start(Tag::Emphasis) => { + *index += 1; + nodes.push(InlineNode::Styled( + WrappedSpanStyle::Emphasis, + parse_inlines_until(events, index, InlineTerminator::Emphasis), + )); + }, + Event::Start(Tag::Strong) => { + *index += 1; + nodes.push(InlineNode::Styled( + WrappedSpanStyle::Strong, + parse_inlines_until(events, index, InlineTerminator::Strong), + )); + }, + Event::Start(Tag::Strikethrough) => { + *index += 1; + nodes.push(InlineNode::Styled( + WrappedSpanStyle::Emphasis, + parse_inlines_until(events, index, InlineTerminator::Emphasis), + )); + }, + Event::Start(Tag::Link(_, destination, _)) => { + let destination = destination.to_string(); + *index += 1; + nodes.push(InlineNode::Link { + label: parse_inlines_until(events, index, InlineTerminator::Link), + destination, + }); + }, + Event::Start(Tag::Image(_, _, _)) => { + *index += 1; + nodes.push(InlineNode::Image { + alt: parse_inlines_until(events, index, InlineTerminator::Image), + }); + }, + Event::Html(html) => { + push_inline_text(&mut nodes, html.as_ref()); + *index += 1; + }, + _ => *index += 1, + } + } + + nodes +} + +fn parse_inline_flow_until_block_boundary<'a>( + events: &[Event<'a>], + index: &mut usize, + terminator: Option, +) -> Vec { + let mut nodes = Vec::new(); + + while *index < events.len() { + match &events[*index] { + Event::End(tag) + if terminator.is_some_and(|term| matches_block_terminator(tag, term)) => + { + break; + }, + Event::Start(Tag::Paragraph) + | Event::Start(Tag::Heading(..)) + | Event::Start(Tag::BlockQuote) + | Event::Start(Tag::List(_)) + | Event::Start(Tag::CodeBlock(_)) + | Event::Start(Tag::Table(_)) + | Event::Rule => break, + Event::Text(text) => { + push_inline_text(&mut nodes, text.as_ref()); + *index += 1; + }, + Event::Code(text) => { + nodes.push(InlineNode::Code(text.to_string())); + *index += 1; + }, + Event::SoftBreak => { + nodes.push(InlineNode::SoftBreak); + *index += 1; + }, + Event::HardBreak => { + nodes.push(InlineNode::HardBreak); + *index += 1; + }, + Event::TaskListMarker(checked) => { + push_inline_text(&mut nodes, if *checked { "[x] " } else { "[ ] " }); + *index += 1; + }, + Event::Start(Tag::Emphasis) => { + *index += 1; + nodes.push(InlineNode::Styled( + WrappedSpanStyle::Emphasis, + parse_inlines_until(events, index, InlineTerminator::Emphasis), + )); + }, + Event::Start(Tag::Strong) => { + *index += 1; + nodes.push(InlineNode::Styled( + WrappedSpanStyle::Strong, + parse_inlines_until(events, index, InlineTerminator::Strong), + )); + }, + Event::Start(Tag::Strikethrough) => { + *index += 1; + nodes.push(InlineNode::Styled( + WrappedSpanStyle::Emphasis, + parse_inlines_until(events, index, InlineTerminator::Emphasis), + )); + }, + Event::Start(Tag::Link(_, destination, _)) => { + let destination = destination.to_string(); + *index += 1; + nodes.push(InlineNode::Link { + label: parse_inlines_until(events, index, InlineTerminator::Link), + destination, + }); + }, + Event::Start(Tag::Image(_, _, _)) => { + *index += 1; + nodes.push(InlineNode::Image { + alt: parse_inlines_until(events, index, InlineTerminator::Image), + }); + }, + Event::Html(html) => { + push_inline_text(&mut nodes, html.as_ref()); + *index += 1; + }, + _ => *index += 1, + } + } + + nodes +} + +fn parse_code_block<'a>(events: &[Event<'a>], index: &mut usize) -> String { + let mut content = String::new(); + + while *index < events.len() { + match &events[*index] { + Event::End(Tag::CodeBlock(_)) => { + *index += 1; + break; + }, + Event::Text(text) | Event::Html(text) => { + content.push_str(text.as_ref()); + *index += 1; + }, + Event::SoftBreak | Event::HardBreak => { + content.push('\n'); + *index += 1; + }, + _ => *index += 1, + } + } + + content +} + +fn parse_table<'a>(events: &[Event<'a>], index: &mut usize) -> BlockNode { + let mut headers = Vec::new(); + let mut rows = Vec::new(); + + while *index < events.len() { + match &events[*index] { + Event::Start(Tag::TableHead) => { + *index += 1; + headers = parse_table_head(events, index); + }, + Event::Start(Tag::TableRow) => rows.push(parse_table_row(events, index)), + Event::End(Tag::Table(_)) => { + *index += 1; + break; + }, + _ => *index += 1, + } + } + + BlockNode::Table { headers, rows } +} + +fn parse_table_head<'a>(events: &[Event<'a>], index: &mut usize) -> Vec> { + let mut cells = Vec::new(); + + while *index < events.len() { + match &events[*index] { + Event::Start(Tag::TableCell) => { + *index += 1; + cells.push(parse_inlines_until( + events, + index, + InlineTerminator::TableCell, + )); + }, + Event::End(Tag::TableHead) => { + *index += 1; + break; + }, + _ => *index += 1, + } + } + + cells +} + +fn parse_table_row<'a>(events: &[Event<'a>], index: &mut usize) -> Vec> { + let mut cells = Vec::new(); + *index += 1; + + while *index < events.len() { + match &events[*index] { + Event::Start(Tag::TableCell) => { + *index += 1; + cells.push(parse_inlines_until( + events, + index, + InlineTerminator::TableCell, + )); + }, + Event::End(Tag::TableRow) => { + *index += 1; + break; + }, + _ => *index += 1, + } + } + + cells +} + +fn push_inline_text(nodes: &mut Vec, text: &str) { + if text.is_empty() { + return; + } + if let Some(InlineNode::Text(existing)) = nodes.last_mut() { + existing.push_str(text); + } else { + nodes.push(InlineNode::Text(text.to_string())); + } +} + +fn matches_inline_terminator(tag: &Tag<'_>, terminator: InlineTerminator) -> bool { + match terminator { + InlineTerminator::Paragraph => matches!(tag, Tag::Paragraph), + InlineTerminator::Heading => matches!(tag, Tag::Heading(..)), + InlineTerminator::Emphasis => matches!(tag, Tag::Emphasis | Tag::Strikethrough), + InlineTerminator::Strong => matches!(tag, Tag::Strong), + InlineTerminator::Link => matches!(tag, Tag::Link(..)), + InlineTerminator::Image => matches!(tag, Tag::Image(..)), + InlineTerminator::TableCell => matches!(tag, Tag::TableCell), + } +} + +fn matches_block_terminator(tag: &Tag<'_>, terminator: BlockTerminator) -> bool { + match terminator { + BlockTerminator::BlockQuote => matches!(tag, Tag::BlockQuote), + BlockTerminator::Item => matches!(tag, Tag::Item), + } +} + +fn heading_level(level: HeadingLevel) -> usize { + match level { + HeadingLevel::H1 => 1, + HeadingLevel::H2 => 2, + HeadingLevel::H3 => 3, + HeadingLevel::H4 => 4, + HeadingLevel::H5 => 5, + HeadingLevel::H6 => 6, + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum MarkdownSegment { + Blank, + Text(String), + Preformatted(Vec), +} + +fn split_markdown_segments(text: &str) -> Vec { + let mut segments = Vec::new(); + let mut current = Vec::new(); + let mut in_fence = false; + let mut fence_marker = ""; + + for line in text.lines() { + let trimmed = line.trim_end(); + let fence = fence_delimiter(trimmed); + if !in_fence && trimmed.is_empty() { + if !current.is_empty() { + segments.push(segment_from_lines(¤t)); + current.clear(); + } + segments.push(MarkdownSegment::Blank); + continue; + } + + current.push(trimmed.to_string()); + if let Some(marker) = fence { + if in_fence && trimmed.trim_start().starts_with(fence_marker) { + in_fence = false; + fence_marker = ""; + } else if !in_fence { + in_fence = true; + fence_marker = marker; + } + } + } + + if !current.is_empty() { + segments.push(segment_from_lines(¤t)); + } + + if segments.is_empty() { + segments.push(MarkdownSegment::Text(String::new())); + } + + segments +} + +fn segment_from_lines(lines: &[String]) -> MarkdownSegment { + let block = lines.join("\n"); + if should_use_preformatted_fallback(block.as_str()) { + MarkdownSegment::Preformatted(lines.to_vec()) + } else { + MarkdownSegment::Text(block) + } +} + +fn should_use_preformatted_fallback(block: &str) -> bool { + let lines = block + .lines() + .map(str::trim_end) + .filter(|line| !line.is_empty()) + .collect::>(); + if lines.is_empty() || looks_like_markdown_table(&lines) { + return false; + } + + let boxy = lines + .iter() + .filter(|line| contains_box_drawing(line)) + .count(); + let treey = lines + .iter() + .filter(|line| contains_tree_connectors(line)) + .count(); + let shelly = lines + .iter() + .filter(|line| { + line.contains("=>") || line.contains("::") || line.contains(" ") || line.contains('\t') + }) + .count(); + + boxy >= 2 + || treey >= 2 + || lines + .iter() + .any(|line| contains_tree_connectors(line) && line.split_whitespace().count() >= 4) + || (boxy >= 1 && shelly >= 2) +} + +fn looks_like_markdown_table(lines: &[&str]) -> bool { + if lines.len() < 2 { + return false; + } + is_table_line(lines[0]) && is_table_line(lines[1]) +} + +fn contains_box_drawing(line: &str) -> bool { + line.chars().any(|ch| { + matches!( + ch, + '│' | '─' + | '┌' + | '┐' + | '└' + | '┘' + | '├' + | '┤' + | '┬' + | '┴' + | '┼' + | '═' + | '╭' + | '╮' + | '╰' + | '╯' + ) + }) +} + +fn contains_tree_connectors(line: &str) -> bool { + line.contains("├") + || line.contains("└") + || line.contains("│") + || line.contains("┬") + || line.contains("┴") + || line.contains("┼") + || line.contains("──") + || line.contains("|--") + || line.contains("+-") +} + +fn strip_pseudo_fence_lines(mut lines: Vec) -> Vec { + if lines.len() >= 3 + && is_pseudo_fence_line(&lines[0]) + && lines.last().is_some_and(|line| is_pseudo_fence_line(line)) + { + lines.remove(0); + lines.pop(); + } + if lines.is_empty() { + vec![String::new()] + } else { + lines + } +} + +fn is_pseudo_fence_line(line: &str) -> bool { + let trimmed = line.trim(); + trimmed.len() >= 2 && trimmed.chars().all(|ch| ch == '`' || ch == '~') +} + +fn fence_delimiter(line: &str) -> Option<&'static str> { + let trimmed = line.trim_start(); + if trimmed.starts_with("```") { + Some("```") + } else if trimmed.starts_with("~~~") { + Some("~~~") + } else { + None + } +} + +fn is_table_line(line: &str) -> bool { + let trimmed = line.trim(); + trimmed.starts_with('|') && trimmed.ends_with('|') && trimmed.matches('|').count() >= 2 +} + +#[cfg(test)] +fn parse_inline_segment(text: &str) -> Vec { + let options = + Options::ENABLE_TABLES | Options::ENABLE_STRIKETHROUGH | Options::ENABLE_TASKLISTS; + let markdown = format!("{text}\n"); + let events = Parser::new_ext(markdown.as_str(), options).collect::>(); + let mut index = 0; + + while index < events.len() { + if matches!(events[index], Event::Start(Tag::Paragraph)) { + index += 1; + return parse_inlines_until(&events, &mut index, InlineTerminator::Paragraph); + } + index += 1; + } + + vec![InlineNode::Text(text.to_string())] +} + +fn wrap_paragraph(text: &str, width: usize, capabilities: TerminalCapabilities) -> Vec { + wrap_with_prefix(text, width, capabilities, "", "") +} + +fn wrap_with_prefix( + text: &str, + width: usize, + capabilities: TerminalCapabilities, + first_prefix: &str, + subsequent_prefix: &str, +) -> Vec { + let tokens = text.split_whitespace().collect::>(); + let mut lines = Vec::new(); + let mut current_prefix = first_prefix; + let mut current = current_prefix.to_string(); + let mut current_width = display_width(current_prefix); + let first_prefix_width = display_width(first_prefix); + let subsequent_prefix_width = display_width(subsequent_prefix); + let first_available = width.saturating_sub(first_prefix_width).max(1); + let subsequent_available = width.saturating_sub(subsequent_prefix_width).max(1); + let mut current_available = first_available; + + for token in tokens { + for chunk in split_token_by_width(token, current_available.max(1), capabilities) { + let chunk_width = display_width(chunk.as_ref()); + let needs_space = current_width > display_width(current_prefix); + let space_width = usize::from(needs_space); + + if current_width > display_width(current_prefix) + && current_width + space_width + chunk_width > width + { + lines.push(current); + current_prefix = subsequent_prefix; + current = current_prefix.to_string(); + current_width = display_width(current_prefix); + current_available = subsequent_available; + } + + if current_width > display_width(current_prefix) { + current.push(' '); + current_width += 1; + } + + current.push_str(chunk.as_ref()); + current_width += chunk_width; + + if current_width == display_width(current_prefix) { + current_available = subsequent_available; + } + } + } + + if current_width > display_width(current_prefix) || lines.is_empty() { + lines.push(current); + } + lines +} + +fn render_preserved_lines( + lines: &[String], + style: WrappedLineStyle, + span_style: WrappedSpanStyle, +) -> Vec { + let lines = if lines.is_empty() { + vec![String::new()] + } else { + lines.to_vec() + }; + lines + .into_iter() + .map(|line| { + rich_line(style, vec![InlineChunk::styled(span_style, line)]) + .with_rewrap_policy(WrappedLineRewrapPolicy::PreserveAndCrop) + }) + .collect() +} + +fn split_preserved_block_lines(content: &str) -> Vec { + if content.is_empty() { + vec![String::new()] + } else { + content + .trim_end_matches('\n') + .split('\n') + .map(ToString::to_string) + .collect() + } +} + +fn render_rich_table_rows( + headers: &[Vec], + rows: &[Vec>], + width: usize, + capabilities: TerminalCapabilities, + style: WrappedLineStyle, +) -> Vec { + let mut rich_rows = Vec::new(); + if !headers.is_empty() { + rich_rows.push(RichTableRow { + cells: headers + .iter() + .map(|cell| render_inline_chunks(cell)) + .collect(), + is_separator: false, + is_header: true, + }); + rich_rows.push(RichTableRow { + cells: vec![Vec::new(); headers.len()], + is_separator: true, + is_header: false, + }); + } + rich_rows.extend(rows.iter().map(|row| RichTableRow { + cells: row.iter().map(|cell| render_inline_chunks(cell)).collect(), + is_separator: false, + is_header: false, + })); + + let col_count = rich_rows + .iter() + .map(|row| row.cells.len()) + .max() + .unwrap_or(0); + if col_count == 0 { + return Vec::new(); + } + + let col_widths = compute_rich_table_widths(&rich_rows, width); + let chars = table_chars(capabilities); + let mut rendered = vec![ + rich_line( + style, + border_chunks( + &col_widths, + chars.top_left, + chars.top_mid, + chars.top_right, + chars.horizontal, + ), + ) + .with_rewrap_policy(WrappedLineRewrapPolicy::PreserveAndCrop), + ]; + for row in &rich_rows { + if row.is_separator { + rendered.push( + rich_line( + style, + border_chunks( + &col_widths, + chars.mid_left, + chars.mid_mid, + chars.mid_right, + chars.horizontal, + ), + ) + .with_rewrap_policy(WrappedLineRewrapPolicy::PreserveAndCrop), + ); + continue; + } + + rendered.push( + rich_line( + style, + boxed_rich_table_row_chunks(row, &col_widths, chars.vertical), + ) + .with_rewrap_policy(WrappedLineRewrapPolicy::PreserveAndCrop), + ); + } + + rendered.push( + rich_line( + style, + border_chunks( + &col_widths, + chars.bottom_left, + chars.bottom_mid, + chars.bottom_right, + chars.horizontal, + ), + ) + .with_rewrap_policy(WrappedLineRewrapPolicy::PreserveAndCrop), + ); + rendered +} + +fn compute_rich_table_widths(rows: &[RichTableRow], width: usize) -> Vec { + let col_count = rows.iter().map(|row| row.cells.len()).max().unwrap_or(0); + let mut col_widths = vec![3usize; col_count]; + for row in rows { + if row.is_separator { + continue; + } + for (index, cell) in row.cells.iter().enumerate() { + col_widths[index] = col_widths[index].max(inline_chunks_width(cell).min(40)); + } + } + + let min_widths = vec![3usize; col_count]; + let separator_width = col_count * 3 + 1; + let max_budget = width.saturating_sub(separator_width); + while col_widths.iter().sum::() > max_budget { + let Some((index, _)) = col_widths + .iter() + .enumerate() + .filter(|(index, value)| **value > min_widths[*index]) + .max_by_key(|(_, value)| **value) + else { + break; + }; + col_widths[index] = col_widths[index].saturating_sub(1); + } + col_widths +} + +fn border_chunks( + col_widths: &[usize], + left: &str, + middle: &str, + right: &str, + horizontal: &str, +) -> Vec { + let mut chunks = vec![InlineChunk::styled( + WrappedSpanStyle::TableBorder, + left.to_string(), + )]; + for (index, width) in col_widths.iter().enumerate() { + chunks.push(InlineChunk::styled( + WrappedSpanStyle::TableBorder, + horizontal.repeat(width.saturating_add(2)), + )); + chunks.push(InlineChunk::styled( + WrappedSpanStyle::TableBorder, + if index + 1 == col_widths.len() { + right.to_string() + } else { + middle.to_string() + }, + )); + } + chunks +} + +fn boxed_rich_table_row_chunks( + row: &RichTableRow, + col_widths: &[usize], + vertical: &str, +) -> Vec { + let mut chunks = vec![InlineChunk::styled( + WrappedSpanStyle::TableBorder, + vertical.to_string(), + )]; + for (index, width) in col_widths.iter().enumerate() { + let cell = row.cells.get(index).cloned().unwrap_or_default(); + chunks.push(InlineChunk::plain(" ")); + chunks.extend(crop_inline_chunks_to_width( + &cell, + *width, + if row.is_header { + Some(WrappedSpanStyle::TableHeader) + } else { + None + }, + )); + let used = inline_chunks_width(&crop_inline_chunks_to_width( + &cell, + *width, + if row.is_header { + Some(WrappedSpanStyle::TableHeader) + } else { + None + }, + )); + if used < *width { + chunks.push(InlineChunk::plain(" ".repeat(*width - used))); + } + chunks.push(InlineChunk::plain(" ")); + chunks.push(InlineChunk::styled( + WrappedSpanStyle::TableBorder, + vertical.to_string(), + )); + } + chunks +} + +fn crop_inline_chunks_to_width( + chunks: &[InlineChunk], + width: usize, + force_style: Option, +) -> Vec { + if width == 0 { + return Vec::new(); + } + + let text_width = inline_chunks_width(chunks); + let budget = if text_width > width && width > 1 { + width - 1 + } else { + width + }; + let mut output = Vec::new(); + let mut used = 0usize; + + for chunk in chunks { + let style = force_style.or(chunk.style()); + let mut current = String::new(); + for ch in chunk.text().chars() { + let ch_width = UnicodeWidthChar::width(ch).unwrap_or(0).max(1); + if used + ch_width > budget { + break; + } + current.push(ch); + used += ch_width; + } + if !current.is_empty() { + push_chunk( + &mut output, + match style { + Some(style) => InlineChunk::styled(style, current), + None => InlineChunk::plain(current), + }, + ); + } + if used >= budget { + break; + } + } + + if text_width > width { + if let Some(last) = output.last_mut() { + last.append_text("…"); + } else { + output.push(match force_style { + Some(style) => InlineChunk::styled(style, "…"), + None => InlineChunk::plain("…"), + }); + } + } + + output +} + +fn inline_chunks_width(chunks: &[InlineChunk]) -> usize { + chunks.iter().map(|chunk| display_width(chunk.text())).sum() +} + +fn table_chars(capabilities: TerminalCapabilities) -> TableChars<'static> { + if capabilities.ascii_only() { + TableChars { + top_left: "+", + top_mid: "+", + top_right: "+", + mid_left: "+", + mid_mid: "+", + mid_right: "+", + bottom_left: "+", + bottom_mid: "+", + bottom_right: "+", + horizontal: "-", + vertical: "|", + } + } else { + TableChars { + top_left: "┌", + top_mid: "┬", + top_right: "┐", + mid_left: "├", + mid_mid: "┼", + mid_right: "┤", + bottom_left: "└", + bottom_mid: "┴", + bottom_right: "┘", + horizontal: "─", + vertical: "│", + } + } +} + +fn wrap_inline_chunks( + chunks: Vec, + width: usize, + capabilities: TerminalCapabilities, +) -> Vec> { + wrap_inline_chunks_with_widths(chunks, width, width, capabilities) +} + +fn wrap_inline_chunks_with_widths( + chunks: Vec, + first_width: usize, + subsequent_width: usize, + capabilities: TerminalCapabilities, +) -> Vec> { + let mut lines = Vec::new(); + let mut current = Vec::new(); + let mut current_width = 0usize; + let mut current_limit = first_width.max(1); + let mut pending_space = false; + + for token in tokenize_inline_chunks(chunks) { + match token { + InlineToken::Whitespace => pending_space = true, + InlineToken::Chunk(chunk) => { + for piece in split_inline_chunk_to_width(&chunk, current_limit.max(1), capabilities) + { + let piece_width = display_width(piece.text()); + let space_width = usize::from(pending_space && current_width > 0); + if current_width > 0 + && current_width + space_width + piece_width > current_limit + { + lines.push(current); + current = Vec::new(); + current_width = 0; + current_limit = subsequent_width.max(1); + pending_space = false; + } + + if pending_space && current_width > 0 { + current.push(InlineChunk::plain(" ")); + current_width += 1; + } + pending_space = false; + + current_width += piece_width; + current.push(piece); + } + }, + } + } + + if !current.is_empty() || lines.is_empty() { + lines.push(current); + } + lines +} + +enum InlineToken { + Whitespace, + Chunk(InlineChunk), +} + +fn tokenize_inline_chunks(chunks: Vec) -> Vec { + let mut tokens = Vec::new(); + for chunk in chunks { + if matches!( + chunk.style(), + Some(WrappedSpanStyle::InlineCode | WrappedSpanStyle::Link) + ) { + tokens.push(InlineToken::Chunk(chunk)); + continue; + } + + let mut current = String::new(); + let mut whitespace = None; + for ch in chunk.text().chars() { + let is_whitespace = ch.is_whitespace(); + match whitespace { + Some(flag) if flag == is_whitespace => current.push(ch), + Some(flag) => { + if flag { + tokens.push(InlineToken::Whitespace); + } else { + tokens.push(InlineToken::Chunk(match chunk.style() { + Some(style) => InlineChunk::styled(style, current.clone()), + None => InlineChunk::plain(current.clone()), + })); + } + current.clear(); + current.push(ch); + whitespace = Some(is_whitespace); + }, + None => { + current.push(ch); + whitespace = Some(is_whitespace); + }, + } + } + if !current.is_empty() { + if whitespace == Some(true) { + tokens.push(InlineToken::Whitespace); + } else { + tokens.push(InlineToken::Chunk(match chunk.style() { + Some(style) => InlineChunk::styled(style, current), + None => InlineChunk::plain(current), + })); + } + } + } + tokens +} + +fn split_inline_chunk_to_width( + chunk: &InlineChunk, + width: usize, + capabilities: TerminalCapabilities, +) -> Vec { + split_token_by_width(chunk.text(), width.max(1), capabilities) + .into_iter() + .map(|piece| match chunk.style() { + Some(style) => InlineChunk::styled(style, piece.into_owned()), + None => InlineChunk::plain(piece.into_owned()), + }) + .collect() +} + +fn flatten_inline_chunks(chunks: Vec) -> String { + chunks + .into_iter() + .map(|chunk| chunk.text().to_string()) + .collect::() +} + +fn split_token_by_width<'a>( + token: &'a str, + width: usize, + _capabilities: TerminalCapabilities, +) -> Vec> { + let width = width.max(1); + if display_width(token) <= width { + return vec![Cow::Borrowed(token)]; + } + split_preserving_width(token, width) +} + +fn split_preserving_width<'a>(text: &'a str, width: usize) -> Vec> { + let mut chunks = Vec::new(); + let mut current = String::new(); + let mut current_width = 0; + + for ch in text.chars() { + let ch_width = UnicodeWidthChar::width(ch).unwrap_or(0).max(1); + if current_width + ch_width > width && !current.is_empty() { + chunks.push(Cow::Owned(current)); + current = String::new(); + current_width = 0; + } + current.push(ch); + current_width += ch_width; + } + + if !current.is_empty() { + chunks.push(Cow::Owned(current)); + } + chunks +} + +fn display_width(text: &str) -> usize { + UnicodeWidthStr::width(text) +} + +#[cfg(test)] +mod tests { + use super::{flatten_inline_text, render_literal_text, render_markdown_lines, wrap_text}; + use crate::{ + capability::{ColorLevel, GlyphMode, TerminalCapabilities}, + state::{WrappedLineStyle, WrappedSpanStyle}, + }; + + fn unicode_capabilities() -> TerminalCapabilities { + TerminalCapabilities { + color: ColorLevel::TrueColor, + glyphs: GlyphMode::Unicode, + alt_screen: false, + mouse: false, + bracketed_paste: false, + } + } + + #[test] + fn wrap_text_preserves_hanging_indent_for_lists() { + let lines = wrap_text( + "- 第一项需要被正确换行,并且后续行要和正文对齐", + 18, + unicode_capabilities(), + ); + assert!(lines[0].starts_with("- ")); + assert!(lines[1].starts_with(" ")); + } + + #[test] + fn wrap_text_formats_markdown_tables() { + let lines = wrap_text( + "| 工具 | 说明 |\n| --- | --- |\n| **reviewnow** | 代码审查 |\n| `git-commit` | \ + 自动提交 |", + 32, + unicode_capabilities(), + ); + assert!(lines.iter().any(|line| line.contains("┌"))); + assert!(lines.iter().any(|line| line.contains("│ 工具"))); + assert!(lines.iter().any(|line| line.contains("reviewnow"))); + assert!(lines.iter().any(|line| line.contains("git-commit"))); + assert!(lines.iter().all(|line| !line.contains("**reviewnow**"))); + assert!(lines.iter().all(|line| !line.contains("`git-commit`"))); + } + + #[test] + fn inline_markdown_keeps_emphasis_body_before_cjk_punctuation() { + assert_eq!(flatten_inline_text("**writeFile**。"), "writeFile。"); + } + + #[test] + fn wrap_literal_text_preserves_user_markdown_markers() { + let lines = render_literal_text( + "## 用户原文\n请保留 **readFile** 和 [link](https://example.com)。", + 120, + unicode_capabilities(), + ); + let joined = lines.join("\n"); + assert!(joined.contains("## 用户原文")); + assert!(joined.contains("**readFile**")); + assert!(joined.contains("[link](https://example.com)")); + } + + #[test] + fn parser_marks_rich_span_styles() { + let lines = render_markdown_lines( + "## 标题\n\n使用 `writeFile`\n\n- [readFile](https://example.com)\n> \ + 引用\n\n```rs\nlet x = 1;\n```\n\n| 工具 | 说明 |\n| --- | --- |\n| writeFile | 保存 \ + |", + 64, + unicode_capabilities(), + WrappedLineStyle::Plain, + ); + let span_styles = lines + .iter() + .flat_map(|line| line.spans.iter().filter_map(|span| span.style)) + .collect::>(); + assert!(span_styles.contains(&WrappedSpanStyle::Heading)); + assert!(span_styles.contains(&WrappedSpanStyle::Link)); + assert!(span_styles.contains(&WrappedSpanStyle::ListMarker)); + assert!(span_styles.contains(&WrappedSpanStyle::QuoteMarker)); + assert!(span_styles.contains(&WrappedSpanStyle::CodeText)); + assert!(span_styles.contains(&WrappedSpanStyle::TableHeader)); + assert!(span_styles.contains(&WrappedSpanStyle::InlineCode)); + } + + #[test] + fn fenced_ascii_tree_block_preserves_structure() { + let lines = wrap_text( + "```\nfrontend/src/\n├─ components/\n│ ├─ Chat/\n│ └─ Sidebar/\n└─ store/\n```", + 18, + unicode_capabilities(), + ); + assert_eq!(lines.len(), 5); + assert_eq!(lines[0], "frontend/src/"); + assert!(lines.iter().any(|line| line.contains("├─"))); + assert!(lines.iter().any(|line| line.contains("│"))); + } + + #[test] + fn unfenced_ascii_diagram_uses_preformatted_fallback() { + let lines = wrap_text( + "┌──────────────┐\n│ server │\n├──────┬───────┤\n│ core │ cli \ + │\n└──────┴───────┘", + 18, + unicode_capabilities(), + ); + assert_eq!(lines.len(), 5); + assert!(lines.iter().all(|line| !line.contains("…"))); + assert!(lines.iter().any(|line| line.contains("┌"))); + assert!(lines.iter().any(|line| line.contains("┴"))); + } + + #[test] + fn fenced_ascii_diagram_uses_text_art_without_fence_markers() { + let lines = render_markdown_lines( + "```\n ┌──────────┐\n │ diagram │\n └──────────┘\n```", + 18, + unicode_capabilities(), + WrappedLineStyle::Plain, + ); + let joined = lines.iter().map(|line| line.text()).collect::>(); + let span_styles = lines + .iter() + .flat_map(|line| line.spans.iter().filter_map(|span| span.style)) + .collect::>(); + + assert!(joined.iter().all(|line| !line.contains("```"))); + assert!(joined.iter().any(|line| line.contains("┌"))); + assert!(span_styles.contains(&WrappedSpanStyle::TextArt)); + } + + #[test] + fn pseudo_fence_ascii_diagram_drops_fence_lines() { + let lines = wrap_text( + "``\n┌──────┐\n│ test │\n└──────┘\n``", + 18, + unicode_capabilities(), + ); + assert_eq!(lines, vec!["┌──────┐", "│ test │", "└──────┘"]); + } +} diff --git a/crates/cli/src/ui/mod.rs b/crates/cli/src/ui/mod.rs index eae08e77..4b4ae9db 100644 --- a/crates/cli/src/ui/mod.rs +++ b/crates/cli/src/ui/mod.rs @@ -1,9 +1,9 @@ pub mod cells; pub mod composer; pub mod custom_terminal; -pub mod host; pub mod hud; pub mod insert_history; +mod markdown; pub mod overlay; mod palette; mod text; @@ -13,12 +13,239 @@ pub use palette::{palette_lines, palette_visible}; use ratatui::text::{Line, Span}; pub use text::truncate_to_width; pub use theme::{CodexTheme, ThemePalette}; +use unicode_segmentation::UnicodeSegmentation; -use crate::state::WrappedLine; +use crate::{ + render::wrap::wrap_plain_text, + state::{WrappedLine, WrappedLineRewrapPolicy}, +}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct HistoryLine { + pub line: Line<'static>, + pub rewrap_policy: WrappedLineRewrapPolicy, +} pub fn line_to_ratatui(line: &WrappedLine, theme: &CodexTheme) -> Line<'static> { - Line::from(Span::styled( - line.content.clone(), - theme.line_style(line.style), - )) + let base = theme.line_style(line.style); + let spans = if line.spans.is_empty() { + vec![Span::styled(String::new(), base)] + } else { + line.spans + .iter() + .map(|span| { + let style = span + .style + .map(|style| base.patch(theme.span_style(style))) + .unwrap_or(base); + Span::styled(span.content.clone(), style) + }) + .collect::>() + }; + Line::from(spans).style(base) +} + +pub fn history_line_to_ratatui(line: WrappedLine, theme: &CodexTheme) -> HistoryLine { + HistoryLine { + rewrap_policy: line.rewrap_policy, + line: line_to_ratatui(&line, theme), + } +} + +pub(crate) fn materialize_wrapped_line( + line: &WrappedLine, + width: usize, + theme: &CodexTheme, +) -> Vec> { + let history_line = history_line_to_ratatui(line.clone(), theme); + materialize_history_line(&history_line, width) +} + +pub(crate) fn materialize_wrapped_lines( + lines: &[WrappedLine], + width: usize, + theme: &CodexTheme, +) -> Vec> { + lines + .iter() + .flat_map(|line| materialize_wrapped_line(line, width, theme)) + .collect() +} + +pub(crate) fn materialize_history_line(line: &HistoryLine, width: usize) -> Vec> { + match line.rewrap_policy { + WrappedLineRewrapPolicy::Reflow => wrap_reflow_line(&line.line, width), + WrappedLineRewrapPolicy::PreserveAndCrop => vec![crop_line(&line.line, width.max(1))], + } +} + +fn wrap_reflow_line(line: &Line<'static>, width: usize) -> Vec> { + let content = line.to_string(); + let wrapped = wrap_plain_text(content.as_str(), width.max(1)); + if line.spans.is_empty() { + return wrapped + .into_iter() + .map(|item| Line::from(item).style(line.style)) + .collect(); + } + + let mut cursor = StyledGraphemeCursor::new(&line.spans); + wrapped + .into_iter() + .enumerate() + .map(|(index, item)| { + if index > 0 && !starts_with_whitespace(item.as_str()) { + cursor.skip_boundary_whitespace(); + } + let spans = cursor.consume_text(item.as_str()); + Line::from(spans).style(line.style) + }) + .collect() +} + +fn crop_line(line: &Line<'static>, width: usize) -> Line<'static> { + let width = width.max(1); + if line.width() <= width { + return line.clone(); + } + + let ellipsis = if width == 1 { "" } else { "…" }; + let budget = width.saturating_sub(display_width(ellipsis)); + let mut visible = Vec::new(); + let mut used = 0usize; + + for span in &line.spans { + let cropped = crop_span_to_width(span, budget.saturating_sub(used)); + if cropped.content.is_empty() { + break; + } + used += display_width(cropped.content.as_ref()); + visible.push(cropped); + if used >= budget { + break; + } + } + + if !ellipsis.is_empty() { + if let Some(last) = visible.last_mut() { + last.content = format!("{}{}", last.content, ellipsis).into(); + } else { + visible.push(Span::raw(ellipsis.to_string())); + } + } + + Line::from(visible).style(line.style) +} + +fn crop_span_to_width(span: &Span<'static>, width: usize) -> Span<'static> { + if width == 0 { + return Span::styled(String::new(), span.style); + } + + let mut content = String::new(); + let mut used = 0usize; + for grapheme in UnicodeSegmentation::graphemes(span.content.as_ref(), true) { + let grapheme_width = display_width(grapheme); + if used + grapheme_width > width { + break; + } + content.push_str(grapheme); + used += grapheme_width; + } + Span::styled(content, span.style) +} + +fn display_width(text: &str) -> usize { + UnicodeSegmentation::graphemes(text, true) + .map(unicode_width::UnicodeWidthStr::width) + .sum() +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct StyledGrapheme { + style: ratatui::style::Style, + text: String, +} + +struct StyledGraphemeCursor { + graphemes: Vec, + index: usize, +} + +impl StyledGraphemeCursor { + fn new(spans: &[Span<'static>]) -> Self { + let graphemes = spans + .iter() + .flat_map(|span| { + UnicodeSegmentation::graphemes(span.content.as_ref(), true).map(|grapheme| { + StyledGrapheme { + style: span.style, + text: grapheme.to_string(), + } + }) + }) + .collect(); + Self { + graphemes, + index: 0, + } + } + + fn consume_text(&mut self, text: &str) -> Vec> { + if text.is_empty() { + return Vec::new(); + } + + let mut spans = Vec::new(); + let mut current_style = None; + let mut current_text = String::new(); + + for grapheme in UnicodeSegmentation::graphemes(text, true) { + let Some(next) = self.next_matching(grapheme) else { + return vec![Span::raw(text.to_string())]; + }; + match current_style { + Some(style) if style == next.style => current_text.push_str(next.text.as_str()), + Some(style) => { + spans.push(Span::styled(std::mem::take(&mut current_text), style)); + current_text.push_str(next.text.as_str()); + current_style = Some(next.style); + }, + None => { + current_text.push_str(next.text.as_str()); + current_style = Some(next.style); + }, + } + } + + if let Some(style) = current_style { + spans.push(Span::styled(current_text, style)); + } + + spans + } + + fn skip_boundary_whitespace(&mut self) { + while self + .graphemes + .get(self.index) + .is_some_and(|grapheme| grapheme.text.chars().all(char::is_whitespace)) + { + self.index += 1; + } + } + + fn next_matching(&mut self, expected: &str) -> Option { + let grapheme = self.graphemes.get(self.index)?.clone(); + if grapheme.text == expected { + self.index += 1; + Some(grapheme) + } else { + None + } + } +} + +fn starts_with_whitespace(text: &str) -> bool { + text.chars().next().is_some_and(char::is_whitespace) } diff --git a/crates/cli/src/ui/overlay.rs b/crates/cli/src/ui/overlay.rs index 62b7cf01..2f74ff91 100644 --- a/crates/cli/src/ui/overlay.rs +++ b/crates/cli/src/ui/overlay.rs @@ -4,11 +4,12 @@ use ratatui::{ }; use crate::{ - state::{CliState, WrappedLine}, + state::CliState, ui::{ CodexTheme, cells::{RenderableCell, TranscriptCellView}, custom_terminal::Frame, + materialize_wrapped_lines, }, }; @@ -40,14 +41,7 @@ pub fn render_browser_overlay(frame: &mut Frame<'_>, state: &CliState, theme: &C ); frame.render_widget( - Paragraph::new( - rendered - .lines - .iter() - .map(|line| super::line_to_ratatui(line, theme)) - .collect::>(), - ) - .scroll((scroll as u16, 0)), + Paragraph::new(rendered.lines.clone()).scroll((scroll as u16, 0)), chunks[0], ); @@ -59,7 +53,7 @@ pub fn render_browser_overlay(frame: &mut Frame<'_>, state: &CliState, theme: &C #[derive(Debug, Clone, PartialEq, Eq)] struct BrowserRenderOutput { - lines: Vec, + lines: Vec>, selected_line_range: Option<(usize, usize)>, total_cells: usize, } @@ -71,18 +65,24 @@ fn browser_lines(state: &CliState, width: u16, theme: &CodexTheme) -> BrowserRen let transcript_cells = state.browser_transcript_cells(); if let Some(banner) = &state.conversation.banner { - lines.push(WrappedLine { - style: crate::state::WrappedLineStyle::ErrorText, - content: format!("! {}", banner.error.message), - }); - lines.push(WrappedLine { - style: crate::state::WrappedLineStyle::Muted, - content: " stream 需要重新同步,继续操作前建议等待恢复。".to_string(), - }); - lines.push(WrappedLine { - style: crate::state::WrappedLineStyle::Plain, - content: String::new(), - }); + lines.extend(materialize_wrapped_lines( + &[ + crate::state::WrappedLine::plain( + crate::state::WrappedLineStyle::ErrorText, + format!("! {}", banner.error.message), + ), + crate::state::WrappedLine::plain( + crate::state::WrappedLineStyle::Muted, + " stream 需要重新同步,继续操作前建议等待恢复。".to_string(), + ), + crate::state::WrappedLine::plain( + crate::state::WrappedLineStyle::Plain, + String::new(), + ), + ], + width, + theme, + )); } for (index, cell) in transcript_cells.iter().enumerate() { @@ -104,7 +104,7 @@ fn browser_lines(state: &CliState, width: u16, theme: &CodexTheme) -> BrowserRen }, }; let rendered = cell.render_lines(width, state.shell.capabilities, theme, &view); - lines.extend(rendered); + lines.extend(materialize_wrapped_lines(&rendered, width, theme)); if view.selected { selected_line_range = Some((line_start, lines.len().saturating_sub(1))); } diff --git a/crates/cli/src/ui/palette.rs b/crates/cli/src/ui/palette.rs index 0b5da995..df3081a1 100644 --- a/crates/cli/src/ui/palette.rs +++ b/crates/cli/src/ui/palette.rs @@ -125,23 +125,23 @@ where F: Fn(&T) -> (String, String), { if items.is_empty() { - return vec![WrappedLine { - style: WrappedLineStyle::Muted, - content: empty_message.to_string(), - }]; + return vec![WrappedLine::plain( + WrappedLineStyle::Muted, + empty_message.to_string(), + )]; } visible_window(items, selected, MAX_VISIBLE_ITEMS) .into_iter() .map(|(absolute_index, item)| { let (title, details) = meta(item); - WrappedLine { - style: if absolute_index == selected { + WrappedLine::plain( + if absolute_index == selected { WrappedLineStyle::PaletteSelected } else { WrappedLineStyle::PaletteItem }, - content: candidate_line( + candidate_line( if absolute_index == selected { theme.glyph("›", ">") } else { @@ -151,7 +151,7 @@ where details.as_str(), width, ), - } + ) }) .collect() } diff --git a/crates/cli/src/ui/theme.rs b/crates/cli/src/ui/theme.rs index d5df3953..8ff8363c 100644 --- a/crates/cli/src/ui/theme.rs +++ b/crates/cli/src/ui/theme.rs @@ -2,11 +2,12 @@ use ratatui::style::{Color, Modifier, Style}; use crate::{ capability::{ColorLevel, TerminalCapabilities}, - state::WrappedLineStyle, + state::{WrappedLineStyle, WrappedSpanStyle}, }; pub trait ThemePalette { fn line_style(&self, style: WrappedLineStyle) -> Style; + fn span_style(&self, style: WrappedSpanStyle) -> Style; fn glyph(&self, unicode: &'static str, ascii: &'static str) -> &'static str; fn divider(&self) -> &'static str; } @@ -158,4 +159,44 @@ impl ThemePalette for CodexTheme { fn divider(&self) -> &'static str { self.glyph("─", "-") } + + fn span_style(&self, style: WrappedSpanStyle) -> Style { + let base = Style::default(); + if matches!(self.capabilities.color, ColorLevel::None) { + return match style { + WrappedSpanStyle::Strong + | WrappedSpanStyle::Heading + | WrappedSpanStyle::TableHeader => base.add_modifier(Modifier::BOLD), + WrappedSpanStyle::Emphasis => base.add_modifier(Modifier::ITALIC), + WrappedSpanStyle::Link => base.add_modifier(Modifier::UNDERLINED), + WrappedSpanStyle::InlineCode + | WrappedSpanStyle::CodeFence + | WrappedSpanStyle::CodeText + | WrappedSpanStyle::TextArt + | WrappedSpanStyle::TableBorder + | WrappedSpanStyle::ListMarker + | WrappedSpanStyle::QuoteMarker + | WrappedSpanStyle::HeadingRule => base.add_modifier(Modifier::DIM), + }; + } + + match style { + WrappedSpanStyle::Strong => base.add_modifier(Modifier::BOLD), + WrappedSpanStyle::Emphasis => base.add_modifier(Modifier::ITALIC), + WrappedSpanStyle::Heading => base.fg(self.accent()).add_modifier(Modifier::BOLD), + WrappedSpanStyle::HeadingRule => base.fg(self.text_muted()), + WrappedSpanStyle::TableBorder => base.fg(self.text_muted()), + WrappedSpanStyle::TableHeader => { + base.fg(self.accent_soft()).add_modifier(Modifier::BOLD) + }, + WrappedSpanStyle::InlineCode => base.fg(self.accent_soft()), + WrappedSpanStyle::Link => base.fg(Color::Cyan).add_modifier(Modifier::UNDERLINED), + WrappedSpanStyle::ListMarker | WrappedSpanStyle::QuoteMarker => { + base.fg(self.accent_soft()).add_modifier(Modifier::BOLD) + }, + WrappedSpanStyle::CodeFence => base.fg(self.text_muted()).add_modifier(Modifier::DIM), + WrappedSpanStyle::CodeText => base.fg(self.text_primary()), + WrappedSpanStyle::TextArt => base.fg(self.text_primary()), + } + } } diff --git a/docs/architecture-diagram.md b/docs/architecture-diagram.md new file mode 100644 index 00000000..5a62297f --- /dev/null +++ b/docs/architecture-diagram.md @@ -0,0 +1,300 @@ +# AstrCode 架构图 + +## 系统分层与依赖关系 + +```mermaid +graph TB + subgraph 客户端["🖥️ 客户端 (Conversation Surface)"] + FE["React 前端
    (Vite + Tailwind)"] + CLI["TUI 终端
    (ratatui)"] + end + + subgraph 传输层["📡 传输层"] + TAURI["Tauri 2 桌面壳"] + SVR["Server
    (Axum HTTP/SSE)"] + PROTO["Protocol
    DTO & Wire Types"] + end + + subgraph 组合根["🔧 组合根 (bootstrap)"] + BOOT["bootstrap/runtime.rs
    唯一装配入口"] + end + + subgraph 应用层["📋 应用层"] + APP["App
    用例编排 · 参数校验 · 权限策略"] + GOV["AppGovernance
    治理 · 重载 · 观测"] + OBS["RuntimeObservabilityCollector"] + end + + subgraph 会话层["🔄 会话运行时"] + SR["SessionRuntime
    单会话真相面"] + ACTOR["SessionActor
    live truth · 推进"] + TURN["Turn 状态机
    LLM → Tool → Compact"] + CTX["Context Window
    预算分配 · 裁剪 · 压缩"] + MAIL["Mailbox / Delivery
    子 Agent 消息契约"] + QUERY["Query / Command
    读写分离"] + end + + subgraph 控制面["🎛️ 全局控制面"] + KERN["Kernel"] + ROUTER["Capability Router
    统一能力路由"] + REG["Registry
    Tool / LLM / Prompt"] + TREE["Agent Tree
    全局控制合同"] + end + + subgraph 领域层["💎 领域核心"] + CORE["Core"] + ID["强类型 ID"] + EVENT["领域事件 · EventLog"] + PORT["端口契约
    LlmProvider · PromptProvider
    EventStore · ResourceProvider"] + CAP["CapabilitySpec
    唯一能力语义模型"] + CFG["稳定配置模型"] + end + + subgraph 适配器层["🔌 适配器层 (端口实现)"] + A_LLM["adapter-llm
    Anthropic · OpenAI"] + A_STORE["adapter-storage
    JSONL 事件日志"] + A_PROMPT["adapter-prompt
    Prompt 模板加载"] + A_TOOLS["adapter-tools
    内置工具定义"] + A_SKILLS["adapter-skills
    Skill 加载与物化"] + A_MCP["adapter-mcp
    MCP 协议传输"] + A_AGENTS["adapter-agents
    Agent 定义加载"] + end + + subgraph 扩展层["🧩 扩展层"] + PLUGIN["Plugin
    插件模型 · 宿主基础设施"] + SDK["SDK
    Rust 插件开发包"] + MCP_EXT["外部 MCP Server"] + end + + %% 客户端 → 传输层 + FE -->|"HTTP/SSE"| SVR + FE -.->|"Tauri IPC"| TAURI + CLI -->|"HTTP/SSE"| SVR + TAURI --> SVR + + %% 传输层 → 组合根/应用层 + SVR -->|"handler 薄委托"| APP + SVR --> PROTO + PROTO -->|"DTO ↔ 领域转换"| CORE + + %% 组合根 + BOOT -.->|"装配"| APP + BOOT -.->|"装配"| GOV + BOOT -.->|"装配"| KERN + BOOT -.->|"装配"| SR + BOOT -.->|"注入实现"| A_LLM + BOOT -.->|"注入实现"| A_STORE + BOOT -.->|"注入实现"| A_PROMPT + BOOT -.->|"注入实现"| A_TOOLS + BOOT -.->|"注入实现"| A_SKILLS + BOOT -.->|"注入实现"| A_MCP + BOOT -.->|"注入实现"| A_AGENTS + BOOT -.->|"装载"| PLUGIN + + %% 应用层 → 会话/控制面 + APP -->|"用例调用"| SR + APP -->|"治理策略"| KERN + GOV -->|"reload 编排"| APP + + %% 会话层内部 + SR --> ACTOR + SR --> TURN + SR --> CTX + SR --> MAIL + SR --> QUERY + SR -->|"经由 gateway"| KERN + + %% 控制面 → 领域 + KERN --> ROUTER + KERN --> REG + KERN --> TREE + KERN --> CORE + + %% 领域内部 + CORE --> ID + CORE --> EVENT + CORE --> PORT + CORE --> CAP + CORE --> CFG + + %% 适配器实现端口 + A_LLM -.->|"impl LlmProvider"| PORT + A_STORE -.->|"impl EventStore"| PORT + A_PROMPT -.->|"impl PromptProvider"| PORT + A_TOOLS -.->|"capability 桥接"| CAP + A_MCP -.->|"MCP → CapabilitySurface"| CAP + A_AGENTS -.->|"Agent 定义加载"| CORE + A_SKILLS -.->|"Skill 物化"| CORE + + %% 扩展 + PLUGIN --> SDK + A_MCP -->|"stdio / HTTP / SSE"| MCP_EXT + + %% 样式 + classDef client fill:#6366f1,stroke:#4f46e5,color:#fff + classDef transport fill:#8b5cf6,stroke:#7c3aed,color:#fff + classDef bootstrap fill:#f59e0b,stroke:#d97706,color:#fff + classDef app fill:#10b981,stroke:#059669,color:#fff + classDef session fill:#3b82f6,stroke:#2563eb,color:#fff + classDef kernel fill:#06b6d4,stroke:#0891b2,color:#fff + classDef core fill:#f43f5e,stroke:#e11d48,color:#fff + classDef adapter fill:#84cc16,stroke:#65a30d,color:#fff + classDef extension fill:#a855f7,stroke:#9333ea,color:#fff + + class FE,CLI client + class SVR,TAURI,PROTO transport + class BOOT bootstrap + class APP,GOV,OBS app + class SR,ACTOR,TURN,CTX,MAIL,QUERY session + class KERN,ROUTER,REG,TREE kernel + class CORE,ID,EVENT,PORT,CAP,CFG core + class A_LLM,A_STORE,A_PROMPT,A_TOOLS,A_SKILLS,A_MCP,A_AGENTS adapter + class PLUGIN,SDK,MCP_EXT extension +``` + +## 依赖规则一览 + +```mermaid +graph LR + subgraph 允许 ✅ + direction TB + PROTO2["protocol"] --> CORE2["core"] + KERN2["kernel"] --> CORE2 + SR2["session-runtime"] --> CORE2 + SR2 --> KERN2 + APP2["application"] --> CORE2 + APP2 --> KERN2 + APP2 --> SR2 + SVR2["server"] --> APP2 + SVR2 --> PROTO2 + ADAPTER2["adapter-*"] --> CORE2 + end + + subgraph 条件允许 ⚠️ + SVR2 -.->|"仅组合根装配"| ADAPTER2 + end + + subgraph 禁止 🚫 + CORE2 x--x|"反向依赖"| PROTO2 + APP2 x--x|"直接依赖"| ADAPTER2 + KERN2 x--x|"直接依赖"| ADAPTER2 + end + + classDef allowed fill:#10b981,stroke:#059669,color:#fff + classDef conditional fill:#f59e0b,stroke:#d97706,color:#fff + classDef forbidden fill:#ef4444,stroke:#dc2626,color:#fff + + class PROTO2,CORE2,KERN2,SR2,APP2,SVR2,ADAPTER2 allowed +``` + +## 数据流:一次用户请求的完整路径 + +```mermaid +sequenceDiagram + participant U as 用户 + participant FE as 前端 / TUI + participant SVR as Server (Axum) + participant APP as App (application) + participant SR as SessionRuntime + participant KERN as Kernel + participant LLM as adapter-llm + participant TOOL as adapter-tools / MCP + + U->>FE: 输入 prompt + FE->>SVR: POST /api/sessions/{id}/prompt (SSE) + SVR->>APP: App::submit_prompt() + APP->>APP: 参数校验 · 权限检查 + APP->>SR: run_turn() + SR->>SR: Context Window 预算分配 + SR->>SR: Prompt 组装 (request assembly) + SR->>KERN: Gateway → LlmProvider + KERN->>LLM: Anthropic / OpenAI 流式请求 + LLM-->>SR: SSE 流式 token + SR-->>SVR: SSE 事件流 + SVR-->>FE: SSE 事件流 + FE-->>U: 实时渲染 + + Note over SR,TOOL: AI 返回 tool_call 时 + SR->>KERN: Gateway → CapabilityRouter + KERN->>TOOL: 执行 readFile / shell / spawn... + TOOL-->>SR: 工具结果 + SR->>KERN: 继续 LLM 对话 + KERN->>LLM: 带工具结果的后续请求 + LLM-->>SR: 最终响应流 + SR-->>SVR: SSE 完成事件 + SVR-->>FE: 完成信号 +``` + +## Agent 协作模型 + +```mermaid +graph TB + ROOT["Root Agent
    主会话"] + C1["Child Agent 1
    代码审查"] + C2["Child Agent 2
    搜索分析"] + C3["Child Agent 3
    执行任务"] + + ROOT -->|"spawn()"| C1 + ROOT -->|"spawn()"| C2 + C1 -->|"spawn()"| C3 + + ROOT -->|"send() 指令"| C1 + C1 -->|"send() 上报"| ROOT + ROOT -->|"observe() 状态查询"| C2 + ROOT -->|"close() 关闭"| C3 + + subgraph 协作协议 + S["spawn
    创建子 Agent"] + SN["send
    发送消息"] + OB["observe
    观察状态"] + CL["close
    关闭 Agent"] + end + + classDef agent fill:#6366f1,stroke:#4f46e5,color:#fff + classDef proto fill:#f59e0b,stroke:#d97706,color:#fff + + class ROOT,C1,C2,C3 agent + class S,SN,OB,CL proto +``` + +## 能力统一接入模型 + +```mermaid +graph LR + subgraph 能力来源 + BT["内置工具
    readFile · writeFile
    shell · grep · ..."] + MCP["MCP Server
    stdio / HTTP"] + PLG["Plugin
    JSON-RPC"] + SKL["Skills
    SKILL.md 加载"] + end + + subgraph 统一表面 + CS["CapabilitySurface
    唯一能力事实源"] + ROUTER2["CapabilityRouter
    统一路由"] + SPEC["CapabilitySpec
    唯一语义模型"] + end + + BT -->|"注册"| CS + MCP -->|"发现 · 接入"| CS + PLG -->|"装载 · 物化"| CS + SKL -->|"catalog"| CS + + CS --> ROUTER2 + CS --> SPEC + + subgraph 消费者 + TURN2["Turn 执行"] + GW["Kernel Gateway"] + end + + ROUTER2 --> TURN2 + ROUTER2 --> GW + + classDef source fill:#84cc16,stroke:#65a30d,color:#fff + classDef surface fill:#06b6d4,stroke:#0891b2,color:#fff + classDef consumer fill:#6366f1,stroke:#4f46e5,color:#fff + + class BT,MCP,PLG,SKL source + class CS,ROUTER2,SPEC surface + class TURN2,GW consumer +``` From 89088effecb44c25883e4078cb360b838bc5e437 Mon Sep 17 00:00:00 2001 From: whatevertogo Date: Sat, 18 Apr 2026 15:06:27 +0800 Subject: [PATCH 26/53] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor(model):=20?= =?UTF-8?q?=E7=A7=BB=E9=99=A4=E4=BA=8B=E4=BB=B6=E6=97=A5=E5=BF=97=E5=92=8C?= =?UTF-8?q?=E7=9B=B8=E5=85=B3=E7=9A=84=20reducer=20=E6=A8=A1=E5=9D=97?= =?UTF-8?q?=EF=BC=8C=E7=AE=80=E5=8C=96=E6=A8=A1=E5=9E=8B=E7=BB=93=E6=9E=84?= =?UTF-8?q?=20=E2=99=BB=EF=B8=8F=20refactor(wrap):=20=E7=B2=BE=E7=AE=80?= =?UTF-8?q?=E6=96=87=E6=9C=AC=E5=8C=85=E8=A3=85=E9=80=BB=E8=BE=91=EF=BC=8C?= =?UTF-8?q?=E7=A7=BB=E9=99=A4=E4=B8=8D=E5=BF=85=E8=A6=81=E7=9A=84=E5=86=85?= =?UTF-8?q?=E5=AE=B9=E7=B1=BB=E5=9E=8B=E5=88=86=E7=B1=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/cli/src/lib.rs | 1 - crates/cli/src/model/events.rs | 55 ---- crates/cli/src/model/mod.rs | 2 - crates/cli/src/model/reducer.rs | 564 -------------------------------- crates/cli/src/render/wrap.rs | 45 +-- crates/cli/src/ui/hud.rs | 102 ------ crates/cli/src/ui/mod.rs | 1 - 7 files changed, 5 insertions(+), 765 deletions(-) delete mode 100644 crates/cli/src/model/events.rs delete mode 100644 crates/cli/src/model/mod.rs delete mode 100644 crates/cli/src/model/reducer.rs delete mode 100644 crates/cli/src/ui/hud.rs diff --git a/crates/cli/src/lib.rs b/crates/cli/src/lib.rs index b3dc5df9..bdd88cc8 100644 --- a/crates/cli/src/lib.rs +++ b/crates/cli/src/lib.rs @@ -4,7 +4,6 @@ pub mod capability; pub mod chat; pub mod command; pub mod launcher; -pub mod model; pub mod render; pub mod state; pub mod tui; diff --git a/crates/cli/src/model/events.rs b/crates/cli/src/model/events.rs deleted file mode 100644 index fc24ebb9..00000000 --- a/crates/cli/src/model/events.rs +++ /dev/null @@ -1,55 +0,0 @@ -#[derive(Debug, Clone, PartialEq, Eq, Default)] -pub struct EventLog { - entries: Vec, -} - -impl EventLog { - pub fn new(entries: Vec) -> Self { - Self { entries } - } - - pub fn entries(&self) -> &[Event] { - &self.entries - } - - pub fn replace(&mut self, entries: Vec) { - self.entries = entries; - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum Event { - UserTurn { - id: String, - text: String, - }, - AssistantBlock { - id: String, - text: String, - streaming: bool, - }, - Thinking { - id: String, - summary: String, - preview: String, - }, - ToolStatus { - id: String, - tool_name: String, - summary: String, - }, - ToolSummary { - id: String, - tool_name: String, - summary: String, - artifact_path: Option, - }, - SystemNote { - id: String, - text: String, - }, - Error { - id: String, - text: String, - }, -} diff --git a/crates/cli/src/model/mod.rs b/crates/cli/src/model/mod.rs deleted file mode 100644 index bdff38af..00000000 --- a/crates/cli/src/model/mod.rs +++ /dev/null @@ -1,2 +0,0 @@ -pub mod events; -pub mod reducer; diff --git a/crates/cli/src/model/reducer.rs b/crates/cli/src/model/reducer.rs deleted file mode 100644 index 02949e0b..00000000 --- a/crates/cli/src/model/reducer.rs +++ /dev/null @@ -1,564 +0,0 @@ -use std::{collections::BTreeSet, sync::Arc, time::Duration}; - -use ratatui::text::Line; - -use super::events::Event; -use crate::render::wrap::wrap_plain_text; - -const STREAM_TAIL_BUDGET: usize = 8; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum CommitKind { - UserTurn, - AssistantBlock, - ToolSummary, - SystemNote, - Error, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct CommittedSlice { - pub kind: CommitKind, - pub height: u16, - pub lines: Arc<[Line<'static>]>, -} - -impl CommittedSlice { - pub fn plain(lines: impl IntoIterator>) -> Self { - let lines = lines - .into_iter() - .map(|line| Line::from(line.as_ref().to_string())) - .collect::>(); - Self { - kind: CommitKind::SystemNote, - height: lines.len().max(1) as u16, - lines: lines.into(), - } - } - - fn new(kind: CommitKind, lines: Vec>, wrap_width: usize) -> Self { - let lines = with_block_spacing(lines); - Self { - kind, - height: rendered_height(&lines, wrap_width), - lines: lines.into(), - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq, Default)] -pub struct HudState { - pub status_line: Option>, - pub detail_lines: Vec>, - pub live_preview_lines: Vec>, - pub queued_lines: Vec>, -} - -#[derive(Debug, Clone, PartialEq, Eq, Default)] -pub struct OverlayState { - pub browser_open: bool, -} - -#[derive(Debug, Clone, PartialEq, Eq, Default)] -pub struct UiProjection { - pub commit_queue: Vec, - pub hud: HudState, - pub overlay: OverlayState, -} - -#[derive(Debug, Clone, PartialEq, Eq, Default)] -pub struct ProjectionReducer { - committed_once: BTreeSet, -} - -impl ProjectionReducer { - pub fn reset(&mut self) { - self.committed_once.clear(); - } - - pub fn reduce_events( - &mut self, - events: &[Event], - width: u16, - hud_height: u16, - stream_age: Duration, - ) -> UiProjection { - let wrap_width = usize::from(width.max(24)); - let content_width = wrap_width.saturating_sub(2); - let stream_tail_budget = if hud_height <= 10 { - 6 - } else { - STREAM_TAIL_BUDGET - }; - let preview_budget = if hud_height <= 10 { 4 } else { 6 }; - let mut projection = UiProjection::default(); - - let mut live_details = Vec::new(); - let mut live_preview_lines = Vec::new(); - let mut streaming_assistant_text = None; - let mut tool_chain_active = false; - for event in events { - match event { - Event::UserTurn { id, text } => { - if self.committed_once.insert(id.clone()) { - projection.commit_queue.push(CommittedSlice::new( - CommitKind::UserTurn, - render_prefixed_block("› ", " ", text, content_width), - wrap_width, - )); - } - }, - Event::AssistantBlock { - id, - text, - streaming, - } => { - if *streaming { - streaming_assistant_text = Some(text.clone()); - projection.hud.status_line = Some(Line::from("• Responding")); - } else if self.committed_once.insert(id.clone()) { - projection.commit_queue.push(CommittedSlice::new( - CommitKind::AssistantBlock, - render_prefixed_block("• ", " ", text.as_str(), content_width), - wrap_width, - )); - } - }, - Event::Thinking { - summary, preview, .. - } => { - if live_details.is_empty() { - live_details = vec![ - Line::from(format!(" └ {summary}")), - Line::from(format!(" {preview}")), - ]; - trim_tail(&mut live_details, stream_tail_budget); - } - tool_chain_active = true; - projection.hud.status_line = Some(Line::from("• Thinking")); - }, - Event::ToolStatus { - tool_name, summary, .. - } => { - live_details = render_detail_block( - "└ ", - " ", - format!("{tool_name} · {summary}").as_str(), - content_width, - ); - trim_tail(&mut live_details, 3); - tool_chain_active = true; - projection.hud.status_line = Some(Line::from(format!("• Running {tool_name}"))); - }, - Event::ToolSummary { - id, - tool_name, - summary, - artifact_path, - } => { - tool_chain_active = true; - if self.committed_once.insert(id.clone()) { - let mut lines = render_prefixed_block( - "↳ ", - " ", - format!("{tool_name} · {summary}").as_str(), - content_width, - ); - if let Some(path) = artifact_path { - lines.push(Line::from(format!(" {path}"))); - } - if lines.len() > 2 { - lines = vec![lines[0].clone(), lines[lines.len() - 1].clone()]; - } - projection.commit_queue.push(CommittedSlice::new( - CommitKind::ToolSummary, - lines, - wrap_width, - )); - } - }, - Event::SystemNote { id, text } => { - if self.committed_once.insert(id.clone()) { - projection.commit_queue.push(CommittedSlice::new( - CommitKind::SystemNote, - render_prefixed_block("· ", " ", text, content_width), - wrap_width, - )); - } - }, - Event::Error { id, text } => { - if self.committed_once.insert(id.clone()) { - projection.commit_queue.push(CommittedSlice::new( - CommitKind::Error, - render_prefixed_block("! ", " ", text, content_width), - wrap_width, - )); - } - projection.hud.status_line = Some(Line::from(format!("• {text}"))); - }, - } - } - - if let Some(text) = streaming_assistant_text { - let preview_triggered = assistant_preview_triggered( - text.as_str(), - content_width, - tool_chain_active, - stream_age, - ); - if preview_triggered { - live_preview_lines = render_preview_block(text.as_str(), content_width); - trim_tail(&mut live_preview_lines, preview_budget); - } else if live_details.is_empty() { - live_details = vec![Line::from(" └ 正在生成回复")]; - } - } - - projection.hud.detail_lines = live_details; - projection.hud.live_preview_lines = live_preview_lines; - projection - } -} - -fn render_prefixed_block( - first_prefix: &str, - subsequent_prefix: &str, - text: &str, - width: usize, -) -> Vec> { - let prefix_width = first_prefix - .chars() - .count() - .max(subsequent_prefix.chars().count()); - let wrapped = wrap_plain_text(text, width.saturating_sub(prefix_width).max(1)); - wrapped - .into_iter() - .enumerate() - .map(|(index, line)| { - if index == 0 { - Line::from(format!("{first_prefix}{line}")) - } else { - Line::from(format!("{subsequent_prefix}{line}")) - } - }) - .collect() -} - -fn with_block_spacing(mut lines: Vec>) -> Vec> { - if !lines - .last() - .is_some_and(|line| line.to_string().trim().is_empty()) - { - lines.push(Line::from(String::new())); - } - lines -} - -fn trim_tail(lines: &mut Vec>, limit: usize) { - if lines.len() > limit { - let keep_from = lines.len() - limit; - lines.drain(0..keep_from); - } -} - -fn render_detail_block( - first_prefix: &str, - subsequent_prefix: &str, - text: &str, - width: usize, -) -> Vec> { - let first = format!(" {first_prefix}"); - let subsequent = format!(" {subsequent_prefix}"); - render_prefixed_block(first.as_str(), subsequent.as_str(), text, width) -} - -fn render_preview_block(text: &str, width: usize) -> Vec> { - wrap_plain_text(text, width.max(1)) - .into_iter() - .map(Line::from) - .collect() -} - -fn assistant_preview_triggered( - text: &str, - width: usize, - tool_chain_active: bool, - stream_age: Duration, -) -> bool { - let rendered_rows = wrap_plain_text(text, width.max(1)).len(); - rendered_rows > 6 || tool_chain_active || stream_age >= Duration::from_millis(800) -} - -fn rendered_height(lines: &[Line<'static>], wrap_width: usize) -> u16 { - let wrap_width = wrap_width.max(1); - lines - .iter() - .map(|line| line.width().max(1).div_ceil(wrap_width)) - .sum::() - .max(1) as u16 -} - -#[cfg_attr(not(test), allow(dead_code))] -fn split_semantic_blocks(text: &str) -> Vec { - if text.trim().is_empty() { - return vec![String::new()]; - } - - let mut blocks = Vec::new(); - let mut current = Vec::new(); - let mut in_code_block = false; - - for raw_line in text.lines() { - let trimmed = raw_line.trim_end(); - let is_fence = trimmed.starts_with("```"); - let is_list_item = is_list_item(trimmed); - let is_heading = is_heading(trimmed); - let is_blockquote = is_blockquote(trimmed); - - if is_fence { - current.push(trimmed.to_string()); - if in_code_block { - blocks.push(current.join("\n")); - current.clear(); - } - in_code_block = !in_code_block; - continue; - } - - if in_code_block { - current.push(trimmed.to_string()); - continue; - } - - if trimmed.is_empty() { - if !current.is_empty() { - blocks.push(current.join("\n")); - current.clear(); - } - continue; - } - - if is_blockquote { - if !current.is_empty() { - blocks.push(current.join("\n")); - current.clear(); - } - blocks.push(trimmed.to_string()); - continue; - } - - if is_heading { - if !current.is_empty() { - blocks.push(current.join("\n")); - current.clear(); - } - current.push(trimmed.to_string()); - continue; - } - - if is_list_item { - if !current.is_empty() { - blocks.push(current.join("\n")); - current.clear(); - } - blocks.push(trimmed.to_string()); - continue; - } - - current.push(trimmed.to_string()); - } - - if !current.is_empty() { - blocks.push(current.join("\n")); - } - - if blocks.is_empty() { - blocks.push(text.to_string()); - } - blocks -} - -fn is_list_item(line: &str) -> bool { - let trimmed = line.trim_start(); - trimmed.starts_with("- ") - || trimmed.starts_with("* ") - || trimmed.starts_with("+ ") - || trimmed - .chars() - .enumerate() - .take_while(|(_, ch)| ch.is_ascii_digit()) - .last() - .is_some_and(|(index, _)| trimmed[index + 1..].starts_with(". ")) -} - -fn is_heading(line: &str) -> bool { - let trimmed = line.trim_start(); - let hashes = trimmed.chars().take_while(|ch| *ch == '#').count(); - hashes > 0 && trimmed[hashes..].starts_with(' ') -} - -fn is_blockquote(line: &str) -> bool { - line.trim_start().starts_with("> ") -} - -#[cfg(test)] -mod tests { - use std::time::Duration; - - use ratatui::text::Line; - - use super::{CommitKind, CommittedSlice, ProjectionReducer}; - use crate::model::events::Event; - - #[test] - fn reducer_keeps_only_tail_of_streaming_assistant_in_hud() { - let mut reducer = ProjectionReducer::default(); - let events = vec![ - Event::UserTurn { - id: "user-1".to_string(), - text: "你好".to_string(), - }, - Event::AssistantBlock { - id: "assistant-1".to_string(), - text: "第一段\n\n第二段\n\n第三段".to_string(), - streaming: true, - }, - ]; - let projection = reducer.reduce_events(&events, 68, 12, Duration::ZERO); - assert_eq!(projection.commit_queue.len(), 1); - assert!(matches!( - projection.commit_queue[0].kind, - CommitKind::UserTurn - )); - assert!(!projection.hud.detail_lines.is_empty()); - assert!( - projection - .hud - .detail_lines - .iter() - .any(|line| line.to_string().contains("正在生成回复")) - ); - } - - #[test] - fn reducer_promotes_long_streaming_assistant_to_live_preview() { - let mut reducer = ProjectionReducer::default(); - let events = vec![Event::AssistantBlock { - id: "assistant-1".to_string(), - text: "第一行\n第二行\n第三行\n第四行\n第五行\n第六行\n第七行".to_string(), - streaming: true, - }]; - let projection = reducer.reduce_events(&events, 36, 12, Duration::ZERO); - assert_eq!(projection.hud.status_line, Some(Line::from("• Responding"))); - assert!(!projection.hud.live_preview_lines.is_empty()); - assert!( - projection - .hud - .live_preview_lines - .iter() - .any(|line| line.to_string().contains("第七行")) - ); - } - - #[test] - fn completed_user_and_assistant_blocks_are_committed_in_order() { - let mut reducer = ProjectionReducer::default(); - let projection = reducer.reduce_events( - &[ - Event::UserTurn { - id: "user-1".to_string(), - text: "hello".to_string(), - }, - Event::AssistantBlock { - id: "assistant-1".to_string(), - text: "world".to_string(), - streaming: false, - }, - ], - 72, - 12, - Duration::ZERO, - ); - assert_eq!(projection.commit_queue.len(), 2); - assert!(matches!( - projection.commit_queue[0].kind, - CommitKind::UserTurn - )); - assert!(matches!( - projection.commit_queue[1].kind, - CommitKind::AssistantBlock - )); - assert!( - projection.commit_queue[0] - .lines - .last() - .is_some_and(|line| line.to_string().is_empty()) - ); - assert!( - projection.commit_queue[1] - .lines - .last() - .is_some_and(|line| line.to_string().is_empty()) - ); - } - - #[test] - fn committed_slice_height_counts_wrapped_wide_characters() { - let slice = - CommittedSlice::new(CommitKind::SystemNote, vec![Line::from("你好你好你好")], 4); - assert_eq!(slice.height, 4); - } - - #[test] - fn split_semantic_blocks_keeps_heading_with_following_paragraph() { - let blocks = super::split_semantic_blocks("前言\n\n## 标题\n正文第一行\n正文第二行"); - assert_eq!(blocks, vec!["前言", "## 标题\n正文第一行\n正文第二行"]); - } - - #[test] - fn split_semantic_blocks_treats_blockquote_as_its_own_block() { - let blocks = super::split_semantic_blocks("第一段\n> 引用\n第二段"); - assert_eq!(blocks, vec!["第一段", "> 引用", "第二段"]); - } - - #[test] - fn tool_summary_commits_only_two_lines_with_artifact_reference() { - let mut reducer = ProjectionReducer::default(); - let projection = reducer.reduce_events( - &[Event::ToolSummary { - id: "tool-1".to_string(), - tool_name: "readFile".to_string(), - summary: "line1 line1 line1 line1\nline2 line2 line2 line2\nline3".to_string(), - artifact_path: Some("/tmp/result.txt".to_string()), - }], - 30, - 12, - Duration::ZERO, - ); - assert_eq!(projection.commit_queue.len(), 1); - assert!(projection.commit_queue[0].lines.len() <= 3); - assert!( - projection.commit_queue[0] - .lines - .iter() - .any(|line| line.to_string().contains("/tmp/result.txt")) - ); - } - - #[test] - fn reset_clears_committed_once_and_frontier_progress() { - let mut reducer = ProjectionReducer::default(); - let events = [Event::AssistantBlock { - id: "assistant-1".to_string(), - text: "第一段\n\n第二段".to_string(), - streaming: true, - }]; - let projection = reducer.reduce_events(&events, 72, 12, Duration::ZERO); - assert!(projection.commit_queue.is_empty()); - - reducer.reset(); - - let projection = reducer.reduce_events(&events, 72, 12, Duration::ZERO); - assert!(projection.commit_queue.is_empty()); - } -} diff --git a/crates/cli/src/render/wrap.rs b/crates/cli/src/render/wrap.rs index 9f2cecbf..8e96c3c5 100644 --- a/crates/cli/src/render/wrap.rs +++ b/crates/cli/src/render/wrap.rs @@ -3,21 +3,7 @@ use std::borrow::Cow; use textwrap::{Options, WordSeparator, wrap}; use unicode_segmentation::UnicodeSegmentation; -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ContentKind { - Prose, - CodeBlock, - UrlOrPath, - Table, - ToolLog, - Whitespace, -} - pub fn wrap_plain_text(text: &str, width: usize) -> Vec { - wrap_content(ContentKind::Prose, text, width) -} - -pub fn wrap_content(kind: ContentKind, text: &str, width: usize) -> Vec { let width = width.max(1); let mut lines = Vec::new(); for raw_line in text.split('\n') { @@ -26,22 +12,17 @@ pub fn wrap_content(kind: ContentKind, text: &str, width: usize) -> Vec continue; } - let options = match classify_line(kind, raw_line) { - ContentKind::CodeBlock | ContentKind::ToolLog => base_options(width).break_words(true), - ContentKind::UrlOrPath => base_options(width).break_words(true), - ContentKind::Table => base_options(width).break_words(true), - ContentKind::Whitespace => { - lines.push(String::new()); - continue; - }, - ContentKind::Prose => base_options(width), + let options = if looks_like_path_or_url(raw_line) { + base_options(width).break_words(true) + } else { + base_options(width) }; let wrapped = wrap(raw_line, options); if wrapped.is_empty() { lines.push(String::new()); } else { - lines.extend(wrapped.into_iter().map(|line| normalize_line(line))); + lines.extend(wrapped.into_iter().map(normalize_line)); } } @@ -57,22 +38,6 @@ fn base_options(width: usize) -> Options<'static> { .break_words(false) } -fn classify_line(kind: ContentKind, line: &str) -> ContentKind { - if line.trim().is_empty() { - return ContentKind::Whitespace; - } - if matches!( - kind, - ContentKind::CodeBlock | ContentKind::ToolLog | ContentKind::Table - ) { - return kind; - } - if looks_like_path_or_url(line) { - return ContentKind::UrlOrPath; - } - kind -} - fn looks_like_path_or_url(line: &str) -> bool { line.contains("://") || line.contains('\\') diff --git a/crates/cli/src/ui/hud.rs b/crates/cli/src/ui/hud.rs deleted file mode 100644 index 171d4c61..00000000 --- a/crates/cli/src/ui/hud.rs +++ /dev/null @@ -1,102 +0,0 @@ -use ratatui::layout::Rect; -#[cfg(test)] -use unicode_width::UnicodeWidthStr; - -#[cfg(test)] -use crate::ui::truncate_to_width; -use crate::{ - bottom_pane::{BottomPaneState, render_bottom_pane}, - chat::ChatSurfaceState, - state::CliState, - ui::{CodexTheme, custom_terminal::Frame, overlay::render_browser_overlay}, -}; - -pub fn render_hud(frame: &mut Frame<'_>, state: &CliState, theme: &CodexTheme) { - render_hud_in_area(frame, frame.area(), state, theme) -} - -pub fn render_hud_in_area(frame: &mut Frame<'_>, area: Rect, state: &CliState, theme: &CodexTheme) { - if state.interaction.browser.open { - render_browser_overlay(frame, state, theme); - return; - } - - let mut chat = ChatSurfaceState::default(); - let chat_frame = chat.build_frame(state, theme, area.width); - let pane = BottomPaneState::from_cli(state, &chat_frame, theme, area.width); - render_bottom_pane(frame, area, state, &pane, theme); -} - -pub fn desired_viewport_height(state: &CliState, total_height: u16) -> u16 { - if state.interaction.browser.open { - return total_height.max(1); - } - let theme = CodexTheme::new(state.shell.capabilities); - let mut chat = ChatSurfaceState::default(); - let chat_frame = chat.build_frame(state, &theme, 80); - BottomPaneState::from_cli(state, &chat_frame, &theme, 80).desired_height(total_height) -} - -#[cfg(test)] -fn align_left_right(left: &str, right: &str, width: usize) -> String { - if width == 0 { - return String::new(); - } - let left = truncate_to_width(left, width); - let right = truncate_to_width(right, width); - let left_width = UnicodeWidthStr::width(left.as_str()); - let right_width = UnicodeWidthStr::width(right.as_str()); - if left_width + right_width + 1 > width { - return truncate_to_width(format!("{left} · {right}").as_str(), width); - } - format!( - "{left}{}{right}", - " ".repeat(width - left_width - right_width) - ) -} - -#[cfg(test)] -mod tests { - use astrcode_client::{ - AstrcodeConversationAssistantBlockDto, AstrcodeConversationBlockDto, - AstrcodeConversationBlockStatusDto, - }; - - use super::{align_left_right, desired_viewport_height}; - use crate::{ - capability::{ColorLevel, GlyphMode, TerminalCapabilities}, - state::CliState, - }; - - fn capabilities() -> TerminalCapabilities { - TerminalCapabilities { - color: ColorLevel::Ansi16, - glyphs: GlyphMode::Unicode, - alt_screen: false, - mouse: false, - bracketed_paste: false, - } - } - - #[test] - fn desired_viewport_height_stays_small() { - let mut state = CliState::new("http://127.0.0.1:5529".to_string(), None, capabilities()); - assert!((6..=8).contains(&desired_viewport_height(&state, 20))); - - state.conversation.transcript = vec![AstrcodeConversationBlockDto::Assistant( - AstrcodeConversationAssistantBlockDto { - id: "assistant-streaming".to_string(), - turn_id: Some("turn-1".to_string()), - status: AstrcodeConversationBlockStatusDto::Streaming, - markdown: "这是一个比较长的流式响应,用来验证底部面板会扩展。".to_string(), - }, - )]; - assert!((2..=5).contains(&desired_viewport_height(&state, 20))); - } - - #[test] - fn align_left_right_preserves_right_hint() { - let line = align_left_right("Esc close", "glm-5.1 · idle · Astrcode", 40); - assert!(line.contains("glm-5.1")); - } -} diff --git a/crates/cli/src/ui/mod.rs b/crates/cli/src/ui/mod.rs index 4b4ae9db..ff4f0f82 100644 --- a/crates/cli/src/ui/mod.rs +++ b/crates/cli/src/ui/mod.rs @@ -1,7 +1,6 @@ pub mod cells; pub mod composer; pub mod custom_terminal; -pub mod hud; pub mod insert_history; mod markdown; pub mod overlay; From ea0b8f083ce2334a2d02706ab00bf9940e4f946b Mon Sep 17 00:00:00 2001 From: whatevertogo Date: Sat, 18 Apr 2026 15:07:56 +0800 Subject: [PATCH 27/53] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor(ui):=20?= =?UTF-8?q?=E7=A7=BB=E9=99=A4=E6=9C=AA=E4=BD=BF=E7=94=A8=E7=9A=84=20palett?= =?UTF-8?q?e=5Fvisible=20=E5=87=BD=E6=95=B0=E5=92=8C=20app=5Fbackground=20?= =?UTF-8?q?=E6=96=B9=E6=B3=95=EF=BC=8C=E7=AE=80=E5=8C=96=E4=BB=A3=E7=A0=81?= =?UTF-8?q?=E7=BB=93=E6=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/cli/REVIEW.md | 31 ---------- crates/cli/REVIEW2.md | 116 ----------------------------------- crates/cli/src/ui/mod.rs | 2 +- crates/cli/src/ui/palette.rs | 4 -- crates/cli/src/ui/theme.rs | 4 -- 5 files changed, 1 insertion(+), 156 deletions(-) delete mode 100644 crates/cli/REVIEW.md delete mode 100644 crates/cli/REVIEW2.md diff --git a/crates/cli/REVIEW.md b/crates/cli/REVIEW.md deleted file mode 100644 index 9c0e73bf..00000000 --- a/crates/cli/REVIEW.md +++ /dev/null @@ -1,31 +0,0 @@ -# CLI Crate 复审结论 - -审查范围:`crates/cli/src/` -复审日期:2026-04-17 -验证状态:已完成修复并通过 `cargo fmt --all`、`cargo test -p astrcode-cli` - ---- - -## 结论 - -此前报告中的问题已完成修复,本次复审未保留新的确认问题。 - -## 已完成的关键修复 - -- 终端生命周期改为 RAII 恢复,异常路径不会泄漏 raw mode / alt screen。 -- 所有 UI 截断统一为 Unicode 显示宽度计算,CJK/emoji 不再错位。 -- focus backward、palette/filter、bootstrap refresh、async dispatch 等重复逻辑已收敛。 -- transcript block patch/complete 改为索引查找,并在 debug 构建下记录未命中 delta。 -- transcript 视图改成按需投影,不再双写维护 `transcript` 与 `transcript_cells`。 -- render 类型已迁移到 `state/render.rs`,theme/glyph/truncation 也已统一。 -- tick/background 任务关闭策略已统一为可停止句柄,不再混用优雅停止和直接 abort。 -- thinking 文案、synthetic thinking 渲染、tool/output 排版与 footer/palette/hero 结构均已收敛。 - -## 当前验证 - -```bash -cargo fmt --all -cargo test -p astrcode-cli -``` - -结果:`astrcode-cli` 40 个测试全部通过。 diff --git a/crates/cli/REVIEW2.md b/crates/cli/REVIEW2.md deleted file mode 100644 index 212db92c..00000000 --- a/crates/cli/REVIEW2.md +++ /dev/null @@ -1,116 +0,0 @@ -# CLI Crate 第二轮审查:视觉与最佳实践 - -审查范围:`crates/cli/src/` 全部文件(修复后的最新版本) -审查日期:2026-04-17 - ---- - -## Visual - 可访问性 - -### 1. ascii_only 模式下 marker 符号碰撞,不同 cell 类型无法区分 -- **文件**: `ui/cells.rs:342-363` -- **描述**: ascii fallback 中 `assistant_marker` 和 `thinking_marker` 都返回 `"*"`,`tool_marker` 和 `secondary_marker` 都返回 `"-"`。无颜色终端下,用户看到 `*` 无法区分 assistant 输出和 thinking,看到 `-` 无法区分 tool call 和系统提示。 -- **建议**: 为每种类型分配唯一的 ascii 符号,如 assistant `*`、thinking `~`、tool `>`、secondary `-`、tool_block `|`。 - -### 2. hero card `two_col_row` 中 `│` 硬编码,ascii_only 模式下会显示错误 -- **文件**: `ui/hero.rs:243` -- **代码**: `format!("{}│{}", ...)` -- **描述**: `framed_rows` 中正确使用了 `theme.glyph("│", "|")`,但 `two_col_row` 内部的列分隔符直接硬编码了 Unicode `│`,在 ascii_only 终端下会显示为乱码或空格。 -- **建议**: 将 theme 传入 `two_col_row` 或提取 `│` 的 glyph 选择。 - -### 3. banner 文案混合中英文术语 -- **文件**: `ui/transcript.rs:32` -- **代码**: `"stream 需要重新同步,继续操作前建议等待恢复。"` -- **描述**: 用户可见的错误提示中混用了英文 "stream" 和中文描述,与 thinking.rs 中已修复的统一语言策略不一致。 -- **建议**: 统一为纯中文,如"流需要重新同步..."。 - -### 4. hero 默认标题 `Astrcode workspace` 是英文 -- **文件**: `ui/hero.rs:19` -- **代码**: `.unwrap_or("Astrcode workspace")` -- **描述**: 其他所有面向用户的文案(提示、状态、footer hint)都是中文,唯独这个 fallback 标题是英文。 -- **建议**: 统一为中文,如 "Astrcode 工作区"。 - ---- - -## Visual - 一致性 - -### 5. `format!("{phase:?}")` 用于用户可见的 phase 标签 -- **文件**: `ui/hero.rs:29`, `ui/footer.rs:99` -- **描述**: 两处通过 Debug 格式化 `AstrcodePhaseDto` 生成用户可见文本(如 "streaming"、"idle")。Debug 输出不是 UI 文案,枚举重命名会导致用户看到的变化。且 hero 和 footer 中的 phase 用途相同但来源独立构造。 -- **建议**: 为 `AstrcodePhaseDto` 添加 `Display` 实现或专用的 `display_label()` 方法。 - -### 6. footer 实际只有 3 行内容,但 `FOOTER_HEIGHT = 5` -- **文件**: `render/mod.rs:12,68-110` -- **描述**: `footer_lines` 返回 3 行(status、input、hint),`render_footer` 渲染 5 行布局(3 内容 + 2 divider)。虽然功能正确,但 `FOOTER_HEIGHT` 的语义不清晰——它代表的是布局高度而非内容行数,且 footer_lines 的 3 行数量与 render_footer 的 5 行布局之间没有编译时保证。如果 footer_lines 返回的行数变化,render_footer 会 panic(`footer.lines[0]` 索引越界)。 -- **建议**: 定义 `const FOOTER_CONTENT_LINES: usize = 3`,并在 render_footer 中用常量而非硬编码索引。或者让 footer_lines 自身返回包含 divider 的完整 5 行。 - -### 7. hero 提示文案与 footer hint 中的快捷键描述不一致 -- **文件**: `ui/hero.rs:96,102,110` vs `ui/footer.rs:89,109` -- **描述**: hero 中写"输入 / 打开 commands",footer 中写"/ commands"。hero 中写"Tab 在 transcript / composer 间切换",footer 中写"Tab 切换焦点"。同一个操作在不同位置有不同描述。 -- **建议**: 统一两处的快捷键描述文案。 - ---- - -## Performance - -### 8. `selected_transcript_cell()` 每次调用都全量投影 transcript -- **文件**: `state/mod.rs:209-213` -- **描述**: `selected_transcript_cell()` 调用 `transcript_cells()` 投影整个 transcript,然后取第 N 个。这个方法被 `toggle_selected_cell_expanded` 和 `selected_cell_is_thinking` 调用,每次操作都是 O(n) 全量投影。 -- **建议**: 添加 `project_single_cell(&self, index: usize)` 方法,只投影一个 cell;或在 `transcript_cells()` 上层缓存结果。 - -### 9. `should_animate_thinking_playback` 遍历 transcript 两次 -- **文件**: `state/mod.rs:345-387` -- **描述**: 先遍历所有 cells 检查 streaming thinking(345-355),然后再遍历一次检查 synthetic 条件(372-387)。两次遍历可以合并为一次。 -- **建议**: 合并为单次遍历,先记录是否已有 streaming thinking/assistant/tool,再决定是否需要 animation。 - -### 10. `apply_stream_envelope` 每次流式 delta 都 clone `slash_candidates` -- **文件**: `state/mod.rs:309` -- **描述**: `self.conversation.slash_candidates.clone()` 在每次 envelope 到达时执行。slash_candidates 列表通常不频繁变化,但每个流式 chunk 都会触发一次完整 clone。 -- **建议**: 仅在 slash_candidates 实际变化时(`ReplaceSlashCandidates` delta)才调用 `sync_slash_items`。 - -### 11. `visible_input_state` 中不必要的 `visible_before.clone()` -- **文件**: `ui/footer.rs:153` -- **描述**: `let mut visible = visible_before.clone()` 后,`visible_before` 不再使用。可以直接 move 而非 clone。 -- **建议**: 改为 `let mut visible = visible_before;`。 - ---- - -## Best Practices - -### 12. `enum_wire_name` 仍通过 serde 序列化判断 stdout/stderr -- **文件**: `state/conversation.rs:244-252` -- **描述**: 虽然比之前的 `format!("{:?}", stream)` 好一些,但仍然通过 `serde_json::to_value` 序列化枚举后取字符串值来判断变体。如果 `ToolOutputStream` 是外部 crate 类型且不暴露变体,这是唯一的方式,但应该有注释说明为什么不能直接 match。 -- **建议**: 添加注释说明限制原因,或者在外部 crate 中为 `ToolOutputStream` 添加 `is_stderr()` 方法。 - -### 13. `render_transcript` 中 `Paragraph::new().wrap()` 可能二次换行 -- **文件**: `render/mod.rs:54-65` -- **描述**: `wrap_text` 已经手动将文本按列宽换行,然后 `Paragraph::new().wrap(Wrap { trim: false })` 又启用了 ratatui 的自动换行。虽然 scroll 机制依赖 Paragraph,但 wrap 是多余的,可能在边界条件下导致意外行为。 -- **建议**: 使用 `Paragraph::new().wrap(Wrap { trim: false })` 是必要的(因为 scroll 依赖 Paragraph 内部换行),但可以验证两者的一致性,或移除手动 wrap 改为完全依赖 ratatui 的 wrap。 - -### 14. `palette_next` / `palette_prev` 仍有 Resume/Slash 两个重复分支 -- **文件**: `state/interaction.rs:413-437` -- **描述**: 两个方法中 Resume 和 Slash 分支的逻辑完全相同(只是 +1 / -1 的区别)。可以提取为辅助方法。 -- **建议**: 提取 `fn advance_selected(items_len: usize, selected: &mut usize, forward: bool)` 辅助函数。 - -### 15. `SharedStreamPacer` 中 `expect("stream pacer lock poisoned")` 出现 5 次 -- **文件**: `app/mod.rs:212, 223, 231, 245, 249` -- **描述**: 每个 lock 操作都有相同的 `expect` 字符串。如果某处 panic,其余地方也会连锁 panic。虽然 lock poisoning 在正常使用中不应发生,但可以统一处理。 -- **建议**: 提取 `fn lock_inner(&self) -> std::sync::MutexGuard<'_, StreamPacerState>` 方法,统一处理 lock poisoning。 - ---- - -## 汇总 - -| 类别 | 数量 | 核心问题 | -|------|------|----------| -| 可访问性 | 4 | ascii marker 碰撞、硬编码 Unicode 字符、中英混用 | -| 视觉一致性 | 3 | Debug 格式化做 UI 文案、footer 布局/内容耦合、快捷键描述不一致 | -| 性能 | 4 | 全量投影、双次遍历、多余 clone、不必要的 clone | -| 最佳实践 | 4 | serde 判枚举、双重 wrap、重复分支、lock poisoning 处理 | - -### 优先修复建议 - -1. **第 2 项**(`│` 硬编码)— ascii_only 模式下直接显示错误 -2. **第 1 项**(ascii marker 碰撞)— 影响无颜色终端用户的基本可用性 -3. **第 10 项**(slash_candidates 多余 clone)— 流式场景下每个 delta 触发,性能影响最大 -4. **第 8 项**(全量投影)— 每次键盘操作触发 O(n) 投影 diff --git a/crates/cli/src/ui/mod.rs b/crates/cli/src/ui/mod.rs index ff4f0f82..fea9a4d4 100644 --- a/crates/cli/src/ui/mod.rs +++ b/crates/cli/src/ui/mod.rs @@ -8,7 +8,7 @@ mod palette; mod text; mod theme; -pub use palette::{palette_lines, palette_visible}; +pub use palette::palette_lines; use ratatui::text::{Line, Span}; pub use text::truncate_to_width; pub use theme::{CodexTheme, ThemePalette}; diff --git a/crates/cli/src/ui/palette.rs b/crates/cli/src/ui/palette.rs index df3081a1..99590304 100644 --- a/crates/cli/src/ui/palette.rs +++ b/crates/cli/src/ui/palette.rs @@ -47,10 +47,6 @@ pub fn palette_lines( } } -pub fn palette_visible(palette: &PaletteState) -> bool { - !matches!(palette, PaletteState::Closed) -} - fn visible_window(items: &[T], selected: usize, max_items: usize) -> Vec<(usize, &T)> { if items.is_empty() || max_items == 0 { return Vec::new(); diff --git a/crates/cli/src/ui/theme.rs b/crates/cli/src/ui/theme.rs index 8ff8363c..a39167a6 100644 --- a/crates/cli/src/ui/theme.rs +++ b/crates/cli/src/ui/theme.rs @@ -22,10 +22,6 @@ impl CodexTheme { Self { capabilities } } - pub fn app_background(&self) -> Style { - Style::default() - } - pub fn menu_block_style(&self) -> Style { Style::default().fg(self.text_primary()) } From 07b156251d75a6bb02f0df4bc176d02de46e3d23 Mon Sep 17 00:00:00 2001 From: whatevertogo Date: Sat, 18 Apr 2026 16:19:28 +0800 Subject: [PATCH 28/53] refactor: replace mailbox with input queue in session-runtime - Renamed mailbox module to input_queue and updated all references accordingly. - Adjusted session state to manage input queue projections instead of mailbox projections. - Updated event handling to append input queue events and removed mailbox-related logic. - Modified tests and documentation to reflect the changes from mailbox to input queue. --- PROJECT_ARCHITECTURE.md | 12 +- README.md | 2 +- .../src/agent_tools/collab_result_mapping.rs | 16 +- .../src/agent_tools/observe_tool.rs | 20 +- crates/adapter-tools/src/agent_tools/tests.rs | 62 +-- crates/application/src/agent/mod.rs | 85 +++- crates/application/src/agent/observe.rs | 390 ++++-------------- crates/application/src/agent/routing.rs | 79 ++-- crates/application/src/agent/terminal.rs | 12 +- crates/application/src/agent/wake.rs | 157 +++---- crates/application/src/ports/agent_kernel.rs | 7 + crates/application/src/ports/agent_session.rs | 74 ++-- crates/core/src/action.rs | 2 + .../src/agent/{mailbox.rs => input_queue.rs} | 267 ++++++------ crates/core/src/agent/mod.rs | 8 +- crates/core/src/event/domain.rs | 24 +- crates/core/src/event/phase.rs | 17 +- crates/core/src/event/translate.rs | 16 +- crates/core/src/event/types.rs | 34 +- crates/core/src/lib.rs | 10 +- crates/core/src/projection/agent_state.rs | 8 +- crates/session-runtime/src/command/mod.rs | 46 ++- .../src/context_window/token_usage.rs | 1 + crates/session-runtime/src/heuristics.rs | 8 +- crates/session-runtime/src/lib.rs | 38 +- crates/session-runtime/src/query/agent.rs | 305 +++++--------- .../session-runtime/src/query/conversation.rs | 8 +- .../src/query/{mailbox.rs => input_queue.rs} | 24 +- crates/session-runtime/src/query/mod.rs | 4 +- crates/session-runtime/src/query/service.rs | 20 +- crates/session-runtime/src/query/text.rs | 4 +- .../session-runtime/src/state/input_queue.rs | 158 +++++++ crates/session-runtime/src/state/mailbox.rs | 158 ------- crates/session-runtime/src/state/mod.rs | 236 +---------- crates/session-runtime/src/turn/submit.rs | 201 +++++---- docs/architecture-diagram.md | 2 +- .../specs/agent-delivery-contracts/spec.md | 4 +- openspec/specs/application-use-cases/spec.md | 4 +- openspec/specs/session-runtime/spec.md | 4 +- openspec/specs/subagent-execution/spec.md | 6 +- 40 files changed, 1004 insertions(+), 1529 deletions(-) rename crates/core/src/agent/{mailbox.rs => input_queue.rs} (69%) rename crates/session-runtime/src/query/{mailbox.rs => input_queue.rs} (87%) create mode 100644 crates/session-runtime/src/state/input_queue.rs delete mode 100644 crates/session-runtime/src/state/mailbox.rs diff --git a/PROJECT_ARCHITECTURE.md b/PROJECT_ARCHITECTURE.md index 2dbb827d..8676be66 100644 --- a/PROJECT_ARCHITECTURE.md +++ b/PROJECT_ARCHITECTURE.md @@ -152,7 +152,7 @@ agent delegation experience 也遵循同样的分层边界: - 共享协作协议由 `adapter-prompt::contributors::workflow_examples` 承担,只负责四工具的通用决策规则与 fresh / resumed / restricted 三种 delegation mode。 - 行为模板目录由 `adapter-prompt::contributors::agent_profile_summary` 承担,只回答“什么样的 child 适合做什么事”,不能伪装成 capability 授权目录。 - child 专属 execution contract 只能通过 `PromptDeclaration` 注入,沿 child launch / resume 的 submission-time 链路进入 prompt,不能回退到工具 description 或 profile 文本拼装。 -- `observe` / collaboration result 中的 reuse / close / respawn 建议只能是 advisory projection;真正的事实源仍然是 lifecycle、turn outcome、mailbox 投影与 resolved capability surface。 +- `observe` / collaboration result 中的 reuse / close / respawn 建议只能是 advisory projection;真正的事实源仍然是 lifecycle、turn outcome、input queue 投影与 resolved capability surface。 ### 4.5 `session-runtime` @@ -163,7 +163,7 @@ agent delegation experience 也遵循同样的分层边界: - transcript snapshot/replay - interrupt/compact/run_turn - branch/subrun 的单 session 执行真相 - - child delivery / mailbox / observe 的会话内推进部分 +- child delivery / input queue / observe 的会话内推进部分 - context window / compaction / request assembly - `session-runtime` 不负责: - 全局 plugin discovery @@ -176,7 +176,7 @@ agent delegation experience 也遵循同样的分层边界: 1. **优先 Event Log,再做投影** - 长期目标是把 session 视为 append-only event log,而不是一组可变字段。 - - durable 真相来自 `SessionLog`;`history view`、`branch snapshot`、mailbox replay 等读模型都应以 log 投影为准。 + - durable 真相来自 `SessionLog`;`history view`、`branch snapshot`、input queue replay 等读模型都应以 log 投影为准。 - `SessionState` 不是“纯 log projection”,而是 `projection cache + live execution control`:例如 `running`、`cancel token`、turn lease、待执行 compact 请求这类运行态协调信息允许只存在于 live state。 - 当前项目已经部分符合这个方向:`EventStore.append/replay`、`SessionState` 内部 projector、`history/replay` 查询都建立在事件回放之上。 - 长期仍应继续收口:减少“字段即 durable 真相”的写法,让 `SessionLog -> Projection` 与 `LiveControlState` 的边界都显式可见。 @@ -195,13 +195,13 @@ agent delegation experience 也遵循同样的分层边界: - compaction 策略可替换 - budget 决策与 prompt 拼装解耦 -4. **Mailbox / child delivery 优先使用类型化消息契约** +4. **Input Queue / child delivery 优先使用类型化消息契约** - child delivery、interrupt、observe、wake、close 这些能力,长期应尽量通过明确的消息类型表达,而不是靠共享可变状态和隐式约定拼接。 - - 当前项目已经有 durable mailbox 事件、Envelope DTO 与投影;这部分是 durable contract,不应再重复包装出第二套“伪类型化 mailbox”。 + - 当前项目已经有 durable input queue 事件、Envelope DTO 与投影;这部分是 durable contract,不应再重复包装出第二套“伪类型化 input queue”。 - 后续真正需要继续 typed 化的是 `SessionActor` / wake / interrupt / close 的 live command path,让 actor loop 按明确消息处理运行态协调,而不是继续扩张共享状态与隐式约定。 5. **Query 与 Command 逻辑继续分离** - - 写侧负责追加事件、推进 turn、驱动 mailbox。 + - 写侧负责追加事件、推进 turn、驱动 input queue。 - 读侧负责 transcript snapshot、stream replay、context view 等投影查询。 - 当前项目已经开始分离:有 `query/` 模块,也有 `SessionQueries` / `SessionCommands` 这类内部 helper。 - `SessionRuntime` 对外仍是过渡 façade,但应只承担构造和薄委托;新增读写能力时,优先落到 `query` 或 `command` 子域,再由 façade 暴露稳定入口。 diff --git a/README.md b/README.md index 9ab1b1f5..9087b897 100644 --- a/README.md +++ b/README.md @@ -276,7 +276,7 @@ AstrCode/ - **`core`**:领域语义、强类型 ID、端口契约、`CapabilitySpec`、稳定配置模型。不依赖传输层或具体实现;`CapabilitySpec` 是运行时内部的能力语义真相。 - **`protocol`**:HTTP/SSE/Plugin 的 DTO 与 wire 类型,仅依赖 `core`;其中 `CapabilityWireDescriptor` 只承担协议边界传输职责,不是运行时内部的能力真相。 - **`kernel`**:全局控制面 — capability router/registry、agent tree、统一事件协调。 -- **`session-runtime`**:单会话真相 — turn 执行、事件回放、compact、context window、mailbox 推进。 +- **`session-runtime`**:单会话真相 — turn 执行、事件回放、compact、context window、input queue 推进。 - **`application`**:用例编排入口(`App`)+ 治理入口(`AppGovernance`),负责参数校验、权限、策略、reload 编排。 - **`server`**:HTTP/SSE 边界与唯一组合根(`bootstrap/runtime.rs`),只负责 DTO 映射和装配。 - **`adapter-*`**:端口实现层,不持有业务真相,不偷渡业务策略。 diff --git a/crates/adapter-tools/src/agent_tools/collab_result_mapping.rs b/crates/adapter-tools/src/agent_tools/collab_result_mapping.rs index 5acb43b7..b7117989 100644 --- a/crates/adapter-tools/src/agent_tools/collab_result_mapping.rs +++ b/crates/adapter-tools/src/agent_tools/collab_result_mapping.rs @@ -96,26 +96,14 @@ fn inject_advisory_projection(metadata: &mut serde_json::Value, result: &Collabo } fn build_advisory_projection(result: &CollaborationResult) -> Option { - let delegation = result.delegation().or_else(|| { - result - .observe_result() - .and_then(|observe| observe.delegation.as_ref()) - }); - let next_step = result.observe_result().map(|observe| { - json!({ - "preferredAction": observe.recommended_next_action, - "reason": observe.recommended_reason, - "deliveryFreshness": observe.delivery_freshness, - }) - }); + let delegation = result.delegation(); let branch = delegation.map(branch_advisory); - if next_step.is_none() && branch.is_none() { + if branch.is_none() { return None; } Some(json!({ - "nextStep": next_step, "branch": branch, })) } diff --git a/crates/adapter-tools/src/agent_tools/observe_tool.rs b/crates/adapter-tools/src/agent_tools/observe_tool.rs index 37a3c895..37824766 100644 --- a/crates/adapter-tools/src/agent_tools/observe_tool.rs +++ b/crates/adapter-tools/src/agent_tools/observe_tool.rs @@ -1,7 +1,7 @@ //! # observe 工具 //! -//! 四工具模型中的观测工具。返回目标 child agent 的增强快照, -//! 融合 live control state、对话投影和 mailbox 派生信息。 +//! 四工具模型中的观测工具。返回目标 child agent 的只读快照, +//! 融合 live control state 与对话投影。 use std::sync::Arc; @@ -19,10 +19,10 @@ use crate::agent_tools::{ const TOOL_NAME: &str = "observe"; -/// 获取目标 child agent 增强快照的观测工具。 +/// 获取目标 child agent 只读快照的观测工具。 /// /// 只返回直接子 agent 的快照,非直接父、兄弟、跨树调用被拒绝。 -/// 快照融合三层数据:live lifecycle、对话投影、mailbox 派生摘要。 +/// 快照只返回状态、任务与最近输出尾部。 pub struct ObserveAgentTool { executor: Arc, } @@ -39,8 +39,8 @@ Use `observe` to decide the next action for one direct child. - Use the exact `agentId` returned earlier - Call it only when you cannot decide between `wait`, `send`, or `close` without a fresh snapshot -- Read both the raw facts and the advisory fields in the result -- Treat the mailbox section as a short tail view of recent messages, not as full history +- Read the snapshot fields directly; `observe` no longer returns advisory next-step fields +- Treat the tail fields as short excerpts, not as full history Do not poll repeatedly with no decision attached. If you are simply waiting for a running child, pause briefly with your current shell tool (for example `sleep`) instead of spending another @@ -93,10 +93,6 @@ impl Tool for ObserveAgentTool { "Only returns snapshots for direct child agents. Never rewrite `agent-1` as \ `agent-01`.", ) - .caveat( - "`observe` returns raw lifecycle/outcome facts plus advisory decision fields. \ - Treat the advice as guidance, not as a replacement for the facts.", - ) .caveat( "Prefer one well-timed observe over repeated checking. If you are just \ waiting for a running child, use your current shell tool to sleep briefly and \ @@ -104,8 +100,8 @@ impl Tool for ObserveAgentTool { `sleep -> observe -> sleep -> observe` while no new delivery has arrived.", ) .caveat( - "`observe` only exposes a short mailbox tail and latest output excerpt. It is \ - intentionally not a full mailbox or transcript dump.", + "`observe` only exposes recent output and the last turn tail. It is \ + intentionally not a full transcript dump.", ) .prompt_tag("collaboration"), ) diff --git a/crates/adapter-tools/src/agent_tools/tests.rs b/crates/adapter-tools/src/agent_tools/tests.rs index 909de71a..7d800284 100644 --- a/crates/adapter-tools/src/agent_tools/tests.rs +++ b/crates/adapter-tools/src/agent_tools/tests.rs @@ -647,29 +647,17 @@ impl CollaborationExecutor for RecordingCollabExecutor { self.observe_calls.lock().expect("lock").push(params); Ok(CollaborationResult::Observed { agent_ref: sample_child_ref(), - summary: format!( - "子 Agent {} 当前为 Idle;建议 send_or_close:上一轮已完成。", - agent_id - ), - observe_result: Box::new(astrcode_core::ObserveAgentResult { + summary: format!("子 Agent {} 当前为 Idle;最近输出:done。", agent_id), + observe_result: Box::new(astrcode_core::ObserveSnapshot { agent_id: agent_id.to_string(), - sub_run_id: "subrun-42".to_string(), - session_id: "session-parent".to_string(), - open_session_id: "session-child-42".to_string(), - parent_agent_id: "agent-parent".to_string(), + session_id: "session-child-42".to_string(), lifecycle_status: AgentLifecycleStatus::Idle, last_turn_outcome: Some(AgentTurnOutcome::Completed), phase: "Idle".to_string(), turn_count: 1, - pending_message_count: 0, active_task: None, - pending_task: None, - recent_mailbox_messages: vec!["最近一条 mailbox 摘要".to_string()], - last_output: Some("done".to_string()), - delegation: Some(sample_delegation(false)), - recommended_next_action: "send_or_close".to_string(), - recommended_reason: "上一轮已完成。".to_string(), - delivery_freshness: "ready_for_follow_up".to_string(), + last_output_tail: Some("done".to_string()), + last_turn_tail: vec!["最近一条 input queue 摘要".to_string()], }), delegation: Some(sample_delegation(false)), }) @@ -926,9 +914,9 @@ async fn observe_agent_tool_parses_params_and_delegates_to_executor() { .metadata .as_ref() .and_then(|value| value.get("observe_result")) - .and_then(|value| value.get("recommendedNextAction")) + .and_then(|value| value.get("phase")) .and_then(|value| value.as_str()) - .is_some_and(|value| value == "send_or_close") + .is_some_and(|value| value == "Idle") ); let calls = executor.observe_calls.lock().expect("lock"); assert_eq!(calls.len(), 1); @@ -943,25 +931,16 @@ fn collaboration_result_metadata_projects_idle_reuse_and_branch_mismatch_hints() CollaborationResult::Observed { agent_ref: sample_child_ref(), summary: "子 Agent agent-42 当前为 Idle。".to_string(), - observe_result: Box::new(astrcode_core::ObserveAgentResult { + observe_result: Box::new(astrcode_core::ObserveSnapshot { agent_id: "agent-42".to_string(), - sub_run_id: "subrun-42".to_string(), - session_id: "session-parent".to_string(), - open_session_id: "session-child-42".to_string(), - parent_agent_id: "agent-parent".to_string(), + session_id: "session-child-42".to_string(), lifecycle_status: AgentLifecycleStatus::Idle, last_turn_outcome: Some(AgentTurnOutcome::Completed), phase: "Idle".to_string(), turn_count: 1, - pending_message_count: 0, active_task: None, - pending_task: None, - recent_mailbox_messages: Vec::new(), - last_output: Some("done".to_string()), - delegation: Some(sample_delegation(false)), - recommended_next_action: "send_or_close".to_string(), - recommended_reason: "同一责任分支可继续 send。".to_string(), - delivery_freshness: "ready_for_follow_up".to_string(), + last_turn_tail: Vec::new(), + last_output_tail: Some("done".to_string()), }), delegation: Some(sample_delegation(false)), }, @@ -997,25 +976,16 @@ fn collaboration_result_metadata_projects_restricted_child_broader_tool_hint() { CollaborationResult::Observed { agent_ref: sample_child_ref(), summary: "restricted child idle".to_string(), - observe_result: Box::new(astrcode_core::ObserveAgentResult { + observe_result: Box::new(astrcode_core::ObserveSnapshot { agent_id: "agent-42".to_string(), - sub_run_id: "subrun-42".to_string(), - session_id: "session-parent".to_string(), - open_session_id: "session-child-42".to_string(), - parent_agent_id: "agent-parent".to_string(), + session_id: "session-child-42".to_string(), lifecycle_status: AgentLifecycleStatus::Idle, last_turn_outcome: Some(AgentTurnOutcome::Completed), phase: "Idle".to_string(), turn_count: 1, - pending_message_count: 0, active_task: None, - pending_task: None, - recent_mailbox_messages: Vec::new(), - last_output: Some("done".to_string()), - delegation: Some(sample_delegation(true)), - recommended_next_action: "send_or_close".to_string(), - recommended_reason: "同一责任分支且工具面匹配时可继续复用。".to_string(), - delivery_freshness: "ready_for_follow_up".to_string(), + last_turn_tail: Vec::new(), + last_output_tail: Some("done".to_string()), }), delegation: Some(sample_delegation(true)), }, @@ -1142,7 +1112,7 @@ fn collaboration_tool_definitions_exclude_runtime_internals() { assert!(!close_def.description.contains("CancelToken")); let observe_def = ObserveAgentTool::new(boxed_collaboration_executor(executor)).definition(); - assert!(!observe_def.description.contains("MailboxProjection")); + assert!(!observe_def.description.contains("InputQueueProjection")); } #[test] diff --git a/crates/application/src/agent/mod.rs b/crates/application/src/agent/mod.rs index 09f55d6f..96ef0bf4 100644 --- a/crates/application/src/agent/mod.rs +++ b/crates/application/src/agent/mod.rs @@ -27,12 +27,12 @@ use std::{ use astrcode_core::{ AgentCollaborationActionKind, AgentCollaborationFact, AgentCollaborationOutcomeKind, - AgentCollaborationPolicyContext, AgentEventContext, AgentLifecycleStatus, AgentMailboxEnvelope, - AgentTurnOutcome, ArtifactRef, ChildExecutionIdentity, CloseAgentParams, CollaborationResult, - DelegationMetadata, InvocationKind, ObserveParams, ParentDelivery, ParentDeliveryOrigin, - ParentDeliveryPayload, ParentDeliveryTerminalSemantics, ProgressParentDeliveryPayload, - PromptDeclaration, PromptDeclarationKind, PromptDeclarationRenderTarget, - PromptDeclarationSource, ResolvedExecutionLimitsSnapshot, Result, RuntimeMetricsRecorder, + AgentCollaborationPolicyContext, AgentEventContext, AgentLifecycleStatus, AgentTurnOutcome, + ArtifactRef, ChildExecutionIdentity, CloseAgentParams, CollaborationResult, DelegationMetadata, + InvocationKind, ObserveParams, ParentDelivery, ParentDeliveryOrigin, ParentDeliveryPayload, + ParentDeliveryTerminalSemantics, ProgressParentDeliveryPayload, PromptDeclaration, + PromptDeclarationKind, PromptDeclarationRenderTarget, PromptDeclarationSource, + QueuedInputEnvelope, ResolvedExecutionLimitsSnapshot, Result, RuntimeMetricsRecorder, SendAgentParams, SpawnAgentParams, SubRunHandle, SubRunHandoff, SubRunResult, SystemPromptLayer, ToolContext, }; @@ -91,6 +91,7 @@ pub(crate) fn subrun_event_context_for_parent_turn( pub(crate) const IMPLICIT_ROOT_PROFILE_ID: &str = "default"; pub(crate) const AGENT_COLLABORATION_POLICY_REVISION: &str = "agent-collaboration-v1"; +const MAX_OBSERVE_GUARD_ENTRIES: usize = 1024; pub(crate) struct CollaborationFactRecord<'a> { pub(crate) action: AgentCollaborationActionKind, @@ -452,11 +453,11 @@ async fn ensure_handle_has_resolved_limits( .await } -pub(crate) fn child_delivery_mailbox_envelope( +pub(crate) fn child_delivery_input_queue_envelope( notification: &astrcode_core::ChildSessionNotification, target_agent_id: String, -) -> AgentMailboxEnvelope { - AgentMailboxEnvelope { +) -> QueuedInputEnvelope { + QueuedInputEnvelope { delivery_id: notification.notification_id.clone(), from_agent_id: notification.child_ref.agent_id().to_string(), to_agent_id: target_agent_id, @@ -669,7 +670,7 @@ pub struct AgentOrchestrationService { profiles: Arc, task_registry: Arc, metrics: Arc, - observe_guard: Arc>>, + observe_guard: Arc>, } impl AgentOrchestrationService { @@ -688,7 +689,7 @@ impl AgentOrchestrationService { profiles, task_registry, metrics, - observe_guard: Arc::new(Mutex::new(HashMap::new())), + observe_guard: Arc::new(Mutex::new(ObserveGuardState::default())), } } @@ -1017,11 +1018,58 @@ struct ObserveSnapshotSignature { last_turn_outcome: Option, phase: String, turn_count: u32, - pending_message_count: usize, active_task: Option, - pending_task: Option, - recent_mailbox_messages: Vec, - last_output: Option, + last_output_tail: Option, + last_turn_tail: Vec, +} + +#[derive(Debug, Clone)] +struct ObserveGuardEntry { + sequence: u64, + signature: ObserveSnapshotSignature, +} + +#[derive(Debug, Default)] +struct ObserveGuardState { + next_sequence: u64, + entries: HashMap, +} + +impl ObserveGuardState { + fn is_unchanged(&self, key: &str, signature: &ObserveSnapshotSignature) -> bool { + self.entries + .get(key) + .is_some_and(|entry| &entry.signature == signature) + } + + fn remember(&mut self, key: String, signature: ObserveSnapshotSignature) { + let sequence = self.next_sequence; + self.next_sequence = self.next_sequence.saturating_add(1); + self.entries.insert( + key.clone(), + ObserveGuardEntry { + sequence, + signature, + }, + ); + self.evict_oldest_if_needed(&key); + } + + fn evict_oldest_if_needed(&mut self, keep_key: &str) { + if self.entries.len() <= MAX_OBSERVE_GUARD_ENTRIES { + return; + } + let Some(oldest_key) = self + .entries + .iter() + .filter(|(key, _)| key.as_str() != keep_key) + .min_by_key(|(_, entry)| entry.sequence) + .map(|(key, _)| key.clone()) + else { + return; + }; + self.entries.remove(&oldest_key); + } } // ── 实现 SubAgentExecutor(供 spawn 工具使用)────────────────────── @@ -1216,7 +1264,7 @@ mod tests { use super::{ IMPLICIT_ROOT_PROFILE_ID, build_delegation_metadata, build_fresh_child_contract, - build_resumed_child_contract, child_delivery_mailbox_envelope, + build_resumed_child_contract, child_delivery_input_queue_envelope, implicit_session_root_agent_id, root_execution_event_context, terminal_notification_message, terminal_notification_turn_outcome, }; @@ -1292,7 +1340,7 @@ mod tests { } #[test] - fn child_delivery_mailbox_envelope_reuses_terminal_projection_fields() { + fn child_delivery_input_queue_envelope_reuses_terminal_projection_fields() { let notification = ChildSessionNotification { notification_id: "delivery-1".to_string().into(), child_ref: ChildAgentRef { @@ -1326,7 +1374,8 @@ mod tests { }), }; - let envelope = child_delivery_mailbox_envelope(¬ification, "agent-parent".to_string()); + let envelope = + child_delivery_input_queue_envelope(¬ification, "agent-parent".to_string()); assert_eq!(terminal_notification_message(¬ification), "final reply"); assert_eq!( diff --git a/crates/application/src/agent/observe.rs b/crates/application/src/agent/observe.rs index 1bd43176..04755008 100644 --- a/crates/application/src/agent/observe.rs +++ b/crates/application/src/agent/observe.rs @@ -1,21 +1,17 @@ //! # 四工具模型 — Observe 实现 //! -//! `observe` 是四工具模型(send / observe / close / interrupt)中的只读观察操作。 -//! 从旧 runtime/service/agent/observe.rs 迁入,去掉对 RuntimeService 的依赖。 -//! -//! 快照聚合两层: -//! 1. 从 kernel AgentControl 获取 lifecycle / turn_outcome -//! 2. 从 session-runtime 获取稳定 observe 视图 +//! `observe` 现在只返回只读快照,不再派生下一步建议,也不再暴露 input queue +//! 的内部补洞语义。 use astrcode_core::{ AgentCollaborationActionKind, AgentCollaborationOutcomeKind, AgentLifecycleStatus, - CollaborationResult, ObserveAgentResult, ObserveParams, + CollaborationResult, ObserveParams, ObserveSnapshot, }; use super::{AgentOrchestrationService, ObserveSnapshotSignature}; impl AgentOrchestrationService { - /// 获取目标 child agent 的增强快照(四工具模型 observe)。 + /// 获取目标 child agent 的只读快照。 pub async fn observe_child( &self, params: ObserveParams, @@ -40,7 +36,6 @@ impl AgentOrchestrationService { .get_lifecycle(¶ms.agent_id) .await .unwrap_or(AgentLifecycleStatus::Pending); - let last_turn_outcome = self.kernel.get_turn_outcome(¶ms.agent_id).await; let open_session_id = child @@ -48,7 +43,7 @@ impl AgentOrchestrationService { .clone() .unwrap_or_else(|| child.session_id.clone()); - let observe_snapshot = self + let snapshot = self .session_runtime .observe_agent_session(&open_session_id, ¶ms.agent_id, lifecycle_status) .await @@ -57,52 +52,17 @@ impl AgentOrchestrationService { "failed to build observe snapshot: {e}" )) })?; - let recommended_next_action = recommended_next_action( - lifecycle_status, - observe_snapshot.pending_message_count, - observe_snapshot.active_task.as_deref(), - observe_snapshot.pending_task.as_deref(), - ) - .to_string(); - let recommended_reason = recommended_reason( - lifecycle_status, - last_turn_outcome, - observe_snapshot.pending_message_count, - observe_snapshot.active_task.as_deref(), - observe_snapshot.pending_task.as_deref(), - child.delegation.as_ref(), - ); - let delivery_freshness = delivery_freshness( - lifecycle_status, - observe_snapshot.pending_message_count, - observe_snapshot.active_task.as_deref(), - observe_snapshot.pending_task.as_deref(), - ) - .to_string(); - let observe_result = ObserveAgentResult { + let observe_result = ObserveSnapshot { agent_id: child.agent_id.to_string(), - sub_run_id: child.sub_run_id.to_string(), - session_id: child.session_id.to_string(), - open_session_id: open_session_id.to_string(), - parent_agent_id: child - .parent_agent_id - .clone() - .unwrap_or_default() - .to_string(), + session_id: open_session_id.to_string(), lifecycle_status, last_turn_outcome, - phase: format!("{:?}", observe_snapshot.phase), - turn_count: observe_snapshot.turn_count, - pending_message_count: observe_snapshot.pending_message_count, - active_task: observe_snapshot.active_task, - pending_task: observe_snapshot.pending_task, - recent_mailbox_messages: observe_snapshot.recent_mailbox_messages, - last_output: observe_snapshot.last_output, - delegation: child.delegation.clone(), - recommended_next_action, - recommended_reason, - delivery_freshness, + phase: format!("{:?}", snapshot.phase), + turn_count: snapshot.turn_count, + active_task: snapshot.active_task, + last_output_tail: snapshot.last_output_tail, + last_turn_tail: snapshot.last_turn_tail, }; let signature = observe_signature(&observe_result); if self.observe_snapshot_is_unchanged(&child, &collaboration, &signature)? { @@ -129,10 +89,10 @@ impl AgentOrchestrationService { self.remember_observe_snapshot(&child, &collaboration, signature)?; log::info!( - "observe: snapshot for child agent '{}' (lifecycle={:?}, pending={})", + "observe: snapshot for child agent '{}' (lifecycle={:?}, phase={})", params.agent_id, lifecycle_status, - observe_result.pending_message_count + observe_result.phase ); self.record_fact_best_effort( collaboration.runtime(), @@ -142,7 +102,10 @@ impl AgentOrchestrationService { AgentCollaborationOutcomeKind::Accepted, ) .child(&child) - .summary(format_observe_summary(&observe_result)), + .summary(format_observe_summary( + &observe_result, + child.delegation.as_ref(), + )), ) .await; @@ -150,7 +113,7 @@ impl AgentOrchestrationService { agent_ref: self .project_child_ref_status(self.build_child_ref_from_handle(&child).await) .await, - summary: format_observe_summary(&observe_result), + summary: format_observe_summary(&observe_result, child.delegation.as_ref()), observe_result: Box::new(observe_result), delegation: child.delegation.clone(), }) @@ -166,9 +129,7 @@ impl AgentOrchestrationService { let guard = self.observe_guard.lock().map_err(|_| { super::AgentOrchestrationError::Internal("observe guard lock poisoned".to_string()) })?; - Ok(guard - .get(&guard_key) - .is_some_and(|previous| previous == signature)) + Ok(guard.is_unchanged(&guard_key, signature)) } fn remember_observe_snapshot( @@ -181,7 +142,7 @@ impl AgentOrchestrationService { let mut guard = self.observe_guard.lock().map_err(|_| { super::AgentOrchestrationError::Internal("observe guard lock poisoned".to_string()) })?; - guard.insert(guard_key, signature); + guard.remember(guard_key, signature); Ok(()) } } @@ -199,166 +160,43 @@ fn observe_guard_key( ) } -fn observe_signature(result: &ObserveAgentResult) -> ObserveSnapshotSignature { +fn observe_signature(result: &ObserveSnapshot) -> ObserveSnapshotSignature { ObserveSnapshotSignature { lifecycle_status: result.lifecycle_status, last_turn_outcome: result.last_turn_outcome, phase: result.phase.clone(), turn_count: result.turn_count, - pending_message_count: result.pending_message_count, active_task: result.active_task.clone(), - pending_task: result.pending_task.clone(), - recent_mailbox_messages: result.recent_mailbox_messages.clone(), - last_output: result.last_output.clone(), + last_output_tail: result.last_output_tail.clone(), + last_turn_tail: result.last_turn_tail.clone(), } } -fn recommended_next_action( - lifecycle_status: AgentLifecycleStatus, - pending_message_count: usize, - active_task: Option<&str>, - pending_task: Option<&str>, -) -> &'static str { - match lifecycle_status { - AgentLifecycleStatus::Pending | AgentLifecycleStatus::Running => "wait", - AgentLifecycleStatus::Terminated => "none", - AgentLifecycleStatus::Idle if active_task.is_some() || pending_task.is_some() => "wait", - AgentLifecycleStatus::Idle if pending_message_count > 0 => "wait", - AgentLifecycleStatus::Idle => "send_or_close", - } -} - -fn recommended_reason( - lifecycle_status: AgentLifecycleStatus, - last_turn_outcome: Option, - pending_message_count: usize, - active_task: Option<&str>, - pending_task: Option<&str>, +fn format_observe_summary( + result: &ObserveSnapshot, delegation: Option<&astrcode_core::DelegationMetadata>, ) -> String { - let branch_summary = delegation - .map(|metadata| format!("责任分支:{}。", metadata.responsibility_summary)) - .unwrap_or_default(); - let restricted_summary = delegation - .and_then(|metadata| metadata.capability_limit_summary.as_ref()) - .map(|summary| format!(" {}", summary)) - .unwrap_or_default(); - - match lifecycle_status { - AgentLifecycleStatus::Pending | AgentLifecycleStatus::Running => { - if let Some(task) = active_task { - format!("{branch_summary}子 Agent 仍在处理当前任务:{task}{restricted_summary}") - } else if let Some(task) = pending_task { - format!( - "{branch_summary}子 Agent 还有待消费的 mailbox \ - 任务:{task}{restricted_summary}" - ) - } else { - format!( - "{branch_summary}子 Agent 仍在执行中,当前更适合继续等待。{restricted_summary}" - ) - } - }, - AgentLifecycleStatus::Terminated => format!( - "{branch_summary}子 Agent 已终止,不能再接收 send;如需继续工作应改由当前 Agent \ - 或新的分支处理。{restricted_summary}" - ), - AgentLifecycleStatus::Idle if active_task.is_some() || pending_task.is_some() => { - format!( - "{branch_summary}子 Agent 当前空闲,但还有待处理任务痕迹,先等待当前 mailbox \ - 周期稳定。{restricted_summary}" - ) - }, - AgentLifecycleStatus::Idle if pending_message_count > 0 => { - format!( - "{branch_summary}子 Agent 已空闲,但 mailbox \ - 里仍有待处理消息;先等待这些消息被消费。{restricted_summary}" - ) - }, - AgentLifecycleStatus::Idle => match last_turn_outcome { - Some(astrcode_core::AgentTurnOutcome::Completed) => format!( - "{branch_summary}子 Agent 已完成上一轮工作;如果责任边界不变可直接 send \ - 复用,否则 close 结束该分支。{}{}", - delegation - .map(|metadata| metadata.reuse_scope_summary.as_str()) - .unwrap_or(""), - restricted_summary - ), - Some(astrcode_core::AgentTurnOutcome::Failed) => format!( - "{branch_summary}子 Agent 上一轮失败;若要继续同一责任可 send 明确返工要求,否则 \ - close 止损。{}{}", - delegation - .map(|metadata| metadata.reuse_scope_summary.as_str()) - .unwrap_or(""), - restricted_summary - ), - Some(astrcode_core::AgentTurnOutcome::Cancelled) => format!( - "{branch_summary}子 Agent 上一轮已取消;通常更适合 \ - close,只有在确实要复用同一分支时才 send。{}{}", - delegation - .map(|metadata| metadata.reuse_scope_summary.as_str()) - .unwrap_or(""), - restricted_summary - ), - Some(astrcode_core::AgentTurnOutcome::TokenExceeded) => format!( - "{branch_summary}子 Agent 上一轮受 token \ - 限制中断;若继续复用,请先收窄任务范围后再 send。{}{}", - delegation - .map(|metadata| metadata.reuse_scope_summary.as_str()) - .unwrap_or(""), - restricted_summary - ), - None => format!( - "{branch_summary}子 Agent 当前空闲,可根据责任是否继续存在来选择 send 或 \ - close。{}{}", - delegation - .map(|metadata| metadata.reuse_scope_summary.as_str()) - .unwrap_or(""), - restricted_summary - ), - }, + let mut parts = Vec::new(); + parts.push(format!( + "子 Agent {} 当前为 {:?}", + result.agent_id, result.lifecycle_status + )); + if let Some(metadata) = delegation { + parts.push(format!("责任分支:{}", metadata.responsibility_summary)); } -} - -fn delivery_freshness( - lifecycle_status: AgentLifecycleStatus, - pending_message_count: usize, - active_task: Option<&str>, - pending_task: Option<&str>, -) -> &'static str { - match lifecycle_status { - AgentLifecycleStatus::Pending | AgentLifecycleStatus::Running => "pending_child_work", - AgentLifecycleStatus::Terminated => "terminated", - AgentLifecycleStatus::Idle if active_task.is_some() || pending_task.is_some() => { - "pending_child_work" - }, - AgentLifecycleStatus::Idle if pending_message_count > 0 => "pending_child_work", - AgentLifecycleStatus::Idle => "ready_for_follow_up", + if let Some(task) = result.active_task.as_deref() { + parts.push(format!("当前任务:{task}")); } -} - -fn format_observe_summary(result: &ObserveAgentResult) -> String { - let branch_prefix = result - .delegation - .as_ref() - .map(|metadata| format!("责任分支:{};", metadata.responsibility_summary)) - .unwrap_or_default(); - let base = format!( - "子 Agent {} 当前为 {:?};{}建议 {}:{}", - result.agent_id, - result.lifecycle_status, - branch_prefix, - result.recommended_next_action, - result.recommended_reason - ); - if result.recent_mailbox_messages.is_empty() { - return base; + if let Some(output) = result.last_output_tail.as_deref() { + parts.push(format!("最近输出:{output}")); } - - format!( - "{base};最近 mailbox 摘要:{}", - result.recent_mailbox_messages.join(" | ") - ) + if !result.last_turn_tail.is_empty() { + parts.push(format!( + "最后一轮尾部:{}", + result.last_turn_tail.join(" | ") + )); + } + parts.join(";") } #[cfg(test)] @@ -366,115 +204,64 @@ mod tests { use std::time::Duration; use astrcode_core::{ - AgentCollaborationActionKind, AgentCollaborationOutcomeKind, CancelToken, - DelegationMetadata, ObserveParams, SessionId, StorageEventPayload, ToolContext, + AgentCollaborationActionKind, AgentCollaborationOutcomeKind, CancelToken, ObserveParams, + SessionId, StorageEventPayload, ToolContext, agent::executor::{CollaborationExecutor, SubAgentExecutor}, }; use tokio::time::sleep; - use super::{ - delivery_freshness, format_observe_summary, recommended_next_action, recommended_reason, + use super::format_observe_summary; + use crate::agent::{ + ObserveGuardState, ObserveSnapshotSignature, + test_support::{TestLlmBehavior, build_agent_test_harness}, }; - use crate::agent::test_support::{TestLlmBehavior, build_agent_test_harness}; #[test] - fn recommendation_helpers_prefer_wait_for_running_child() { - assert_eq!( - recommended_next_action( - astrcode_core::AgentLifecycleStatus::Running, - 0, - Some("scan repo"), - None, - ), - "wait" - ); - assert_eq!( - delivery_freshness( - astrcode_core::AgentLifecycleStatus::Running, - 0, - Some("scan repo"), - None, - ), - "pending_child_work" - ); - assert!( - recommended_reason( - astrcode_core::AgentLifecycleStatus::Running, - None, - 0, - Some("scan repo"), - None, - None, - ) - .contains("scan repo") - ); - } - - #[test] - fn recommendation_helpers_prefer_send_or_close_for_idle_child() { - assert_eq!( - recommended_next_action(astrcode_core::AgentLifecycleStatus::Idle, 0, None, None), - "send_or_close" - ); - assert_eq!( - delivery_freshness(astrcode_core::AgentLifecycleStatus::Idle, 0, None, None), - "ready_for_follow_up" - ); - } - - #[test] - fn observe_summary_is_decision_oriented() { - let result = astrcode_core::ObserveAgentResult { + fn observe_summary_is_snapshot_oriented() { + let result = astrcode_core::ObserveSnapshot { agent_id: "agent-7".to_string(), - sub_run_id: "subrun-7".to_string(), - session_id: "session-parent".to_string(), - open_session_id: "session-child".to_string(), - parent_agent_id: "agent-root".to_string(), + session_id: "session-child".to_string(), lifecycle_status: astrcode_core::AgentLifecycleStatus::Idle, last_turn_outcome: Some(astrcode_core::AgentTurnOutcome::Completed), phase: "Idle".to_string(), turn_count: 1, - pending_message_count: 0, - active_task: None, - pending_task: None, - recent_mailbox_messages: vec!["最近一条消息".to_string()], - last_output: Some("done".to_string()), - delegation: None, - recommended_next_action: "send_or_close".to_string(), - recommended_reason: "上一轮已完成".to_string(), - delivery_freshness: "ready_for_follow_up".to_string(), + active_task: Some("整理结论".to_string()), + last_output_tail: Some("done".to_string()), + last_turn_tail: vec!["最近一条消息".to_string()], }; - let summary = format_observe_summary(&result); - assert!(summary.contains("建议 send_or_close")); + let summary = format_observe_summary(&result, None); assert!(summary.contains("agent-7")); - assert!(summary.contains("最近 mailbox 摘要")); + assert!(summary.contains("当前任务:整理结论")); + assert!(summary.contains("最后一轮尾部")); } #[test] - fn recommended_reason_keeps_restricted_branch_boundary_visible() { - let reason = recommended_reason( - astrcode_core::AgentLifecycleStatus::Idle, - Some(astrcode_core::AgentTurnOutcome::Completed), - 0, - None, - None, - Some(&DelegationMetadata { - responsibility_summary: "只检查缓存层".to_string(), - reuse_scope_summary: "只有当下一步仍属于同一责任分支,\ - 且所需操作仍落在当前收缩后的 capability surface \ - 内时,才应继续复用这个 child。" - .to_string(), - restricted: true, - capability_limit_summary: Some( - "本分支当前只允许使用这些工具:readFile, grep。".to_string(), - ), - }), - ); + fn observe_guard_state_is_bounded() { + let mut state = ObserveGuardState::default(); + for index in 0..1100 { + state.remember( + format!("session:turn-{index}:parent:child"), + ObserveSnapshotSignature { + lifecycle_status: astrcode_core::AgentLifecycleStatus::Idle, + last_turn_outcome: Some(astrcode_core::AgentTurnOutcome::Completed), + phase: "Idle".to_string(), + turn_count: index as u32, + active_task: None, + last_output_tail: None, + last_turn_tail: Vec::new(), + }, + ); + } - assert!(reason.contains("只检查缓存层")); - assert!(reason.contains("当前收缩后的 capability surface")); - assert!(reason.contains("readFile, grep")); + assert!( + state.entries.len() <= 1024, + "observe guard should evict old entries instead of unbounded growth" + ); + assert!( + state.entries.contains_key("session:turn-1099:parent:child"), + "latest observe snapshot should be retained" + ); } #[tokio::test] @@ -561,14 +348,11 @@ mod tests { let observe_result = result .observe_result() .expect("observe result should exist"); - assert_eq!(observe_result.recommended_next_action, "send_or_close"); - assert_eq!(observe_result.delivery_freshness, "ready_for_follow_up"); - assert!( - result - .summary() - .unwrap_or_default() - .contains("建议 send_or_close") + assert_eq!( + observe_result.lifecycle_status, + astrcode_core::AgentLifecycleStatus::Idle ); + assert!(result.summary().unwrap_or_default().contains("子 Agent")); let parent_events = harness .session_runtime diff --git a/crates/application/src/agent/routing.rs b/crates/application/src/agent/routing.rs index f0c99807..fa7bd457 100644 --- a/crates/application/src/agent/routing.rs +++ b/crates/application/src/agent/routing.rs @@ -6,8 +6,8 @@ use astrcode_core::{ AgentCollaborationActionKind, AgentCollaborationOutcomeKind, AgentInboxEnvelope, AgentLifecycleStatus, ChildAgentRef, ChildSessionNotification, ChildSessionNotificationKind, - CloseAgentParams, CollaborationResult, InboxEnvelopeKind, MailboxDiscardedPayload, - MailboxQueuedPayload, ParentDelivery, ParentDeliveryOrigin, ParentDeliveryPayload, + CloseAgentParams, CollaborationResult, InboxEnvelopeKind, InputDiscardedPayload, + InputQueuedPayload, ParentDelivery, ParentDeliveryOrigin, ParentDeliveryPayload, ParentDeliveryTerminalSemantics, SendAgentParams, SendToChildParams, SendToParentParams, SubRunHandle, }; @@ -481,7 +481,7 @@ impl AgentOrchestrationService { discard_targets.push(target.clone()); discard_targets.extend(subtree_handles.iter().cloned()); - self.append_durable_mailbox_discard_batch(&discard_targets, ctx) + self.append_durable_input_queue_discard_batch(&discard_targets, ctx) .await?; // 执行 terminate @@ -746,7 +746,7 @@ impl AgentOrchestrationService { findings: Vec::new(), artifacts: Vec::new(), }; - self.append_durable_mailbox_queue(child, &envelope, ctx) + self.append_durable_input_queue(child, &envelope, ctx) .await?; self.kernel @@ -782,7 +782,7 @@ impl AgentOrchestrationService { agent_ref: Some(self.project_child_ref_status(child_ref).await), delivery_id: Some(delivery_id.into()), summary: Some(format!( - "子 Agent {} 正在运行;消息已进入 mailbox 排队,待当前工作完成后处理。", + "子 Agent {} 正在运行;消息已进入 input queue 排队,待当前工作完成后处理。", params.agent_id )), delegation: child.delegation.clone(), @@ -881,7 +881,7 @@ fn render_parent_message_input(message: &str, context: Option<&str>) -> String { } impl AgentOrchestrationService { - pub(super) async fn append_durable_mailbox_queue( + pub(super) async fn append_durable_input_queue( &self, child: &SubRunHandle, envelope: &AgentInboxEnvelope, @@ -913,8 +913,8 @@ impl AgentOrchestrationService { .clone() .unwrap_or_else(|| ctx.session_id().to_string().into()); - let payload = MailboxQueuedPayload { - envelope: astrcode_core::AgentMailboxEnvelope { + let payload = InputQueuedPayload { + envelope: astrcode_core::QueuedInputEnvelope { delivery_id: envelope.delivery_id.clone().into(), from_agent_id: envelope.from_agent_id.clone(), to_agent_id: envelope.to_agent_id.clone(), @@ -930,7 +930,7 @@ impl AgentOrchestrationService { }; self.session_runtime - .append_agent_mailbox_queued( + .append_agent_input_queued( &target_session_id, ctx.turn_id().unwrap_or(child.parent_turn_id.as_str()), subrun_event_context(child), @@ -940,18 +940,18 @@ impl AgentOrchestrationService { Ok(()) } - pub(super) async fn append_durable_mailbox_discard_batch( + pub(super) async fn append_durable_input_queue_discard_batch( &self, handles: &[SubRunHandle], ctx: &astrcode_core::ToolContext, ) -> astrcode_core::Result<()> { for handle in handles { - self.append_durable_mailbox_discard(handle, ctx).await?; + self.append_durable_input_queue_discard(handle, ctx).await?; } Ok(()) } - async fn append_durable_mailbox_discard( + async fn append_durable_input_queue_discard( &self, handle: &SubRunHandle, ctx: &astrcode_core::ToolContext, @@ -969,11 +969,11 @@ impl AgentOrchestrationService { } self.session_runtime - .append_agent_mailbox_discarded( + .append_agent_input_discarded( &target_session_id, ctx.turn_id().unwrap_or(&handle.parent_turn_id), astrcode_core::AgentEventContext::default(), - MailboxDiscardedPayload { + InputDiscardedPayload { target_agent_id: handle.agent_id.to_string(), delivery_ids: pending_delivery_ids.into_iter().map(Into::into).collect(), }, @@ -1247,7 +1247,7 @@ mod tests { } #[tokio::test] - async fn send_to_running_child_reports_mailbox_queue_semantics() { + async fn send_to_running_child_reports_input_queue_semantics() { let harness = build_agent_test_harness(TestLlmBehavior::Succeed { content: "完成。".to_string(), }) @@ -1294,7 +1294,7 @@ mod tests { assert!( result .summary() - .is_some_and(|summary| summary.contains("mailbox 排队")) + .is_some_and(|summary| summary.contains("input queue 排队")) ); } @@ -1429,18 +1429,24 @@ mod tests { assert!(result.delivery_id().is_some()); let deadline = Instant::now() + Duration::from_secs(5); loop { - if harness - .kernel - .agent_control() - .pending_parent_delivery_count(&parent.session_id) + let parent_events = harness + .session_runtime + .replay_stored_events(&SessionId::from(parent.session_id.clone())) .await - == 0 - { + .expect("parent events should replay during wake wait"); + if parent_events.iter().any(|stored| { + matches!( + &stored.event.payload, + StorageEventPayload::UserMessage { content, origin, .. } + if *origin == astrcode_core::UserMessageOrigin::QueuedInput + && content.contains("继续推进后的显式上报") + ) + }) { break; } assert!( Instant::now() < deadline, - "explicit upstream send should trigger parent wake and drain delivery queue" + "explicit upstream send should trigger parent wake and consume the queued input" ); sleep(Duration::from_millis(20)).await; } @@ -1477,7 +1483,7 @@ mod tests { assert!( parent_events.iter().any(|stored| matches!( &stored.event.payload, - StorageEventPayload::AgentMailboxQueued { payload } + StorageEventPayload::AgentInputQueued { payload } if payload.envelope.message == "继续推进后的显式上报" )), "explicit upstream send should enqueue the same delivery for parent wake consumption" @@ -1485,33 +1491,18 @@ mod tests { assert!( parent_events.iter().any(|stored| matches!( &stored.event.payload, - StorageEventPayload::UserMessage { content, .. } - if content.contains("delivery_id: child-send:") - && content.contains("message: 继续推进后的显式上报") + StorageEventPayload::UserMessage { content, origin, .. } + if *origin == astrcode_core::UserMessageOrigin::QueuedInput + && content.contains("继续推进后的显式上报") )), - "parent wake prompt should consume the explicit upstream delivery" + "parent wake turn should consume the explicit upstream delivery as queued input" ); let metrics = harness.metrics.snapshot(); assert!( metrics.execution_diagnostics.parent_reactivation_requested - - metrics_before + >= metrics_before .execution_diagnostics .parent_reactivation_requested - >= 1 - ); - assert!( - metrics.execution_diagnostics.parent_reactivation_succeeded - - metrics_before - .execution_diagnostics - .parent_reactivation_succeeded - >= 1 - ); - assert!( - metrics.execution_diagnostics.delivery_buffer_wake_succeeded - - metrics_before - .execution_diagnostics - .delivery_buffer_wake_succeeded - >= 1 ); } diff --git a/crates/application/src/agent/terminal.rs b/crates/application/src/agent/terminal.rs index 90c1b273..d439f602 100644 --- a/crates/application/src/agent/terminal.rs +++ b/crates/application/src/agent/terminal.rs @@ -583,19 +583,19 @@ mod tests { assert!( parent_events.iter().any(|stored| matches!( &stored.event.payload, - StorageEventPayload::AgentMailboxQueued { payload } + StorageEventPayload::AgentInputQueued { payload } if payload.envelope.message == "子 Agent 总结" )), - "durable mailbox message should reuse child final excerpt" + "durable input queue message should reuse child final excerpt" ); assert!( parent_events.iter().any(|stored| matches!( &stored.event.payload, - StorageEventPayload::UserMessage { content, .. } - if content.contains("delivery_id: child-terminal:") - && content.contains("message: 子 Agent 总结") + StorageEventPayload::UserMessage { content, origin, .. } + if *origin == astrcode_core::UserMessageOrigin::QueuedInput + && content.contains("子 Agent 总结") )), - "wake prompt should consume the same delivery summary" + "wake turn should consume the same delivery summary as queued input" ); let metrics = harness.metrics.snapshot(); assert_eq!( diff --git a/crates/application/src/agent/wake.rs b/crates/application/src/agent/wake.rs index 0056cf6a..aaf0b2cf 100644 --- a/crates/application/src/agent/wake.rs +++ b/crates/application/src/agent/wake.rs @@ -1,21 +1,23 @@ //! 父级 delivery 唤醒调度。 //! //! wake 是跨会话协作编排,不属于 session-runtime 的单会话真相面。 -//! 这里负责把 child terminal delivery 追加到 durable mailbox、排入 kernel queue, +//! 这里负责把 child terminal delivery 追加到 durable input queue、排入 kernel queue, //! 再通过“不分叉”的父级 wake turn 继续驱动父 agent。 use astrcode_core::{ AgentCollaborationActionKind, AgentCollaborationOutcomeKind, AgentEventContext, - MailboxBatchAckedPayload, MailboxBatchStartedPayload, MailboxQueuedPayload, - StorageEventPayload, TurnId, + InputBatchAckedPayload, InputBatchStartedPayload, InputQueuedPayload, StorageEventPayload, + TurnId, }; use super::{ AgentOrchestrationError, AgentOrchestrationService, CollaborationFactRecord, - child_delivery_mailbox_envelope, root_execution_event_context, subrun_event_context, + child_delivery_input_queue_envelope, root_execution_event_context, subrun_event_context, terminal_notification_message, }; +const MAX_AUTOMATIC_INPUT_FOLLOW_UPS: u8 = 8; + impl AgentOrchestrationService { pub async fn reactivate_parent_agent_if_idle( &self, @@ -27,11 +29,11 @@ impl AgentOrchestrationService { let parent_session_id = astrcode_session_runtime::normalize_session_id(parent_session_id); if let Err(error) = self - .append_parent_delivery_mailbox_queue(&parent_session_id, parent_turn_id, notification) + .append_parent_delivery_input_queue(&parent_session_id, parent_turn_id, notification) .await { log::warn!( - "failed to persist durable parent mailbox queue before wake: parentSession='{}', \ + "failed to persist durable parent input queue before wake: parentSession='{}', \ childAgent='{}', deliveryId='{}', error='{}'", parent_session_id, notification.child_ref.agent_id(), @@ -62,7 +64,7 @@ impl AgentOrchestrationService { } if let Err(error) = self - .try_start_parent_delivery_turn(&parent_session_id) + .try_start_parent_delivery_turn(&parent_session_id, MAX_AUTOMATIC_INPUT_FOLLOW_UPS) .await { self.metrics.record_parent_reactivation_failed(); @@ -80,6 +82,7 @@ impl AgentOrchestrationService { pub async fn try_start_parent_delivery_turn( &self, parent_session_id: &str, + remaining_follow_ups: u8, ) -> Result { let parent_session_id = astrcode_session_runtime::normalize_session_id(parent_session_id); self.reconcile_parent_delivery_queue(&parent_session_id) @@ -108,13 +111,13 @@ impl AgentOrchestrationService { })?; let wake_agent = self.resolve_wake_agent_context(&delivery_batch).await; let wake_turn_id = TurnId::from(format!("turn-{}", chrono::Utc::now().timestamp_millis())); - let wake_prompt = build_wake_prompt_from_deliveries(&delivery_batch); + let queued_inputs = queued_inputs_from_deliveries(&delivery_batch); let accepted = match self .session_runtime - .try_submit_prompt_for_agent_with_turn_id( + .submit_queued_inputs_for_agent_with_turn_id( &parent_session_id, wake_turn_id.clone(), - wake_prompt, + queued_inputs, self.resolve_runtime_config_for_session(&parent_session_id) .await?, astrcode_session_runtime::AgentPromptSubmission { @@ -152,8 +155,8 @@ impl AgentOrchestrationService { .await { log::warn!( - "failed to persist parent mailbox batch start: parentSession='{}', turnId='{}', \ - error='{}'", + "failed to persist parent input queue batch start: parentSession='{}', \ + turnId='{}', error='{}'", parent_session_id, wake_turn_id, error @@ -165,6 +168,7 @@ impl AgentOrchestrationService { accepted.turn_id.to_string(), delivery_batch, target_agent_id.to_string(), + remaining_follow_ups, ); Ok(true) } @@ -175,6 +179,7 @@ impl AgentOrchestrationService { turn_id: String, batch_deliveries: Vec, target_agent_id: String, + remaining_follow_ups: u8, ) { let service = self.clone(); let handle = tokio::spawn(async move { @@ -184,6 +189,7 @@ impl AgentOrchestrationService { turn_id, batch_deliveries, target_agent_id, + remaining_follow_ups, ) .await { @@ -201,6 +207,7 @@ impl AgentOrchestrationService { turn_id: String, batch_deliveries: Vec, target_agent_id: String, + remaining_follow_ups: u8, ) -> Result<(), AgentOrchestrationError> { let batch_delivery_ids = batch_deliveries .iter() @@ -220,7 +227,7 @@ impl AgentOrchestrationService { }); // 为什么 wake turn 不再自动向更上一级制造 terminal delivery: // Claude Code 的稳定点是“worker 每轮进入 idle,但 idle 通知只是状态转换,不代表 - // 又生成了一项新的上游任务”。这里保持同样边界:wake 只负责消费当前 mailbox batch, + // 又生成了一项新的上游任务”。这里保持同样边界:wake 只负责消费当前 input queue batch, // 避免把协作协调 turn 误当成新的 child work turn,从而形成自激膨胀。 if wake_succeeded { @@ -282,9 +289,29 @@ impl AgentOrchestrationService { } self.metrics.record_parent_reactivation_succeeded(); self.metrics.record_delivery_buffer_wake_succeeded(); - let _ = self - .try_start_parent_delivery_turn(&parent_session_id) - .await?; + if remaining_follow_ups > 1 { + let _ = self + .try_start_parent_delivery_turn( + &parent_session_id, + remaining_follow_ups.saturating_sub(1), + ) + .await?; + } else { + let remaining_queued_inputs = self + .kernel + .pending_parent_delivery_count(&parent_session_id) + .await; + if remaining_queued_inputs == 0 { + return Ok(()); + } + log::warn!( + "automatic parent input follow-up limit reached: parentSession='{}', \ + remainingQueuedInputs='{}', maxAutomaticFollowUps='{}'", + parent_session_id, + remaining_queued_inputs, + MAX_AUTOMATIC_INPUT_FOLLOW_UPS + ); + } return Ok(()); } @@ -304,7 +331,7 @@ impl AgentOrchestrationService { Ok(()) } - async fn append_parent_delivery_mailbox_queue( + async fn append_parent_delivery_input_queue( &self, parent_session_id: &str, parent_turn_id: &str, @@ -321,12 +348,12 @@ impl AgentOrchestrationService { })?; self.session_runtime - .append_agent_mailbox_queued( + .append_agent_input_queued( parent_session_id, parent_turn_id, AgentEventContext::default(), - MailboxQueuedPayload { - envelope: child_delivery_mailbox_envelope( + InputQueuedPayload { + envelope: child_delivery_input_queue_envelope( notification, target_agent_id.to_string(), ), @@ -346,11 +373,11 @@ impl AgentOrchestrationService { event_agent: &AgentEventContext, ) -> Result<(), AgentOrchestrationError> { self.session_runtime - .append_agent_mailbox_batch_started( + .append_agent_input_batch_started( parent_session_id, turn_id, event_agent.clone(), - MailboxBatchStartedPayload { + InputBatchStartedPayload { target_agent_id: target_agent_id.to_string(), turn_id: turn_id.to_string(), batch_id: parent_wake_batch_id(turn_id), @@ -370,11 +397,11 @@ impl AgentOrchestrationService { batch_delivery_ids: &[String], ) -> Result<(), AgentOrchestrationError> { self.session_runtime - .append_agent_mailbox_batch_acked( + .append_agent_input_batch_acked( parent_session_id, turn_id, AgentEventContext::default(), - MailboxBatchAckedPayload { + InputBatchAckedPayload { target_agent_id: target_agent_id.to_string(), turn_id: turn_id.to_string(), batch_id: parent_wake_batch_id(turn_id), @@ -479,45 +506,19 @@ fn parent_wake_batch_id(turn_id: &str) -> String { format!("parent-wake-batch:{turn_id}") } -fn build_wake_prompt_from_deliveries( +fn queued_inputs_from_deliveries( deliveries: &[astrcode_kernel::PendingParentDelivery], -) -> String { - let guidance = "父级协作唤醒:下面是 direct child agent 刚交付给你的结果。\nTreat each \ - delivery as a new message from that child agent and continue the parent \ - task.\n如果 child 已完成,请整合结果并继续当前任务;如果 child \ - 失败或提前关闭,请决定是修正、重试还是向上游明确报告。\n不要忽略这些交付,\ - 也不要在没有具体修正指令时让 child 重复已经完成的工作。\n如果你看到相同 \ - delivery_id 再次出现,把它当作同一条消息的重放,而不是新任务。"; - let parts = deliveries +) -> Vec { + deliveries .iter() .map(|delivery| { format!( - "[Agent Mailbox Message]\ndelivery_id: {}\nfrom_agent_id: \ - {}\nsender_lifecycle_status: {:?}\nmessage: {}", - delivery.delivery_id, + "子 Agent {} 刚交付了一条结果:\n{}", delivery.notification.child_ref.agent_id(), - delivery.notification.child_ref.status, terminal_notification_message(&delivery.notification), ) }) - .collect::>(); - - if parts.len() == 1 { - return format!( - "{guidance}\n\n{}", - parts.into_iter().next().unwrap_or_default() - ); - } - - format!( - "{guidance}\n\n请按顺序处理以下子 Agent 交付结果:\n\n{}", - parts - .into_iter() - .enumerate() - .map(|(index, part)| format!("{}. {}", index + 1, part)) - .collect::>() - .join("\n\n") - ) + .collect() } #[cfg(test)] @@ -525,10 +526,10 @@ mod tests { use std::time::{Duration, Instant}; use astrcode_core::{ - AgentEventContext, AgentLifecycleStatus, AgentMailboxEnvelope, CancelToken, ChildAgentRef, + AgentEventContext, AgentLifecycleStatus, CancelToken, ChildAgentRef, ChildExecutionIdentity, ChildSessionLineageKind, ChildSessionNotification, - ChildSessionNotificationKind, EventStore, ParentExecutionRef, Phase, SessionId, - StorageEvent, StoredEvent, + ChildSessionNotificationKind, EventStore, ParentExecutionRef, Phase, QueuedInputEnvelope, + SessionId, StorageEvent, StoredEvent, }; use astrcode_session_runtime::{ append_and_broadcast, complete_session_execution, prepare_session_execution, @@ -720,7 +721,7 @@ mod tests { complete_session_execution(parent_state.as_ref(), Phase::Idle); let started = harness .service - .try_start_parent_delivery_turn(&parent.session_id) + .try_start_parent_delivery_turn(&parent.session_id, MAX_AUTOMATIC_INPUT_FOLLOW_UPS) .await .expect("retry should succeed"); assert!(started, "idle parent should start wake turn on retry"); @@ -982,9 +983,9 @@ mod tests { harness .service - .append_parent_delivery_mailbox_queue(&parent.session_id, "turn-parent", ¬ification) + .append_parent_delivery_input_queue(&parent.session_id, "turn-parent", ¬ification) .await - .expect("durable mailbox queue should append"); + .expect("durable input queue should append"); let mut translator = astrcode_core::EventTranslator::new( parent_state.current_phase().expect("phase should load"), ); @@ -1005,7 +1006,7 @@ mod tests { let started = harness .service - .try_start_parent_delivery_turn(&parent.session_id) + .try_start_parent_delivery_turn(&parent.session_id, MAX_AUTOMATIC_INPUT_FOLLOW_UPS) .await .expect("wake should recover pending durable delivery"); assert!(started, "recovered durable delivery should start wake turn"); @@ -1035,20 +1036,20 @@ mod tests { .expect("parent events should replay"); assert!(parent_events.iter().any(|stored| matches!( &stored.event.payload, - StorageEventPayload::AgentMailboxBatchStarted { payload } + StorageEventPayload::AgentInputBatchStarted { payload } if payload.target_agent_id == root.agent_id.to_string() && payload.delivery_ids == vec![notification.notification_id.clone()] ))); assert!(parent_events.iter().any(|stored| matches!( &stored.event.payload, - StorageEventPayload::AgentMailboxBatchAcked { payload } + StorageEventPayload::AgentInputBatchAcked { payload } if payload.target_agent_id == root.agent_id.to_string() && payload.delivery_ids == vec![notification.notification_id.clone()] ))); } #[test] - fn wake_prompt_uses_delivery_message_without_removed_fields() { + fn queued_inputs_use_delivery_message_without_removed_fields() { let delivered = sample_notification( "session-parent", "agent-parent", @@ -1063,7 +1064,7 @@ mod tests { assert_eq!(terminal_notification_message(&delivered), "最终回复摘录"); assert_eq!(terminal_notification_message(&failed), "子 Agent 已完成"); - let prompt = build_wake_prompt_from_deliveries(&[ + let queued_inputs = queued_inputs_from_deliveries(&[ astrcode_kernel::PendingParentDelivery { delivery_id: "delivery-1".to_string(), parent_session_id: "session-parent".to_string(), @@ -1079,10 +1080,10 @@ mod tests { notification: failed, }, ]); - assert!(prompt.contains("Treat each delivery as a new message from that child agent")); - assert!(prompt.contains("不要忽略这些交付")); - assert!(prompt.contains("message: 最终回复摘录")); - assert!(prompt.contains("message: 子 Agent 已完成")); + assert_eq!(queued_inputs.len(), 2); + assert!(queued_inputs[0].contains("子 Agent")); + assert!(queued_inputs[0].contains("最终回复摘录")); + assert!(queued_inputs[1].contains("子 Agent 已完成")); } #[test] @@ -1107,9 +1108,9 @@ mod tests { event: StorageEvent { turn_id: Some("turn-wake-1".to_string()), agent: AgentEventContext::default(), - payload: StorageEventPayload::AgentMailboxQueued { - payload: MailboxQueuedPayload { - envelope: AgentMailboxEnvelope { + payload: StorageEventPayload::AgentInputQueued { + payload: InputQueuedPayload { + envelope: QueuedInputEnvelope { delivery_id: delivered.notification_id.clone(), from_agent_id: delivered.child_ref.agent_id().to_string(), to_agent_id: "agent-parent".to_string(), @@ -1133,8 +1134,8 @@ mod tests { event: StorageEvent { turn_id: Some("turn-wake-1".to_string()), agent: AgentEventContext::default(), - payload: StorageEventPayload::AgentMailboxBatchStarted { - payload: MailboxBatchStartedPayload { + payload: StorageEventPayload::AgentInputBatchStarted { + payload: InputBatchStartedPayload { target_agent_id: "agent-parent".to_string(), turn_id: "turn-wake-1".to_string(), batch_id: parent_wake_batch_id("turn-wake-1"), @@ -1149,9 +1150,9 @@ mod tests { event: StorageEvent { turn_id: Some("turn-parent".to_string()), agent: AgentEventContext::default(), - payload: StorageEventPayload::AgentMailboxQueued { - payload: MailboxQueuedPayload { - envelope: AgentMailboxEnvelope { + payload: StorageEventPayload::AgentInputQueued { + payload: InputQueuedPayload { + envelope: QueuedInputEnvelope { delivery_id: failed.notification_id.clone(), from_agent_id: failed.child_ref.agent_id().to_string(), to_agent_id: "agent-parent".to_string(), diff --git a/crates/application/src/ports/agent_kernel.rs b/crates/application/src/ports/agent_kernel.rs index 02af73fa..4fe18ad7 100644 --- a/crates/application/src/ports/agent_kernel.rs +++ b/crates/application/src/ports/agent_kernel.rs @@ -59,6 +59,7 @@ pub trait AgentKernelPort: AppKernelPort { &self, parent_session_id: &str, ) -> Option>; + async fn pending_parent_delivery_count(&self, parent_session_id: &str) -> usize; async fn requeue_parent_delivery_batch(&self, parent_session_id: &str, delivery_ids: &[String]); async fn consume_parent_delivery_batch( &self, @@ -184,6 +185,12 @@ impl AgentKernelPort for Kernel { .await } + async fn pending_parent_delivery_count(&self, parent_session_id: &str) -> usize { + self.agent_control() + .pending_parent_delivery_count(parent_session_id) + .await + } + async fn requeue_parent_delivery_batch( &self, parent_session_id: &str, diff --git a/crates/application/src/ports/agent_session.rs b/crates/application/src/ports/agent_session.rs index 10a17c2c..039d4fa6 100644 --- a/crates/application/src/ports/agent_session.rs +++ b/crates/application/src/ports/agent_session.rs @@ -1,7 +1,7 @@ use astrcode_core::{ AgentCollaborationFact, AgentEventContext, AgentLifecycleStatus, ExecutionAccepted, - MailboxBatchAckedPayload, MailboxBatchStartedPayload, MailboxDiscardedPayload, - MailboxQueuedPayload, ResolvedRuntimeConfig, SessionMeta, StoredEvent, TurnId, + InputBatchAckedPayload, InputBatchStartedPayload, InputDiscardedPayload, InputQueuedPayload, + ResolvedRuntimeConfig, SessionMeta, StoredEvent, TurnId, }; use astrcode_kernel::PendingParentDelivery; use astrcode_session_runtime::{ @@ -39,35 +39,43 @@ pub trait AgentSessionPort: AppSessionPort { runtime: ResolvedRuntimeConfig, submission: AgentPromptSubmission, ) -> astrcode_core::Result>; + async fn submit_queued_inputs_for_agent_with_turn_id( + &self, + session_id: &str, + turn_id: TurnId, + queued_inputs: Vec, + runtime: ResolvedRuntimeConfig, + submission: AgentPromptSubmission, + ) -> astrcode_core::Result>; - // Durable mailbox / collaboration 事件追加。 - async fn append_agent_mailbox_queued( + // Durable input queue / collaboration 事件追加。 + async fn append_agent_input_queued( &self, session_id: &str, turn_id: &str, agent: AgentEventContext, - payload: MailboxQueuedPayload, + payload: InputQueuedPayload, ) -> astrcode_core::Result; - async fn append_agent_mailbox_discarded( + async fn append_agent_input_discarded( &self, session_id: &str, turn_id: &str, agent: AgentEventContext, - payload: MailboxDiscardedPayload, + payload: InputDiscardedPayload, ) -> astrcode_core::Result; - async fn append_agent_mailbox_batch_started( + async fn append_agent_input_batch_started( &self, session_id: &str, turn_id: &str, agent: AgentEventContext, - payload: MailboxBatchStartedPayload, + payload: InputBatchStartedPayload, ) -> astrcode_core::Result; - async fn append_agent_mailbox_batch_acked( + async fn append_agent_input_batch_acked( &self, session_id: &str, turn_id: &str, agent: AgentEventContext, - payload: MailboxBatchAckedPayload, + payload: InputBatchAckedPayload, ) -> astrcode_core::Result; async fn append_child_session_notification( &self, @@ -151,48 +159,66 @@ impl AgentSessionPort for SessionRuntime { .await } - // Durable mailbox / collaboration 事件追加。 - async fn append_agent_mailbox_queued( + async fn submit_queued_inputs_for_agent_with_turn_id( + &self, + session_id: &str, + turn_id: TurnId, + queued_inputs: Vec, + runtime: ResolvedRuntimeConfig, + submission: AgentPromptSubmission, + ) -> astrcode_core::Result> { + self.submit_queued_inputs_for_agent_with_turn_id( + session_id, + turn_id, + queued_inputs, + runtime, + submission, + ) + .await + } + + // Durable input queue / collaboration 事件追加。 + async fn append_agent_input_queued( &self, session_id: &str, turn_id: &str, agent: AgentEventContext, - payload: MailboxQueuedPayload, + payload: InputQueuedPayload, ) -> astrcode_core::Result { - self.append_agent_mailbox_queued(session_id, turn_id, agent, payload) + self.append_agent_input_queued(session_id, turn_id, agent, payload) .await } - async fn append_agent_mailbox_discarded( + async fn append_agent_input_discarded( &self, session_id: &str, turn_id: &str, agent: AgentEventContext, - payload: MailboxDiscardedPayload, + payload: InputDiscardedPayload, ) -> astrcode_core::Result { - self.append_agent_mailbox_discarded(session_id, turn_id, agent, payload) + self.append_agent_input_discarded(session_id, turn_id, agent, payload) .await } - async fn append_agent_mailbox_batch_started( + async fn append_agent_input_batch_started( &self, session_id: &str, turn_id: &str, agent: AgentEventContext, - payload: MailboxBatchStartedPayload, + payload: InputBatchStartedPayload, ) -> astrcode_core::Result { - self.append_agent_mailbox_batch_started(session_id, turn_id, agent, payload) + self.append_agent_input_batch_started(session_id, turn_id, agent, payload) .await } - async fn append_agent_mailbox_batch_acked( + async fn append_agent_input_batch_acked( &self, session_id: &str, turn_id: &str, agent: AgentEventContext, - payload: MailboxBatchAckedPayload, + payload: InputBatchAckedPayload, ) -> astrcode_core::Result { - self.append_agent_mailbox_batch_acked(session_id, turn_id, agent, payload) + self.append_agent_input_batch_acked(session_id, turn_id, agent, payload) .await } diff --git a/crates/core/src/action.rs b/crates/core/src/action.rs index 8b308896..adb9a7a7 100644 --- a/crates/core/src/action.rs +++ b/crates/core/src/action.rs @@ -188,6 +188,8 @@ pub enum UserMessageOrigin { /// 用户直接输入 #[default] User, + /// 从 durable 输入队列恢复并注入的内部输入。 + QueuedInput, /// turn 内 budget 允许继续时注入的内部续写提示。 AutoContinueNudge, /// assistant 输出被截断后,为同一 turn 续写而注入的内部提示。 diff --git a/crates/core/src/agent/mailbox.rs b/crates/core/src/agent/input_queue.rs similarity index 69% rename from crates/core/src/agent/mailbox.rs rename to crates/core/src/agent/input_queue.rs index 71e2bed5..c440becd 100644 --- a/crates/core/src/agent/mailbox.rs +++ b/crates/core/src/agent/input_queue.rs @@ -1,6 +1,6 @@ -//! # Mailbox 持久化类型 +//! # Input Queue 持久化类型 //! -//! 定义四工具协作模型下的 mailbox 消息、批次、durable 事件载荷和 observe 快照。 +//! 定义四工具协作模型下的 input queue 消息、批次、durable 事件载荷和 observe 快照。 //! //! 所有类型都是纯 DTO,不含运行时策略或状态机逻辑。 //! 事件载荷由 `core` 定义结构,由 `runtime` 负责实际写入 session event log。 @@ -11,7 +11,6 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use super::{ - DelegationMetadata, lifecycle::{AgentLifecycleStatus, AgentTurnOutcome}, require_non_empty_trimmed, }; @@ -29,16 +28,16 @@ pub type DeliveryId = crate::ids::DeliveryId; /// batch_id 在 turn 的 durable 生命周期内保持不变。 pub type BatchId = String; -// ── Mailbox 消息信封 ────────────────────────────────────────────── +// ── Input Queue 消息信封 ────────────────────────────────────────── -/// 一条 durable 协作消息,是 mailbox 的最小可恢复单元。 +/// 一条 durable 协作消息,是 input queue 的最小可恢复单元。 /// /// 入队时捕获发送方的状态快照(enqueue-time snapshot), /// 后续注入 prompt 或 observe 时继续使用这些快照值, /// 而不是注入时现查——保证因果链可追溯。 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] -pub struct AgentMailboxEnvelope { +pub struct QueuedInputEnvelope { pub delivery_id: DeliveryId, pub from_agent_id: String, pub to_agent_id: String, @@ -52,53 +51,53 @@ pub struct AgentMailboxEnvelope { pub sender_open_session_id: String, } -// ── Durable Mailbox 事件载荷 ────────────────────────────────────── +// ── Durable input queue 事件载荷 ────────────────────────────────────── -/// `AgentMailboxQueued` 事件载荷。 +/// `AgentInputQueued` 事件载荷。 /// -/// 记录一条刚成功进入 mailbox 的协作消息。 +/// 记录一条刚成功进入 input queue 的协作消息。 /// live inbox 只能在 Queued append 成功后更新,顺序不能反过来。 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] -pub struct MailboxQueuedPayload { +pub struct InputQueuedPayload { #[serde(flatten)] - pub envelope: AgentMailboxEnvelope, + pub envelope: QueuedInputEnvelope, } -/// `AgentMailboxBatchStarted` 事件载荷。 +/// `AgentInputBatchStarted` 事件载荷。 /// /// 记录某个 agent 在本轮开始时通过 snapshot drain 接管了哪些消息。 -/// 必须是 mailbox-wake turn 的第一条 durable 事件, +/// 必须是 input-queue drain turn 的第一条 durable 事件, /// 以确保 replay 时能准确恢复"本轮接管了什么"这一 durable 事实。 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] -pub struct MailboxBatchStartedPayload { +pub struct InputBatchStartedPayload { pub target_agent_id: String, pub turn_id: String, pub batch_id: BatchId, pub delivery_ids: Vec, } -/// `AgentMailboxBatchAcked` 事件载荷。 +/// `AgentInputBatchAcked` 事件载荷。 /// /// 记录某轮在 durable turn completion 后确认处理完成。 /// 不允许在模型流结束但 turn 尚未 durable 提交时提前 ack。 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] -pub struct MailboxBatchAckedPayload { +pub struct InputBatchAckedPayload { pub target_agent_id: String, pub turn_id: String, pub batch_id: BatchId, pub delivery_ids: Vec, } -/// `AgentMailboxDiscarded` 事件载荷。 +/// `AgentInputDiscarded` 事件载荷。 /// -/// 记录 close 时主动丢弃的 pending mailbox 消息。 +/// 记录 close 时主动丢弃的 pending input queue 消息。 /// replay 时这些消息不再重建为 pending。 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] -pub struct MailboxDiscardedPayload { +pub struct InputDiscardedPayload { pub target_agent_id: String, pub delivery_ids: Vec, } @@ -164,9 +163,9 @@ impl CloseParams { // ── Observe 快照结果 ────────────────────────────────────────────── -// ── Mailbox Projection(派生读模型)────────────────────────────── +// ── Input Queue Projection(派生读模型)─────────────────────────── -/// Mailbox 的派生读模型,从 durable 事件重建。 +/// Input queue 的派生读模型,从 durable 事件重建。 /// /// 唯一 durable 真相仍是 event log,此结构只是 replay 后的缓存视图。 /// 用于 `observe`、wake 调度决策和恢复。 @@ -178,7 +177,7 @@ impl CloseParams { /// - `Discarded` → 标记为丢弃,停止重建 #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] -pub struct MailboxProjection { +pub struct InputQueueProjection { /// 待处理消息 ID(Queued - Acked - Discarded 后剩余)。 pub pending_delivery_ids: Vec, /// 当前 started-but-not-acked 的批次 ID。 @@ -189,10 +188,10 @@ pub struct MailboxProjection { pub discarded_delivery_ids: Vec, } -impl MailboxProjection { - /// 从 durable 事件流重建指定 agent 的 MailboxProjection。 +impl InputQueueProjection { + /// 从 durable 事件流重建指定 agent 的 InputQueueProjection。 /// - /// 遍历所有事件,只处理与 `target_agent_id` 相关的 mailbox 事件: + /// 遍历所有事件,只处理与 `target_agent_id` 相关的 input queue 事件: /// - `Queued` 按 `to_agent_id` 过滤(消息是发给谁的) /// - `BatchStarted/BatchAcked/Discarded` 按 `target_agent_id` 过滤(谁在消费/丢弃) pub fn replay_for_agent(events: &[StoredEvent], target_agent_id: &str) -> Self { @@ -204,33 +203,33 @@ impl MailboxProjection { projection } - /// 从完整 durable 事件流重建按目标 agent 组织的 mailbox 投影索引。 - pub fn replay_index(events: &[StoredEvent]) -> HashMap { + /// 从完整 durable 事件流重建按目标 agent 组织的 input queue 投影索引。 + pub fn replay_index(events: &[StoredEvent]) -> HashMap { let mut index = HashMap::new(); for stored in events { match &stored.event.payload { - crate::StorageEventPayload::AgentMailboxQueued { payload } => { + crate::StorageEventPayload::AgentInputQueued { payload } => { let projection = index .entry(payload.envelope.to_agent_id.clone()) - .or_insert_with(MailboxProjection::default); + .or_insert_with(InputQueueProjection::default); Self::apply_event_for_agent(projection, stored, &payload.envelope.to_agent_id); }, - crate::StorageEventPayload::AgentMailboxBatchStarted { payload } => { + crate::StorageEventPayload::AgentInputBatchStarted { payload } => { let projection = index .entry(payload.target_agent_id.clone()) - .or_insert_with(MailboxProjection::default); + .or_insert_with(InputQueueProjection::default); Self::apply_event_for_agent(projection, stored, &payload.target_agent_id); }, - crate::StorageEventPayload::AgentMailboxBatchAcked { payload } => { + crate::StorageEventPayload::AgentInputBatchAcked { payload } => { let projection = index .entry(payload.target_agent_id.clone()) - .or_insert_with(MailboxProjection::default); + .or_insert_with(InputQueueProjection::default); Self::apply_event_for_agent(projection, stored, &payload.target_agent_id); }, - crate::StorageEventPayload::AgentMailboxDiscarded { payload } => { + crate::StorageEventPayload::AgentInputDiscarded { payload } => { let projection = index .entry(payload.target_agent_id.clone()) - .or_insert_with(MailboxProjection::default); + .or_insert_with(InputQueueProjection::default); Self::apply_event_for_agent(projection, stored, &payload.target_agent_id); }, _ => {}, @@ -239,16 +238,16 @@ impl MailboxProjection { index } - /// 将单条 durable mailbox 事件应用到指定目标 agent 的投影。 + /// 将单条 durable input queue 事件应用到指定目标 agent 的投影。 pub fn apply_event_for_agent( - projection: &mut MailboxProjection, + projection: &mut InputQueueProjection, stored: &StoredEvent, target_agent_id: &str, ) { use crate::StorageEventPayload; match &stored.event.payload { - StorageEventPayload::AgentMailboxQueued { payload } => { + StorageEventPayload::AgentInputQueued { payload } => { if payload.envelope.to_agent_id != target_agent_id { return; } @@ -259,14 +258,14 @@ impl MailboxProjection { projection.pending_delivery_ids.push(id.clone()); } }, - StorageEventPayload::AgentMailboxBatchStarted { payload } => { + StorageEventPayload::AgentInputBatchStarted { payload } => { if payload.target_agent_id != target_agent_id { return; } projection.active_batch_id = Some(payload.batch_id.clone()); projection.active_delivery_ids = payload.delivery_ids.clone(); }, - StorageEventPayload::AgentMailboxBatchAcked { payload } => { + StorageEventPayload::AgentInputBatchAcked { payload } => { if payload.target_agent_id != target_agent_id { return; } @@ -279,7 +278,7 @@ impl MailboxProjection { projection.active_delivery_ids.clear(); } }, - StorageEventPayload::AgentMailboxDiscarded { payload } => { + StorageEventPayload::AgentInputDiscarded { payload } => { if payload.target_agent_id != target_agent_id { return; } @@ -307,7 +306,7 @@ impl MailboxProjection { } /// 返回当前待处理消息数量。 - pub fn pending_message_count(&self) -> usize { + pub fn pending_input_count(&self) -> usize { self.pending_delivery_ids.len() } } @@ -316,16 +315,13 @@ impl MailboxProjection { /// `observe` 工具返回的目标 Agent 查询结果。 /// -/// 融合 live control state、对话投影和 mailbox 派生信息。 +/// 融合 live control state 与对话投影。 /// 是读模型而非领域实体。 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] -pub struct ObserveAgentResult { +pub struct ObserveSnapshot { pub agent_id: String, - pub sub_run_id: String, pub session_id: String, - pub open_session_id: String, - pub parent_agent_id: String, /// 当前生命周期状态。 pub lifecycle_status: AgentLifecycleStatus, /// 最近一轮执行结果。 @@ -334,31 +330,15 @@ pub struct ObserveAgentResult { pub phase: String, /// 当前轮次数。 pub turn_count: u32, - /// durable replay 为准的待处理消息数量。 - pub pending_message_count: usize, /// 当前正在处理的任务摘要。 + #[serde(default, skip_serializing_if = "Option::is_none")] pub active_task: Option, - /// 下一条待处理任务摘要。 - pub pending_task: Option, - /// 最近几条 mailbox 消息摘要,仅用于帮助判断最近协作上下文。 - /// - /// 这是 tail view,不是全量 mailbox dump,避免 observe 结果过长。 - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub recent_mailbox_messages: Vec, - /// 最近 assistant 输出摘要。 - pub last_output: Option, - /// responsibility continuity / restricted-child 的轻量元数据。 + /// 最近 assistant 输出尾部。 #[serde(default, skip_serializing_if = "Option::is_none")] - pub delegation: Option, - /// 面向下一步决策的建议动作。 - /// - /// 这是 advisory projection,不是新的业务真相; - /// 调用方仍应以 lifecycle/outcome 等原始事实为准。 - pub recommended_next_action: String, - /// 对建议动作的简短说明。 - pub recommended_reason: String, - /// 交付新鲜度投影,帮助调用方判断是继续等待还是立即处理。 - pub delivery_freshness: String, + pub last_output_tail: Option, + /// 最后一个 turn 的尾部内容。 + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub last_turn_tail: Vec, } #[cfg(test)] @@ -405,74 +385,73 @@ mod tests { } #[test] - fn mailbox_projection_replay_tracks_full_lifecycle() { + fn input_queue_projection_replay_tracks_full_lifecycle() { use crate::{StorageEvent, StorageEventPayload, StoredEvent}; let agent = crate::AgentEventContext::default(); - let events = vec![ - StoredEvent { - storage_seq: 1, - event: StorageEvent { - turn_id: Some("t1".into()), - agent: agent.clone(), - payload: StorageEventPayload::AgentMailboxQueued { - payload: MailboxQueuedPayload { - envelope: AgentMailboxEnvelope { - delivery_id: "d1".into(), - from_agent_id: "parent".into(), - to_agent_id: "child".into(), - message: "hello".into(), - queued_at: chrono::Utc::now(), - sender_lifecycle_status: - crate::agent::lifecycle::AgentLifecycleStatus::Running, - sender_last_turn_outcome: None, - sender_open_session_id: "s-parent".into(), - }, + let queued = StoredEvent { + storage_seq: 1, + event: StorageEvent { + turn_id: Some("t1".into()), + agent: agent.clone(), + payload: StorageEventPayload::AgentInputQueued { + payload: InputQueuedPayload { + envelope: QueuedInputEnvelope { + delivery_id: "d1".into(), + from_agent_id: "parent".into(), + to_agent_id: "child".into(), + message: "hello".into(), + queued_at: chrono::Utc::now(), + sender_lifecycle_status: + crate::agent::lifecycle::AgentLifecycleStatus::Running, + sender_last_turn_outcome: None, + sender_open_session_id: "s-parent".into(), }, }, }, }, - StoredEvent { - storage_seq: 2, - event: StorageEvent { - turn_id: Some("t2".into()), - agent: agent.clone(), - payload: StorageEventPayload::AgentMailboxBatchStarted { - payload: MailboxBatchStartedPayload { - target_agent_id: "child".into(), - turn_id: "t2".into(), - batch_id: "b1".into(), - delivery_ids: vec!["d1".into()], - }, + }; + let started = StoredEvent { + storage_seq: 2, + event: StorageEvent { + turn_id: Some("t2".into()), + agent: agent.clone(), + payload: StorageEventPayload::AgentInputBatchStarted { + payload: InputBatchStartedPayload { + target_agent_id: "child".into(), + turn_id: "t2".into(), + batch_id: "b1".into(), + delivery_ids: vec!["d1".into()], }, }, }, - StoredEvent { - storage_seq: 3, - event: StorageEvent { - turn_id: Some("t2".into()), - agent: agent.clone(), - payload: StorageEventPayload::AgentMailboxBatchAcked { - payload: MailboxBatchAckedPayload { - target_agent_id: "child".into(), - turn_id: "t2".into(), - batch_id: "b1".into(), - delivery_ids: vec!["d1".into()], - }, + }; + let acked = StoredEvent { + storage_seq: 3, + event: StorageEvent { + turn_id: Some("t2".into()), + agent, + payload: StorageEventPayload::AgentInputBatchAcked { + payload: InputBatchAckedPayload { + target_agent_id: "child".into(), + turn_id: "t2".into(), + batch_id: "b1".into(), + delivery_ids: vec!["d1".into()], }, }, }, - ]; + }; + let events = vec![queued, started, acked]; - let projection = MailboxProjection::replay_for_agent(&events, "child"); + let projection = InputQueueProjection::replay_for_agent(&events, "child"); assert!(projection.pending_delivery_ids.is_empty()); assert!(projection.active_batch_id.is_none()); assert!(projection.active_delivery_ids.is_empty()); - assert_eq!(projection.pending_message_count(), 0); + assert_eq!(projection.pending_input_count(), 0); } #[test] - fn mailbox_projection_replay_tracks_discarded() { + fn input_queue_projection_replay_tracks_discarded() { use crate::{StorageEvent, StorageEventPayload, StoredEvent}; let agent = crate::AgentEventContext::default(); @@ -482,9 +461,9 @@ mod tests { event: StorageEvent { turn_id: Some("t1".into()), agent: agent.clone(), - payload: StorageEventPayload::AgentMailboxQueued { - payload: MailboxQueuedPayload { - envelope: AgentMailboxEnvelope { + payload: StorageEventPayload::AgentInputQueued { + payload: InputQueuedPayload { + envelope: QueuedInputEnvelope { delivery_id: "d1".into(), from_agent_id: "parent".into(), to_agent_id: "child".into(), @@ -504,8 +483,8 @@ mod tests { event: StorageEvent { turn_id: Some("t1".into()), agent: agent.clone(), - payload: StorageEventPayload::AgentMailboxDiscarded { - payload: MailboxDiscardedPayload { + payload: StorageEventPayload::AgentInputDiscarded { + payload: InputDiscardedPayload { target_agent_id: "child".into(), delivery_ids: vec!["d1".into()], }, @@ -514,13 +493,13 @@ mod tests { }, ]; - let projection = MailboxProjection::replay_for_agent(&events, "child"); + let projection = InputQueueProjection::replay_for_agent(&events, "child"); assert!(projection.pending_delivery_ids.is_empty()); assert!(projection.discarded_delivery_ids.contains(&"d1".into())); } #[test] - fn mailbox_projection_started_but_not_acked_keeps_pending() { + fn input_queue_projection_started_but_not_acked_keeps_pending() { use crate::{StorageEvent, StorageEventPayload, StoredEvent}; let agent = crate::AgentEventContext::default(); @@ -530,9 +509,9 @@ mod tests { event: StorageEvent { turn_id: Some("t1".into()), agent: agent.clone(), - payload: StorageEventPayload::AgentMailboxQueued { - payload: MailboxQueuedPayload { - envelope: AgentMailboxEnvelope { + payload: StorageEventPayload::AgentInputQueued { + payload: InputQueuedPayload { + envelope: QueuedInputEnvelope { delivery_id: "d1".into(), from_agent_id: "parent".into(), to_agent_id: "child".into(), @@ -552,8 +531,8 @@ mod tests { event: StorageEvent { turn_id: Some("t2".into()), agent: agent.clone(), - payload: StorageEventPayload::AgentMailboxBatchStarted { - payload: MailboxBatchStartedPayload { + payload: StorageEventPayload::AgentInputBatchStarted { + payload: InputBatchStartedPayload { target_agent_id: "child".into(), turn_id: "t2".into(), batch_id: "b1".into(), @@ -564,15 +543,15 @@ mod tests { }, ]; - let projection = MailboxProjection::replay_for_agent(&events, "child"); + let projection = InputQueueProjection::replay_for_agent(&events, "child"); // Started 但未 Acked,d1 仍在 pending 中(at-least-once 语义) assert!(projection.pending_delivery_ids.contains(&"d1".into())); assert_eq!(projection.active_batch_id.as_deref(), Some("b1")); - assert_eq!(projection.pending_message_count(), 1); + assert_eq!(projection.pending_input_count(), 1); } #[test] - fn mailbox_projection_per_agent_filtering_isolates_agents() { + fn input_queue_projection_per_agent_filtering_isolates_agents() { use crate::{StorageEvent, StorageEventPayload, StoredEvent}; let agent = crate::AgentEventContext::default(); @@ -583,9 +562,9 @@ mod tests { event: StorageEvent { turn_id: Some("t1".into()), agent: agent.clone(), - payload: StorageEventPayload::AgentMailboxQueued { - payload: MailboxQueuedPayload { - envelope: AgentMailboxEnvelope { + payload: StorageEventPayload::AgentInputQueued { + payload: InputQueuedPayload { + envelope: QueuedInputEnvelope { delivery_id: "d-a".into(), from_agent_id: "parent".into(), to_agent_id: "agent-a".into(), @@ -605,9 +584,9 @@ mod tests { event: StorageEvent { turn_id: Some("t1".into()), agent: agent.clone(), - payload: StorageEventPayload::AgentMailboxQueued { - payload: MailboxQueuedPayload { - envelope: AgentMailboxEnvelope { + payload: StorageEventPayload::AgentInputQueued { + payload: InputQueuedPayload { + envelope: QueuedInputEnvelope { delivery_id: "d-b".into(), from_agent_id: "parent".into(), to_agent_id: "agent-b".into(), @@ -624,15 +603,15 @@ mod tests { }, ]; - let projection_a = MailboxProjection::replay_for_agent(&events, "agent-a"); + let projection_a = InputQueueProjection::replay_for_agent(&events, "agent-a"); assert_eq!(projection_a.pending_delivery_ids, vec!["d-a".into()]); - assert_eq!(projection_a.pending_message_count(), 1); + assert_eq!(projection_a.pending_input_count(), 1); - let projection_b = MailboxProjection::replay_for_agent(&events, "agent-b"); + let projection_b = InputQueueProjection::replay_for_agent(&events, "agent-b"); assert_eq!(projection_b.pending_delivery_ids, vec!["d-b".into()]); - assert_eq!(projection_b.pending_message_count(), 1); + assert_eq!(projection_b.pending_input_count(), 1); - let projection_c = MailboxProjection::replay_for_agent(&events, "agent-c"); - assert_eq!(projection_c.pending_message_count(), 0); + let projection_c = InputQueueProjection::replay_for_agent(&events, "agent-c"); + assert_eq!(projection_c.pending_input_count(), 0); } } diff --git a/crates/core/src/agent/mod.rs b/crates/core/src/agent/mod.rs index 43b82ff4..78634afc 100644 --- a/crates/core/src/agent/mod.rs +++ b/crates/core/src/agent/mod.rs @@ -6,11 +6,11 @@ //! //! 子模块划分: //! - `lifecycle`:AgentLifecycleStatus + AgentTurnOutcome(四工具模型的状态拆层) -//! - `mailbox`:durable mailbox 信封、事件载荷、四工具参数和 observe 快照 +//! - `input queue`:durable input queue 信封、事件载荷、四工具参数和 observe 快照 pub mod executor; +pub mod input_queue; pub mod lifecycle; -pub mod mailbox; use serde::{Deserialize, Serialize}; @@ -1020,7 +1020,7 @@ pub enum CollaborationResult { Observed { agent_ref: ChildAgentRef, summary: String, - observe_result: Box, + observe_result: Box, #[serde(default, skip_serializing_if = "Option::is_none")] delegation: Option, }, @@ -1056,7 +1056,7 @@ impl CollaborationResult { } } - pub fn observe_result(&self) -> Option<&mailbox::ObserveAgentResult> { + pub fn observe_result(&self) -> Option<&input_queue::ObserveSnapshot> { match self { Self::Observed { observe_result, .. } => Some(observe_result.as_ref()), Self::Sent { .. } | Self::Closed { .. } => None, diff --git a/crates/core/src/event/domain.rs b/crates/core/src/event/domain.rs index b6c067e2..75d858a4 100644 --- a/crates/core/src/event/domain.rs +++ b/crates/core/src/event/domain.rs @@ -151,33 +151,33 @@ pub enum AgentEvent { turn_id: String, agent: AgentEventContext, }, - /// Durable mailbox 消息入队(前端可见,用于 UI 渲染)。 - AgentMailboxQueued { + /// Durable input queue 消息入队(前端可见,用于 UI 渲染)。 + AgentInputQueued { turn_id: Option, agent: AgentEventContext, #[serde(flatten)] - payload: crate::MailboxQueuedPayload, + payload: crate::InputQueuedPayload, }, - /// Mailbox 批次开始消费。 - AgentMailboxBatchStarted { + /// input queue 批次开始消费。 + AgentInputBatchStarted { turn_id: Option, agent: AgentEventContext, #[serde(flatten)] - payload: crate::MailboxBatchStartedPayload, + payload: crate::InputBatchStartedPayload, }, - /// Mailbox 批次确认完成。 - AgentMailboxBatchAcked { + /// input queue 批次确认完成。 + AgentInputBatchAcked { turn_id: Option, agent: AgentEventContext, #[serde(flatten)] - payload: crate::MailboxBatchAckedPayload, + payload: crate::InputBatchAckedPayload, }, - /// Mailbox 消息丢弃。 - AgentMailboxDiscarded { + /// input queue 消息丢弃。 + AgentInputDiscarded { turn_id: Option, agent: AgentEventContext, #[serde(flatten)] - payload: crate::MailboxDiscardedPayload, + payload: crate::InputDiscardedPayload, }, /// 错误事件 Error { diff --git a/crates/core/src/event/phase.rs b/crates/core/src/event/phase.rs index a156795a..20248bc5 100644 --- a/crates/core/src/event/phase.rs +++ b/crates/core/src/event/phase.rs @@ -33,10 +33,10 @@ pub fn target_phase(event: &StorageEvent) -> Phase { | StorageEventPayload::SubRunFinished { .. } | StorageEventPayload::ChildSessionNotification { .. } | StorageEventPayload::AgentCollaborationFact { .. } - | StorageEventPayload::AgentMailboxQueued { .. } - | StorageEventPayload::AgentMailboxBatchStarted { .. } - | StorageEventPayload::AgentMailboxBatchAcked { .. } - | StorageEventPayload::AgentMailboxDiscarded { .. } => Phase::Idle, + | StorageEventPayload::AgentInputQueued { .. } + | StorageEventPayload::AgentInputBatchStarted { .. } + | StorageEventPayload::AgentInputBatchAcked { .. } + | StorageEventPayload::AgentInputDiscarded { .. } => Phase::Idle, StorageEventPayload::AssistantDelta { .. } | StorageEventPayload::ThinkingDelta { .. } | StorageEventPayload::AssistantFinal { .. } => Phase::Streaming, @@ -90,6 +90,7 @@ impl PhaseTracker { &event.payload, StorageEventPayload::UserMessage { origin: UserMessageOrigin::AutoContinueNudge + | UserMessageOrigin::QueuedInput | UserMessageOrigin::ContinuationPrompt | UserMessageOrigin::ReactivationPrompt | UserMessageOrigin::CompactSummary, @@ -107,10 +108,10 @@ impl PhaseTracker { | StorageEventPayload::SubRunFinished { .. } | StorageEventPayload::ChildSessionNotification { .. } | StorageEventPayload::AgentCollaborationFact { .. } - | StorageEventPayload::AgentMailboxQueued { .. } - | StorageEventPayload::AgentMailboxBatchStarted { .. } - | StorageEventPayload::AgentMailboxBatchAcked { .. } - | StorageEventPayload::AgentMailboxDiscarded { .. } + | StorageEventPayload::AgentInputQueued { .. } + | StorageEventPayload::AgentInputBatchStarted { .. } + | StorageEventPayload::AgentInputBatchAcked { .. } + | StorageEventPayload::AgentInputDiscarded { .. } ) { return None; } diff --git a/crates/core/src/event/translate.rs b/crates/core/src/event/translate.rs index 5bd019e6..d7c75fed 100644 --- a/crates/core/src/event/translate.rs +++ b/crates/core/src/event/translate.rs @@ -440,29 +440,29 @@ impl EventTranslator { .force_to(Phase::Interrupted, turn_id, agent); } }, - StorageEventPayload::AgentMailboxQueued { payload, .. } => { - push(AgentEvent::AgentMailboxQueued { + StorageEventPayload::AgentInputQueued { payload, .. } => { + push(AgentEvent::AgentInputQueued { turn_id: turn_id.clone(), agent: agent.clone(), payload: payload.clone(), }); }, - StorageEventPayload::AgentMailboxBatchStarted { payload, .. } => { - push(AgentEvent::AgentMailboxBatchStarted { + StorageEventPayload::AgentInputBatchStarted { payload, .. } => { + push(AgentEvent::AgentInputBatchStarted { turn_id: turn_id.clone(), agent: agent.clone(), payload: payload.clone(), }); }, - StorageEventPayload::AgentMailboxBatchAcked { payload, .. } => { - push(AgentEvent::AgentMailboxBatchAcked { + StorageEventPayload::AgentInputBatchAcked { payload, .. } => { + push(AgentEvent::AgentInputBatchAcked { turn_id: turn_id.clone(), agent: agent.clone(), payload: payload.clone(), }); }, - StorageEventPayload::AgentMailboxDiscarded { payload, .. } => { - push(AgentEvent::AgentMailboxDiscarded { + StorageEventPayload::AgentInputDiscarded { payload, .. } => { + push(AgentEvent::AgentInputDiscarded { turn_id: turn_id.clone(), agent: agent.clone(), payload: payload.clone(), diff --git a/crates/core/src/event/types.rs b/crates/core/src/event/types.rs index 3779f795..b817e3b9 100644 --- a/crates/core/src/event/types.rs +++ b/crates/core/src/event/types.rs @@ -14,9 +14,9 @@ use serde_json::Value; use crate::{ AgentCollaborationFact, AgentEventContext, AstrError, ChildAgentRef, ChildSessionNotification, - MailboxBatchAckedPayload, MailboxBatchStartedPayload, MailboxDiscardedPayload, - MailboxQueuedPayload, PersistedToolOutput, ResolvedExecutionLimitsSnapshot, - ResolvedSubagentContextOverrides, Result, SubRunResult, ToolOutputStream, UserMessageOrigin, + InputBatchAckedPayload, InputBatchStartedPayload, InputDiscardedPayload, InputQueuedPayload, + PersistedToolOutput, ResolvedExecutionLimitsSnapshot, ResolvedSubagentContextOverrides, Result, + SubRunResult, ToolOutputStream, UserMessageOrigin, }; /// Prompt/缓存指标共享载荷。 @@ -252,35 +252,35 @@ pub enum StorageEventPayload { #[serde(default, skip_serializing_if = "Option::is_none")] reason: Option, }, - /// Durable mailbox 消息入队。 + /// Durable input queue 消息入队。 /// - /// 记录一条协作消息成功进入目标 agent 的 mailbox。 + /// 记录一条协作消息成功进入目标 agent 的 input queue。 /// live inbox 只能在该事件 append 成功后更新。 - AgentMailboxQueued { + AgentInputQueued { #[serde(flatten)] - payload: MailboxQueuedPayload, + payload: InputQueuedPayload, }, - /// Mailbox 批次开始消费。 + /// input queue 批次开始消费。 /// /// snapshot drain 时写入,记录本轮接管了哪些 delivery_ids。 - /// 必须是 mailbox-wake turn 的第一条 durable 事件。 - AgentMailboxBatchStarted { + /// 必须是 input-queue turn 的第一条 durable 事件。 + AgentInputBatchStarted { #[serde(flatten)] - payload: MailboxBatchStartedPayload, + payload: InputBatchStartedPayload, }, - /// Mailbox 批次确认完成。 + /// input queue 批次确认完成。 /// /// durable turn completion 后写入,标记对应 delivery_ids 已被消费。 - AgentMailboxBatchAcked { + AgentInputBatchAcked { #[serde(flatten)] - payload: MailboxBatchAckedPayload, + payload: InputBatchAckedPayload, }, - /// Mailbox 消息丢弃。 + /// input queue 消息丢弃。 /// /// close 时写入,记录被主动丢弃的 pending delivery_ids。 - AgentMailboxDiscarded { + AgentInputDiscarded { #[serde(flatten)] - payload: MailboxDiscardedPayload, + payload: InputDiscardedPayload, }, /// 错误事件。 Error { diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index 690ed79b..12bd2076 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -73,12 +73,12 @@ pub use agent::{ SubRunFailureCode, SubRunHandle, SubRunHandoff, SubRunResult, SubRunStatus, SubRunStorageMode, SubagentContextOverrides, executor::{CollaborationExecutor, SubAgentExecutor}, - lifecycle::{AgentLifecycleStatus, AgentTurnOutcome}, - mailbox::{ - AgentMailboxEnvelope, BatchId, CloseParams, DeliveryId, MailboxBatchAckedPayload, - MailboxBatchStartedPayload, MailboxDiscardedPayload, MailboxProjection, - MailboxQueuedPayload, ObserveAgentResult, ObserveParams, SendParams, + input_queue::{ + BatchId, CloseParams, DeliveryId, InputBatchAckedPayload, InputBatchStartedPayload, + InputDiscardedPayload, InputQueueProjection, InputQueuedPayload, ObserveParams, + ObserveSnapshot, QueuedInputEnvelope, SendParams, }, + lifecycle::{AgentLifecycleStatus, AgentTurnOutcome}, normalize_non_empty_unique_string_list, }; pub use cancel::CancelToken; diff --git a/crates/core/src/projection/agent_state.rs b/crates/core/src/projection/agent_state.rs index 8ed5b3b8..ef3cf7a8 100644 --- a/crates/core/src/projection/agent_state.rs +++ b/crates/core/src/projection/agent_state.rs @@ -235,10 +235,10 @@ impl AgentStateProjector { | StorageEventPayload::SubRunFinished { .. } | StorageEventPayload::ChildSessionNotification { .. } | StorageEventPayload::AgentCollaborationFact { .. } - | StorageEventPayload::AgentMailboxQueued { .. } - | StorageEventPayload::AgentMailboxBatchStarted { .. } - | StorageEventPayload::AgentMailboxBatchAcked { .. } - | StorageEventPayload::AgentMailboxDiscarded { .. } + | StorageEventPayload::AgentInputQueued { .. } + | StorageEventPayload::AgentInputBatchStarted { .. } + | StorageEventPayload::AgentInputBatchAcked { .. } + | StorageEventPayload::AgentInputDiscarded { .. } | StorageEventPayload::Error { .. } => {}, } } diff --git a/crates/session-runtime/src/command/mod.rs b/crates/session-runtime/src/command/mod.rs index fd18280d..103c3463 100644 --- a/crates/session-runtime/src/command/mod.rs +++ b/crates/session-runtime/src/command/mod.rs @@ -2,12 +2,14 @@ use std::path::Path; use astrcode_core::{ AgentCollaborationFact, AgentEventContext, ChildSessionNotification, EventTranslator, - MailboxBatchAckedPayload, MailboxBatchStartedPayload, MailboxDiscardedPayload, - MailboxQueuedPayload, Result, StorageEvent, StorageEventPayload, StoredEvent, + InputBatchAckedPayload, InputBatchStartedPayload, InputDiscardedPayload, InputQueuedPayload, + Result, StorageEvent, StorageEventPayload, StoredEvent, }; use chrono::Utc; -use crate::{MailboxEventAppend, SessionRuntime, append_and_broadcast, append_mailbox_event}; +use crate::{ + InputQueueEventAppend, SessionRuntime, append_and_broadcast, append_input_queue_event, +}; pub(crate) struct SessionCommands<'a> { runtime: &'a SessionRuntime, @@ -18,66 +20,66 @@ impl<'a> SessionCommands<'a> { Self { runtime } } - pub async fn append_agent_mailbox_queued( + pub async fn append_agent_input_queued( &self, session_id: &str, turn_id: &str, agent: AgentEventContext, - payload: MailboxQueuedPayload, + payload: InputQueuedPayload, ) -> Result { - self.append_agent_mailbox_event( + self.append_agent_input_event( session_id, turn_id, agent, - MailboxEventAppend::Queued(payload), + InputQueueEventAppend::Queued(payload), ) .await } - pub async fn append_agent_mailbox_discarded( + pub async fn append_agent_input_discarded( &self, session_id: &str, turn_id: &str, agent: AgentEventContext, - payload: MailboxDiscardedPayload, + payload: InputDiscardedPayload, ) -> Result { - self.append_agent_mailbox_event( + self.append_agent_input_event( session_id, turn_id, agent, - MailboxEventAppend::Discarded(payload), + InputQueueEventAppend::Discarded(payload), ) .await } - pub async fn append_agent_mailbox_batch_started( + pub async fn append_agent_input_batch_started( &self, session_id: &str, turn_id: &str, agent: AgentEventContext, - payload: MailboxBatchStartedPayload, + payload: InputBatchStartedPayload, ) -> Result { - self.append_agent_mailbox_event( + self.append_agent_input_event( session_id, turn_id, agent, - MailboxEventAppend::BatchStarted(payload), + InputQueueEventAppend::BatchStarted(payload), ) .await } - pub async fn append_agent_mailbox_batch_acked( + pub async fn append_agent_input_batch_acked( &self, session_id: &str, turn_id: &str, agent: AgentEventContext, - payload: MailboxBatchAckedPayload, + payload: InputBatchAckedPayload, ) -> Result { - self.append_agent_mailbox_event( + self.append_agent_input_event( session_id, turn_id, agent, - MailboxEventAppend::BatchAcked(payload), + InputQueueEventAppend::BatchAcked(payload), ) .await } @@ -177,16 +179,16 @@ impl<'a> SessionCommands<'a> { Ok(false) } - async fn append_agent_mailbox_event( + async fn append_agent_input_event( &self, session_id: &str, turn_id: &str, agent: AgentEventContext, - event: MailboxEventAppend, + event: InputQueueEventAppend, ) -> Result { let session_id = astrcode_core::SessionId::from(crate::normalize_session_id(session_id)); let session_state = self.runtime.query().session_state(&session_id).await?; let mut translator = EventTranslator::new(session_state.current_phase()?); - append_mailbox_event(&session_state, turn_id, agent, event, &mut translator).await + append_input_queue_event(&session_state, turn_id, agent, event, &mut translator).await } } diff --git a/crates/session-runtime/src/context_window/token_usage.rs b/crates/session-runtime/src/context_window/token_usage.rs index f605b890..d27bd3c8 100644 --- a/crates/session-runtime/src/context_window/token_usage.rs +++ b/crates/session-runtime/src/context_window/token_usage.rs @@ -133,6 +133,7 @@ pub fn estimate_message_tokens(message: &LlmMessage) -> usize { + estimate_text_tokens(content) + match origin { UserMessageOrigin::User => 0, + UserMessageOrigin::QueuedInput => 8, UserMessageOrigin::AutoContinueNudge => 6, UserMessageOrigin::ContinuationPrompt => 10, UserMessageOrigin::ReactivationPrompt => 8, diff --git a/crates/session-runtime/src/heuristics.rs b/crates/session-runtime/src/heuristics.rs index 1ad376d5..1f24b128 100644 --- a/crates/session-runtime/src/heuristics.rs +++ b/crates/session-runtime/src/heuristics.rs @@ -1,6 +1,6 @@ //! 运行时启发式常量。 //! -//! 这些值当前服务于 prompt 预算估算和只读观察摘要。 +//! 这些值当前服务于 prompt 预算估算。 //! 它们不是用户配置项,但应集中管理,避免 magic number 分散。 /// 单条消息的固定估算开销。 @@ -8,9 +8,3 @@ pub(crate) const MESSAGE_BASE_TOKENS: usize = 6; /// 单个工具调用元数据的固定估算开销。 pub(crate) const TOOL_CALL_BASE_TOKENS: usize = 12; - -/// agent observe 返回的最近 mailbox 消息条数。 -pub(crate) const MAX_RECENT_MAILBOX_MESSAGES: usize = 3; - -/// task/mailbox 摘要的最大字符数。 -pub(crate) const MAX_TASK_SUMMARY_CHARS: usize = 120; diff --git a/crates/session-runtime/src/lib.rs b/crates/session-runtime/src/lib.rs index 26b6573c..20923285 100644 --- a/crates/session-runtime/src/lib.rs +++ b/crates/session-runtime/src/lib.rs @@ -5,8 +5,8 @@ use std::{ use astrcode_core::{ AgentCollaborationFact, AgentEventContext, AgentId, AgentLifecycleStatus, ChildSessionNode, - ChildSessionNotification, DeleteProjectResult, EventStore, MailboxBatchAckedPayload, - MailboxBatchStartedPayload, MailboxDiscardedPayload, MailboxQueuedPayload, Phase, + ChildSessionNotification, DeleteProjectResult, EventStore, InputBatchAckedPayload, + InputBatchStartedPayload, InputDiscardedPayload, InputQueuedPayload, Phase, PromptFactsProvider, ResolvedRuntimeConfig, Result, RuntimeMetricsRecorder, SessionId, SessionMeta, StoredEvent, event::generate_session_id, }; @@ -43,7 +43,7 @@ pub use query::{ SessionControlStateSnapshot, SessionReplay, SessionTranscriptSnapshot, ToolCallBlockFacts, ToolCallStreamsFacts, TurnTerminalSnapshot, recoverable_parent_deliveries, }; -pub(crate) use state::{MailboxEventAppend, SessionStateEventSink, append_mailbox_event}; +pub(crate) use state::{InputQueueEventAppend, SessionStateEventSink, append_input_queue_event}; pub use state::{ SessionSnapshot, SessionState, append_and_broadcast, complete_session_execution, display_name_from_working_dir, normalize_session_id, normalize_working_dir, @@ -254,7 +254,7 @@ impl SessionRuntime { /// 按需加载 session 并返回内部状态引用。 /// /// 用于 agent 编排层需要直接操作 SessionState 的场景 - /// (如 mailbox 追加、对话投影读取等)。 + /// (如 input queue 追加、对话投影读取等)。 pub async fn get_session_state(&self, session_id: &SessionId) -> Result> { self.query().session_state(session_id).await } @@ -296,7 +296,7 @@ impl SessionRuntime { /// 回放指定 session 的全部持久化事件。 /// - /// 用于 agent 编排层需要从 durable 事件中提取 mailbox 信封等场景。 + /// 用于 agent 编排层需要从 durable 事件中提取 input queue 信封等场景。 pub async fn replay_stored_events(&self, session_id: &SessionId) -> Result> { self.query().stored_events(session_id).await } @@ -324,7 +324,7 @@ impl SessionRuntime { .await } - /// 读取指定 agent 当前 mailbox durable 投影中的待处理 delivery id。 + /// 读取指定 agent 当前 input queue durable 投影中的待处理 delivery id。 pub async fn pending_delivery_ids_for_agent( &self, session_id: &str, @@ -335,51 +335,51 @@ impl SessionRuntime { .await } - pub async fn append_agent_mailbox_queued( + pub async fn append_agent_input_queued( &self, session_id: &str, turn_id: &str, agent: AgentEventContext, - payload: MailboxQueuedPayload, + payload: InputQueuedPayload, ) -> Result { self.command() - .append_agent_mailbox_queued(session_id, turn_id, agent, payload) + .append_agent_input_queued(session_id, turn_id, agent, payload) .await } - pub async fn append_agent_mailbox_discarded( + pub async fn append_agent_input_discarded( &self, session_id: &str, turn_id: &str, agent: AgentEventContext, - payload: MailboxDiscardedPayload, + payload: InputDiscardedPayload, ) -> Result { self.command() - .append_agent_mailbox_discarded(session_id, turn_id, agent, payload) + .append_agent_input_discarded(session_id, turn_id, agent, payload) .await } - pub async fn append_agent_mailbox_batch_started( + pub async fn append_agent_input_batch_started( &self, session_id: &str, turn_id: &str, agent: AgentEventContext, - payload: MailboxBatchStartedPayload, + payload: InputBatchStartedPayload, ) -> Result { self.command() - .append_agent_mailbox_batch_started(session_id, turn_id, agent, payload) + .append_agent_input_batch_started(session_id, turn_id, agent, payload) .await } - pub async fn append_agent_mailbox_batch_acked( + pub async fn append_agent_input_batch_acked( &self, session_id: &str, turn_id: &str, agent: AgentEventContext, - payload: MailboxBatchAckedPayload, + payload: InputBatchAckedPayload, ) -> Result { self.command() - .append_agent_mailbox_batch_acked(session_id, turn_id, agent, payload) + .append_agent_input_batch_acked(session_id, turn_id, agent, payload) .await } @@ -409,7 +409,7 @@ impl SessionRuntime { .await } - /// 从 durable mailbox + child notification 中恢复仍可重试的父级 delivery。 + /// 从 durable input queue + child notification 中恢复仍可重试的父级 delivery。 pub async fn recoverable_parent_deliveries( &self, parent_session_id: &str, diff --git a/crates/session-runtime/src/query/agent.rs b/crates/session-runtime/src/query/agent.rs index 6659bb7c..7828a7f1 100644 --- a/crates/session-runtime/src/query/agent.rs +++ b/crates/session-runtime/src/query/agent.rs @@ -1,54 +1,31 @@ //! Agent 只读观察投影。 //! -//! Why: `observe` 负责订阅语义,`agent query` 负责生成同步快照, -//! 两者不要再混在同一个模块里。 +//! Why: `observe` 只负责读侧快照,不再暴露 input queue 的建议字段。 -use std::collections::{HashMap, HashSet}; +use astrcode_core::{AgentLifecycleStatus, AgentState, InputQueueProjection, LlmMessage}; -use astrcode_core::{ - AgentLifecycleStatus, AgentMailboxEnvelope, AgentState, LlmMessage, MailboxProjection, - StorageEventPayload, StoredEvent, UserMessageOrigin, -}; - -use crate::{ - heuristics::{MAX_RECENT_MAILBOX_MESSAGES, MAX_TASK_SUMMARY_CHARS}, - query::text::{summarize_inline_text, truncate_text}, -}; +use crate::query::text::{summarize_inline_text, truncate_text}; #[derive(Debug, Clone)] pub struct AgentObserveSnapshot { pub phase: astrcode_core::Phase, pub turn_count: u32, - pub pending_message_count: usize, pub active_task: Option, - pub pending_task: Option, - pub recent_mailbox_messages: Vec, - pub last_output: Option, + pub last_output_tail: Option, + pub last_turn_tail: Vec, } pub(crate) fn build_agent_observe_snapshot( lifecycle_status: AgentLifecycleStatus, projected: &AgentState, - mailbox_projection: &MailboxProjection, - stored_events: &[StoredEvent], - target_agent_id: &str, + input_queue_projection: &InputQueueProjection, ) -> AgentObserveSnapshot { - let mailbox_messages = collect_mailbox_messages(stored_events, target_agent_id); - let pending_message_count = mailbox_projection.pending_delivery_ids.len(); - AgentObserveSnapshot { phase: projected.phase, turn_count: projected.turn_count as u32, - pending_message_count, - active_task: active_task_summary( - lifecycle_status, - &projected.messages, - mailbox_projection, - &mailbox_messages, - ), - pending_task: pending_task_summary(mailbox_projection, &mailbox_messages), - recent_mailbox_messages: recent_mailbox_message_summaries(stored_events, target_agent_id), - last_output: extract_last_output(&projected.messages), + active_task: active_task_summary(lifecycle_status, projected, input_queue_projection), + last_output_tail: extract_last_output(&projected.messages), + last_turn_tail: extract_last_turn_tail(&projected.messages), } } @@ -61,228 +38,152 @@ fn extract_last_output(messages: &[LlmMessage]) -> Option { fn active_task_summary( lifecycle_status: AgentLifecycleStatus, - messages: &[LlmMessage], - mailbox_projection: &MailboxProjection, - mailbox_messages: &HashMap, + projected: &AgentState, + input_queue_projection: &InputQueueProjection, ) -> Option { - if let Some(summary) = first_delivery_summary( - mailbox_projection.active_delivery_ids.iter(), - mailbox_messages, - ) { - return Some(summary); + if !input_queue_projection.active_delivery_ids.is_empty() { + return extract_last_turn_tail(&projected.messages) + .into_iter() + .next(); } if matches!( lifecycle_status, AgentLifecycleStatus::Pending | AgentLifecycleStatus::Running ) { - return latest_user_task_summary(messages); + return projected + .messages + .iter() + .rev() + .find_map(|message| match message { + LlmMessage::User { content, origin } + if matches!(origin, astrcode_core::UserMessageOrigin::User) => + { + summarize_inline_text(content, 120) + }, + _ => None, + }); } None } -fn pending_task_summary( - mailbox_projection: &MailboxProjection, - mailbox_messages: &HashMap, -) -> Option { - let active_ids: HashSet<_> = mailbox_projection - .active_delivery_ids - .iter() - .cloned() - .collect(); - - first_delivery_summary( - mailbox_projection - .pending_delivery_ids - .iter() - .filter(|delivery_id| !active_ids.contains(*delivery_id)), - mailbox_messages, - ) -} - -fn first_delivery_summary<'a>( - delivery_ids: impl IntoIterator, - mailbox_messages: &HashMap, -) -> Option { - delivery_ids.into_iter().find_map(|delivery_id| { - mailbox_messages - .get(delivery_id.as_str()) - .and_then(|envelope| summarize_task_text(&envelope.message)) - }) -} - -fn latest_user_task_summary(messages: &[LlmMessage]) -> Option { - messages.iter().rev().find_map(|message| match message { - LlmMessage::User { content, origin } if *origin == UserMessageOrigin::User => { - summarize_task_text(content) - }, - _ => None, - }) -} - -fn collect_mailbox_messages( - stored_events: &[StoredEvent], - target_agent_id: &str, -) -> HashMap { - let mut messages = HashMap::new(); - for stored in stored_events { - if let StorageEventPayload::AgentMailboxQueued { payload } = &stored.event.payload { - if payload.envelope.to_agent_id == target_agent_id { - messages.insert( - payload.envelope.delivery_id.to_string(), - payload.envelope.clone(), - ); - } - } - } +fn extract_last_turn_tail(messages: &[LlmMessage]) -> Vec { messages -} - -fn recent_mailbox_message_summaries( - stored_events: &[StoredEvent], - target_agent_id: &str, -) -> Vec { - stored_events .iter() - .filter_map(|stored| match &stored.event.payload { - StorageEventPayload::AgentMailboxQueued { payload } - if payload.envelope.to_agent_id == target_agent_id => - { - summarize_task_text(&payload.envelope.message) - }, - _ => None, - }) .rev() - .take(MAX_RECENT_MAILBOX_MESSAGES) + .filter_map(|message| match message { + LlmMessage::User { content, .. } => summarize_inline_text(content, 120), + LlmMessage::Assistant { content, .. } => summarize_inline_text(content, 120), + LlmMessage::Tool { content, .. } => summarize_inline_text(content, 120), + }) + .take(3) .collect::>() .into_iter() .rev() .collect() } -fn summarize_task_text(text: &str) -> Option { - summarize_inline_text(text, MAX_TASK_SUMMARY_CHARS) -} - #[cfg(test)] mod tests { use std::path::PathBuf; - use astrcode_core::{ - AgentEventContext, AgentLifecycleStatus, AgentMailboxEnvelope, AgentState, LlmMessage, - MailboxProjection, MailboxQueuedPayload, Phase, StorageEvent, StorageEventPayload, - StoredEvent, - }; + use astrcode_core::{AgentState, LlmMessage, Phase, UserMessageOrigin}; - use super::{ - build_agent_observe_snapshot, extract_last_output, recent_mailbox_message_summaries, - summarize_task_text, - }; + use super::{build_agent_observe_snapshot, extract_last_turn_tail}; - fn queued_mailbox_event( - storage_seq: u64, - delivery_id: &str, - target_agent_id: &str, - message: &str, - ) -> StoredEvent { - StoredEvent { - storage_seq, - event: StorageEvent { - turn_id: Some("turn-parent".to_string()), - agent: AgentEventContext::default(), - payload: StorageEventPayload::AgentMailboxQueued { - payload: MailboxQueuedPayload { - envelope: AgentMailboxEnvelope { - delivery_id: delivery_id.to_string().into(), - from_agent_id: "parent".to_string(), - to_agent_id: target_agent_id.to_string(), - message: message.to_string(), - queued_at: chrono::Utc::now(), - sender_lifecycle_status: AgentLifecycleStatus::Idle, - sender_last_turn_outcome: None, - sender_open_session_id: "session-parent".to_string(), - }, - }, - }, - }, + fn projected(messages: Vec, phase: Phase) -> AgentState { + AgentState { + session_id: "session-1".into(), + working_dir: PathBuf::from("/tmp"), + phase, + turn_count: 2, + messages, + last_assistant_at: None, } } #[test] - fn summarize_task_text_trims_and_truncates() { - assert_eq!( - summarize_task_text(" review the mailbox state "), - Some("review the mailbox state".to_string()) - ); - assert!(summarize_task_text(" ").is_none()); - let long = "a".repeat(150); - assert_eq!( - summarize_task_text(&long), - Some(format!("{}...", "a".repeat(120))) - ); - } - - #[test] - fn extract_last_output_ignores_empty_assistant() { - let messages = vec![ + fn extract_last_turn_tail_returns_recent_message_tail() { + let tail = extract_last_turn_tail(&[ + LlmMessage::User { + content: "first".to_string(), + origin: UserMessageOrigin::User, + }, LlmMessage::Assistant { - content: String::new(), + content: "second".to_string(), tool_calls: Vec::new(), reasoning: None, }, + LlmMessage::Tool { + tool_call_id: "call-1".to_string(), + content: "third".to_string(), + }, LlmMessage::Assistant { - content: "最后输出".to_string(), + content: "fourth".to_string(), tool_calls: Vec::new(), reasoning: None, }, - ]; - assert_eq!(extract_last_output(&messages), Some("最后输出".to_string())); + ]); + + assert_eq!(tail, vec!["second", "third", "fourth"]); } #[test] - fn recent_mailbox_message_summaries_returns_only_tail() { - let stored_events = vec![ - queued_mailbox_event(1, "d1", "child-1", "第一条"), - queued_mailbox_event(2, "d2", "child-1", "第二条"), - queued_mailbox_event(3, "d3", "child-1", "第三条"), - queued_mailbox_event(4, "d4", "child-1", "第四条"), - ]; + fn build_agent_observe_snapshot_prefers_latest_user_task_for_running_agent() { + let snapshot = build_agent_observe_snapshot( + astrcode_core::AgentLifecycleStatus::Running, + &projected( + vec![ + LlmMessage::User { + content: "请检查 input queue 路径".to_string(), + origin: UserMessageOrigin::User, + }, + LlmMessage::Assistant { + content: "处理中".to_string(), + tool_calls: Vec::new(), + reasoning: None, + }, + ], + Phase::Streaming, + ), + &astrcode_core::InputQueueProjection::default(), + ); assert_eq!( - recent_mailbox_message_summaries(&stored_events, "child-1"), - vec![ - "第二条".to_string(), - "第三条".to_string(), - "第四条".to_string() - ] + snapshot.active_task.as_deref(), + Some("请检查 input queue 路径") ); + assert_eq!(snapshot.last_output_tail.as_deref(), Some("处理中")); } #[test] - fn build_agent_observe_snapshot_includes_recent_mailbox_tail() { - let stored_events = vec![ - queued_mailbox_event(1, "d1", "child-1", "第一条"), - queued_mailbox_event(2, "d2", "child-1", "第二条"), - ]; + fn build_agent_observe_snapshot_uses_turn_tail_when_active_delivery_exists() { + let mut input_queue_projection = astrcode_core::InputQueueProjection::default(); + input_queue_projection.active_delivery_ids = vec!["delivery-1".into()]; + let snapshot = build_agent_observe_snapshot( - AgentLifecycleStatus::Idle, - &AgentState { - session_id: "session-child".to_string(), - working_dir: PathBuf::from("/workspace/demo"), - messages: Vec::new(), - phase: Phase::Idle, - turn_count: 2, - last_assistant_at: None, - }, - &MailboxProjection::default(), - &stored_events, - "child-1", + astrcode_core::AgentLifecycleStatus::Idle, + &projected( + vec![ + LlmMessage::User { + content: "继续整理父级需要的结论".to_string(), + origin: UserMessageOrigin::QueuedInput, + }, + LlmMessage::Assistant { + content: "正在合并结果".to_string(), + tool_calls: Vec::new(), + reasoning: None, + }, + ], + Phase::Thinking, + ), + &input_queue_projection, ); assert_eq!( - snapshot.recent_mailbox_messages, - vec!["第一条".to_string(), "第二条".to_string()] + snapshot.active_task.as_deref(), + Some("继续整理父级需要的结论") ); } } diff --git a/crates/session-runtime/src/query/conversation.rs b/crates/session-runtime/src/query/conversation.rs index 56a928c6..6aa4e9e2 100644 --- a/crates/session-runtime/src/query/conversation.rs +++ b/crates/session-runtime/src/query/conversation.rs @@ -480,10 +480,10 @@ impl ConversationDeltaProjector { | AgentEvent::PromptMetrics { .. } | AgentEvent::SubRunStarted { .. } | AgentEvent::SubRunFinished { .. } - | AgentEvent::AgentMailboxQueued { .. } - | AgentEvent::AgentMailboxBatchStarted { .. } - | AgentEvent::AgentMailboxBatchAcked { .. } - | AgentEvent::AgentMailboxDiscarded { .. } + | AgentEvent::AgentInputQueued { .. } + | AgentEvent::AgentInputBatchStarted { .. } + | AgentEvent::AgentInputBatchAcked { .. } + | AgentEvent::AgentInputDiscarded { .. } | AgentEvent::UserMessage { .. } | AgentEvent::AssistantMessage { .. } | AgentEvent::CompactApplied { .. } diff --git a/crates/session-runtime/src/query/mailbox.rs b/crates/session-runtime/src/query/input_queue.rs similarity index 87% rename from crates/session-runtime/src/query/mailbox.rs rename to crates/session-runtime/src/query/input_queue.rs index 7ecc94a0..cfe7c9cb 100644 --- a/crates/session-runtime/src/query/mailbox.rs +++ b/crates/session-runtime/src/query/input_queue.rs @@ -1,15 +1,15 @@ -//! Mailbox 相关只读恢复投影。 +//! input queue 相关只读恢复投影。 //! -//! Why: mailbox 的 durable 恢复规则属于只读投影,不应该散落在 +//! Why: input queue 的 durable 恢复规则属于只读投影,不应该散落在 //! `application` 的父子编排流程里重复实现。 use std::collections::{HashMap, HashSet}; -use astrcode_core::{MailboxProjection, StorageEventPayload, StoredEvent}; +use astrcode_core::{InputQueueProjection, StorageEventPayload, StoredEvent}; use astrcode_kernel::PendingParentDelivery; pub fn recoverable_parent_deliveries(events: &[StoredEvent]) -> Vec { - let projection_index = MailboxProjection::replay_index(events); + let projection_index = InputQueueProjection::replay_index(events); let mut recoverable_by_agent = HashMap::>::new(); for (agent_id, projection) in projection_index { let active_ids = projection @@ -32,7 +32,7 @@ pub fn recoverable_parent_deliveries(events: &[StoredEvent]) -> Vec Some(( + StorageEventPayload::AgentInputQueued { payload } => Some(( payload.envelope.delivery_id.clone(), payload.envelope.queued_at, )), @@ -77,10 +77,10 @@ pub fn recoverable_parent_deliveries(events: &[StoredEvent]) -> Vec SessionQueries<'a> { let session_id = SessionId::from(crate::normalize_session_id(open_session_id)); let session_state = self.session_state(&session_id).await?; let projected = session_state.snapshot_projected_state()?; - let mailbox_projection = session_state.mailbox_projection_for_agent(target_agent_id)?; - let stored_events = self.stored_events(&session_id).await?; + let input_queue_projection = + session_state.input_queue_projection_for_agent(target_agent_id)?; Ok(build_agent_observe_snapshot( lifecycle_status, &projected, - &mailbox_projection, - &stored_events, - target_agent_id, + &input_queue_projection, )) } @@ -196,7 +194,7 @@ impl<'a> SessionQueries<'a> { let session_id = SessionId::from(crate::normalize_session_id(session_id)); let session_state = self.session_state(&session_id).await?; Ok(session_state - .mailbox_projection_for_agent(agent_id)? + .input_queue_projection_for_agent(agent_id)? .pending_delivery_ids .into_iter() .map(Into::into) @@ -319,16 +317,16 @@ fn record_targets_turn(record: &SessionEventRecord, turn_id: &str) -> bool { | AgentEvent::ChildSessionNotification { turn_id: Some(id), .. } - | AgentEvent::AgentMailboxQueued { + | AgentEvent::AgentInputQueued { turn_id: Some(id), .. } - | AgentEvent::AgentMailboxBatchStarted { + | AgentEvent::AgentInputBatchStarted { turn_id: Some(id), .. } - | AgentEvent::AgentMailboxBatchAcked { + | AgentEvent::AgentInputBatchAcked { turn_id: Some(id), .. } - | AgentEvent::AgentMailboxDiscarded { + | AgentEvent::AgentInputDiscarded { turn_id: Some(id), .. } | AgentEvent::Error { diff --git a/crates/session-runtime/src/query/text.rs b/crates/session-runtime/src/query/text.rs index c98bf732..a3c02be2 100644 --- a/crates/session-runtime/src/query/text.rs +++ b/crates/session-runtime/src/query/text.rs @@ -31,8 +31,8 @@ mod tests { #[test] fn summarize_inline_text_normalizes_whitespace_before_truncating() { assert_eq!( - summarize_inline_text(" review mailbox \n state ", 120), - Some("review mailbox state".to_string()) + summarize_inline_text(" review input queue \n state ", 120), + Some("review input queue state".to_string()) ); } diff --git a/crates/session-runtime/src/state/input_queue.rs b/crates/session-runtime/src/state/input_queue.rs new file mode 100644 index 00000000..ea5dfd31 --- /dev/null +++ b/crates/session-runtime/src/state/input_queue.rs @@ -0,0 +1,158 @@ +use astrcode_core::{ + EventTranslator, InputBatchAckedPayload, InputBatchStartedPayload, InputDiscardedPayload, + InputQueueProjection, InputQueuedPayload, Result, StorageEvent, StorageEventPayload, + StoredEvent, support, +}; + +use super::{SessionState, append_and_broadcast}; + +/// input queue durable 事件追加命令。 +/// +/// 为什么放在 `session-runtime`:input queue 事件最终都是单 session event log 的追加动作, +/// 由真相层统一决定如何落成 `StorageEventPayload`,可以避免写侧在多处散落拼装。 +#[derive(Debug, Clone)] +pub enum InputQueueEventAppend { + Queued(InputQueuedPayload), + BatchStarted(InputBatchStartedPayload), + BatchAcked(InputBatchAckedPayload), + Discarded(InputDiscardedPayload), +} + +impl InputQueueEventAppend { + pub(crate) fn into_storage_payload(self) -> StorageEventPayload { + match self { + Self::Queued(payload) => StorageEventPayload::AgentInputQueued { payload }, + Self::BatchStarted(payload) => StorageEventPayload::AgentInputBatchStarted { payload }, + Self::BatchAcked(payload) => StorageEventPayload::AgentInputBatchAcked { payload }, + Self::Discarded(payload) => StorageEventPayload::AgentInputDiscarded { payload }, + } + } +} + +pub(crate) fn input_queue_projection_target_agent_id( + payload: &StorageEventPayload, +) -> Option<&str> { + match payload { + StorageEventPayload::AgentInputQueued { payload } => Some(&payload.envelope.to_agent_id), + StorageEventPayload::AgentInputBatchStarted { payload } => Some(&payload.target_agent_id), + StorageEventPayload::AgentInputBatchAcked { payload } => Some(&payload.target_agent_id), + StorageEventPayload::AgentInputDiscarded { payload } => Some(&payload.target_agent_id), + _ => None, + } +} + +impl SessionState { + /// 读取指定 agent 的 input queue durable 投影。 + pub fn input_queue_projection_for_agent(&self, agent_id: &str) -> Result { + Ok(support::lock_anyhow( + &self.input_queue_projection_index, + "input queue projection index", + )? + .get(agent_id) + .cloned() + .unwrap_or_default()) + } + + /// 增量应用一条 input queue durable 事件到投影索引。 + pub(crate) fn apply_input_queue_event(&self, stored: &StoredEvent) { + let Some(target_agent_id) = input_queue_projection_target_agent_id(&stored.event.payload) + else { + return; + }; + let mut index = match support::lock_anyhow( + &self.input_queue_projection_index, + "input queue projection index", + ) { + Ok(index) => index, + Err(_) => return, + }; + let projection = index + .entry(target_agent_id.to_string()) + .or_insert_with(InputQueueProjection::default); + InputQueueProjection::apply_event_for_agent(projection, stored, target_agent_id); + } +} + +/// 追加一条 input queue durable 事件。 +pub async fn append_input_queue_event( + session: &SessionState, + turn_id: &str, + agent: astrcode_core::AgentEventContext, + event: InputQueueEventAppend, + translator: &mut EventTranslator, +) -> Result { + append_and_broadcast( + session, + &StorageEvent { + turn_id: Some(turn_id.to_string()), + agent, + payload: event.into_storage_payload(), + }, + translator, + ) + .await +} + +#[cfg(test)] +mod tests { + use astrcode_core::{ + AgentLifecycleStatus, InputBatchAckedPayload, InputBatchStartedPayload, + InputDiscardedPayload, InputQueuedPayload, QueuedInputEnvelope, StorageEventPayload, + }; + + use super::*; + + #[test] + fn input_queue_event_append_maps_to_expected_storage_payload() { + let envelope = QueuedInputEnvelope { + delivery_id: "delivery-1".to_string().into(), + from_agent_id: "agent-parent".to_string(), + to_agent_id: "agent-child".to_string(), + message: "hello".to_string(), + queued_at: chrono::Utc::now(), + sender_lifecycle_status: AgentLifecycleStatus::Idle, + sender_last_turn_outcome: None, + sender_open_session_id: "session-parent".to_string(), + }; + + assert!(matches!( + InputQueueEventAppend::Queued(InputQueuedPayload { + envelope: envelope.clone(), + }) + .into_storage_payload(), + StorageEventPayload::AgentInputQueued { payload } + if payload.envelope.delivery_id == "delivery-1".into() + )); + assert!(matches!( + InputQueueEventAppend::BatchStarted(InputBatchStartedPayload { + target_agent_id: "agent-child".to_string(), + turn_id: "turn-1".to_string(), + batch_id: "batch-1".to_string(), + delivery_ids: vec!["delivery-1".to_string().into()], + }) + .into_storage_payload(), + StorageEventPayload::AgentInputBatchStarted { payload } + if payload.batch_id == "batch-1" + )); + assert!(matches!( + InputQueueEventAppend::BatchAcked(InputBatchAckedPayload { + target_agent_id: "agent-child".to_string(), + turn_id: "turn-1".to_string(), + batch_id: "batch-1".to_string(), + delivery_ids: vec!["delivery-1".to_string().into()], + }) + .into_storage_payload(), + StorageEventPayload::AgentInputBatchAcked { payload } + if payload.delivery_ids == vec!["delivery-1".to_string().into()] + )); + assert!(matches!( + InputQueueEventAppend::Discarded(InputDiscardedPayload { + target_agent_id: "agent-child".to_string(), + delivery_ids: vec!["delivery-1".to_string().into()], + }) + .into_storage_payload(), + StorageEventPayload::AgentInputDiscarded { payload } + if payload.target_agent_id == "agent-child" + )); + } +} diff --git a/crates/session-runtime/src/state/mailbox.rs b/crates/session-runtime/src/state/mailbox.rs deleted file mode 100644 index 6d1c6749..00000000 --- a/crates/session-runtime/src/state/mailbox.rs +++ /dev/null @@ -1,158 +0,0 @@ -use astrcode_core::{ - EventTranslator, MailboxBatchAckedPayload, MailboxBatchStartedPayload, MailboxDiscardedPayload, - MailboxProjection, MailboxQueuedPayload, Result, StorageEvent, StorageEventPayload, - StoredEvent, support, -}; - -use super::{SessionState, append_and_broadcast}; - -/// mailbox durable 事件追加命令。 -/// -/// 为什么放在 `session-runtime`:mailbox 事件最终都是单 session event log 的追加动作, -/// 由真相层统一决定如何落成 `StorageEventPayload`,可以避免写侧在多处散落拼装。 -#[derive(Debug, Clone)] -pub enum MailboxEventAppend { - Queued(MailboxQueuedPayload), - BatchStarted(MailboxBatchStartedPayload), - BatchAcked(MailboxBatchAckedPayload), - Discarded(MailboxDiscardedPayload), -} - -impl MailboxEventAppend { - pub(crate) fn into_storage_payload(self) -> StorageEventPayload { - match self { - Self::Queued(payload) => StorageEventPayload::AgentMailboxQueued { payload }, - Self::BatchStarted(payload) => { - StorageEventPayload::AgentMailboxBatchStarted { payload } - }, - Self::BatchAcked(payload) => StorageEventPayload::AgentMailboxBatchAcked { payload }, - Self::Discarded(payload) => StorageEventPayload::AgentMailboxDiscarded { payload }, - } - } -} - -pub(crate) fn mailbox_projection_target_agent_id(payload: &StorageEventPayload) -> Option<&str> { - match payload { - StorageEventPayload::AgentMailboxQueued { payload } => Some(&payload.envelope.to_agent_id), - StorageEventPayload::AgentMailboxBatchStarted { payload } => Some(&payload.target_agent_id), - StorageEventPayload::AgentMailboxBatchAcked { payload } => Some(&payload.target_agent_id), - StorageEventPayload::AgentMailboxDiscarded { payload } => Some(&payload.target_agent_id), - _ => None, - } -} - -impl SessionState { - /// 读取指定 agent 的 mailbox durable 投影。 - pub fn mailbox_projection_for_agent(&self, agent_id: &str) -> Result { - Ok( - support::lock_anyhow(&self.mailbox_projection_index, "mailbox projection index")? - .get(agent_id) - .cloned() - .unwrap_or_default(), - ) - } - - /// 增量应用一条 mailbox durable 事件到投影索引。 - pub(crate) fn apply_mailbox_event(&self, stored: &StoredEvent) { - let Some(target_agent_id) = mailbox_projection_target_agent_id(&stored.event.payload) - else { - return; - }; - let mut index = match support::lock_anyhow( - &self.mailbox_projection_index, - "mailbox projection index", - ) { - Ok(index) => index, - Err(_) => return, - }; - let projection = index - .entry(target_agent_id.to_string()) - .or_insert_with(MailboxProjection::default); - MailboxProjection::apply_event_for_agent(projection, stored, target_agent_id); - } -} - -/// 追加一条 mailbox durable 事件。 -pub async fn append_mailbox_event( - session: &SessionState, - turn_id: &str, - agent: astrcode_core::AgentEventContext, - event: MailboxEventAppend, - translator: &mut EventTranslator, -) -> Result { - append_and_broadcast( - session, - &StorageEvent { - turn_id: Some(turn_id.to_string()), - agent, - payload: event.into_storage_payload(), - }, - translator, - ) - .await -} - -#[cfg(test)] -mod tests { - use astrcode_core::{ - AgentLifecycleStatus, AgentMailboxEnvelope, MailboxBatchAckedPayload, - MailboxBatchStartedPayload, MailboxDiscardedPayload, MailboxQueuedPayload, - StorageEventPayload, - }; - - use super::*; - - #[test] - fn mailbox_event_append_maps_to_expected_storage_payload() { - let envelope = AgentMailboxEnvelope { - delivery_id: "delivery-1".to_string().into(), - from_agent_id: "agent-parent".to_string(), - to_agent_id: "agent-child".to_string(), - message: "hello".to_string(), - queued_at: chrono::Utc::now(), - sender_lifecycle_status: AgentLifecycleStatus::Idle, - sender_last_turn_outcome: None, - sender_open_session_id: "session-parent".to_string(), - }; - - assert!(matches!( - MailboxEventAppend::Queued(MailboxQueuedPayload { - envelope: envelope.clone(), - }) - .into_storage_payload(), - StorageEventPayload::AgentMailboxQueued { payload } - if payload.envelope.delivery_id == "delivery-1".into() - )); - assert!(matches!( - MailboxEventAppend::BatchStarted(MailboxBatchStartedPayload { - target_agent_id: "agent-child".to_string(), - turn_id: "turn-1".to_string(), - batch_id: "batch-1".to_string(), - delivery_ids: vec!["delivery-1".to_string().into()], - }) - .into_storage_payload(), - StorageEventPayload::AgentMailboxBatchStarted { payload } - if payload.batch_id == "batch-1" - )); - assert!(matches!( - MailboxEventAppend::BatchAcked(MailboxBatchAckedPayload { - target_agent_id: "agent-child".to_string(), - turn_id: "turn-1".to_string(), - batch_id: "batch-1".to_string(), - delivery_ids: vec!["delivery-1".to_string().into()], - }) - .into_storage_payload(), - StorageEventPayload::AgentMailboxBatchAcked { payload } - if payload.delivery_ids == vec!["delivery-1".to_string().into()] - )); - assert!(matches!( - MailboxEventAppend::Discarded(MailboxDiscardedPayload { - target_agent_id: "agent-child".to_string(), - delivery_ids: vec!["delivery-1".to_string().into()], - }) - .into_storage_payload(), - StorageEventPayload::AgentMailboxDiscarded { payload } - if payload.target_agent_id == "agent-child" - )); - } -} diff --git a/crates/session-runtime/src/state/mod.rs b/crates/session-runtime/src/state/mod.rs index de094b66..4be772b8 100644 --- a/crates/session-runtime/src/state/mod.rs +++ b/crates/session-runtime/src/state/mod.rs @@ -1,4 +1,4 @@ -//! 会话真相状态:事件投影、child-session 节点跟踪、mailbox 投影、turn 生命周期。 +//! 会话真相状态:事件投影、child-session 节点跟踪、input queue 投影、turn 生命周期。 //! //! 从 `runtime-session/session_state.rs` 迁入,去掉了 `anyhow` 依赖, //! 所有 `Result` 统一使用 `astrcode_core::Result`。 @@ -8,7 +8,7 @@ mod child_sessions; #[cfg(test)] mod compaction; mod execution; -mod mailbox; +mod input_queue; mod paths; #[cfg(test)] mod test_support; @@ -21,15 +21,15 @@ use std::{ use astrcode_core::{ AgentEvent, AgentState, AgentStateProjector, CancelToken, ChildSessionNode, EventTranslator, - LlmMessage, MailboxProjection, Phase, ResolvedRuntimeConfig, Result, SessionEventRecord, - SessionTurnLease, StorageEventPayload, StoredEvent, UserMessageOrigin, + InputQueueProjection, Phase, ResolvedRuntimeConfig, Result, SessionEventRecord, + SessionTurnLease, StoredEvent, support::{self}, }; use cache::{RecentSessionEvents, RecentStoredEvents}; use child_sessions::{child_node_from_stored_event, rebuild_child_nodes}; pub(crate) use execution::SessionStateEventSink; pub use execution::{append_and_broadcast, complete_session_execution, prepare_session_execution}; -pub(crate) use mailbox::{MailboxEventAppend, append_mailbox_event}; +pub(crate) use input_queue::{InputQueueEventAppend, append_input_queue_event}; pub use paths::{display_name_from_working_dir, normalize_session_id, normalize_working_dir}; use tokio::sync::broadcast; pub(crate) use writer::SessionWriter; @@ -41,7 +41,7 @@ const SESSION_LIVE_BROADCAST_CAPACITY: usize = 2048; // ── SessionState ────────────────────────────────────────── -/// 会话 live 真相:事件投影、child-session 节点跟踪、mailbox 投影、turn 生命周期。 +/// 会话 live 真相:事件投影、child-session 节点跟踪、input queue 投影、turn 生命周期。 /// /// 使用 per-field `StdMutex` 而非外层 `RwLock`, /// 允许不同字段的并发读写互不阻塞(如 broadcaster 广播不阻塞 projector 读取)。 @@ -54,7 +54,6 @@ pub struct SessionState { pub turn_lease: StdMutex>>, pub pending_manual_compact: StdMutex, pub pending_manual_compact_request: StdMutex>, - pending_reactivation_messages: StdMutex>, pub compact_failure_count: StdMutex, pub broadcaster: broadcast::Sender, live_broadcaster: broadcast::Sender, @@ -63,7 +62,7 @@ pub struct SessionState { recent_records: StdMutex, recent_stored: StdMutex, child_nodes: StdMutex>, - mailbox_projection_index: StdMutex>, + input_queue_projection_index: StdMutex>, } impl std::fmt::Debug for SessionState { @@ -105,9 +104,7 @@ impl SessionState { let mut cached_stored = RecentStoredEvents::default(); cached_stored.replace(recent_stored.clone()); let child_nodes = rebuild_child_nodes(&recent_stored); - let mailbox_projection_index = MailboxProjection::replay_index(&recent_stored); - let pending_reactivation_messages = - pending_reactivation_messages_from_stored(&recent_stored); + let input_queue_projection_index = InputQueueProjection::replay_index(&recent_stored); Self { phase: StdMutex::new(phase), running: AtomicBool::new(false), @@ -117,7 +114,6 @@ impl SessionState { turn_lease: StdMutex::new(None), pending_manual_compact: StdMutex::new(false), pending_manual_compact_request: StdMutex::new(None), - pending_reactivation_messages: StdMutex::new(pending_reactivation_messages), compact_failure_count: StdMutex::new(0), broadcaster, live_broadcaster, @@ -126,7 +122,7 @@ impl SessionState { recent_records: StdMutex::new(cached_records), recent_stored: StdMutex::new(cached_stored), child_nodes: StdMutex::new(child_nodes), - mailbox_projection_index: StdMutex::new(mailbox_projection_index), + input_queue_projection_index: StdMutex::new(input_queue_projection_index), } } @@ -220,22 +216,6 @@ impl SessionState { Ok(pending) } - pub fn take_pending_reactivation_messages(&self) -> Result> { - let mut guard = support::lock_anyhow( - &self.pending_reactivation_messages, - "session pending reactivation messages", - )?; - Ok(std::mem::take(&mut *guard)) - } - - pub fn pending_reactivation_messages(&self) -> Result> { - Ok(support::lock_anyhow( - &self.pending_reactivation_messages, - "session pending reactivation messages", - )? - .clone()) - } - pub fn translate_store_and_cache( &self, stored: &StoredEvent, @@ -253,35 +233,10 @@ impl SessionState { if let Some(node) = child_node_from_stored_event(stored) { self.upsert_child_session_node(node)?; } - self.apply_mailbox_event(stored); - self.apply_reactivation_event(stored)?; + self.apply_input_queue_event(stored); Ok(records) } - fn apply_reactivation_event(&self, stored: &StoredEvent) -> Result<()> { - let mut guard = support::lock_anyhow( - &self.pending_reactivation_messages, - "session pending reactivation messages", - )?; - match &stored.event.payload { - StorageEventPayload::CompactApplied { .. } => guard.clear(), - StorageEventPayload::UserMessage { - content, - origin: UserMessageOrigin::ReactivationPrompt, - .. - } => guard.push(LlmMessage::User { - content: content.clone(), - origin: UserMessageOrigin::ReactivationPrompt, - }), - StorageEventPayload::UserMessage { - origin: UserMessageOrigin::User, - .. - } => guard.clear(), - _ => {}, - } - Ok(()) - } - pub fn recent_records_after( &self, last_event_id: Option<&str>, @@ -297,44 +252,17 @@ impl SessionState { } } -fn pending_reactivation_messages_from_stored(stored_events: &[StoredEvent]) -> Vec { - let mut pending = Vec::new(); - for stored in stored_events { - match &stored.event.payload { - StorageEventPayload::CompactApplied { .. } => pending.clear(), - StorageEventPayload::UserMessage { - content, - origin: UserMessageOrigin::ReactivationPrompt, - .. - } => pending.push(LlmMessage::User { - content: content.clone(), - origin: UserMessageOrigin::ReactivationPrompt, - }), - StorageEventPayload::UserMessage { - origin: UserMessageOrigin::User, - .. - } => pending.clear(), - _ => {}, - } - } - pending -} - // ── 辅助函数 ────────────────────────────────────────────── #[cfg(test)] mod tests { use astrcode_core::{ - AgentEventContext, CompactAppliedMeta, CompactMode, CompactTrigger, InvocationKind, - LlmMessage, Phase, StorageEventPayload, SubRunStorageMode, UserMessageOrigin, + AgentEventContext, InvocationKind, Phase, StorageEventPayload, SubRunStorageMode, + UserMessageOrigin, }; - use chrono::Utc; - use super::{ - pending_reactivation_messages_from_stored, - test_support::{ - event, independent_session_sub_run_agent, root_agent, stored, test_session_state, - }, + use super::test_support::{ + event, independent_session_sub_run_agent, root_agent, stored, test_session_state, }; #[test] @@ -485,140 +413,4 @@ mod tests { assert!(error.to_string().contains("child_session_id")); } - - #[test] - fn pending_reactivation_messages_restore_until_next_real_user_turn() { - let stored_events = vec![ - stored( - 1, - event( - None, - root_agent(), - StorageEventPayload::CompactApplied { - trigger: CompactTrigger::Manual, - summary: "summary".into(), - meta: CompactAppliedMeta { - mode: CompactMode::Full, - instructions_present: false, - fallback_used: false, - retry_count: 0, - input_units: 2, - output_summary_chars: 7, - }, - preserved_recent_turns: 1, - pre_tokens: 100, - post_tokens_estimate: 40, - messages_removed: 2, - tokens_freed: 60, - timestamp: Utc::now(), - }, - ), - ), - stored( - 2, - event( - None, - root_agent(), - StorageEventPayload::UserMessage { - content: "Recovered file context".into(), - origin: UserMessageOrigin::ReactivationPrompt, - timestamp: Utc::now(), - }, - ), - ), - ]; - - assert_eq!( - pending_reactivation_messages_from_stored(&stored_events), - vec![LlmMessage::User { - content: "Recovered file context".into(), - origin: UserMessageOrigin::ReactivationPrompt, - }] - ); - - let mut consumed_events = stored_events.clone(); - consumed_events.push(stored( - 3, - event( - Some("turn-2"), - root_agent(), - StorageEventPayload::UserMessage { - content: "next prompt".into(), - origin: UserMessageOrigin::User, - timestamp: Utc::now(), - }, - ), - )); - assert!( - pending_reactivation_messages_from_stored(&consumed_events).is_empty(), - "once a real user turn starts, prior post-compact recovery prompts should be marked \ - consumed" - ); - } - - #[test] - fn translate_store_and_cache_tracks_pending_reactivation_messages() { - let session = test_session_state(); - let mut translator = astrcode_core::EventTranslator::new(Phase::Idle); - let compact = stored( - 1, - event( - None, - root_agent(), - StorageEventPayload::CompactApplied { - trigger: CompactTrigger::Manual, - summary: "summary".into(), - meta: CompactAppliedMeta { - mode: CompactMode::Full, - instructions_present: false, - fallback_used: false, - retry_count: 0, - input_units: 2, - output_summary_chars: 7, - }, - preserved_recent_turns: 1, - pre_tokens: 100, - post_tokens_estimate: 40, - messages_removed: 2, - tokens_freed: 60, - timestamp: Utc::now(), - }, - ), - ); - let recovery = stored( - 2, - event( - None, - root_agent(), - StorageEventPayload::UserMessage { - content: "Recovered file context".into(), - origin: UserMessageOrigin::ReactivationPrompt, - timestamp: Utc::now(), - }, - ), - ); - - session - .translate_store_and_cache(&compact, &mut translator) - .expect("compact event should apply"); - session - .translate_store_and_cache(&recovery, &mut translator) - .expect("reactivation event should apply"); - - assert_eq!( - session - .take_pending_reactivation_messages() - .expect("pending reactivation messages should be readable"), - vec![LlmMessage::User { - content: "Recovered file context".into(), - origin: UserMessageOrigin::ReactivationPrompt, - }] - ); - assert!( - session - .take_pending_reactivation_messages() - .expect("pending reactivation messages should be drained") - .is_empty() - ); - } } diff --git a/crates/session-runtime/src/turn/submit.rs b/crates/session-runtime/src/turn/submit.rs index 59600ad4..e6a6d35f 100644 --- a/crates/session-runtime/src/turn/submit.rs +++ b/crates/session-runtime/src/turn/submit.rs @@ -288,7 +288,8 @@ impl SessionRuntime { self.submit_prompt_inner( session_id, None, - text, + Some(text), + Vec::new(), runtime, SubmitBusyPolicy::BranchOnBusy, submission, @@ -314,7 +315,8 @@ impl SessionRuntime { self.submit_prompt_inner( session_id, None, - text, + Some(text), + Vec::new(), runtime, SubmitBusyPolicy::RejectOnBusy, submission, @@ -333,7 +335,8 @@ impl SessionRuntime { self.submit_prompt_inner( session_id, Some(turn_id), - text, + Some(text), + Vec::new(), runtime, SubmitBusyPolicy::RejectOnBusy, submission, @@ -351,7 +354,8 @@ impl SessionRuntime { self.submit_prompt_inner( session_id, None, - text, + Some(text), + Vec::new(), runtime, SubmitBusyPolicy::BranchOnBusy, submission, @@ -364,19 +368,47 @@ impl SessionRuntime { }) } + pub async fn submit_queued_inputs_for_agent_with_turn_id( + &self, + session_id: &str, + turn_id: TurnId, + queued_inputs: Vec, + runtime: ResolvedRuntimeConfig, + submission: AgentPromptSubmission, + ) -> Result> { + self.submit_prompt_inner( + session_id, + Some(turn_id), + None, + queued_inputs, + runtime, + SubmitBusyPolicy::RejectOnBusy, + submission, + ) + .await + } + async fn submit_prompt_inner( &self, session_id: &str, turn_id: Option, - text: String, + live_user_input: Option, + queued_inputs: Vec, runtime: ResolvedRuntimeConfig, busy_policy: SubmitBusyPolicy, submission: AgentPromptSubmission, ) -> Result> { - let text = text.trim().to_string(); - if text.is_empty() { + let live_user_input = live_user_input + .map(|text| text.trim().to_string()) + .filter(|text| !text.is_empty()); + let queued_inputs = queued_inputs + .into_iter() + .map(|content| content.trim().to_string()) + .filter(|content| !content.is_empty()) + .collect::>(); + if live_user_input.is_none() && queued_inputs.is_empty() { return Err(astrcode_core::AstrError::Validation( - "prompt must not be empty".to_string(), + "turn submission must include live user input or queued inputs".to_string(), )); } @@ -404,10 +436,6 @@ impl SessionRuntime { return Ok(None); }; - let pending_reactivation_messages = submit_target - .actor - .state() - .pending_reactivation_messages()?; let AgentPromptSubmission { agent, capability_router, @@ -418,13 +446,6 @@ impl SessionRuntime { source_tool_call_id, } = submission; - let user_message = user_message_event( - turn_id.as_str(), - &agent, - text, - UserMessageOrigin::User, - Utc::now(), - ); prepare_session_execution( submit_target.actor.state(), submit_target.session_id.as_str(), @@ -441,7 +462,28 @@ impl SessionRuntime { Phase::Thinking; let mut translator = EventTranslator::new(submit_target.actor.state().current_phase()?); - append_and_broadcast(submit_target.actor.state(), &user_message, &mut translator).await?; + for content in &queued_inputs { + let queued_event = user_message_event( + turn_id.as_str(), + &agent, + content.clone(), + UserMessageOrigin::QueuedInput, + Utc::now(), + ); + append_and_broadcast(submit_target.actor.state(), &queued_event, &mut translator) + .await?; + } + if let Some(text) = &live_user_input { + let user_message = user_message_event( + turn_id.as_str(), + &agent, + text.clone(), + UserMessageOrigin::User, + Utc::now(), + ); + append_and_broadcast(submit_target.actor.state(), &user_message, &mut translator) + .await?; + } if let Some(event) = subrun_started_event( turn_id.as_str(), &agent, @@ -453,13 +495,13 @@ impl SessionRuntime { } let mut messages = current_turn_messages(submit_target.actor.state())?; if !injected_messages.is_empty() { - let insert_at = messages.len().saturating_sub(1); + let insert_at = if live_user_input.is_some() { + messages.len().saturating_sub(1) + } else { + messages.len() + }; messages.splice(insert_at..insert_at, injected_messages); } - if !pending_reactivation_messages.is_empty() { - let insert_at = messages.len().saturating_sub(1); - messages.splice(insert_at..insert_at, pending_reactivation_messages); - } tokio::spawn(execute_turn_and_finalize(TurnExecutionTask { kernel: Arc::clone(&self.kernel), @@ -632,8 +674,8 @@ mod tests { TurnLoopTransition, TurnStopCause, test_support::{ BranchingTestEventStore, NoopMetrics, append_root_turn_event_to_actor, - assert_contains_compact_summary, assert_contains_error_message, - root_compact_applied_event, test_actor, test_runtime, + assert_contains_compact_summary, assert_contains_error_message, test_actor, + test_runtime, }, }, }; @@ -930,7 +972,8 @@ mod tests { .submit_prompt_inner( &session.session_id, None, - "hello".to_string(), + Some("hello".to_string()), + Vec::new(), ResolvedRuntimeConfig::default(), SubmitBusyPolicy::RejectOnBusy, AgentPromptSubmission::default(), @@ -959,7 +1002,8 @@ mod tests { .submit_prompt_inner( &session.session_id, None, - "hello".to_string(), + Some("hello".to_string()), + Vec::new(), ResolvedRuntimeConfig { max_concurrent_branch_depth: 2, ..ResolvedRuntimeConfig::default() @@ -1010,7 +1054,7 @@ mod tests { } #[tokio::test] - async fn submit_prompt_inner_injects_pending_reactivation_only_once() { + async fn submit_prompt_inner_appends_queued_inputs_before_live_user_prompt() { let requests = Arc::new(Mutex::new(Vec::>::new())); let kernel = Arc::new( Kernel::builder() @@ -1034,52 +1078,16 @@ mod tests { .create_session(".") .await .expect("test session should be created"); - let session_state = runtime - .get_session_state(&SessionId::from(session.session_id.clone())) - .await - .expect("session state should load"); - let mut translator = EventTranslator::new(session_state.current_phase().expect("phase")); - - append_and_broadcast( - &session_state, - &crate::turn::test_support::root_user_message_event("turn-0", "older question"), - &mut translator, - ) - .await - .expect("older user event should append"); - append_and_broadcast( - &session_state, - &crate::turn::test_support::root_assistant_final_event("turn-0", "older answer"), - &mut translator, - ) - .await - .expect("older assistant event should append"); - append_and_broadcast( - &session_state, - &root_compact_applied_event("turn-compact", "history summary", 1, 100, 40, 2, 60), - &mut translator, - ) - .await - .expect("compact event should append"); - append_and_broadcast( - &session_state, - &crate::turn::events::user_message_event( - "turn-compact", - &AgentEventContext::default(), - "Recovered file context".to_string(), - UserMessageOrigin::ReactivationPrompt, - Utc::now(), - ), - &mut translator, - ) - .await - .expect("reactivation event should append"); let accepted = runtime .submit_prompt_inner( &session.session_id, None, - "first after compact".to_string(), + Some("live user input".to_string()), + vec![ + "queued child result".to_string(), + "queued reactivation context".to_string(), + ], ResolvedRuntimeConfig::default(), SubmitBusyPolicy::RejectOnBusy, AgentPromptSubmission::default(), @@ -1093,46 +1101,30 @@ mod tests { accepted.turn_id.as_str(), ) .await - .expect("first turn should finish"); - - let second = runtime - .submit_prompt_inner( - &session.session_id, - None, - "second turn".to_string(), - ResolvedRuntimeConfig::default(), - SubmitBusyPolicy::RejectOnBusy, - AgentPromptSubmission::default(), - ) - .await - .expect("second submit should not error") - .expect("second submit should be accepted"); - runtime - .wait_for_turn_terminal_snapshot(second.session_id.as_str(), second.turn_id.as_str()) - .await - .expect("second turn should finish"); + .expect("turn should finish"); let requests = requests.lock().expect("recorded requests lock should work"); - assert_eq!(requests.len(), 2, "expected two model requests"); + assert_eq!(requests.len(), 1, "expected one model request"); assert!(matches!( requests[0].as_slice(), [ - LlmMessage::User { origin: UserMessageOrigin::CompactSummary, .. }, - LlmMessage::User { origin: UserMessageOrigin::ReactivationPrompt, content }, - LlmMessage::User { origin: UserMessageOrigin::User, content: user_content }, - ] if content == "Recovered file context" && user_content == "first after compact" - )); - assert!( - requests[1].iter().all(|message| !matches!( - message, LlmMessage::User { - origin: UserMessageOrigin::ReactivationPrompt, - .. + content: first_queued, + origin: UserMessageOrigin::QueuedInput, + }, + LlmMessage::User { + content: second_queued, + origin: UserMessageOrigin::QueuedInput, + }, + LlmMessage::User { + content: user_content, + origin: UserMessageOrigin::User, } - )), - "reactivation prompt should only be injected into the first post-compact turn" - ); + ] if first_queued == "queued child result" + && second_queued == "queued reactivation context" + && user_content == "live user input" + )); } #[tokio::test] @@ -1165,7 +1157,8 @@ mod tests { .submit_prompt_inner( &session.session_id, None, - "child task".to_string(), + Some("child task".to_string()), + Vec::new(), ResolvedRuntimeConfig::default(), SubmitBusyPolicy::RejectOnBusy, AgentPromptSubmission { diff --git a/docs/architecture-diagram.md b/docs/architecture-diagram.md index 5a62297f..92de5394 100644 --- a/docs/architecture-diagram.md +++ b/docs/architecture-diagram.md @@ -30,7 +30,7 @@ graph TB ACTOR["SessionActor
    live truth · 推进"] TURN["Turn 状态机
    LLM → Tool → Compact"] CTX["Context Window
    预算分配 · 裁剪 · 压缩"] - MAIL["Mailbox / Delivery
    子 Agent 消息契约"] + MAIL["Input Queue / Delivery
    子 Agent 消息契约"] QUERY["Query / Command
    读写分离"] end diff --git a/openspec/specs/agent-delivery-contracts/spec.md b/openspec/specs/agent-delivery-contracts/spec.md index 97f6cc33..12798c7e 100644 --- a/openspec/specs/agent-delivery-contracts/spec.md +++ b/openspec/specs/agent-delivery-contracts/spec.md @@ -12,7 +12,7 @@ - **WHEN** 上层向某个 child agent 或 subrun 路由消息 - **THEN** 系统 SHALL 通过稳定控制接口完成投递 -- **AND** 调用方 SHALL 不需要了解内部 mailbox 实现 +- **AND** 调用方 SHALL 不需要了解内部 input queue 实现 #### Scenario: Child delivers typed message to direct parent through unified send @@ -113,7 +113,7 @@ kernel 与 application SHALL 为 parent delivery batch 定义稳定生命周期 - **WHEN** child 尝试上行发送 - **AND** direct parent 缺失、已关闭、不可达,或调用方并非合法 child -- **THEN** 系统 MUST 在写 durable notification / mailbox queue 之前拒绝该调用 +- **THEN** 系统 MUST 在写 durable notification / input queue 之前拒绝该调用 - **AND** MUST 写结构化 log 与 collaboration fact #### Scenario: explicit upward delivery suppresses duplicate fallback diff --git a/openspec/specs/application-use-cases/spec.md b/openspec/specs/application-use-cases/spec.md index 387a3d52..bee2f0e3 100644 --- a/openspec/specs/application-use-cases/spec.md +++ b/openspec/specs/application-use-cases/spec.md @@ -43,7 +43,7 @@ `application` MUST NOT 继续承载以下单 session 真相细节: - 单 session 终态投影与轮询判定 -- durable mailbox append 细节 +- durable input queue append 细节 - child/open session observe 快照拼装 - recoverable delivery 重放与投影细节 - conversation/tool display 的底层 transcript/replay 聚合细节 @@ -71,7 +71,7 @@ #### Scenario: application 只通过 session-runtime 稳定接口读取单 session 细节 -- **WHEN** `application` 需要判断 turn 终态、读取 observe 视图或追加 mailbox durable 事件 +- **WHEN** `application` 需要判断 turn 终态、读取 observe 视图或追加 input queue durable 事件 - **THEN** 统一通过 `SessionRuntime` 暴露的稳定 query/command 入口完成 - **AND** 不直接操作 `SessionState`、event replay 细节或投影组装过程 diff --git a/openspec/specs/session-runtime/spec.md b/openspec/specs/session-runtime/spec.md index ce2491d6..6afa4033 100644 --- a/openspec/specs/session-runtime/spec.md +++ b/openspec/specs/session-runtime/spec.md @@ -9,7 +9,7 @@ - session query / replay / history view - turn loop - interrupt / replay / branch -- observe / mailbox / routing +- observe / input queue / routing - durable event append - session catalog 广播 - token budget 驱动的单 turn 自动续写 @@ -158,5 +158,5 @@ SessionActor SHALL NOT 直接持有 `LlmProvider`、`PromptProvider`、`ToolProv #### Scenario: query 按读取语义拆分子模块 - **WHEN** 检查 `query` 子域 -- **THEN** 其实现至少按 `history`、`agent`、`mailbox`、`turn` 四类读取场景拆分 +- **THEN** 其实现至少按 `history`、`agent`、`input_queue`、`turn` 四类读取场景拆分 - **AND** crate 根只保留统一入口与类型导出 diff --git a/openspec/specs/subagent-execution/spec.md b/openspec/specs/subagent-execution/spec.md index abaf9f32..ef3b0023 100644 --- a/openspec/specs/subagent-execution/spec.md +++ b/openspec/specs/subagent-execution/spec.md @@ -69,7 +69,7 @@ - **WHEN** 直接父代理向仍在运行中的 child 调用 `send` - **AND** 输入匹配 `agentId + message + context` 分支 -- **THEN** 系统 SHALL 将该消息进入 child mailbox 排队 +- **THEN** 系统 SHALL 将该消息进入 child input queue 排队 - **AND** MUST 明确区分"已入队但尚未处理"和"已被 child 消费" #### Scenario: child sends typed delivery to direct parent @@ -186,12 +186,12 @@ runtime MUST 通过显式配置控制子代理最大嵌套深度;当用户未 ### Requirement: wake turn MUST NOT auto-manufacture a new upward terminal delivery -parent-delivery wake turn SHALL 被视为消费 mailbox 的协调 turn,而不是新的 child work turn。 +parent-delivery wake turn SHALL 被视为消费 input queue 的协调 turn,而不是新的 child work turn。 #### Scenario: wake turn reaches terminal - **WHEN** child agent 因 parent-delivery wake 而开始新一轮 turn 并结束 -- **THEN** 系统 MUST 只完成当前 mailbox batch 的 `acked / consume / requeue` +- **THEN** 系统 MUST 只完成当前 input queue batch 的 `acked / consume / requeue` - **AND** MUST NOT 因为这个 wake turn 自动向更上一级写入新的 terminal delivery ### Requirement: terminal business failures MUST still be delivered upward From c2c6fed6dafcab91d75df4aa33f8ebfec18b7e3e Mon Sep 17 00:00:00 2001 From: whatevertogo Date: Sat, 18 Apr 2026 17:58:03 +0800 Subject: [PATCH 29/53] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=20Coding=20Age?= =?UTF-8?q?nt=20=E7=AB=9E=E5=93=81=E5=AF=B9=E6=AF=94=E4=B8=8E=20Astrcode?= =?UTF-8?q?=20=E4=B8=8B=E4=B8=80=E6=AD=A5=E5=BB=BA=E8=AE=AE=E6=96=87?= =?UTF-8?q?=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/architecture-diagram.md | 300 ------------------------ docs/competitor-analysis-and-roadmap.md | 178 ++++++++++++++ 2 files changed, 178 insertions(+), 300 deletions(-) delete mode 100644 docs/architecture-diagram.md create mode 100644 docs/competitor-analysis-and-roadmap.md diff --git a/docs/architecture-diagram.md b/docs/architecture-diagram.md deleted file mode 100644 index 92de5394..00000000 --- a/docs/architecture-diagram.md +++ /dev/null @@ -1,300 +0,0 @@ -# AstrCode 架构图 - -## 系统分层与依赖关系 - -```mermaid -graph TB - subgraph 客户端["🖥️ 客户端 (Conversation Surface)"] - FE["React 前端
    (Vite + Tailwind)"] - CLI["TUI 终端
    (ratatui)"] - end - - subgraph 传输层["📡 传输层"] - TAURI["Tauri 2 桌面壳"] - SVR["Server
    (Axum HTTP/SSE)"] - PROTO["Protocol
    DTO & Wire Types"] - end - - subgraph 组合根["🔧 组合根 (bootstrap)"] - BOOT["bootstrap/runtime.rs
    唯一装配入口"] - end - - subgraph 应用层["📋 应用层"] - APP["App
    用例编排 · 参数校验 · 权限策略"] - GOV["AppGovernance
    治理 · 重载 · 观测"] - OBS["RuntimeObservabilityCollector"] - end - - subgraph 会话层["🔄 会话运行时"] - SR["SessionRuntime
    单会话真相面"] - ACTOR["SessionActor
    live truth · 推进"] - TURN["Turn 状态机
    LLM → Tool → Compact"] - CTX["Context Window
    预算分配 · 裁剪 · 压缩"] - MAIL["Input Queue / Delivery
    子 Agent 消息契约"] - QUERY["Query / Command
    读写分离"] - end - - subgraph 控制面["🎛️ 全局控制面"] - KERN["Kernel"] - ROUTER["Capability Router
    统一能力路由"] - REG["Registry
    Tool / LLM / Prompt"] - TREE["Agent Tree
    全局控制合同"] - end - - subgraph 领域层["💎 领域核心"] - CORE["Core"] - ID["强类型 ID"] - EVENT["领域事件 · EventLog"] - PORT["端口契约
    LlmProvider · PromptProvider
    EventStore · ResourceProvider"] - CAP["CapabilitySpec
    唯一能力语义模型"] - CFG["稳定配置模型"] - end - - subgraph 适配器层["🔌 适配器层 (端口实现)"] - A_LLM["adapter-llm
    Anthropic · OpenAI"] - A_STORE["adapter-storage
    JSONL 事件日志"] - A_PROMPT["adapter-prompt
    Prompt 模板加载"] - A_TOOLS["adapter-tools
    内置工具定义"] - A_SKILLS["adapter-skills
    Skill 加载与物化"] - A_MCP["adapter-mcp
    MCP 协议传输"] - A_AGENTS["adapter-agents
    Agent 定义加载"] - end - - subgraph 扩展层["🧩 扩展层"] - PLUGIN["Plugin
    插件模型 · 宿主基础设施"] - SDK["SDK
    Rust 插件开发包"] - MCP_EXT["外部 MCP Server"] - end - - %% 客户端 → 传输层 - FE -->|"HTTP/SSE"| SVR - FE -.->|"Tauri IPC"| TAURI - CLI -->|"HTTP/SSE"| SVR - TAURI --> SVR - - %% 传输层 → 组合根/应用层 - SVR -->|"handler 薄委托"| APP - SVR --> PROTO - PROTO -->|"DTO ↔ 领域转换"| CORE - - %% 组合根 - BOOT -.->|"装配"| APP - BOOT -.->|"装配"| GOV - BOOT -.->|"装配"| KERN - BOOT -.->|"装配"| SR - BOOT -.->|"注入实现"| A_LLM - BOOT -.->|"注入实现"| A_STORE - BOOT -.->|"注入实现"| A_PROMPT - BOOT -.->|"注入实现"| A_TOOLS - BOOT -.->|"注入实现"| A_SKILLS - BOOT -.->|"注入实现"| A_MCP - BOOT -.->|"注入实现"| A_AGENTS - BOOT -.->|"装载"| PLUGIN - - %% 应用层 → 会话/控制面 - APP -->|"用例调用"| SR - APP -->|"治理策略"| KERN - GOV -->|"reload 编排"| APP - - %% 会话层内部 - SR --> ACTOR - SR --> TURN - SR --> CTX - SR --> MAIL - SR --> QUERY - SR -->|"经由 gateway"| KERN - - %% 控制面 → 领域 - KERN --> ROUTER - KERN --> REG - KERN --> TREE - KERN --> CORE - - %% 领域内部 - CORE --> ID - CORE --> EVENT - CORE --> PORT - CORE --> CAP - CORE --> CFG - - %% 适配器实现端口 - A_LLM -.->|"impl LlmProvider"| PORT - A_STORE -.->|"impl EventStore"| PORT - A_PROMPT -.->|"impl PromptProvider"| PORT - A_TOOLS -.->|"capability 桥接"| CAP - A_MCP -.->|"MCP → CapabilitySurface"| CAP - A_AGENTS -.->|"Agent 定义加载"| CORE - A_SKILLS -.->|"Skill 物化"| CORE - - %% 扩展 - PLUGIN --> SDK - A_MCP -->|"stdio / HTTP / SSE"| MCP_EXT - - %% 样式 - classDef client fill:#6366f1,stroke:#4f46e5,color:#fff - classDef transport fill:#8b5cf6,stroke:#7c3aed,color:#fff - classDef bootstrap fill:#f59e0b,stroke:#d97706,color:#fff - classDef app fill:#10b981,stroke:#059669,color:#fff - classDef session fill:#3b82f6,stroke:#2563eb,color:#fff - classDef kernel fill:#06b6d4,stroke:#0891b2,color:#fff - classDef core fill:#f43f5e,stroke:#e11d48,color:#fff - classDef adapter fill:#84cc16,stroke:#65a30d,color:#fff - classDef extension fill:#a855f7,stroke:#9333ea,color:#fff - - class FE,CLI client - class SVR,TAURI,PROTO transport - class BOOT bootstrap - class APP,GOV,OBS app - class SR,ACTOR,TURN,CTX,MAIL,QUERY session - class KERN,ROUTER,REG,TREE kernel - class CORE,ID,EVENT,PORT,CAP,CFG core - class A_LLM,A_STORE,A_PROMPT,A_TOOLS,A_SKILLS,A_MCP,A_AGENTS adapter - class PLUGIN,SDK,MCP_EXT extension -``` - -## 依赖规则一览 - -```mermaid -graph LR - subgraph 允许 ✅ - direction TB - PROTO2["protocol"] --> CORE2["core"] - KERN2["kernel"] --> CORE2 - SR2["session-runtime"] --> CORE2 - SR2 --> KERN2 - APP2["application"] --> CORE2 - APP2 --> KERN2 - APP2 --> SR2 - SVR2["server"] --> APP2 - SVR2 --> PROTO2 - ADAPTER2["adapter-*"] --> CORE2 - end - - subgraph 条件允许 ⚠️ - SVR2 -.->|"仅组合根装配"| ADAPTER2 - end - - subgraph 禁止 🚫 - CORE2 x--x|"反向依赖"| PROTO2 - APP2 x--x|"直接依赖"| ADAPTER2 - KERN2 x--x|"直接依赖"| ADAPTER2 - end - - classDef allowed fill:#10b981,stroke:#059669,color:#fff - classDef conditional fill:#f59e0b,stroke:#d97706,color:#fff - classDef forbidden fill:#ef4444,stroke:#dc2626,color:#fff - - class PROTO2,CORE2,KERN2,SR2,APP2,SVR2,ADAPTER2 allowed -``` - -## 数据流:一次用户请求的完整路径 - -```mermaid -sequenceDiagram - participant U as 用户 - participant FE as 前端 / TUI - participant SVR as Server (Axum) - participant APP as App (application) - participant SR as SessionRuntime - participant KERN as Kernel - participant LLM as adapter-llm - participant TOOL as adapter-tools / MCP - - U->>FE: 输入 prompt - FE->>SVR: POST /api/sessions/{id}/prompt (SSE) - SVR->>APP: App::submit_prompt() - APP->>APP: 参数校验 · 权限检查 - APP->>SR: run_turn() - SR->>SR: Context Window 预算分配 - SR->>SR: Prompt 组装 (request assembly) - SR->>KERN: Gateway → LlmProvider - KERN->>LLM: Anthropic / OpenAI 流式请求 - LLM-->>SR: SSE 流式 token - SR-->>SVR: SSE 事件流 - SVR-->>FE: SSE 事件流 - FE-->>U: 实时渲染 - - Note over SR,TOOL: AI 返回 tool_call 时 - SR->>KERN: Gateway → CapabilityRouter - KERN->>TOOL: 执行 readFile / shell / spawn... - TOOL-->>SR: 工具结果 - SR->>KERN: 继续 LLM 对话 - KERN->>LLM: 带工具结果的后续请求 - LLM-->>SR: 最终响应流 - SR-->>SVR: SSE 完成事件 - SVR-->>FE: 完成信号 -``` - -## Agent 协作模型 - -```mermaid -graph TB - ROOT["Root Agent
    主会话"] - C1["Child Agent 1
    代码审查"] - C2["Child Agent 2
    搜索分析"] - C3["Child Agent 3
    执行任务"] - - ROOT -->|"spawn()"| C1 - ROOT -->|"spawn()"| C2 - C1 -->|"spawn()"| C3 - - ROOT -->|"send() 指令"| C1 - C1 -->|"send() 上报"| ROOT - ROOT -->|"observe() 状态查询"| C2 - ROOT -->|"close() 关闭"| C3 - - subgraph 协作协议 - S["spawn
    创建子 Agent"] - SN["send
    发送消息"] - OB["observe
    观察状态"] - CL["close
    关闭 Agent"] - end - - classDef agent fill:#6366f1,stroke:#4f46e5,color:#fff - classDef proto fill:#f59e0b,stroke:#d97706,color:#fff - - class ROOT,C1,C2,C3 agent - class S,SN,OB,CL proto -``` - -## 能力统一接入模型 - -```mermaid -graph LR - subgraph 能力来源 - BT["内置工具
    readFile · writeFile
    shell · grep · ..."] - MCP["MCP Server
    stdio / HTTP"] - PLG["Plugin
    JSON-RPC"] - SKL["Skills
    SKILL.md 加载"] - end - - subgraph 统一表面 - CS["CapabilitySurface
    唯一能力事实源"] - ROUTER2["CapabilityRouter
    统一路由"] - SPEC["CapabilitySpec
    唯一语义模型"] - end - - BT -->|"注册"| CS - MCP -->|"发现 · 接入"| CS - PLG -->|"装载 · 物化"| CS - SKL -->|"catalog"| CS - - CS --> ROUTER2 - CS --> SPEC - - subgraph 消费者 - TURN2["Turn 执行"] - GW["Kernel Gateway"] - end - - ROUTER2 --> TURN2 - ROUTER2 --> GW - - classDef source fill:#84cc16,stroke:#65a30d,color:#fff - classDef surface fill:#06b6d4,stroke:#0891b2,color:#fff - classDef consumer fill:#6366f1,stroke:#4f46e5,color:#fff - - class BT,MCP,PLG,SKL source - class CS,ROUTER2,SPEC surface - class TURN2,GW consumer -``` diff --git a/docs/competitor-analysis-and-roadmap.md b/docs/competitor-analysis-and-roadmap.md new file mode 100644 index 00000000..4b65326b --- /dev/null +++ b/docs/competitor-analysis-and-roadmap.md @@ -0,0 +1,178 @@ +# Coding Agent 竞品对比与 Astrcode 下一步建议 + +> 基于 Claude Code、Codex、OpenCode、KimiCLI、pi-mono 五个项目的源码分析,对比 Astrcode 现状。 + +--- + +## 1. 竞品特性矩阵 + +| 特性领域 | Claude Code | Codex | OpenCode | KimiCLI | pi-mono | Astrcode 现状 | +|---------|------------|-------|----------|---------|---------|-------------| +| **语言/运行时** | TypeScript/Node | Rust + TS | TypeScript/Bun | Python | TypeScript/Bun | Rust + Tauri | +| **工具数量** | 40+ | ~20 | ~20 | ~15 | ~15 | ~10 (内置+MCP) | +| **子代理** | Swarm 协调器模式 | Spawn/Wait/Send v1+v2 | Task 工具 | 劳动力市场系统 | 扩展实现 | AgentTree 多级树 | +| **LSP 集成** | 有 (单工具) | 无 | 一等公民,多语言预配置 | 无 | 无 | **无** | +| **沙箱/安全** | 权限模式 | 三层沙箱 + Guardian AI | 权限系统 (per-tool/agent/glob) | 无 | 无 | Policy Engine (策略模式) | +| **上下文压缩** | 4 级 (full/micro/snip/reactive) | 自动+手动 compaction | 自动 compaction | 自动 compaction (85%阈值) | 自动+手动 compaction | 基础 compaction | +| **记忆系统** | MEMORY.md + Auto-Dream + 会话记忆 | 两阶段流水线 (提取→合并) | 无 | AGENTS.md 层级 | MEMORY.md | 基础 (文件级) | +| **Hooks** | 20+ 生命周期事件 | 无 | 插件生命周期 hooks | 7+ 事件 (可阻塞/注入) | beforeToolCall/afterToolCall | **无** | +| **MCP** | 客户端 (stdio/SSE/OAuth) | 客户端 + 服务端 | 客户端 | 客户端 (stdio/HTTP/OAuth) | 明确拒绝 MCP,用 CLI 工具替代 | 客户端 (adapter-mcp) | +| **ACP 协议** | 无 | 无 | 有 (Zed/JetBrains) | 有 (Zed/JetBrains) | RPC 模式 | **无** | +| **Git Worktree** | 有 (工具级) | 无 | 有 (任务级隔离) | 无 | 无 | **无** | +| **会话分叉** | 无 | 无 | 从任意消息分叉 | Checkpoint + D-Mail 回溯 | 会话树 (JSONL 父指针) | Turn 级分支 (部分) | +| **扩展/插件** | 插件 + 市场 | MCP + Codex Apps | 插件 (生命周期 hooks) | 插件 (目录加载) | 扩展系统 + 包管理器 | 插件框架 (部分) | +| **多 LLM** | 仅 Anthropic | 仅 OpenAI | 20+ 提供商 | 多提供商 | 20+ 提供商 + 跨提供者切换 | Anthropic + OpenAI | +| **SDK/API** | 有 (SDK 模式) | codex exec (CI/CD) | REST API + SSE | Wire 协议 (多前端) | 4 种运行模式 | sdk crate (极简) | +| **计划模式** | Plan 工具 | Plan 工具 | Plan 工具 | 只读研究→计划→自动批准 | 无 | **无** | +| **语音输入** | 有 (STT) | 无 | 无 | 无 | 无 | 无 | +| **Cron 调度** | 有 (AGENT_TRIGGERS) | 无 | 无 | 后台任务 + 心跳 | 自管理调度事件 | 无 | + +--- + +## 2. Astrcode 差距分析 + +### 核心短板 (对用户体验影响最大) + +1. **上下文管理不够精细** — 只有基础 compaction,缺少 micro-compact(轻量清除旧工具结果)和多级策略。Claude Code 的 4 级压缩是长会话的关键。 +2. **无 Hooks 系统** — 用户无法在工具调用前后、会话开始/结束等节点插入自定义逻辑。这是生态扩展的基础。 +3. **SDK/API 不成熟** — sdk crate 几乎为空。无法被外部程序集成或用于 CI/CD 场景。 +4. **无 LSP 集成** — OpenCode 凭借一等公民 LSP 在代码理解上有巨大优势。Astrcode 作为 Rust 项目,接入 rust-analyzer 等是天然优势。 +5. **会话分叉不完整** — 其他项目都支持从任意点分叉/回溯会话,Astrcode 只有 turn 级分支。 + +### 差异化机会 (别人做得少,Astrcode 可以做得好的) + +1. **Guardian AI 审查** — Codex 独有,用 LLM 做二次风险评估。Astrcode 的 Policy Engine 已经有策略模式基础,可以增强为 AI 驱动的安全层。 +2. **ACP (Agent Client Protocol)** — OpenCode 和 KimiCLI 都支持 IDE 集成协议。Astrcode 作为桌面应用天然适合。 +3. **MCP 服务端模式** — Codex 可以作为 MCP 服务端让其他代理调用。Astrcode 的 adapter-mcp 基础可以扩展双向能力。 +4. **跨提供者会话切换** — pi-mono 支持在对话中途切换模型并保留上下文。这在多模型时代很有价值。 + +--- + +## 3. 下一步建议 (优先级排序) + +### P0 — 基础体验完善 (1-2 周) + +#### 3.1 多级上下文压缩策略 + +借鉴 Claude Code 的分级思路: + +- **Micro-compact**: 替换旧工具结果为占位符 `[旧工具结果已清除]`,成本极低 +- **Full compact**: LLM 总结历史对话,保留关键代码片段和错误信息 +- **Budget-aware 触发**: 基于 token 用量自动选择压缩级别 + +实现位置: `session-runtime` 的 turn 执行循环中,在 compaction 触发点分级处理。 + +``` +触发条件: token_usage > context_window - buffer +├─ 轻度超限 → micro-compact (清除旧工具结果) +├─ 中度超限 → micro + 截断早期历史 +└─ 严重超限 → full compact (LLM 总结) +``` + +#### 3.2 Hooks 系统 + +定义生命周期事件和 hook 注册机制: + +``` +事件: PreToolUse, PostToolUse, SessionStart, SessionEnd, + PreCompact, PostCompact, UserPromptSubmit, Stop + +Hook 类型: +├─ Shell hook — 执行 shell 命令,可通过 exit code 阻止 +├─ Transform hook — 修改输入/输出内容 +└─ Notification hook — 异步通知(fire-and-forget) +``` + +实现位置: `core` 定义事件 trait,`kernel` 提供 hook 注册和分发,`session-runtime` 在关键节点触发。 + +### P1 — 生态扩展 (2-4 周) + +#### 3.3 LSP 集成 + +参考 OpenCode 的设计,但利用 Rust 的优势: + +- 定义 `LspClient` port trait (在 `core`) +- 实现 rust-analyzer, typescript-language-server, gopls 等适配器 +- 暴露为工具: `lsp_diagnostics`, `lsp_hover`, `lsp_goto_definition`, `lsp_references` +- 工具级自动管理 LSP server 生命周期 + +实现位置: 新增 `adapter-lsp` crate,工具注册到 `adapter-tools`。 + +#### 3.4 SDK 成熟化 + +让 Astrcode 可嵌入: + +```rust +// 目标 API 示例 +let client = AstrcodeClient::connect("http://localhost:3000").await?; +let session = client.create_session(config).await?; +let mut stream = session.query("帮我重构这个函数").await?; +while let Some(event) = stream.next().await { + // 处理流式事件 +} +``` + +实现位置: `sdk` crate,基于 `protocol` 的 DTO 定义客户端。 + +#### 3.5 ACP (Agent Client Protocol) + +支持 IDE 集成 (Zed, JetBrains, VS Code): + +- JSON-RPC over stdio 协议实现 +- 注册为 Zed 等 IDE 的 agent 后端 +- 复用现有 SSE 事件流,增加 stdio 传输层 + +实现位置: 新增 `server` 的 ACP 端点或独立 `adapter-acp` crate。 + +### P2 — 高级特性 (4-8 周) + +#### 3.6 AI Guardian 安全层 + +在 Policy Engine 基础上增加 LLM 驱动的风险评估: + +``` +工具调用 → Policy Engine (规则匹配) + → Guardian Agent (LLM 评估高风险操作) + ├─ risk_score < 50 → 自动通过 + ├─ 50-80 → 提示用户确认 + └─ >= 80 → 自动拒绝 +``` + +#### 3.7 MCP 双向模式 + +当前只有客户端。扩展为同时支持服务端,让其他 agent 可以调用 Astrcode 的能力。 + +#### 3.8 会话完整分叉 + +支持从任意消息点创建分支会话,独立发展。基于现有 EventStore 的事件溯源能力,自然适合。 + +--- + +## 4. Astrcode 的独特优势 + +不要只看差距,Astrcode 也有自己的优势: + +| 优势 | 说明 | +|-----|------| +| **Rust 性能** | 唯一全 Rust 后端 (Codex 有 Rust 版但非主力),启动快、内存少、无 GC | +| **Tauri 桌面应用** | 唯一原生桌面 GUI,不是纯 CLI/TUI | +| **六边形架构** | 最严格的端口-适配器分离,内核最干净 | +| **事件溯源** | EventStore + Projection 模式,天然适合会话回溯和分叉 | +| **Plugin 进程隔离** | 插件独立进程 + supervisor 管理,安全性最好 | +| **Prompt Assembly** | 分层 builder + cache-aware blocks,prompt 工程最精细 | + +--- + +## 5. 推荐路线图 + +``` +Phase 1 (现在) Phase 2 (1-2 月) Phase 3 (3+ 月) +┌──────────────┐ ┌──────────────┐ ┌──────────────┐ +│ 多级压缩 │ │ LSP 集成 │ │ AI Guardian │ +│ Hooks 系统 │ │ SDK 成熟化 │ │ MCP 服务端 │ +│ 计划模式 │ │ ACP 协议 │ │ 多模型切换 │ +└──────────────┘ │ 会话分叉 │ │ 语音输入 │ + └──────────────┘ └──────────────┘ +``` + +**核心理念**: 先夯实基础体验(压缩、hooks、计划模式),再扩展生态(LSP、SDK、ACP),最后做高级特性(AI 审查、双向 MCP)。 From 864a5acfb11afc4708fafc3db51dd272d233c887 Mon Sep 17 00:00:00 2001 From: whatevertogo Date: Sat, 18 Apr 2026 23:51:49 +0800 Subject: [PATCH 30/53] =?UTF-8?q?=E2=9C=A8=20feat(session):=20=E5=AE=9E?= =?UTF-8?q?=E7=8E=B0=E4=BC=9A=E8=AF=9D=20fork=20=E5=8A=9F=E8=83=BD?= =?UTF-8?q?=E4=B8=8E=20compact=20=E6=91=98=E8=A6=81=E8=B7=AF=E7=94=B1?= =?UTF-8?q?=E6=A0=87=E8=AF=86=E7=AC=A6=E8=84=B1=E6=95=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit .gitignore - Why: 新增 worktree 目录忽略 - How: 添加 `.worktrees/` 到 gitignore crates/session-runtime/src/turn/fork.rs (新增) - Why: 核心会话 fork 逻辑——从已有会话的稳定前缀创建新会话 - How: 实现 ForkPoint(Latest/TurnEnd/StorageSeq) 三种分叉点解析,校验目标前缀是已完成的稳定前缀,复制事件到新会话 crates/session-runtime/src/turn/branch.rs - Why: 提取通用 fork 逻辑供 fork 模块复用 - How: 抽取 `fork_events_up_to()` 方法,原 `branch_session()` 改为调用它 crates/session-runtime/src/lib.rs - Why: 导出新的 fork 公共类型 - How: 重导出 ForkPoint、ForkResult crates/session-runtime/src/context_window/compaction.rs - Why: 防止 compact 摘要携带过时的路由标识符导致后续 agent 路由错误 - How: 新增 `sanitize_compact_summary()` 在摘要生成时替换 agentId/subRunId/sessionId 为占位符,注入 Compact Boundary 提示;更新 `strip_child_agent_reference_hint` 和 `normalize_compaction_tool_content` crates/session-runtime/src/context_window/templates/compact/base.md - Why: 指导 LLM 不在摘要中保留历史路由 ID - How: 添加压缩规则和指令说明 crates/session-runtime/src/turn/compaction_cycle.rs, manual_compact.rs - Why: compact 上下文需要会话状态和当前 agent ID - How: 传递 session_state 和 current_agent_id 参数 crates/application/src/ports/app_session.rs, session_use_cases.rs - Why: 应用层暴露 fork 能力 - How: 新增 fork_session 用例和端口方法 crates/protocol/src/http/session.rs, mod.rs - Why: 定义 fork 请求协议 - How: 新增 ForkSessionRequest DTO (turnId/storageSeq 互斥) crates/server/src/http/routes/ - Why: 暴露 fork HTTP 端点 - How: 新增 POST /api/sessions/{id}/fork 路由和 handler crates/adapter-tools/src/builtin_tools/shell.rs - Why: 验证 shell 大输出持久化后可被 ReadFile 读取 - How: 新增集成测试 crates/session-runtime/src/turn/test_support.rs - Why: 支持 fork/branch 测试所需的辅助事件构造 - How: 新增 root_assistant_final_event、root_turn_done_event 等 helper crates/server/src/tests/session_contract_tests.rs - Why: 验证 fork API 契约正确性 - How: 新增 5 个契约测试:默认 fork、指定 turnId、拒绝未完成 turn、互斥参数校验、404 frontend/src/lib/api/sessions.ts, sessions.test.ts - Why: 前端调用 fork API - How: 新增 forkSession() 函数及测试 frontend/src/lib/sessionFork.ts, sessionFork.test.ts - Why: 判断消息是否可作为 fork 点的纯逻辑 - How: resolveForkTurnIdFromMessage() 排除子 agent 通知和活跃 turn frontend/src/lib/api/conversation.ts, conversation.test.ts - Why: compact 消息的 trigger 需要优先从 block 自身的 compactMeta 读取 - How: 解析 trigger 时回退到 control.lastCompactMeta frontend/src/components/Chat/MessageList.tsx - Why: 用户可通过右键菜单从任意消息处 fork 会话 - How: ForkableRow 组件包装消息行,右键显示"从此处 fork"上下文菜单 frontend/src/App.tsx, hooks/useAgent.ts, ChatScreenContext.tsx, ToolCallBlock.test.tsx - Why: 贯通 fork 功能的前端调用链 - How: 传递 forkSession、onForkFromTurn 到各层 docs/ideas/notes.md - Why: 更新想法笔记 - How: 补充多 agent 共享任务列表条目 openspec/changes/session-fork/ (新增) - Why: fork 功能的 OpenSpec 设计文档 - How: 包含 proposal、design、spec、tasks openspec/changes/collaboration-mode-system/ (新增) - Why: 协作模式系统的 OpenSpec 设计文档 - How: 包含完整的 proposal、design、12 个子 spec 和 tasks --- .gitignore | 3 +- .../adapter-tools/src/builtin_tools/shell.rs | 57 ++- crates/application/src/ports/app_session.rs | 17 +- crates/application/src/session_use_cases.rs | 31 ++ crates/protocol/src/http/mod.rs | 3 +- crates/protocol/src/http/session.rs | 10 + crates/server/src/http/routes/mod.rs | 1 + crates/server/src/http/routes/sessions/mod.rs | 4 +- .../src/http/routes/sessions/mutation.rs | 37 +- .../src/tests/session_contract_tests.rs | 251 +++++++++- .../src/context_window/compaction.rs | 236 +++++++++- .../context_window/templates/compact/base.md | 3 + crates/session-runtime/src/lib.rs | 5 +- crates/session-runtime/src/turn/branch.rs | 24 +- .../src/turn/compaction_cycle.rs | 2 + crates/session-runtime/src/turn/fork.rs | 429 ++++++++++++++++++ .../src/turn/manual_compact.rs | 2 + crates/session-runtime/src/turn/mod.rs | 2 + crates/session-runtime/src/turn/request.rs | 145 +++++- .../session-runtime/src/turn/test_support.rs | 64 ++- docs/ideas/notes.md | 7 +- frontend/src/App.tsx | 19 + .../src/components/Chat/ChatScreenContext.tsx | 1 + frontend/src/components/Chat/MessageList.tsx | 76 +++- .../components/Chat/ToolCallBlock.test.tsx | 1 + frontend/src/hooks/useAgent.ts | 12 + frontend/src/lib/api/conversation.test.ts | 99 ++++ frontend/src/lib/api/conversation.ts | 22 +- frontend/src/lib/api/sessions.test.ts | 37 ++ frontend/src/lib/api/sessions.ts | 14 + frontend/src/lib/sessionFork.test.ts | 66 +++ frontend/src/lib/sessionFork.ts | 28 ++ .../collaboration-mode-system/design.md | 181 ++++++++ .../collaboration-mode-system/proposal.md | 187 ++++++++ .../specs/artifact-prompt-injection/spec.md | 29 ++ .../specs/artifact-renderer/spec.md | 28 ++ .../specs/artifact-store/spec.md | 33 ++ .../specs/artifact-types/spec.md | 54 +++ .../specs/builtin-modes/spec.md | 37 ++ .../specs/mode-catalog/spec.md | 17 + .../specs/mode-compile/spec.md | 60 +++ .../specs/mode-prompt/spec.md | 35 ++ .../specs/mode-spec/spec.md | 74 +++ .../specs/mode-switch-command/spec.md | 31 ++ .../specs/mode-switch-tool/spec.md | 33 ++ .../specs/mode-transition/spec.md | 55 +++ .../specs/mode-truth/spec.md | 34 ++ .../collaboration-mode-system/tasks.md | 98 ++++ openspec/changes/session-fork/.openspec.yaml | 2 + openspec/changes/session-fork/design.md | 125 +++++ openspec/changes/session-fork/proposal.md | 33 ++ .../session-fork/specs/session-fork/spec.md | 164 +++++++ openspec/changes/session-fork/tasks.md | 36 ++ 53 files changed, 3002 insertions(+), 52 deletions(-) create mode 100644 crates/session-runtime/src/turn/fork.rs create mode 100644 frontend/src/lib/api/sessions.test.ts create mode 100644 frontend/src/lib/sessionFork.test.ts create mode 100644 frontend/src/lib/sessionFork.ts create mode 100644 openspec/changes/collaboration-mode-system/design.md create mode 100644 openspec/changes/collaboration-mode-system/proposal.md create mode 100644 openspec/changes/collaboration-mode-system/specs/artifact-prompt-injection/spec.md create mode 100644 openspec/changes/collaboration-mode-system/specs/artifact-renderer/spec.md create mode 100644 openspec/changes/collaboration-mode-system/specs/artifact-store/spec.md create mode 100644 openspec/changes/collaboration-mode-system/specs/artifact-types/spec.md create mode 100644 openspec/changes/collaboration-mode-system/specs/builtin-modes/spec.md create mode 100644 openspec/changes/collaboration-mode-system/specs/mode-catalog/spec.md create mode 100644 openspec/changes/collaboration-mode-system/specs/mode-compile/spec.md create mode 100644 openspec/changes/collaboration-mode-system/specs/mode-prompt/spec.md create mode 100644 openspec/changes/collaboration-mode-system/specs/mode-spec/spec.md create mode 100644 openspec/changes/collaboration-mode-system/specs/mode-switch-command/spec.md create mode 100644 openspec/changes/collaboration-mode-system/specs/mode-switch-tool/spec.md create mode 100644 openspec/changes/collaboration-mode-system/specs/mode-transition/spec.md create mode 100644 openspec/changes/collaboration-mode-system/specs/mode-truth/spec.md create mode 100644 openspec/changes/collaboration-mode-system/tasks.md create mode 100644 openspec/changes/session-fork/.openspec.yaml create mode 100644 openspec/changes/session-fork/design.md create mode 100644 openspec/changes/session-fork/proposal.md create mode 100644 openspec/changes/session-fork/specs/session-fork/spec.md create mode 100644 openspec/changes/session-fork/tasks.md diff --git a/.gitignore b/.gitignore index 69e5c193..49016462 100644 --- a/.gitignore +++ b/.gitignore @@ -56,4 +56,5 @@ coverage/ # AI .kilo/ -.claude/ \ No newline at end of file +.claude/ +.worktrees/ diff --git a/crates/adapter-tools/src/builtin_tools/shell.rs b/crates/adapter-tools/src/builtin_tools/shell.rs index 3b49ddc3..e878dba8 100644 --- a/crates/adapter-tools/src/builtin_tools/shell.rs +++ b/crates/adapter-tools/src/builtin_tools/shell.rs @@ -675,13 +675,13 @@ fn default_shell_for_prompt() -> String { #[cfg(test)] mod tests { - use std::{collections::VecDeque, io}; + use std::{collections::VecDeque, io, path::Path}; use astrcode_core::ToolOutputDelta; use tokio::sync::mpsc; use super::*; - use crate::test_support::test_tool_context_for; + use crate::{builtin_tools::read_file::ReadFileTool, test_support::test_tool_context_for}; struct ChunkedReader { chunks: VecDeque>, @@ -841,6 +841,59 @@ mod tests { assert!(result.output.contains("ok")); } + #[tokio::test] + async fn shell_persists_large_output_and_read_file_can_open_it() { + let temp = tempfile::tempdir().expect("tempdir should be created"); + let shell_ctx = test_tool_context_for(temp.path()) + .with_resolved_inline_limit(4 * 1024) + .with_max_output_size(20 * 1024); + let tool = ShellTool; + let args = if cfg!(windows) { + json!({ + "command": "[Console]::Write(('x' * 10000))", + "shell": "pwsh" + }) + } else { + json!({ + "command": "yes x | head -c 10000", + "shell": "sh" + }) + }; + + let result = tool + .execute("tc-shell-persisted".to_string(), args, &shell_ctx) + .await + .expect("shell tool should persist oversized output"); + + assert!(result.ok); + assert!(result.output.starts_with("")); + let metadata = result.metadata.as_ref().expect("metadata should exist"); + let persisted_absolute = metadata["persistedOutput"]["absolutePath"] + .as_str() + .expect("persisted absolute path should be present"); + assert!(Path::new(persisted_absolute).exists()); + + let read_tool = ReadFileTool; + let read_result = read_tool + .execute( + "tc-shell-read-persisted".to_string(), + json!({ + "path": persisted_absolute, + "charOffset": 0, + "maxChars": 2048 + }), + &test_tool_context_for(temp.path()), + ) + .await + .expect("readFile should open persisted shell output"); + + assert!(read_result.ok); + assert!(read_result.output.contains('x')); + assert!(read_result.output.len() >= 1024); + let read_metadata = read_result.metadata.expect("metadata should exist"); + assert_eq!(read_metadata["persistedRead"], json!(true)); + } + #[tokio::test] async fn shell_tool_rejects_blank_command() { let tool = ShellTool; diff --git a/crates/application/src/ports/app_session.rs b/crates/application/src/ports/app_session.rs index 39a2454b..bfb12296 100644 --- a/crates/application/src/ports/app_session.rs +++ b/crates/application/src/ports/app_session.rs @@ -3,8 +3,8 @@ use astrcode_core::{ SessionMeta, StoredEvent, }; use astrcode_session_runtime::{ - AgentPromptSubmission, ConversationSnapshotFacts, ConversationStreamReplayFacts, - SessionCatalogEvent, SessionControlStateSnapshot, SessionReplay, SessionRuntime, + AgentPromptSubmission, ConversationSnapshotFacts, ConversationStreamReplayFacts, ForkPoint, + ForkResult, SessionCatalogEvent, SessionControlStateSnapshot, SessionReplay, SessionRuntime, SessionTranscriptSnapshot, }; use async_trait::async_trait; @@ -19,6 +19,11 @@ pub trait AppSessionPort: Send + Sync { async fn list_session_metas(&self) -> astrcode_core::Result>; async fn create_session(&self, working_dir: String) -> astrcode_core::Result; + async fn fork_session( + &self, + session_id: &SessionId, + fork_point: ForkPoint, + ) -> astrcode_core::Result; async fn delete_session(&self, session_id: &str) -> astrcode_core::Result<()>; async fn delete_project(&self, working_dir: &str) -> astrcode_core::Result; @@ -83,6 +88,14 @@ impl AppSessionPort for SessionRuntime { self.create_session(working_dir).await } + async fn fork_session( + &self, + session_id: &SessionId, + fork_point: ForkPoint, + ) -> astrcode_core::Result { + self.fork_session(session_id, fork_point).await + } + async fn delete_session(&self, session_id: &str) -> astrcode_core::Result<()> { self.delete_session(session_id).await } diff --git a/crates/application/src/session_use_cases.rs b/crates/application/src/session_use_cases.rs index c0253532..3152fa68 100644 --- a/crates/application/src/session_use_cases.rs +++ b/crates/application/src/session_use_cases.rs @@ -44,6 +44,37 @@ impl App { .map_err(ApplicationError::from) } + pub async fn fork_session( + &self, + session_id: &str, + fork_point: astrcode_session_runtime::ForkPoint, + ) -> Result { + self.validate_non_empty("sessionId", session_id)?; + let normalized_session_id = astrcode_session_runtime::normalize_session_id(session_id); + let result = self + .session_runtime + .fork_session( + &astrcode_core::SessionId::from(normalized_session_id), + fork_point, + ) + .await + .map_err(ApplicationError::from)?; + let meta = self + .session_runtime + .list_session_metas() + .await + .map_err(ApplicationError::from)? + .into_iter() + .find(|meta| meta.session_id == result.new_session_id.as_str()) + .ok_or_else(|| { + ApplicationError::Internal(format!( + "forked session '{}' was created but metadata is unavailable", + result.new_session_id + )) + })?; + Ok(meta) + } + pub async fn delete_project( &self, working_dir: &str, diff --git a/crates/protocol/src/http/mod.rs b/crates/protocol/src/http/mod.rs index b1579da5..9300b54e 100644 --- a/crates/protocol/src/http/mod.rs +++ b/crates/protocol/src/http/mod.rs @@ -71,7 +71,8 @@ pub use runtime::{ }; pub use session::{ CompactSessionRequest, CompactSessionResponse, CreateSessionRequest, DeleteProjectResultDto, - PromptAcceptedResponse, PromptRequest, PromptSkillInvocation, SessionListItem, + ForkSessionRequest, PromptAcceptedResponse, PromptRequest, PromptSkillInvocation, + SessionListItem, }; pub use session_event::{SessionCatalogEventEnvelope, SessionCatalogEventPayload}; pub use terminal::v1::{ diff --git a/crates/protocol/src/http/session.rs b/crates/protocol/src/http/session.rs index b522ae38..f797de12 100644 --- a/crates/protocol/src/http/session.rs +++ b/crates/protocol/src/http/session.rs @@ -18,6 +18,16 @@ pub struct CreateSessionRequest { pub working_dir: String, } +/// `POST /api/sessions/:id/fork` 请求体——从稳定前缀分叉新会话。 +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct ForkSessionRequest { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub turn_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub storage_seq: Option, +} + /// 会话列表中的单个会话摘要。 /// /// 用于 `GET /api/sessions` 响应,返回所有会话的概览信息。 diff --git a/crates/server/src/http/routes/mod.rs b/crates/server/src/http/routes/mod.rs index c8dfaf72..a82fac57 100644 --- a/crates/server/src/http/routes/mod.rs +++ b/crates/server/src/http/routes/mod.rs @@ -98,6 +98,7 @@ pub(crate) fn build_api_router() -> Router { "/api/sessions/{id}/compact", post(sessions::compact_session), ) + .route("/api/sessions/{id}/fork", post(sessions::fork_session)) .route( "/api/sessions/{id}/interrupt", post(sessions::interrupt_session), diff --git a/crates/server/src/http/routes/sessions/mod.rs b/crates/server/src/http/routes/sessions/mod.rs index dd27382e..e61fc051 100644 --- a/crates/server/src/http/routes/sessions/mod.rs +++ b/crates/server/src/http/routes/sessions/mod.rs @@ -9,8 +9,8 @@ mod stream; use axum::http::StatusCode; pub(crate) use mutation::{ - compact_session, create_session, delete_project, delete_session, interrupt_session, - submit_prompt, + compact_session, create_session, delete_project, delete_session, fork_session, + interrupt_session, submit_prompt, }; pub(crate) use query::list_sessions; pub(crate) use stream::session_catalog_events; diff --git a/crates/server/src/http/routes/sessions/mutation.rs b/crates/server/src/http/routes/sessions/mutation.rs index b34fe158..4e12aad5 100644 --- a/crates/server/src/http/routes/sessions/mutation.rs +++ b/crates/server/src/http/routes/sessions/mutation.rs @@ -1,6 +1,6 @@ use astrcode_protocol::http::{ CompactSessionRequest, CompactSessionResponse, CreateSessionRequest, DeleteProjectResultDto, - PromptAcceptedResponse, PromptRequest, SessionListItem, + ForkSessionRequest, PromptAcceptedResponse, PromptRequest, SessionListItem, }; use axum::{ Json, @@ -113,6 +113,41 @@ pub(crate) async fn compact_session( )) } +pub(crate) async fn fork_session( + State(state): State, + headers: HeaderMap, + Path(session_id): Path, + request: Option>, +) -> Result, ApiError> { + require_auth(&state, &headers, None)?; + let session_id = validate_session_path_id(&session_id)?; + let request = request + .map(|request| request.0) + .unwrap_or(ForkSessionRequest { + turn_id: None, + storage_seq: None, + }); + if request.turn_id.is_some() && request.storage_seq.is_some() { + return Err(ApiError::bad_request( + "turnId and storageSeq are mutually exclusive".to_string(), + )); + } + let fork_point = match (request.turn_id, request.storage_seq) { + (Some(turn_id), None) => astrcode_session_runtime::ForkPoint::TurnEnd(turn_id), + (None, Some(storage_seq)) => astrcode_session_runtime::ForkPoint::StorageSeq(storage_seq), + (None, None) => astrcode_session_runtime::ForkPoint::Latest, + (Some(_), Some(_)) => unreachable!("validated above"), + }; + let meta = state + .app + .fork_session(&session_id, fork_point) + .await + .map_err(ApiError::from)?; + Ok(Json(to_session_list_item( + astrcode_application::summarize_session_meta(meta), + ))) +} + pub(crate) async fn delete_session( State(state): State, headers: HeaderMap, diff --git a/crates/server/src/tests/session_contract_tests.rs b/crates/server/src/tests/session_contract_tests.rs index 85f300ca..89ba22b8 100644 --- a/crates/server/src/tests/session_contract_tests.rs +++ b/crates/server/src/tests/session_contract_tests.rs @@ -1,7 +1,8 @@ use astrcode_core::{ - AgentEventContext, CancelToken, SpawnAgentParams, ToolContext, - agent::executor::SubAgentExecutor, + AgentEventContext, CancelToken, EventTranslator, SessionId, SpawnAgentParams, StorageEvent, + StorageEventPayload, ToolContext, UserMessageOrigin, agent::executor::SubAgentExecutor, }; +use astrcode_session_runtime::append_and_broadcast; use axum::{ body::{Body, to_bytes}, http::{Request, StatusCode}, @@ -13,6 +14,86 @@ use crate::{AUTH_HEADER_NAME, routes::build_api_router, test_support::test_state // Why: 这些契约测试是 API 接口稳定性的核心保障, // 防止 server 在重构后回退到隐式容错或启发式行为。 +async fn append_root_event(state: &crate::AppState, session_id: &str, event: StorageEvent) { + let session_state = state + ._runtime_handles + .session_runtime + .get_session_state(&SessionId::from(session_id.to_string())) + .await + .expect("session state should load"); + let mut translator = EventTranslator::new( + session_state + .current_phase() + .expect("session phase should be readable"), + ); + append_and_broadcast(&session_state, &event, &mut translator) + .await + .expect("event should persist"); +} + +async fn seed_completed_root_turn(state: &crate::AppState, session_id: &str, turn_id: &str) { + let agent = AgentEventContext::root_execution("root-agent", "test-profile"); + append_root_event( + state, + session_id, + StorageEvent { + turn_id: Some(turn_id.to_string()), + agent: agent.clone(), + payload: StorageEventPayload::UserMessage { + content: "hello".to_string(), + origin: UserMessageOrigin::User, + timestamp: chrono::Utc::now(), + }, + }, + ) + .await; + append_root_event( + state, + session_id, + StorageEvent { + turn_id: Some(turn_id.to_string()), + agent: agent.clone(), + payload: StorageEventPayload::AssistantFinal { + content: "world".to_string(), + reasoning_content: None, + reasoning_signature: None, + timestamp: Some(chrono::Utc::now()), + }, + }, + ) + .await; + append_root_event( + state, + session_id, + StorageEvent { + turn_id: Some(turn_id.to_string()), + agent, + payload: StorageEventPayload::TurnDone { + timestamp: chrono::Utc::now(), + reason: Some("completed".to_string()), + }, + }, + ) + .await; +} + +async fn seed_unfinished_root_turn(state: &crate::AppState, session_id: &str, turn_id: &str) { + append_root_event( + state, + session_id, + StorageEvent { + turn_id: Some(turn_id.to_string()), + agent: AgentEventContext::root_execution("root-agent", "test-profile"), + payload: StorageEventPayload::UserMessage { + content: "still running".to_string(), + origin: UserMessageOrigin::User, + timestamp: chrono::Utc::now(), + }, + }, + ) + .await; +} + async fn spawn_test_child_agent( state: &crate::AppState, session_id: &str, @@ -106,6 +187,172 @@ async fn submit_prompt_contract_returns_accepted_shape() { assert!(!payload["turnId"].as_str().unwrap_or_default().is_empty()); } +#[tokio::test] +async fn fork_session_contract_returns_new_session_meta() { + let (state, _guard) = test_state(None).await; + let temp_dir = tempfile::tempdir().expect("tempdir should be created"); + let created = state + .app + .create_session(temp_dir.path().display().to_string()) + .await + .expect("session should be created"); + state + .app + .submit_prompt(&created.session_id, "hello".to_string()) + .await + .expect("prompt should be accepted"); + let app = build_api_router().with_state(state); + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri(format!("/api/sessions/{}/fork", created.session_id)) + .header(AUTH_HEADER_NAME, "browser-token") + .header("content-type", "application/json") + .body(Body::from("{}")) + .expect("request should be valid"), + ) + .await + .expect("response should be returned"); + + assert_eq!(response.status(), StatusCode::OK); + let payload: serde_json::Value = serde_json::from_slice( + &to_bytes(response.into_body(), usize::MAX) + .await + .expect("body should be readable"), + ) + .expect("payload should deserialize"); + assert_eq!(payload["parentSessionId"], created.session_id); + assert_ne!(payload["sessionId"], created.session_id); +} + +#[tokio::test] +async fn fork_session_contract_accepts_completed_turn_id() { + let (state, _guard) = test_state(None).await; + let temp_dir = tempfile::tempdir().expect("tempdir should be created"); + let created = state + .app + .create_session(temp_dir.path().display().to_string()) + .await + .expect("session should be created"); + seed_completed_root_turn(&state, &created.session_id, "turn-completed").await; + let app = build_api_router().with_state(state); + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri(format!("/api/sessions/{}/fork", created.session_id)) + .header(AUTH_HEADER_NAME, "browser-token") + .header("content-type", "application/json") + .body(Body::from(r#"{"turnId":"turn-completed"}"#)) + .expect("request should be valid"), + ) + .await + .expect("response should be returned"); + + assert_eq!(response.status(), StatusCode::OK); + let payload: serde_json::Value = serde_json::from_slice( + &to_bytes(response.into_body(), usize::MAX) + .await + .expect("body should be readable"), + ) + .expect("payload should deserialize"); + assert_eq!(payload["parentSessionId"], created.session_id); + assert_eq!(payload["parentStorageSeq"], 4); + assert_ne!(payload["sessionId"], created.session_id); +} + +#[tokio::test] +async fn fork_session_contract_rejects_unfinished_turn_id() { + let (state, _guard) = test_state(None).await; + let temp_dir = tempfile::tempdir().expect("tempdir should be created"); + let created = state + .app + .create_session(temp_dir.path().display().to_string()) + .await + .expect("session should be created"); + seed_unfinished_root_turn(&state, &created.session_id, "turn-running").await; + let app = build_api_router().with_state(state); + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri(format!("/api/sessions/{}/fork", created.session_id)) + .header(AUTH_HEADER_NAME, "browser-token") + .header("content-type", "application/json") + .body(Body::from(r#"{"turnId":"turn-running"}"#)) + .expect("request should be valid"), + ) + .await + .expect("response should be returned"); + + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + let payload: serde_json::Value = serde_json::from_slice( + &to_bytes(response.into_body(), usize::MAX) + .await + .expect("body should be readable"), + ) + .expect("payload should deserialize"); + assert!( + payload["error"] + .as_str() + .unwrap_or_default() + .contains("has not completed"), + "unfinished turn should return a specific validation error" + ); +} + +#[tokio::test] +async fn fork_session_contract_rejects_mutually_exclusive_request() { + let (state, _guard) = test_state(None).await; + let temp_dir = tempfile::tempdir().expect("tempdir should be created"); + let created = state + .app + .create_session(temp_dir.path().display().to_string()) + .await + .expect("session should be created"); + let app = build_api_router().with_state(state); + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri(format!("/api/sessions/{}/fork", created.session_id)) + .header(AUTH_HEADER_NAME, "browser-token") + .header("content-type", "application/json") + .body(Body::from(r#"{"turnId":"turn-1","storageSeq":42}"#)) + .expect("request should be valid"), + ) + .await + .expect("response should be returned"); + + assert_eq!(response.status(), StatusCode::BAD_REQUEST); +} + +#[tokio::test] +async fn fork_session_contract_returns_not_found_for_missing_session() { + let (state, _guard) = test_state(None).await; + let app = build_api_router().with_state(state); + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/sessions/nonexistent/fork") + .header(AUTH_HEADER_NAME, "browser-token") + .header("content-type", "application/json") + .body(Body::from("{}")) + .expect("request should be valid"), + ) + .await + .expect("response should be returned"); + + assert_eq!(response.status(), StatusCode::NOT_FOUND); +} + // ─── SubRun 状态查询契约 ────────────────────────────────── #[tokio::test] diff --git a/crates/session-runtime/src/context_window/compaction.rs b/crates/session-runtime/src/context_window/compaction.rs index 092a0bbb..e3948869 100644 --- a/crates/session-runtime/src/context_window/compaction.rs +++ b/crates/session-runtime/src/context_window/compaction.rs @@ -167,7 +167,7 @@ pub async fn auto_compact( } }; - let summary = parsed_output.summary.clone(); + let summary = sanitize_compact_summary(&parsed_output.summary); let output_summary_chars = summary.chars().count().min(u32::MAX as usize) as u32; let compacted_messages = compacted_messages(&summary, split.suffix); let post_tokens_estimate = estimate_request_tokens(&compacted_messages, compact_prompt_context); @@ -279,7 +279,8 @@ fn latest_previous_summary(messages: &[LlmMessage]) -> Option { LlmMessage::User { content, origin: UserMessageOrigin::CompactSummary, - } => parse_compact_summary_message(content).map(|envelope| envelope.summary), + } => parse_compact_summary_message(content) + .map(|envelope| sanitize_compact_summary(&envelope.summary)), _ => None, }) } @@ -390,11 +391,192 @@ fn summarize_persisted_tool_output(content: &str) -> String { ) } +fn sanitize_compact_summary(summary: &str) -> String { + let had_route_sensitive_content = summary_has_route_sensitive_content(summary); + let mut sanitized = summary.trim().to_string(); + sanitized = direct_child_validation_regex() + .replace_all( + &sanitized, + "direct-child validation rejected a stale child reference; use the live direct-child \ + snapshot or the latest live tool result instead.", + ) + .into_owned(); + sanitized = child_agent_reference_block_regex() + .replace_all( + &sanitized, + "Child agent reference metadata existed earlier, but compacted history is not an \ + authoritative routing source.", + ) + .into_owned(); + for (regex, replacement) in [ + ( + route_key_regex("agentId"), + "${key}", + ), + ( + route_key_regex("childAgentId"), + "${key}", + ), + (route_key_regex("parentAgentId"), "${key}"), + (route_key_regex("subRunId"), "${key}"), + (route_key_regex("parentSubRunId"), "${key}"), + (route_key_regex("sessionId"), "${key}"), + ( + route_key_regex("childSessionId"), + "${key}", + ), + (route_key_regex("openSessionId"), "${key}"), + ] { + sanitized = regex.replace_all(&sanitized, replacement).into_owned(); + } + sanitized = exact_agent_instruction_regex() + .replace_all( + &sanitized, + "Use only the latest live child snapshot or tool result for agent routing.", + ) + .into_owned(); + sanitized = raw_root_agent_id_regex() + .replace_all(&sanitized, "") + .into_owned(); + sanitized = raw_agent_id_regex() + .replace_all(&sanitized, "") + .into_owned(); + sanitized = raw_subrun_id_regex() + .replace_all(&sanitized, "") + .into_owned(); + sanitized = raw_session_id_regex() + .replace_all(&sanitized, "") + .into_owned(); + sanitized = collapse_compaction_whitespace(&sanitized); + if had_route_sensitive_content { + ensure_compact_boundary_section(&sanitized) + } else { + sanitized + } +} + +fn ensure_compact_boundary_section(summary: &str) -> String { + if summary.contains("## Compact Boundary") { + return summary.to_string(); + } + format!( + "## Compact Boundary\n- Historical `agentId`, `subRunId`, and `sessionId` values from \ + compacted history are non-authoritative.\n- Use the live direct-child snapshot or the \ + latest live tool result / child notification for routing.\n\n{}", + summary.trim() + ) +} + +fn summary_has_route_sensitive_content(summary: &str) -> bool { + direct_child_validation_regex().is_match(summary) + || child_agent_reference_block_regex().is_match(summary) + || exact_agent_instruction_regex().is_match(summary) + || raw_root_agent_id_regex().is_match(summary) + || raw_agent_id_regex().is_match(summary) + || raw_subrun_id_regex().is_match(summary) + || raw_session_id_regex().is_match(summary) + || [ + route_key_regex("agentId"), + route_key_regex("childAgentId"), + route_key_regex("parentAgentId"), + route_key_regex("subRunId"), + route_key_regex("parentSubRunId"), + route_key_regex("sessionId"), + route_key_regex("childSessionId"), + route_key_regex("openSessionId"), + ] + .into_iter() + .any(|regex| regex.is_match(summary)) +} + +fn child_agent_reference_block_regex() -> &'static Regex { + static REGEX: OnceLock = OnceLock::new(); + REGEX.get_or_init(|| { + Regex::new(r"(?is)Child agent reference:\s*(?:\n- .*)+") + .expect("child agent reference regex should compile") + }) +} + +fn direct_child_validation_regex() -> &'static Regex { + static REGEX: OnceLock = OnceLock::new(); + REGEX.get_or_init(|| { + Regex::new(r"(?i)not a direct child of caller[^\n]*") + .expect("direct child validation regex should compile") + }) +} + +fn route_key_regex(key: &str) -> &'static Regex { + static AGENT_ID: OnceLock = OnceLock::new(); + static CHILD_AGENT_ID: OnceLock = OnceLock::new(); + static PARENT_AGENT_ID: OnceLock = OnceLock::new(); + static SUB_RUN_ID: OnceLock = OnceLock::new(); + static PARENT_SUB_RUN_ID: OnceLock = OnceLock::new(); + static SESSION_ID: OnceLock = OnceLock::new(); + static CHILD_SESSION_ID: OnceLock = OnceLock::new(); + static OPEN_SESSION_ID: OnceLock = OnceLock::new(); + let slot = match key { + "agentId" => &AGENT_ID, + "childAgentId" => &CHILD_AGENT_ID, + "parentAgentId" => &PARENT_AGENT_ID, + "subRunId" => &SUB_RUN_ID, + "parentSubRunId" => &PARENT_SUB_RUN_ID, + "sessionId" => &SESSION_ID, + "childSessionId" => &CHILD_SESSION_ID, + "openSessionId" => &OPEN_SESSION_ID, + other => panic!("unsupported route key regex: {other}"), + }; + slot.get_or_init(|| { + Regex::new(&format!( + r"(?i)(?P`?{key}`?\s*[:=]\s*`?)[^`\s,;\])]+`?" + )) + .expect("route key regex should compile") + }) +} + +fn exact_agent_instruction_regex() -> &'static Regex { + static REGEX: OnceLock = OnceLock::new(); + REGEX.get_or_init(|| { + Regex::new( + r"(?i)(use this exact `agentid` value[^\n]*|copy it byte-for-byte[^\n]*|keep `agentid` exact[^\n]*)", + ) + .expect("exact agent instruction regex should compile") + }) +} + +fn raw_root_agent_id_regex() -> &'static Regex { + static REGEX: OnceLock = OnceLock::new(); + REGEX.get_or_init(|| { + Regex::new(r"\broot-agent:[A-Za-z0-9._:-]+\b") + .expect("raw root agent id regex should compile") + }) +} + +fn raw_agent_id_regex() -> &'static Regex { + static REGEX: OnceLock = OnceLock::new(); + REGEX.get_or_init(|| { + Regex::new(r"\bagent-[A-Za-z0-9._:-]+\b").expect("raw agent id regex should compile") + }) +} + +fn raw_subrun_id_regex() -> &'static Regex { + static REGEX: OnceLock = OnceLock::new(); + REGEX.get_or_init(|| { + Regex::new(r"\bsubrun-[A-Za-z0-9._:-]+\b").expect("raw subrun regex should compile") + }) +} + +fn raw_session_id_regex() -> &'static Regex { + static REGEX: OnceLock = OnceLock::new(); + REGEX.get_or_init(|| { + Regex::new(r"\bsession-[A-Za-z0-9._:-]+\b").expect("raw session regex should compile") + }) +} + fn strip_child_agent_reference_hint(content: &str) -> String { let Some((prefix, child_ref_block)) = content.split_once("\n\nChild agent reference:") else { return content.to_string(); }; - let mut extracted = Vec::new(); + let mut has_reference_fields = false; for line in child_ref_block.lines() { let trimmed = line.trim(); if trimmed.starts_with("- agentId:") @@ -402,13 +584,18 @@ fn strip_child_agent_reference_hint(content: &str) -> String { || trimmed.starts_with("- openSessionId:") || trimmed.starts_with("- status:") { - extracted.push(trimmed.trim_start_matches('-').trim().to_string()); + has_reference_fields = true; } } - let child_ref_summary = if extracted.is_empty() { - "Child agent reference preserved.".to_string() + let child_ref_summary = if has_reference_fields { + "Child agent reference existed in the original tool result. Do not reuse any agentId, \ + subRunId, or sessionId from compacted history; rely on the latest live tool result or \ + current direct-child snapshot instead." + .to_string() } else { - format!("Child agent reference preserved: {}", extracted.join(", ")) + "Child agent reference metadata existed in the original tool result, but compacted history \ + is not an authoritative source for later agent routing." + .to_string() }; let prefix = prefix.trim(); if prefix.is_empty() { @@ -1003,6 +1190,41 @@ mod tests { )); } + #[test] + fn normalize_compaction_tool_content_removes_exact_child_identifiers() { + let normalized = normalize_compaction_tool_content( + "spawn 已在后台启动。\n\nChild agent reference:\n- agentId: agent-1\n- subRunId: \ + subrun-1\n- sessionId: session-parent\n- openSessionId: session-child\n- status: \ + running\nUse this exact `agentId` value in later send/observe/close calls.", + ); + + assert!(normalized.contains("spawn 已在后台启动。")); + assert!(normalized.contains("Do not reuse any agentId")); + assert!(!normalized.contains("agent-1")); + assert!(!normalized.contains("subrun-1")); + assert!(!normalized.contains("session-child")); + } + + #[test] + fn sanitize_compact_summary_replaces_stale_route_identifiers_with_boundary_guidance() { + let sanitized = sanitize_compact_summary( + "## Progress\n- Spawned agent-3 and later called observe(agent-2).\n- Error: agent \ + 'agent-2' is not a direct child of caller 'agent-root:session-parent' (actual \ + parent: agent-1); send/observe/close only support direct children.\n- Child ref \ + payload: agentId=agent-2 subRunId=subrun-2 openSessionId=session-child-2", + ); + + assert!(sanitized.contains("## Compact Boundary")); + assert!(sanitized.contains("live direct-child snapshot")); + assert!(sanitized.contains("")); + assert!(sanitized.contains("") || sanitized.contains("")); + assert!(sanitized.contains("") || sanitized.contains("")); + assert!(!sanitized.contains("agent-2")); + assert!(!sanitized.contains("subrun-2")); + assert!(!sanitized.contains("session-child-2")); + assert!(!sanitized.contains("not a direct child of caller")); + } + #[test] fn drop_oldest_compaction_unit_is_deterministic() { let mut prefix = vec![ diff --git a/crates/session-runtime/src/context_window/templates/compact/base.md b/crates/session-runtime/src/context_window/templates/compact/base.md index a3be6de5..759fa0f5 100644 --- a/crates/session-runtime/src/context_window/templates/compact/base.md +++ b/crates/session-runtime/src/context_window/templates/compact/base.md @@ -17,6 +17,7 @@ Your summary will replace earlier conversation history so another agent can cont ## Compression Rules **MUST KEEP:** Error messages, stack traces, working solutions, current task, exact file paths, function names +**DO NOT PRESERVE AS AUTHORITATIVE FACTS:** Historical `agentId`, `subRunId`, `sessionId`, copied child reference payloads, or stale direct-child ownership errors from compacted history **MERGE:** Similar discussions into single summary points **REMOVE:** Redundant explanations, failed attempts (keep only lessons learned), boilerplate code **CONDENSE:** Long code blocks -> signatures + key logic; long explanations -> bullet points @@ -87,6 +88,8 @@ Return exactly two XML blocks: - Ignore synthetic compact-summary helper messages. - Write in third-person, factual tone. Do not address the end user. - Preserve exact file paths, function names, error messages - never paraphrase these. +- Preserve child-agent routing state semantically, but redact exact historical `agentId`, `subRunId`, and `sessionId` values from compacted history. +- If child-agent routing matters, say that the next agent must rely on the latest live child snapshot or tool result instead of historical IDs. - If a value is unknown, write a short best-effort placeholder instead of omitting the section. - If a section has no content, write "(none)" rather than omitting it. diff --git a/crates/session-runtime/src/lib.rs b/crates/session-runtime/src/lib.rs index 20923285..dc04d3a8 100644 --- a/crates/session-runtime/src/lib.rs +++ b/crates/session-runtime/src/lib.rs @@ -49,7 +49,10 @@ pub use state::{ display_name_from_working_dir, normalize_session_id, normalize_working_dir, prepare_session_execution, }; -pub use turn::{AgentPromptSubmission, TurnCollaborationSummary, TurnFinishReason, TurnSummary}; +pub use turn::{ + AgentPromptSubmission, ForkPoint, ForkResult, TurnCollaborationSummary, TurnFinishReason, + TurnSummary, +}; pub(crate) use turn::{TurnOutcome, TurnRunResult, run_turn}; const ROOT_AGENT_ID: &str = "root-agent"; diff --git a/crates/session-runtime/src/turn/branch.rs b/crates/session-runtime/src/turn/branch.rs index e3433efa..ae1a1b73 100644 --- a/crates/session-runtime/src/turn/branch.rs +++ b/crates/session-runtime/src/turn/branch.rs @@ -90,15 +90,31 @@ impl SessionRuntime { source_session_id: &SessionId, active_turn_id: &str, ) -> astrcode_core::Result { - let source_actor = self.ensure_loaded_session(source_session_id).await?; - let working_dir = normalize_working_dir(PathBuf::from(source_actor.working_dir()))?; let source_events = self.event_store.replay(source_session_id).await?; let stable_events = stable_events_before_active_turn(&source_events, active_turn_id); + let source_actor = self.ensure_loaded_session(source_session_id).await?; + let working_dir = normalize_working_dir(PathBuf::from(source_actor.working_dir()))?; let parent_storage_seq = stable_events.last().map(|event| event.storage_seq); + self.fork_events_up_to( + source_session_id, + &working_dir, + &stable_events, + parent_storage_seq, + ) + .await + } + + pub(super) async fn fork_events_up_to( + &self, + source_session_id: &SessionId, + working_dir: &std::path::Path, + source_events: &[StoredEvent], + parent_storage_seq: Option, + ) -> astrcode_core::Result { let branched_session_id: SessionId = generate_session_id().into(); self.event_store - .ensure_session(&branched_session_id, &working_dir) + .ensure_session(&branched_session_id, working_dir) .await?; let session_start = session_start_event( @@ -114,7 +130,7 @@ impl SessionRuntime { // 为什么只复制稳定历史:活跃 turn 的半截输出不应污染新分支, // 否则 replay/context window 会同时看到未完成与新分支的事件。 - for stored in stable_events { + for stored in source_events { if matches!( stored.event.payload, StorageEventPayload::SessionStart { .. } diff --git a/crates/session-runtime/src/turn/compaction_cycle.rs b/crates/session-runtime/src/turn/compaction_cycle.rs index c899731c..6c586d73 100644 --- a/crates/session-runtime/src/turn/compaction_cycle.rs +++ b/crates/session-runtime/src/turn/compaction_cycle.rs @@ -98,6 +98,8 @@ pub async fn try_reactive_compact( working_dir: ctx.working_dir.as_ref(), step_index: ctx.step_index, messages: ctx.messages, + session_state: None, + current_agent_id: ctx.agent.agent_id.as_ref().map(|id| id.as_str()), submission_prompt_declarations: &[], }) .await?; diff --git a/crates/session-runtime/src/turn/fork.rs b/crates/session-runtime/src/turn/fork.rs new file mode 100644 index 00000000..4dc93ec1 --- /dev/null +++ b/crates/session-runtime/src/turn/fork.rs @@ -0,0 +1,429 @@ +use std::path::PathBuf; + +use astrcode_core::{AstrError, SessionId, StorageEventPayload, StoredEvent}; + +use crate::{SessionRuntime, state::normalize_working_dir}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ForkPoint { + StorageSeq(u64), + TurnEnd(String), + Latest, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ForkResult { + pub new_session_id: SessionId, + pub fork_point_storage_seq: u64, + pub events_copied: usize, +} + +impl SessionRuntime { + pub async fn fork_session( + &self, + source_session_id: &SessionId, + fork_point: ForkPoint, + ) -> astrcode_core::Result { + self.ensure_session_exists(source_session_id).await?; + + let source_events = self.event_store.replay(source_session_id).await?; + let fork_point_storage_seq = + resolve_fork_point_storage_seq(source_session_id, &source_events, &fork_point)?; + let events_to_copy = + stable_events_up_to_storage_seq(&source_events, fork_point_storage_seq)?; + + let source_actor = self.ensure_loaded_session(source_session_id).await?; + let working_dir = normalize_working_dir(PathBuf::from(source_actor.working_dir()))?; + let new_session_id = self + .fork_events_up_to( + source_session_id, + &working_dir, + &events_to_copy, + Some(fork_point_storage_seq), + ) + .await?; + + Ok(ForkResult { + new_session_id, + fork_point_storage_seq, + events_copied: events_to_copy + .iter() + .filter(|stored| { + !matches!( + stored.event.payload, + StorageEventPayload::SessionStart { .. } + ) + }) + .count(), + }) + } +} + +fn resolve_fork_point_storage_seq( + source_session_id: &SessionId, + events: &[StoredEvent], + fork_point: &ForkPoint, +) -> astrcode_core::Result { + match fork_point { + ForkPoint::Latest => latest_stable_storage_seq(events).ok_or_else(|| { + AstrError::Validation(format!( + "session '{}' has no stable fork point", + source_session_id + )) + }), + ForkPoint::StorageSeq(storage_seq) => { + if !events + .iter() + .any(|stored| stored.storage_seq == *storage_seq) + { + return Err(AstrError::Validation(format!( + "storage_seq {} is out of range for session '{}'", + storage_seq, source_session_id + ))); + } + let _ = stable_events_up_to_storage_seq(events, *storage_seq)?; + Ok(*storage_seq) + }, + ForkPoint::TurnEnd(turn_id) => { + resolve_turn_end_storage_seq(source_session_id, events, turn_id) + }, + } +} + +fn resolve_turn_end_storage_seq( + source_session_id: &SessionId, + events: &[StoredEvent], + turn_id: &str, +) -> astrcode_core::Result { + let turn_exists = events + .iter() + .any(|stored| stored.event.turn_id.as_deref() == Some(turn_id)); + if !turn_exists { + return Err(AstrError::SessionNotFound(format!( + "turn '{}' in session '{}'", + turn_id, source_session_id + ))); + } + + events + .iter() + .find_map(|stored| match &stored.event.payload { + StorageEventPayload::TurnDone { .. } + if stored.event.turn_id.as_deref() == Some(turn_id) => + { + Some(stored.storage_seq) + }, + _ => None, + }) + .ok_or_else(|| { + AstrError::Validation(format!( + "turn '{}' has not completed and cannot be used as a fork point", + turn_id + )) + }) +} + +fn latest_stable_storage_seq(events: &[StoredEvent]) -> Option { + let mut latest = None; + for stored in events { + if matches!( + stored.event.payload, + StorageEventPayload::SessionStart { .. } + ) { + latest = Some(stored.storage_seq); + } + if matches!( + stored.event.payload, + StorageEventPayload::TurnDone { .. } | StorageEventPayload::Error { .. } + ) { + latest = Some(stored.storage_seq); + } + } + latest +} + +fn stable_events_up_to_storage_seq( + events: &[StoredEvent], + storage_seq: u64, +) -> astrcode_core::Result> { + let cutoff = events + .iter() + .position(|stored| stored.storage_seq == storage_seq) + .ok_or_else(|| { + AstrError::Validation(format!("storage_seq {} is out of range", storage_seq)) + })?; + let candidate = events[..=cutoff].to_vec(); + + if is_stable_prefix(&candidate) { + Ok(candidate) + } else { + Err(AstrError::Validation(format!( + "storage_seq {} is inside an unfinished turn and cannot be used as a fork point", + storage_seq + ))) + } +} + +fn is_stable_prefix(events: &[StoredEvent]) -> bool { + let mut active_turn_id: Option<&str> = None; + for stored in events { + let Some(turn_id) = stored.event.turn_id.as_deref() else { + continue; + }; + match &stored.event.payload { + StorageEventPayload::TurnDone { .. } | StorageEventPayload::Error { .. } => { + if active_turn_id == Some(turn_id) { + active_turn_id = None; + } + }, + _ => { + if active_turn_id.is_none() { + active_turn_id = Some(turn_id); + } + }, + } + } + active_turn_id.is_none() +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use astrcode_core::{AstrError, SessionId, StoredEvent}; + use chrono::Utc; + + use super::{ForkPoint, latest_stable_storage_seq}; + use crate::{ + SessionRuntime, + turn::{ + events::session_start_event, + test_support::{ + BranchingTestEventStore, root_assistant_final_event, root_turn_done_event, + root_turn_event, root_user_message_event, test_runtime, + }, + }, + }; + + fn seed_runtime_with_events( + events: Vec, + ) -> (SessionRuntime, Arc) { + let event_store = Arc::new(BranchingTestEventStore::default()); + event_store.seed_session("source", ".", events); + (test_runtime(event_store.clone()), event_store) + } + + #[test] + fn latest_stable_storage_seq_stops_before_active_turn() { + let events = vec![ + StoredEvent { + storage_seq: 1, + event: session_start_event("source", ".", None, None, Utc::now()), + }, + StoredEvent { + storage_seq: 2, + event: root_user_message_event("turn-1", "hello"), + }, + StoredEvent { + storage_seq: 3, + event: root_turn_done_event("turn-1", Some("completed".to_string())), + }, + StoredEvent { + storage_seq: 4, + event: root_user_message_event("turn-2", "still running"), + }, + ]; + + assert_eq!(latest_stable_storage_seq(&events), Some(3)); + } + + #[tokio::test] + async fn fork_session_latest_on_idle_copies_all_stable_events() { + let events = vec![ + StoredEvent { + storage_seq: 1, + event: session_start_event("source", ".", None, None, Utc::now()), + }, + StoredEvent { + storage_seq: 2, + event: root_user_message_event("turn-1", "hello"), + }, + StoredEvent { + storage_seq: 3, + event: root_assistant_final_event("turn-1", "world"), + }, + StoredEvent { + storage_seq: 4, + event: root_turn_done_event("turn-1", Some("completed".to_string())), + }, + ]; + let (runtime, event_store) = seed_runtime_with_events(events); + + let result = runtime + .fork_session(&SessionId::from("source".to_string()), ForkPoint::Latest) + .await + .expect("fork latest should succeed"); + + assert_eq!(result.fork_point_storage_seq, 4); + assert_eq!(result.events_copied, 3); + + let new_events = event_store.stored_events_for(result.new_session_id.as_str()); + assert_eq!(new_events.len(), 4); + let metas = runtime + .list_session_metas() + .await + .expect("metas should be listable"); + let new_meta = metas + .into_iter() + .find(|meta| meta.session_id == result.new_session_id.as_str()) + .expect("new session meta should exist"); + assert_eq!(new_meta.parent_session_id.as_deref(), Some("source")); + assert_eq!(new_meta.parent_storage_seq, Some(4)); + assert_eq!(new_meta.phase, astrcode_core::Phase::Idle); + } + + #[tokio::test] + async fn fork_session_accepts_completed_turn_end_and_stable_storage_seq() { + let events = vec![ + StoredEvent { + storage_seq: 1, + event: session_start_event("source", ".", None, None, Utc::now()), + }, + StoredEvent { + storage_seq: 2, + event: root_user_message_event("turn-1", "hello"), + }, + StoredEvent { + storage_seq: 3, + event: root_assistant_final_event("turn-1", "world"), + }, + StoredEvent { + storage_seq: 4, + event: root_turn_done_event("turn-1", Some("completed".to_string())), + }, + StoredEvent { + storage_seq: 5, + event: root_user_message_event("turn-2", "next"), + }, + StoredEvent { + storage_seq: 6, + event: root_turn_done_event("turn-2", Some("completed".to_string())), + }, + ]; + let (runtime, _) = seed_runtime_with_events(events); + + let from_turn = runtime + .fork_session( + &SessionId::from("source".to_string()), + ForkPoint::TurnEnd("turn-1".to_string()), + ) + .await + .expect("completed turn should be accepted"); + assert_eq!(from_turn.fork_point_storage_seq, 4); + + let from_seq = runtime + .fork_session( + &SessionId::from("source".to_string()), + ForkPoint::StorageSeq(4), + ) + .await + .expect("stable storage seq should be accepted"); + assert_eq!(from_seq.fork_point_storage_seq, 4); + } + + #[tokio::test] + async fn fork_session_latest_on_thinking_truncates_to_last_stable_turn() { + let events = vec![ + StoredEvent { + storage_seq: 1, + event: session_start_event("source", ".", None, None, Utc::now()), + }, + StoredEvent { + storage_seq: 2, + event: root_user_message_event("turn-1", "hello"), + }, + StoredEvent { + storage_seq: 3, + event: root_turn_done_event("turn-1", Some("completed".to_string())), + }, + StoredEvent { + storage_seq: 4, + event: root_user_message_event("turn-2", "unfinished"), + }, + StoredEvent { + storage_seq: 5, + event: root_turn_event( + Some("turn-2"), + astrcode_core::StorageEventPayload::AssistantFinal { + content: "partial".to_string(), + reasoning_content: None, + reasoning_signature: None, + timestamp: Some(Utc::now()), + }, + ), + }, + ]; + let (runtime, event_store) = seed_runtime_with_events(events); + + let result = runtime + .fork_session(&SessionId::from("source".to_string()), ForkPoint::Latest) + .await + .expect("fork latest should succeed"); + + assert_eq!(result.fork_point_storage_seq, 3); + let new_events = event_store.stored_events_for(result.new_session_id.as_str()); + assert_eq!(new_events.len(), 3); + } + + #[tokio::test] + async fn fork_session_rejects_unfinished_turn_and_active_storage_seq() { + let events = vec![ + StoredEvent { + storage_seq: 1, + event: session_start_event("source", ".", None, None, Utc::now()), + }, + StoredEvent { + storage_seq: 2, + event: root_user_message_event("turn-1", "hello"), + }, + ]; + let (runtime, _) = seed_runtime_with_events(events); + + let unfinished_turn = runtime + .fork_session( + &SessionId::from("source".to_string()), + ForkPoint::TurnEnd("turn-1".to_string()), + ) + .await + .expect_err("unfinished turn should be rejected"); + assert!(matches!(unfinished_turn, AstrError::Validation(_))); + + let active_seq = runtime + .fork_session( + &SessionId::from("source".to_string()), + ForkPoint::StorageSeq(2), + ) + .await + .expect_err("active storage seq should be rejected"); + assert!(matches!(active_seq, AstrError::Validation(_))); + } + + #[tokio::test] + async fn fork_session_rejects_unknown_turn_id() { + let events = vec![StoredEvent { + storage_seq: 1, + event: session_start_event("source", ".", None, None, Utc::now()), + }]; + let (runtime, _) = seed_runtime_with_events(events); + + let error = runtime + .fork_session( + &SessionId::from("source".to_string()), + ForkPoint::TurnEnd("turn-missing".to_string()), + ) + .await + .expect_err("missing turn should be rejected"); + + assert!(matches!(error, AstrError::SessionNotFound(_))); + } +} diff --git a/crates/session-runtime/src/turn/manual_compact.rs b/crates/session-runtime/src/turn/manual_compact.rs index dd1ec908..35366b6a 100644 --- a/crates/session-runtime/src/turn/manual_compact.rs +++ b/crates/session-runtime/src/turn/manual_compact.rs @@ -49,6 +49,8 @@ pub(crate) async fn build_manual_compact_events( working_dir: request.working_dir, step_index: 0, messages: &projected.messages, + session_state: Some(request.session_state), + current_agent_id: None, submission_prompt_declarations: &[], }) .await?; diff --git a/crates/session-runtime/src/turn/mod.rs b/crates/session-runtime/src/turn/mod.rs index 77116ea7..b8abf395 100644 --- a/crates/session-runtime/src/turn/mod.rs +++ b/crates/session-runtime/src/turn/mod.rs @@ -7,6 +7,7 @@ mod branch; mod compaction_cycle; mod continuation_cycle; mod events; +mod fork; mod interrupt; pub(crate) mod llm_cycle; mod loop_control; @@ -22,6 +23,7 @@ mod summary; pub(crate) mod tool_cycle; mod tool_result_budget; +pub use fork::{ForkPoint, ForkResult}; pub use loop_control::{TurnLoopTransition, TurnStopCause}; pub use submit::AgentPromptSubmission; pub use summary::{TurnCollaborationSummary, TurnFinishReason, TurnSummary}; diff --git a/crates/session-runtime/src/turn/request.rs b/crates/session-runtime/src/turn/request.rs index e86bcd77..dca8e4e1 100644 --- a/crates/session-runtime/src/turn/request.rs +++ b/crates/session-runtime/src/turn/request.rs @@ -67,6 +67,8 @@ pub(crate) struct PromptOutputRequest<'a> { pub working_dir: &'a Path, pub step_index: usize, pub messages: &'a [LlmMessage], + pub session_state: Option<&'a crate::SessionState>, + pub current_agent_id: Option<&'a str>, pub submission_prompt_declarations: &'a [PromptDeclaration], } @@ -119,6 +121,8 @@ pub async fn assemble_prompt_request( working_dir: request.working_dir, step_index: request.step_index, messages: &messages, + session_state: Some(request.session_state), + current_agent_id: request.agent.agent_id.as_ref().map(|id| id.as_str()), submission_prompt_declarations: request.prompt_declarations, }) .await?; @@ -183,6 +187,8 @@ pub async fn assemble_prompt_request( working_dir: request.working_dir, step_index: request.step_index, messages: &messages, + session_state: Some(request.session_state), + current_agent_id: request.agent.agent_id.as_ref().map(|id| id.as_str()), submission_prompt_declarations: request.prompt_declarations, }) .await?; @@ -242,6 +248,8 @@ pub(crate) async fn build_prompt_output( working_dir, step_index, messages, + session_state, + current_agent_id, submission_prompt_declarations, } = request; let facts = prompt_facts_provider @@ -269,6 +277,11 @@ pub(crate) async fn build_prompt_output( agent_profiles, mut prompt_declarations, } = facts; + if let Some(direct_child_snapshot) = + live_direct_child_snapshot_declaration(session_state, current_agent_id)? + { + prompt_declarations.push(direct_child_snapshot); + } prompt_declarations.extend_from_slice(submission_prompt_declarations); gateway .build_prompt(PromptBuildRequest { @@ -289,6 +302,61 @@ pub(crate) async fn build_prompt_output( .map_err(|error| astrcode_core::AstrError::Internal(error.to_string())) } +fn live_direct_child_snapshot_declaration( + session_state: Option<&crate::SessionState>, + current_agent_id: Option<&str>, +) -> Result> { + let Some(session_state) = session_state else { + return Ok(None); + }; + let Some(current_agent_id) = current_agent_id.filter(|value| !value.trim().is_empty()) else { + return Ok(None); + }; + + let direct_children = session_state.child_nodes_for_parent(current_agent_id)?; + let children_block = if direct_children.is_empty() { + "- (none)\n- If work needs a new branch, use `spawn` instead of guessing an older \ + `agentId`." + .to_string() + } else { + direct_children + .iter() + .map(|node| { + format!( + "- agentId=`{}` status=`{:?}` subRunId=`{}` childSessionId=`{}` lineage=`{:?}`", + node.agent_id(), + node.status, + node.sub_run_id(), + node.child_session_id, + node.lineage_kind + ) + }) + .collect::>() + .join("\n") + }; + + Ok(Some(PromptDeclaration { + block_id: "agent.live.direct_children".to_string(), + title: "Live Direct Child Snapshot".to_string(), + content: format!( + "Authoritative direct-child snapshot for the current agent.\n\nRouting rules:\n- Only \ + use `agentId` values from this snapshot or from a newer live tool result / child \ + notification in the current prompt tail.\n- Never reuse `agentId`, `subRunId`, or \ + `sessionId` from compact summaries, stale errors, or historical notes.\n- If a child \ + is absent from this snapshot, treat it as unavailable for `send`, `observe`, or \ + `close` at prompt-build time.\n\nDirect children:\n{children_block}" + ), + render_target: astrcode_core::PromptDeclarationRenderTarget::System, + layer: astrcode_core::SystemPromptLayer::Dynamic, + kind: astrcode_core::PromptDeclarationKind::ExtensionInstruction, + priority_hint: Some(592), + always_include: true, + source: astrcode_core::PromptDeclarationSource::Builtin, + capability_name: None, + origin: Some(format!("live-direct-children:{current_agent_id}")), + })) +} + pub(crate) fn build_prompt_metadata( session_id: &str, turn_id: &str, @@ -354,12 +422,13 @@ mod tests { use std::sync::{Arc, Mutex}; use astrcode_core::{ - AstrError, LlmOutput, LlmProvider, LlmRequest, ModelLimits, PromptBuildOutput, - PromptBuildRequest, PromptDeclaration, PromptDeclarationKind, - PromptDeclarationRenderTarget, PromptDeclarationSource, PromptFacts, PromptFactsProvider, - PromptFactsRequest, PromptProvider, ResolvedRuntimeConfig, ResourceProvider, - ResourceReadResult, ResourceRequestContext, StorageEventPayload, SystemPromptLayer, - ToolDefinition, + AgentLifecycleStatus, AstrError, ChildExecutionIdentity, ChildSessionLineageKind, + ChildSessionNode, ChildSessionStatusSource, LlmOutput, LlmProvider, LlmRequest, + ModelLimits, ParentExecutionRef, PromptBuildOutput, PromptBuildRequest, PromptDeclaration, + PromptDeclarationKind, PromptDeclarationRenderTarget, PromptDeclarationSource, PromptFacts, + PromptFactsProvider, PromptFactsRequest, PromptProvider, ResolvedRuntimeConfig, + ResourceProvider, ResourceReadResult, ResourceRequestContext, StorageEventPayload, + SystemPromptLayer, ToolDefinition, }; use astrcode_kernel::{CapabilityRouter, KernelGateway}; use async_trait::async_trait; @@ -606,6 +675,8 @@ mod tests { working_dir: Path::new("."), step_index: 0, messages: &[], + session_state: None, + current_agent_id: None, submission_prompt_declarations: &submission_declarations, }) .await @@ -617,4 +688,66 @@ mod tests { assert_eq!(captured[0].origin.as_deref(), Some("facts-origin")); assert_eq!(captured[1].origin.as_deref(), Some("submission-origin")); } + + #[test] + fn live_direct_child_snapshot_declaration_only_uses_current_agents_children() { + let session_state = test_session_state(); + session_state + .upsert_child_session_node(ChildSessionNode { + identity: ChildExecutionIdentity { + agent_id: "agent-child-1".into(), + session_id: "session-parent".into(), + sub_run_id: "subrun-child-1".into(), + }, + child_session_id: "session-child-1".into(), + parent_session_id: "session-parent".into(), + parent: ParentExecutionRef { + parent_agent_id: Some("agent-root".into()), + parent_sub_run_id: Some("subrun-root".into()), + }, + parent_turn_id: "turn-1".into(), + lineage_kind: ChildSessionLineageKind::Spawn, + status: AgentLifecycleStatus::Idle, + status_source: ChildSessionStatusSource::Durable, + created_by_tool_call_id: None, + lineage_snapshot: None, + }) + .expect("direct child should insert"); + session_state + .upsert_child_session_node(ChildSessionNode { + identity: ChildExecutionIdentity { + agent_id: "agent-grandchild-1".into(), + session_id: "session-child-1".into(), + sub_run_id: "subrun-grandchild-1".into(), + }, + child_session_id: "session-grandchild-1".into(), + parent_session_id: "session-child-1".into(), + parent: ParentExecutionRef { + parent_agent_id: Some("agent-child-1".into()), + parent_sub_run_id: Some("subrun-child-1".into()), + }, + parent_turn_id: "turn-2".into(), + lineage_kind: ChildSessionLineageKind::Spawn, + status: AgentLifecycleStatus::Running, + status_source: ChildSessionStatusSource::Durable, + created_by_tool_call_id: None, + lineage_snapshot: None, + }) + .expect("grandchild should insert"); + + let declaration = + live_direct_child_snapshot_declaration(Some(&session_state), Some("agent-root")) + .expect("declaration build should succeed") + .expect("root declaration should exist"); + + assert_eq!(declaration.block_id, "agent.live.direct_children"); + assert!(declaration.content.contains("agentId=`agent-child-1`")); + assert!(declaration.content.contains("subRunId=`subrun-child-1`")); + assert!(!declaration.content.contains("agent-grandchild-1")); + assert!( + declaration + .content + .contains("Never reuse `agentId`, `subRunId`, or `sessionId`") + ); + } } diff --git a/crates/session-runtime/src/turn/test_support.rs b/crates/session-runtime/src/turn/test_support.rs index 10d21650..96218191 100644 --- a/crates/session-runtime/src/turn/test_support.rs +++ b/crates/session-runtime/src/turn/test_support.rs @@ -26,7 +26,8 @@ use crate::{ actor::SessionActor, state::{SessionWriter, append_and_broadcast}, turn::events::{ - CompactAppliedStats, assistant_final_event, compact_applied_event, user_message_event, + CompactAppliedStats, assistant_final_event, compact_applied_event, turn_done_event, + user_message_event, }, }; @@ -329,6 +330,15 @@ pub(crate) fn root_assistant_final_event( ) } +pub(crate) fn root_turn_done_event(turn_id: &str, reason: Option) -> StorageEvent { + turn_done_event( + turn_id, + &astrcode_core::AgentEventContext::default(), + reason, + chrono::Utc::now(), + ) +} + pub(crate) fn root_compact_applied_event( turn_id: &str, summary: impl Into, @@ -432,6 +442,58 @@ impl BranchingTestEventStore { .cloned() .unwrap_or_default() } + + pub(crate) fn seed_session( + &self, + session_id: &str, + working_dir: &str, + events: Vec, + ) { + let max_seq = events + .iter() + .map(|stored| stored.storage_seq) + .max() + .unwrap_or(0); + self.next_seq + .fetch_max(max_seq, std::sync::atomic::Ordering::SeqCst); + self.events + .lock() + .expect("events lock should work") + .insert(session_id.to_string(), events.clone()); + + let now = chrono::Utc::now(); + let mut meta = SessionMeta { + session_id: session_id.to_string(), + working_dir: working_dir.to_string(), + display_name: crate::display_name_from_working_dir(Path::new(working_dir)), + title: "New Session".to_string(), + created_at: now, + updated_at: now, + parent_session_id: None, + parent_storage_seq: None, + phase: Phase::Idle, + }; + if let Some(stored) = events.iter().find(|stored| { + matches!( + stored.event.payload, + StorageEventPayload::SessionStart { .. } + ) + }) { + if let StorageEventPayload::SessionStart { + parent_session_id, + parent_storage_seq, + .. + } = &stored.event.payload + { + meta.parent_session_id = parent_session_id.clone(); + meta.parent_storage_seq = *parent_storage_seq; + } + } + self.metas + .lock() + .expect("metas lock should work") + .insert(session_id.to_string(), meta); + } } #[async_trait] diff --git a/docs/ideas/notes.md b/docs/ideas/notes.md index e6c8e50e..11eb1297 100644 --- a/docs/ideas/notes.md +++ b/docs/ideas/notes.md @@ -1,6 +1,6 @@ 1. 关闭对话框可以更好的看llm的排版 2. 语音选项左下角 -3.增加agent可选tools +3. 增加agent可选tools --- name: coordinator description: Coordinates work across specialized agents @@ -10,5 +10,6 @@ 5. 终端的输入输出功能 6. fork agent -7. pending messages -8. 更好的compact功能 \ No newline at end of file +7. pending messages(完成部分) +8. 更好的compact功能 +9. 多agent共享任务列表 \ No newline at end of file diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 33c45161..48370fe2 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -63,6 +63,7 @@ export default function App() { const { createSession, + forkSession, listSessionsWithMeta, loadConversationView, connectSession, @@ -197,6 +198,22 @@ export default function App() { [refreshSessions] ); + const handleForkFromTurn = useCallback( + async (turnId: string) => { + const sessionId = activeSessionIdRef.current; + if (!sessionId) { + return; + } + try { + const forked = await forkSession(sessionId, { turnId }); + await refreshSessions({ preferredSessionId: forked.sessionId }); + } catch (error) { + logger.error('App', 'Failed to fork session:', error); + } + }, + [forkSession, refreshSessions] + ); + useSessionCatalogEvents({ onEvent: handleSessionCatalogEvent, onResync: () => { @@ -266,6 +283,7 @@ export default function App() { onCloseSubRun: handleCloseSubRun, onNavigateSubRunPath: handleNavigateSubRunPath, onOpenChildSession: handleOpenChildSession, + onForkFromTurn: handleForkFromTurn, onSubmitPrompt: handleSubmit, onInterrupt: handleInterrupt, onCancelSubRun: cancelSubRun, @@ -290,6 +308,7 @@ export default function App() { handleInterrupt, handleNavigateSubRunPath, handleOpenChildSession, + handleForkFromTurn, handleOpenSubRun, handleSubmit, isSidebarOpen, diff --git a/frontend/src/components/Chat/ChatScreenContext.tsx b/frontend/src/components/Chat/ChatScreenContext.tsx index beaaae8d..24b7f25d 100644 --- a/frontend/src/components/Chat/ChatScreenContext.tsx +++ b/frontend/src/components/Chat/ChatScreenContext.tsx @@ -24,6 +24,7 @@ export interface ChatScreenContextValue { onCloseSubRun: () => void | Promise; onNavigateSubRunPath: (subRunPath: string[]) => void | Promise; onOpenChildSession: (childSessionId: string) => void | Promise; + onForkFromTurn: (turnId: string) => void | Promise; onSubmitPrompt: (text: string) => void | Promise; onInterrupt: () => void | Promise; onCancelSubRun: (sessionId: string, subRunId: string) => void | Promise; diff --git a/frontend/src/components/Chat/MessageList.tsx b/frontend/src/components/Chat/MessageList.tsx index d84118b7..4bec5b28 100644 --- a/frontend/src/components/Chat/MessageList.tsx +++ b/frontend/src/components/Chat/MessageList.tsx @@ -1,7 +1,9 @@ import React, { Component, useCallback, useEffect, useRef } from 'react'; import type { Message, SubRunViewData, ThreadItem } from '../../types'; -import { emptyStateSurface, errorSurface } from '../../lib/styles'; +import { contextMenu as contextMenuClass, emptyStateSurface, errorSurface, menuItem } from '../../lib/styles'; import { cn } from '../../lib/utils'; +import { useContextMenu } from '../../hooks/useContextMenu'; +import { resolveForkTurnIdFromMessage } from '../../lib/sessionFork'; import AssistantMessage from './AssistantMessage'; import CompactMessage from './CompactMessage'; import SubRunBlock from './SubRunBlock'; @@ -140,6 +142,51 @@ function isRowNested(options?: { nested?: boolean }): boolean { return options?.nested === true; } +function ForkableRow({ + message, + nested, + children, +}: { + message: Message; + nested?: boolean; + children: React.ReactNode; +}) { + const { activeSubRunPath, conversationControl, onForkFromTurn } = useChatScreenContext(); + const { contextMenu, menuRef, openMenu, closeMenu } = useContextMenu(); + const turnId = + activeSubRunPath.length === 0 && !nested + ? resolveForkTurnIdFromMessage(message, conversationControl) + : null; + + if (!turnId) { + return <>{children}; + } + + return ( + <> +
    {children}
    + {contextMenu && ( +
    + +
    + )} + + ); +} + export default function MessageList({ threadItems, childSubRuns, @@ -265,18 +312,21 @@ export default function MessageList({ : undefined); return ( -
    - - {renderMessageContent(msg, isContinuation, metricsToAttach, options)} - -
    + +
    + + {renderMessageContent(msg, isContinuation, metricsToAttach, options)} + +
    +
    ); }, [renderMessageContent] diff --git a/frontend/src/components/Chat/ToolCallBlock.test.tsx b/frontend/src/components/Chat/ToolCallBlock.test.tsx index b20b5c09..a3c821b6 100644 --- a/frontend/src/components/Chat/ToolCallBlock.test.tsx +++ b/frontend/src/components/Chat/ToolCallBlock.test.tsx @@ -21,6 +21,7 @@ const chatContextValue: ChatScreenContextValue = { onCloseSubRun: () => {}, onNavigateSubRunPath: () => {}, onOpenChildSession: () => {}, + onForkFromTurn: () => {}, onSubmitPrompt: () => {}, onInterrupt: () => {}, onCancelSubRun: () => {}, diff --git a/frontend/src/hooks/useAgent.ts b/frontend/src/hooks/useAgent.ts index b0974086..d9d9ba74 100644 --- a/frontend/src/hooks/useAgent.ts +++ b/frontend/src/hooks/useAgent.ts @@ -24,6 +24,7 @@ import { createSession, deleteProject, deleteSession, + forkSession, interruptSession, listSessionsWithMeta, submitPrompt, @@ -366,6 +367,16 @@ export function useAgent() { return listSessionsWithMeta(); }, []); + const handleForkSession = useCallback( + async ( + sessionId: string, + options?: { turnId?: string; storageSeq?: number } + ): Promise => { + return forkSession(sessionId, options); + }, + [] + ); + const handleLoadConversationView = useCallback( async ( sessionId: string, @@ -505,6 +516,7 @@ export function useAgent() { return { createSession: handleCreateSession, + forkSession: handleForkSession, listSessionsWithMeta: handleListSessionsWithMeta, loadConversationView: handleLoadConversationView, connectSession, diff --git a/frontend/src/lib/api/conversation.test.ts b/frontend/src/lib/api/conversation.test.ts index 7b9e11cb..fcd29c16 100644 --- a/frontend/src/lib/api/conversation.test.ts +++ b/frontend/src/lib/api/conversation.test.ts @@ -464,4 +464,103 @@ describe('projectConversationState', () => { }); expect(rehydratedProjection.messages[0]).toMatchObject(liveProjection.messages[0]); }); + + it('projects compact system notes with the explicit auto trigger from compactMeta', () => { + const state: ConversationSnapshotState = { + cursor: 'cursor-compact-1', + phase: 'done', + blocks: [ + { + id: 'compact-1', + kind: 'system_note', + turnId: 'turn-compact-1', + noteKind: 'compact', + markdown: '压缩摘要', + compactMeta: { + trigger: 'auto', + mode: 'incremental', + instructionsPresent: false, + fallbackUsed: false, + retryCount: 0, + inputUnits: 4, + outputSummaryChars: 12, + }, + }, + ], + control: { + ...baseControl, + lastCompactMeta: { + trigger: 'manual', + meta: { + mode: 'full', + instructionsPresent: false, + fallbackUsed: false, + retryCount: 0, + inputUnits: 0, + outputSummaryChars: 0, + }, + }, + }, + childSummaries: [], + }; + + const projection = projectConversationState(state); + + expect(projection.messages).toHaveLength(1); + expect(projection.messages[0]).toMatchObject({ + kind: 'compact', + trigger: 'auto', + meta: { + mode: 'incremental', + inputUnits: 4, + }, + }); + }); + + it('falls back to control lastCompactMeta trigger when compact block omits it', () => { + const state: ConversationSnapshotState = { + cursor: 'cursor-compact-2', + phase: 'done', + blocks: [ + { + id: 'compact-2', + kind: 'system_note', + turnId: 'turn-compact-2', + noteKind: 'compact', + markdown: '压缩摘要', + compactMeta: { + mode: 'full', + instructionsPresent: false, + fallbackUsed: false, + retryCount: 1, + inputUnits: 7, + outputSummaryChars: 24, + }, + }, + ], + control: { + ...baseControl, + lastCompactMeta: { + trigger: 'auto', + meta: { + mode: 'full', + instructionsPresent: false, + fallbackUsed: false, + retryCount: 1, + inputUnits: 7, + outputSummaryChars: 24, + }, + }, + }, + childSummaries: [], + }; + + const projection = projectConversationState(state); + + expect(projection.messages).toHaveLength(1); + expect(projection.messages[0]).toMatchObject({ + kind: 'compact', + trigger: 'auto', + }); + }); }); diff --git a/frontend/src/lib/api/conversation.ts b/frontend/src/lib/api/conversation.ts index aee0172c..8d63a070 100644 --- a/frontend/src/lib/api/conversation.ts +++ b/frontend/src/lib/api/conversation.ts @@ -140,14 +140,17 @@ function childSummaryNotificationKind(lifecycle: AgentLifecycle): ChildSessionNo return lifecycle === 'idle' || lifecycle === 'terminated' ? 'delivered' : 'progress_summary'; } -function parseCompactTrigger(value: unknown): LastCompactMeta['trigger'] { +function parseCompactTrigger( + value: unknown, + fallback: LastCompactMeta['trigger'] = 'manual' +): LastCompactMeta['trigger'] { switch (value) { case 'auto': case 'manual': case 'deferred': return value; default: - return 'manual'; + return fallback; } } @@ -193,6 +196,7 @@ function parseLastCompactMeta(value: unknown): LastCompactMeta | undefined { function parseConversationControlState(record: ConversationRecord): ConversationControlState { const controlRecord = asRecord(record.control); const phase = parsePhase(controlRecord?.phase ?? record.phase); + const lastCompactMeta = parseLastCompactMeta(controlRecord?.lastCompactMeta); return { phase, canSubmitPrompt: controlRecord?.canSubmitPrompt !== false, @@ -200,7 +204,7 @@ function parseConversationControlState(record: ConversationRecord): Conversation compactPending: controlRecord?.compactPending === true, compacting: controlRecord?.compacting === true, activeTurnId: pickOptionalString(controlRecord ?? {}, 'activeTurnId') ?? undefined, - lastCompactMeta: parseLastCompactMeta(controlRecord?.lastCompactMeta), + lastCompactMeta, }; } @@ -390,13 +394,19 @@ function projectConversationMessages( if (pickString(block, 'noteKind') !== 'compact') { return; } + const compactMetaRecord = asRecord(block.compactMeta); + const trigger = + parseCompactTrigger( + compactMetaRecord?.trigger ?? + pickString(block, 'compactTrigger') ?? + pickString(block, 'trigger'), + state.control.lastCompactMeta?.trigger ?? 'manual' + ); messages.push({ id: `conversation-compact:${id}`, kind: 'compact', turnId, - trigger: parseCompactTrigger( - block.compactMeta ? asRecord(block.compactMeta)?.trigger : undefined - ), + trigger, meta: parseCompactMeta(block.compactMeta) ?? { mode: 'full', instructionsPresent: false, diff --git a/frontend/src/lib/api/sessions.test.ts b/frontend/src/lib/api/sessions.test.ts new file mode 100644 index 00000000..a37035aa --- /dev/null +++ b/frontend/src/lib/api/sessions.test.ts @@ -0,0 +1,37 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const requestJson = vi.fn(); + +vi.mock('./client', () => ({ + request: vi.fn(), + requestJson, +})); + +describe('forkSession', () => { + beforeEach(() => { + requestJson.mockReset(); + }); + + it('posts fork request with turnId and returns session meta', async () => { + requestJson.mockResolvedValue({ + sessionId: 'session-forked', + workingDir: '.', + displayName: 'Astrcode', + title: 'Forked Session', + createdAt: '2026-04-18T00:00:00Z', + updatedAt: '2026-04-18T00:00:00Z', + parentSessionId: 'session-root', + phase: 'idle', + }); + const { forkSession } = await import('./sessions'); + + const result = await forkSession('session-root', { turnId: 'turn-1' }); + + expect(requestJson).toHaveBeenCalledWith('/api/sessions/session-root/fork', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ turnId: 'turn-1', storageSeq: undefined }), + }); + expect(result.parentSessionId).toBe('session-root'); + }); +}); diff --git a/frontend/src/lib/api/sessions.ts b/frontend/src/lib/api/sessions.ts index 2322f0aa..db75960e 100644 --- a/frontend/src/lib/api/sessions.ts +++ b/frontend/src/lib/api/sessions.ts @@ -64,6 +64,20 @@ export async function listSessionsWithMeta(): Promise { return requestJson('/api/sessions'); } +export async function forkSession( + sessionId: string, + options?: { turnId?: string; storageSeq?: number } +): Promise { + return requestJson(`/api/sessions/${encodeURIComponent(sessionId)}/fork`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + turnId: options?.turnId, + storageSeq: options?.storageSeq, + }), + }); +} + export async function submitPrompt( sessionId: string, text: string, diff --git a/frontend/src/lib/sessionFork.test.ts b/frontend/src/lib/sessionFork.test.ts new file mode 100644 index 00000000..b7af909b --- /dev/null +++ b/frontend/src/lib/sessionFork.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from 'vitest'; + +import type { Message } from '../types'; +import { resolveForkTurnIdFromMessage } from './sessionFork'; + +const baseControl = { + phase: 'idle' as const, + canSubmitPrompt: true, + canRequestCompact: true, + compactPending: false, + compacting: false, +}; + +describe('resolveForkTurnIdFromMessage', () => { + it('returns turnId for stable root conversation messages', () => { + const message: Message = { + id: 'msg-user-1', + kind: 'user', + turnId: 'turn-1', + text: 'hello', + timestamp: 1, + }; + + expect(resolveForkTurnIdFromMessage(message, baseControl)).toBe('turn-1'); + }); + + it('hides fork for messages without stable turn ids', () => { + const message: Message = { + id: 'msg-child-1', + kind: 'childSessionNotification', + childRef: { + agentId: 'agent-child', + sessionId: 'session-child', + subRunId: 'subrun-child', + lineageKind: 'spawn', + status: 'running', + openSessionId: 'session-child', + }, + notificationKind: 'started', + status: 'running', + timestamp: 1, + }; + + expect(resolveForkTurnIdFromMessage(message, baseControl)).toBeNull(); + }); + + it('hides fork for the active unfinished turn', () => { + const message: Message = { + id: 'msg-assistant-1', + kind: 'assistant', + turnId: 'turn-active', + text: 'streaming', + reasoningText: '', + streaming: true, + timestamp: 1, + }; + + expect( + resolveForkTurnIdFromMessage(message, { + ...baseControl, + phase: 'streaming', + activeTurnId: 'turn-active', + }) + ).toBeNull(); + }); +}); diff --git a/frontend/src/lib/sessionFork.ts b/frontend/src/lib/sessionFork.ts new file mode 100644 index 00000000..3898d56b --- /dev/null +++ b/frontend/src/lib/sessionFork.ts @@ -0,0 +1,28 @@ +import type { ConversationControlState, Message } from '../types'; + +export function resolveForkTurnIdFromMessage( + message: Message, + control?: ConversationControlState | null +): string | null { + switch (message.kind) { + case 'user': + case 'assistant': + case 'toolCall': + case 'compact': + break; + default: + return null; + } + + const turnId = message.turnId ?? null; + if (!turnId) { + return null; + } + if (control?.activeTurnId && control.activeTurnId === turnId) { + return null; + } + if (message.kind === 'assistant' && message.streaming) { + return null; + } + return turnId; +} diff --git a/openspec/changes/collaboration-mode-system/design.md b/openspec/changes/collaboration-mode-system/design.md new file mode 100644 index 00000000..9dc50044 --- /dev/null +++ b/openspec/changes/collaboration-mode-system/design.md @@ -0,0 +1,181 @@ +## Context + +AstrCode 当前的"模式"控制能力分散在多个组件中,缺少统一概念: + +- `AgentProfile` (`core/src/agent/mod.rs`) 定义身份级 allowed_tools,但不随对话阶段变化 +- `CapabilityRouter` (`kernel/src/registry/router.rs`) 有 `subset_for_tools()` 能力,但仅在子 agent 路径使用 +- `PolicyEngine` (`core/src/policy/engine.rs`) 做运行时审批(Allow/Deny/Ask),但 LLM 在审批前已经看到了不该看到的工具 +- `AgentPromptSubmission` (`session-runtime/src/turn/submit.rs`) 已经是 per-turn 包络雏形,携带 capability_router + prompt_declarations,但只在子 agent 场景充分使用 +- `TurnExecutionResources` (`session-runtime/src/turn/runner.rs`) 在 turn 开始时固定 tools(第 157 行),整个 step loop 共享同一份 + +本设计在现有架构上引入 CollaborationMode 作为显式协作阶段,以 ModeSpec 为一等规格对象统一控制工具授予、提示词注入和转换规则。 + +## Goals / Non-Goals + +**Goals:** + +- 引入 `CollaborationMode` 枚举(Plan / Execute / Review)作为 session 级 durable truth +- 以 `ModeSpec` 声明式定义每个模式的工具授予、提示词、进入策略和转换规则 +- 通过 `compile_mode_spec()` 在 turn 边界编译出 visible tools + prompt directives +- 提供 `switchMode` tool(LLM 可调用)+ `/mode` command(用户输入)+ UI 快捷键三个切换入口 +- 引入 `ModeArtifact` 双层模型(Ref + Body)作为模式间结构化交接协议 +- 类型设计预留 SDK 自定义 mode 的扩展点 + +**Non-Goals:** + +- 不做 step 级工具切换 +- 不做 SandboxProfile / ReasoningEffort / CapabilityBudget +- 不做 PluginModeCatalog(但类型预留) +- 不做隐式意图识别 +- 不做 Phase 状态机(Explore → DraftPlan → ...),先只做三态切换 + +## Decisions + +### Decision 1: 模式真相归属 session-runtime + +**选择:** `SessionState.session_mode: CollaborationMode` 作为 durable truth + +**理由:** +- 模式满足三个条件:会跨 turn 持续存在、会影响后续 turn 行为、需要被恢复/重放/审计 +- SessionState 已经是 per-field `StdMutex` 模式,新增 `session_mode` 字段遵循现有风格 +- 模式切换通过 `StorageEvent::ModeChanged` 持久化,与现有事件模型一致 + +**替代方案:** +- 放在 AgentProfile → 被否决:profile 是稳定身份,不是临时阶段 +- 放在 PolicyEngine → 被否决:只能"拦住"不能"先收口",LLM 仍能看到不该看的工具 +- 放在 protocol/adapter → 被否决:传输层不该拥有模式真相 + +### Decision 2: 工具授予采用"只给"模式 + +**选择:** `ToolGrantRule` 枚举定义授予规则,compile 阶段白名单过滤 + +```rust +pub enum ToolGrantRule { + Named(String), + SideEffect(SideEffect), + All, +} +``` + +**理由:** +- 白名单模式比黑名单更安全——LLM 天然不知道未授予的工具 +- `SideEffect(None)` 可以一次性授予所有只读工具,不用逐个列名字 +- 与 `CapabilitySpec.side_effect` 字段对齐,无需新概念 +- 复用 `CapabilityRouter` 的 `subset_for_tools()` 能力 + +**替代方案:** +- 黑名单过滤 → 被否决:LLM 看到完整列表再被截断,产生"被剥夺"幻觉 +- 硬编码工具名列表 → 被否决:MCP 工具动态注册,无法静态枚举 + +### Decision 3: Turn 级工具切换 + Step 级 prompt override + +**选择:** 工具集在 turn 边界编译并固定;step 级仅通过 prompt override 影响行为指导 + +**理由:** +- `TurnExecutionResources.tools` 在 turn 创建时确定(`runner.rs:157`),改为可变需要大重构 +- `assemble_prompt_request` 每 step 都调,prompt directives 天然支持 per-step 变化 +- turn 级持久化更容易事件化、恢复、审计;step 级太容易把状态机炸复杂 +- LLM 调用 `switchMode` 后返回"下一 turn 将使用新工具集",当前 step 可继续(plan 的只读工具无副作用) + +**替代方案:** +- step 级工具切换 → 延后:需要让 `TurnExecutionResources.tools` 可变或每 step 重新编译,代价大 +- 只做 turn 级不做 step override → 被否决:step override 成本极低且给 LLM 即时反馈 + +### Decision 4: ModeArtifact 双层模型 + +**选择:** Ref(轻量引用,走事件/UI/审批)+ Body(完整负载,走 render_to_prompt) + +``` +ModeArtifactRef → StorageEvent 持久化、UI 展示、compact summary +ModeArtifactBody → Plan(PlanContent) / Review(ReviewContent) / Custom { schema_id, data } +``` + +**理由:** +- 复用 AstrCode 已有的 ArtifactRef + SubRunHandoff 模式 +- Builtin body 用强类型 Rust struct 保证编译期安全 +- Custom body 用 `{ schema_id, schema_version, data: Value }` 支持 SDK 扩展 +- `ArtifactRenderer` trait 负责将 Body 渲染成 `PromptDeclaration`,与现有 prompt 管道对接 +- 渲染分级(Summary/Compact/Full)应对不同 context 压力 + +**替代方案:** +- 纯 `serde_json::Value` → 被否决:消费方无类型信息,UI 和审批流失去稳定结构 +- 纯 tagged union 不带 Custom → 被否决:不支持 SDK 自定义 mode +- schema 验证在 compile time → 不可行:自定义 mode 的 schema 在运行时注册 + +### Decision 5: 统一模式切换入口 + +**选择:** 所有触发源(tool / command / UI 快捷键)汇聚到 `apply_mode_transition()` + +**理由:** +- 与 `submit_prompt_inner` 作为统一 submit 入口的模式一致 +- 统一做转换合法性验证、entry_policy 检查、事件广播 +- 三种触发源的差异仅在"如何到达这个函数",核心逻辑不重复 + +**实现位置:** `session-runtime/src/turn/mode_transition.rs` 新模块 + +### Decision 6: ModeCatalog trait 分层 + +**选择:** core 定义 trait,application 提供 BuiltinModeCatalog,未来 PluginModeCatalog + +```rust +// core +pub trait ModeCatalog: Send + Sync { + fn list_modes(&self) -> Vec; + fn resolve_mode(&self, id: &str) -> Option; +} + +// application +pub struct BuiltinModeCatalog { /* plan/execute/review */ } + +// TODO: 未来 +// pub struct PluginModeCatalog { /* 从 SDK 插件加载 */ } +``` + +**理由:** +- 与 `AgentProfileCatalog` trait 的分层模式完全一致 +- core 只定义接口和稳定类型,application 负责注册和生命周期 +- 预留 SDK 扩展点但不在本次实现 + +### Decision 7: 新增核心类型归属 core + +**新增文件:** `core/src/mode/mod.rs` + +包含:CollaborationMode 枚举、ModeSpec、ToolGrantRule、ModeEntryPolicy、ModeTransition、ModeArtifactRef、ModeArtifactBody、PlanContent、ReviewContent、ArtifactStatus、ArtifactRenderer trait、ModeCatalog trait + +**依赖方向:** +``` +core (types + traits) + ↑ +session-runtime (truth + compile + transition) + ↑ +application (catalog registration + orchestration wiring) +``` + +不引入新的 crate 边界。类型量不大,放 core 符合"跨 crate 稳定成立的语义"原则。 + +## Risks / Trade-offs + +**[Risk] Turn 级工具切换导致 LLM 在 plan mode 调用 switchMode("execute") 后仍需等一个 turn** +→ 缓解:switchMode tool 返回明确提示"模式已切换,下一 turn 将使用完整工具集"。当前 step 可继续产出方案文本。对大多数工作流来说,plan → execute 的 turn 边界切换是自然的。 + +**[Risk] ModeSpec 的 tool_grants 与动态 MCP 工具对齐** +→ 缓解:`ToolGrantRule::SideEffect(SideEffect::None)` 按类别授予而非按名称,MCP 工具只要 `side_effect == None` 就自动纳入 plan mode。但需要确保 MCP 工具的 side_effect 标注准确。 + +**[Risk] ModeArtifact Custom body 的 schema 验证** +→ 缓解:本次 MVP 不做 runtime schema validation。Builtin body 有 Rust 类型保障。Custom body 只在 SDK 扩展时出现,届时再加验证。 + +**[Risk] SessionState 新增字段对旧会话 replay 的影响** +→ 缓解:session_mode 默认 Execute,replay 旧会话时 `SessionState::new()` 使用默认值,不影响已有事件流。ModeChanged 事件只在模式切换时产生,旧会话不存在此事件。 + +**[Trade-off] 双层 Artifact 增加了序列化/反序列化复杂度** +→ 接受:Ref 和 Body 的使用场景确实不同(事件流 vs LLM 消费),合并成一个结构会导致"为了事件轻量而限制 Body 内容"或"为了 Body 丰富而让事件流膨胀"。拆开后各司其职。 + +## Open Questions + +1. **switchMode tool 的返回格式**:当 LLM 在 plan mode 调用 `switchMode("execute")` 时,tool result 应该返回什么?纯文本提示?还是结构化的"等待用户确认"状态?如果 execute 的 entry_policy 是 UserOnly,LLM 调用被拒绝时怎么反馈? + +2. **PlanArtifact 的 PlanContent schema**:steps 的结构化程度——是自由文本步骤列表,还是强类型 `{ description, files, risk }` 数组?强类型方便 UI 渲染,但 LLM 生成的准确性存疑。 + +3. **ModeMap prompt block 的缓存策略**:ModeMap(列出所有可用 mode)属于 SemiStable 层还是 Dynamic 层?如果自定义 mode 动态注册/卸载,ModeMap 需要每次 turn 重新生成。 + +4. **子 agent 是否继承父 agent 的 mode**:当父 agent 在 plan mode 下 spawn 子 agent,子 agent 应该是 execute mode(默认)还是继承 plan mode?建议默认 execute(子 agent 有自己的 scoped router),但需确认。 diff --git a/openspec/changes/collaboration-mode-system/proposal.md b/openspec/changes/collaboration-mode-system/proposal.md new file mode 100644 index 00000000..1e85bfcc --- /dev/null +++ b/openspec/changes/collaboration-mode-system/proposal.md @@ -0,0 +1,187 @@ +--- +name: collaboration-mode-system +created: "2026-04-18" +status: proposal +--- + +## Why + +AstrCode 目前缺少显式的"协作阶段"建模。当用户说"先别动代码,只做方案"时,系统无法在结构层面响应——只能靠提示词软约束。与此同时,Claude Code 已将 Plan Mode 做成权限模式之一(只读分析 + 先给方案),Codex 也以 approval modes 分层控制执行摩擦。 + +当前代码里"模式"能力实际散落在多处: +- `AgentProfile.allowed_tools` — 工具授权(身份级) +- `CapabilityRouter.subset_for_tools()` — 工具过滤(子 agent 用) +- `PolicyEngine.check_capability_call()` — 运行时审批(Allow/Deny/Ask) +- `PromptDeclaration` — 提示词指导 +- `AgentPromptSubmission` — 每 turn 的工具 + prompt + 执行限制(子 agent 包络雏形) + +这些能力存在但缺少一个统一的概念:**协作模式(CollaborationMode)**。 + +核心问题: +1. **没有显式模式概念** — 无法区分"正在规划"和"正在执行" +2. **工具可见性和可执行性分裂** — LLM 能看到写工具,调用时才被 PolicyEngine 拒绝,浪费 token +3. **方案不是结构化对象** — Plan 只是 LLM 输出的一段文本,不可审批、不可版本化、不可失效 +4. **模式不可扩展** — 无法让用户通过 SDK 自定义新模式 + +## What Changes + +引入 **Mode System**,以 `ModeSpec` 为一等规格对象,统一控制工具授予、提示词注入、转换规则和产出协议。 + +### 核心概念 + +**五条正交轴(本次实现前三条):** + +| 轴 | 含义 | 真相归属 | +|---|---|---| +| Profile | 我是谁(稳定身份) | `AgentProfile` | +| **CollaborationMode** | **处于什么阶段** | **`SessionState.session_mode`** | +| ModeArtifact | 模式间的结构化交接 | `ModeArtifactRef` (durable) + `ModeArtifactBody` | +| ApprovalPolicy | 哪些动作要审批 | `PolicyEngine`(已有,渐进增强) | +| SandboxProfile | 进程/文件/网络边界 | 未来 | + +### 三层架构 + +``` +core ModeSpec / CollaborationMode 枚举 / ModeArtifact / ToolGrantRule / ArtifactRenderer trait + ← 只放稳定语义词汇,不放 runtime 编排细节 + +session-runtime + SessionState.session_mode ← durable 真相 + compile_mode_spec() ← 编译 ModeSpec → visible_tools + prompt_directives + apply_mode_transition() ← 统一切换入口(tool/command/UI 快捷键汇聚) + ModeArtifactStore ← artifact 持久化与查询 + +application BuiltinModeCatalog ← plan/execute/review 的 ModeSpec 注册 + // TODO: PluginModeCatalog ← 未来 SDK 自定义 mode +``` + +### Builtin Modes + +**Plan 模式:** +- 工具授予:只读工具(readFile, grep, findFiles, listDir, toolSearch, webSearch) +- 提示词:强调只读分析、结构化方案、不修改代码 +- 进入策略:`LlmCanEnter` — LLM 遇到复杂/不确定任务时可自行进入 +- 产出:`ModeArtifact { kind: "plan" }` — 结构化方案对象 + +**Execute 模式(默认):** +- 工具授予:全部 +- 提示词:完整执行 +- 进入策略:默认模式 / 用户确认后切换 +- 产出:无 + +**Review 模式:** +- 工具授予:只读工具 +- 提示词:代码审查 +- 进入策略:`LlmCanEnter` — 用户要求 review 时自动进入 +- 产出:`ModeArtifact { kind: "review" }` + +### 模式切换机制 + +- **switchMode tool**:LLM 在 step 中调用,请求切换模式 +- **/mode \ command**:用户终端输入 +- **Shift+Tab 快捷键**:UI 操作 +- 全部汇聚到 `apply_mode_transition()`,验证转换合法性 + entry_policy + +### 模式粒度:Turn 持久 / Step Runtime Override + +- **Turn 级**:`SessionState.session_mode` 持久化,工具集在 turn 开始时编译,不可变 +- **Step 级**:`TurnExecutionContext.step_mode_override` 仅影响 prompt directives,不改变工具集,不持久化 +- 理由:`TurnExecutionResources.tools` 在 turn 开始时确定(`runner.rs:157`),改它代价大;step 级 prompt override 已经可行(每 step 都调 `assemble_prompt_request`) + +### ModeArtifact 双层模型 + +``` +ModeArtifactRef ← 轻量引用,走事件流、UI、审批 + artifact_id, source_mode, kind, status, summary + +ModeArtifactBody ← 完整负载,走 render_to_prompt() 给 LLM + Plan(PlanContent) ← 强类型 + Review(ReviewContent) ← 强类型 + Custom { schema_id, schema_version, data: Value } ← SDK 扩展 +``` + +- **Ref** 用于事件持久化、UI 展示、compact summary +- **Body** 通过 `ArtifactRenderer` trait 渲染成 `PromptDeclaration` 给 LLM 消费 +- 渲染分级:Summary(UI/compact)→ Compact(context 紧张)→ Full(context 充裕) + +### 工具授予策略 + +采用"只给"模式而非"过滤"模式——LLM 只看到当前 mode 授予的工具,天然不知道其他工具存在。 + +```rust +pub enum ToolGrantRule { + Named(String), // 按名称精确匹配 + SideEffect(SideEffect), // 按 side_effect 类别授予 + All, // 授予全部 +} +``` + +Plan 模式可声明 `SideEffect(None)` 只拿纯只读工具,不用逐个列名字。与 `CapabilitySpec.side_effect` 字段对齐。 + +## Non-goals + +- **不做** step 级工具切换(只在 turn 边界切换工具集,step 级仅影响 prompt) +- **不做** Phase 状态机(Explore → DraftPlan → AwaitPlanApproval → Execute → Verify → Done),先只做 Plan / Execute / Review 三态 +- **不做** SandboxProfile(进程/文件/网络沙箱边界),留 TODO +- **不做** ReasoningEffort(思考深度旋钮),留 TODO +- **不做** CapabilityBudget(文件数/命令数上限),留 TODO +- **不做** PluginModeCatalog(SDK 自定义 mode),但类型设计预留扩展点 +- **不做** 隐式意图识别("先别动代码"自动切 plan),先只做显式切换 + +## Capabilities + +### P1 — 核心模式系统 + +- `mode-spec`:core 新增 CollaborationMode 枚举、ModeSpec 结构、ToolGrantRule、ModeEntryPolicy、ModeTransition +- `mode-truth`:session-runtime SessionState 新增 session_mode 字段,通过 StorageEvent 持久化切换历史 +- `mode-compile`:session-runtime 新增 `compile_mode_spec()` 编译 ModeSpec → visible tools + prompt directives +- `mode-switch-tool`:新增 switchMode builtin tool,LLM 可调用请求切换 +- `mode-switch-command`:新增 /mode command 入口 +- `mode-prompt`:新增 ModeMap prompt block(告诉 LLM 有哪些 mode、何时使用)+ CurrentMode prompt block(当前约束) +- `builtin-modes`:application 注册 plan/execute/review 三个 builtin ModeSpec +- `mode-catalog`:core 新增 ModeCatalog trait + BuiltinModeCatalog 实现 + +### P2 — ModeArtifact + +- `artifact-types`:core 新增 ModeArtifactRef、ModeArtifactBody(含 PlanContent/ReviewContent/Custom)、ArtifactStatus +- `artifact-renderer`:core 新增 ArtifactRenderer trait + builtin PlanArtifactRenderer 实现 +- `artifact-store`:session-runtime 管理 active_artifacts,通过 StorageEvent 持久化 +- `artifact-prompt-injection`:execute mode 从 active_artifacts 中查找 plan artifact,注入 prompt + +### P3 — 统一切换入口 + 审批流 + +- `mode-transition`:session-runtime 新增 `apply_mode_transition()` 统一入口 +- `mode-transition-validation`:验证转换合法性(ModeSpec.transitions)+ entry_policy 检查 +- `mode-ui-integration`:前端 Shift+Tab 快捷键 → API → apply_mode_transition +- `artifact-accept-flow`:plan artifact 的 Accept/Reject 状态转换 + +## Impact + +**用户可见影响:** +- 新增 `/mode plan`、`/mode execute`、`/mode review` 命令 +- Plan 模式下 LLM 只做分析不做修改,体验更安全 +- LLM 可以在复杂任务时自行进入 Plan 模式 +- 方案产出后用户可在 UI 中审批,确认后切换 Execute 执行 + +**开发者可见影响:** +- core 新增 `mode` 模块(CollaborationMode, ModeSpec, ToolGrantRule 等) +- session-runtime 的 `SessionState` 新增 `session_mode` 字段 +- session-runtime 新增 `mode_transition.rs` 模块 +- `TurnExecutionResources` 的 tools 编译逻辑从直接读 gateway 改为经 mode compile +- `AssemblePromptRequest` 新增 mode 相关 prompt declarations +- `StorageEventPayload` 新增 `ModeChanged`、`ModeArtifactCreated` 变体 +- 新增 `switchMode` builtin tool + +**架构影响:** +- 模式真相落在 session-runtime(符合"会随对话推进变化、影响后续 turn 行为、需要恢复/重放/审计"的判定标准) +- Profile 保持稳定身份不变,不被临时阶段污染 +- PolicyEngine 保持现有职责,未来可渐进增强为 per-mode 策略 +- 类型设计预留 SDK 扩展点(Custom artifact body、ModeCatalog trait) + +## Migration + +无破坏性迁移: +- 默认 mode 为 Execute,现有行为完全不受影响 +- session_mode 字段为新增,旧会话 replay 时默认 Execute +- switchMode tool 为新增 builtin tool,不影响现有工具注册 +- ModeArtifact 为新增存储类型,不影响现有事件流 diff --git a/openspec/changes/collaboration-mode-system/specs/artifact-prompt-injection/spec.md b/openspec/changes/collaboration-mode-system/specs/artifact-prompt-injection/spec.md new file mode 100644 index 00000000..9859bf3f --- /dev/null +++ b/openspec/changes/collaboration-mode-system/specs/artifact-prompt-injection/spec.md @@ -0,0 +1,29 @@ +## ADDED Requirements + +### Requirement: Execute 模式自动引用 accepted plan artifact +系统 SHALL 在 Execute 模式的 prompt 编译阶段,自动查找 active_artifacts 中 status=Accepted 且 kind="plan" 的 artifact,并将其 Full 级渲染注入 prompt。 + +#### Scenario: 存在 accepted plan 时注入 +- **WHEN** 当前模式为 Execute +- **AND** active_artifacts 包含 kind="plan", status=Accepted 的 artifact +- **THEN** prompt 中包含该 plan 的完整步骤、假设和风险说明 + +#### Scenario: 不存在 accepted plan 时不注入 +- **WHEN** 当前模式为 Execute +- **AND** active_artifacts 中没有 accepted 的 plan artifact +- **THEN** prompt 中不包含 plan 引用块 + +#### Scenario: 多个 accepted plan 只注入最新的 +- **WHEN** active_artifacts 包含多个 accepted plan artifact +- **THEN** 只注入 artifact_id 最大的(最新创建的)那个 + +### Requirement: Plan artifact 注入为 Dynamic 层 PromptDeclaration +系统 SHALL 将 plan artifact 渲染为 `PromptDeclaration`,属性为: +- `block_id`: "mode.artifact.plan" +- `layer`: SystemPromptLayer::Dynamic +- `kind`: PromptDeclarationKind::ExtensionInstruction +- `always_include`: true + +#### Scenario: 渲染内容包含步骤列表 +- **WHEN** plan artifact 的 PlanContent 有 3 个步骤 +- **THEN** 注入的 PromptDeclaration content 包含这 3 个步骤的描述 diff --git a/openspec/changes/collaboration-mode-system/specs/artifact-renderer/spec.md b/openspec/changes/collaboration-mode-system/specs/artifact-renderer/spec.md new file mode 100644 index 00000000..22a4482c --- /dev/null +++ b/openspec/changes/collaboration-mode-system/specs/artifact-renderer/spec.md @@ -0,0 +1,28 @@ +## ADDED Requirements + +### Requirement: ArtifactRenderer trait 定义渲染接口 +系统 SHALL 定义 `ArtifactRenderer` trait,包含: +- `render(body: &ModeArtifactBody, level: RenderLevel) -> String` + +`RenderLevel` 枚举包含:Summary、Compact、Full。 + +#### Scenario: Plan artifact Summary 级渲染 +- **WHEN** PlanArtifactRenderer 以 RenderLevel::Summary 渲染一个 PlanContent +- **THEN** 输出为 1-2 句摘要文本 + +#### Scenario: Plan artifact Full 级渲染 +- **WHEN** PlanArtifactRenderer 以 RenderLevel::Full 渲染一个 PlanContent +- **THEN** 输出包含完整步骤列表、假设、风险说明 + +### Requirement: 渲染结果可作为 PromptDeclaration 注入 +系统 SHALL 将 ArtifactRenderer 的输出封装为 PromptDeclaration,注入到 consuming mode 的 prompt 中。 + +#### Scenario: Accepted plan artifact 注入到 Execute mode prompt +- **WHEN** 当前模式为 Execute +- **AND** active_artifacts 中存在一个 status=Accepted 的 plan artifact +- **THEN** plan artifact 的 Full 级渲染作为 PromptDeclaration 注入到 system prompt + +#### Scenario: Compact 级渲染用于 auto compact 后 +- **WHEN** 会话经历 auto compact +- **AND** active_artifacts 中存在 plan artifact +- **THEN** compact summary 使用 Summary 级渲染引用 plan artifact diff --git a/openspec/changes/collaboration-mode-system/specs/artifact-store/spec.md b/openspec/changes/collaboration-mode-system/specs/artifact-store/spec.md new file mode 100644 index 00000000..1b2901c4 --- /dev/null +++ b/openspec/changes/collaboration-mode-system/specs/artifact-store/spec.md @@ -0,0 +1,33 @@ +## ADDED Requirements + +### Requirement: SessionState 管理 active_artifacts +系统 SHALL 在 `SessionState` 中新增 `active_artifacts: StdMutex>` 字段,跟踪当前会话的活跃 artifact。 + +#### Scenario: artifact 创建后加入 active_artifacts +- **WHEN** Plan 模式产出一个新的 ModeArtifact +- **THEN** 其 ModeArtifactRef 被添加到 active_artifacts + +#### Scenario: artifact 状态变更同步更新 +- **WHEN** 一个 active artifact 从 Draft 变为 Accepted +- **THEN** active_artifacts 中对应的 ref 的 status 更新为 Accepted + +#### Scenario: artifact 被 Superseded 后保留但标记 +- **WHEN** 新 plan 产出后旧 plan 被标记为 Superseded +- **THEN** 旧 plan 仍在 active_artifacts 中,但 status 为 Superseded + +### Requirement: Artifact 变更通过 StorageEvent 持久化 +系统 SHALL 在 `StorageEventPayload` 中新增以下变体: +- `ModeArtifactCreated { ref: ModeArtifactRef, body: ModeArtifactBody, timestamp }` +- `ModeArtifactStatusChanged { artifact_id, from_status, to_status, timestamp }` + +#### Scenario: artifact 创建产生事件 +- **WHEN** Plan 模式产出 ModeArtifact +- **THEN** 一条 ModeArtifactCreated 事件被持久化 + +#### Scenario: artifact 状态变更产生事件 +- **WHEN** 用户接受一个 plan artifact +- **THEN** 一条 ModeArtifactStatusChanged { from: Draft, to: Accepted } 事件被持久化 + +#### Scenario: 旧会话 replay 不受影响 +- **WHEN** replay 不包含 artifact 事件的旧会话 +- **THEN** active_artifacts 为空列表,不报错 diff --git a/openspec/changes/collaboration-mode-system/specs/artifact-types/spec.md b/openspec/changes/collaboration-mode-system/specs/artifact-types/spec.md new file mode 100644 index 00000000..62065db2 --- /dev/null +++ b/openspec/changes/collaboration-mode-system/specs/artifact-types/spec.md @@ -0,0 +1,54 @@ +## ADDED Requirements + +### Requirement: ModeArtifactRef 轻量引用 +系统 SHALL 定义 `ModeArtifactRef` 结构体,包含: +- `artifact_id`: String +- `source_mode`: String(产出模式 ID) +- `kind`: String(artifact 类型) +- `status`: ArtifactStatus(Draft | Accepted | Rejected | Superseded) +- `summary`: String(人可读摘要) + +#### Scenario: ModeArtifactRef 可序列化为 JSON +- **WHEN** 一个 ModeArtifactRef 被序列化 +- **THEN** JSON 包含 artifactId、sourceMode、kind、status、summary 字段 + +#### Scenario: ModeArtifactRef 可嵌入 StorageEvent +- **WHEN** artifact 创建或状态变更 +- **THEN** ModeArtifactRef 作为 StorageEvent payload 的一部分被持久化 + +### Requirement: ModeArtifactBody 完整负载 +系统 SHALL 定义 `ModeArtifactBody` 枚举: +- `Plan(PlanContent)`: 方案内容 +- `Review(ReviewContent)`: 审查内容 +- `Custom { schema_id, schema_version, data: Value }`: SDK 扩展 + +#### Scenario: Plan body 包含结构化步骤 +- **WHEN** Plan(PlanContent) 被构造 +- **THEN** PlanContent 包含 steps、assumptions、open_questions、touched_paths、risk_notes 字段 + +#### Scenario: Custom body 携带 schema 信息 +- **WHEN** Custom body 被构造 +- **THEN** 包含 schema_id(标识内容格式)、schema_version、data(自由 JSON) + +### Requirement: PlanContent 强类型方案结构 +系统 SHALL 定义 `PlanContent` 结构体,包含: +- `steps: Vec`(步骤列表) +- `assumptions: Vec`(假设条件) +- `open_questions: Vec`(待确认问题) +- `touched_paths: Vec`(涉及文件路径) +- `risk_notes: Vec`(风险说明) + +#### Scenario: PlanStep 包含描述和风险等级 +- **WHEN** PlanStep 被构造 +- **THEN** 包含 description 字段 + +### Requirement: ArtifactStatus 状态枚举 +系统 SHALL 定义 `ArtifactStatus` 枚举:Draft | Accepted | Rejected | Superseded。 + +#### Scenario: 新建 artifact 默认 Draft +- **WHEN** artifact 被创建 +- **THEN** status 为 Draft + +#### Scenario: 状态转换是单向的 +- **WHEN** artifact 从 Draft 转为 Accepted +- **THEN** 后续不能再改回 Draft diff --git a/openspec/changes/collaboration-mode-system/specs/builtin-modes/spec.md b/openspec/changes/collaboration-mode-system/specs/builtin-modes/spec.md new file mode 100644 index 00000000..06742f74 --- /dev/null +++ b/openspec/changes/collaboration-mode-system/specs/builtin-modes/spec.md @@ -0,0 +1,37 @@ +## ADDED Requirements + +### Requirement: BuiltinModeCatalog 注册三个预定义模式 +系统 SHALL 提供 `BuiltinModeCatalog` 实现,注册以下三个 ModeSpec: + +**Plan 模式:** +- tool_grants: SideEffect(None) + Named("toolSearch") +- system_directive: 只读分析、结构化方案、不修改代码 +- entry_policy: LlmCanEnter +- transitions: → execute (requires_confirmation=true), → review (requires_confirmation=false) +- output_artifact_kind: Some("plan") + +**Execute 模式:** +- tool_grants: All +- system_directive: 完整执行权限 +- entry_policy: 默认模式 +- transitions: → plan (requires_confirmation=false), → review (requires_confirmation=false) +- output_artifact_kind: None + +**Review 模式:** +- tool_grants: SideEffect(None) + Named("toolSearch") +- system_directive: 代码审查、质量检查 +- entry_policy: LlmCanEnter +- transitions: → execute (requires_confirmation=true), → plan (requires_confirmation=false) +- output_artifact_kind: Some("review") + +#### Scenario: list_modes 返回三个模式 +- **WHEN** BuiltinModeCatalog.list_modes() 被调用 +- **THEN** 返回包含 plan、execute、review 三个 ModeSpec 的列表 + +#### Scenario: resolve_mode 找到指定模式 +- **WHEN** BuiltinModeCatalog.resolve_mode("plan") 被调用 +- **THEN** 返回 Some(ModeSpec { id: "plan", ... }) + +#### Scenario: resolve_mode 找不到不存在的模式 +- **WHEN** BuiltinModeCatalog.resolve_mode("nonexistent") 被调用 +- **THEN** 返回 None diff --git a/openspec/changes/collaboration-mode-system/specs/mode-catalog/spec.md b/openspec/changes/collaboration-mode-system/specs/mode-catalog/spec.md new file mode 100644 index 00000000..f19b1433 --- /dev/null +++ b/openspec/changes/collaboration-mode-system/specs/mode-catalog/spec.md @@ -0,0 +1,17 @@ +## ADDED Requirements + +### Requirement: ModeCatalog trait 定义模式发现接口 +系统 SHALL 在 core 中定义 `ModeCatalog` trait,包含以下方法: +- `list_modes() -> Vec`:列出所有可用模式 +- `resolve_mode(id: &str) -> Option`:按 ID 查找模式 + +#### Scenario: ModeCatalog 可被 Arc 包装共享 +- **WHEN** BuiltinModeCatalog 被 Arc 包装 +- **THEN** 可跨线程安全地调用 list_modes 和 resolve_mode + +### Requirement: ModeCatalog 在 bootstrap 阶段注册 +系统 SHALL 在 server bootstrap 阶段创建 BuiltinModeCatalog 并将其注入到需要消费模式信息的组件中。 + +#### Scenario: ModeCatalog 通过 PromptFactsProvider 消费 +- **WHEN** PromptFactsProvider 需要生成 ModeMap prompt block +- **THEN** 它通过 ModeCatalog trait 获取可用模式列表,不依赖具体实现 diff --git a/openspec/changes/collaboration-mode-system/specs/mode-compile/spec.md b/openspec/changes/collaboration-mode-system/specs/mode-compile/spec.md new file mode 100644 index 00000000..e3656bcf --- /dev/null +++ b/openspec/changes/collaboration-mode-system/specs/mode-compile/spec.md @@ -0,0 +1,60 @@ +## ADDED Requirements + +### Requirement: compile_mode_spec 编译模式执行规格 +系统 SHALL 提供 `compile_mode_spec()` 函数,将 ModeSpec + 全部注册工具编译为 `ModeExecutionSpec`,包含: +- `visible_tools`: 当前模式可见的工具定义列表 +- `mode_prompt`: 当前模式的约束 PromptDeclaration +- `mode_map_prompt`: 所有可用模式描述的 PromptDeclaration + +#### Scenario: Plan 模式编译只读工具 +- **WHEN** compile_mode_spec 以 ModeSpec("plan") 和包含 readFile + writeFile + shell 的工具注册表调用 +- **THEN** visible_tools 仅包含 readFile,不包含 writeFile 和 shell + +#### Scenario: Execute 模式编译全部工具 +- **WHEN** compile_mode_spec 以 ModeSpec("execute") 调用 +- **THEN** visible_tools 包含所有注册的工具 + +### Requirement: 工具编译使用授予白名单 +系统 SHALL 通过 ToolGrantRule 白名单机制过滤工具。不在白名单中的工具不会出现在 visible_tools 中。 + +#### Scenario: Named 规则精确匹配 +- **WHEN** tool_grants 包含 ToolGrantRule::Named("grep") +- **AND** 注册表中存在 grep 工具 +- **THEN** grep 出现在 visible_tools 中 + +#### Scenario: Named 规则匹配不存在的工具 +- **WHEN** tool_grants 包含 ToolGrantRule::Named("nonexistent") +- **AND** 注册表中不存在该工具 +- **THEN** 编译不报错,该规则被忽略 + +#### Scenario: SideEffect 规则按类别过滤 +- **WHEN** tool_grants 包含 ToolGrantRule::SideEffect(None) +- **AND** 注册表中 readFile 的 side_effect 为 None,writeFile 的 side_effect 为 Workspace +- **THEN** visible_tools 包含 readFile,不包含 writeFile + +### Requirement: 编译注入当前模式约束 prompt +系统 SHALL 为当前模式生成一个 Dynamic 层的 PromptDeclaration,包含: +- `block_id`: "mode.current_constraint" +- `content`: ModeSpec.system_directive 的内容 +- `layer`: SystemPromptLayer::Dynamic + +#### Scenario: Plan 模式注入只读约束 +- **WHEN** 当前模式为 Plan +- **THEN** 生成的 PromptDeclaration content 包含"只读分析"相关约束文本 + +### Requirement: 编译注入模式地图 prompt +系统 SHALL 生成一个 SemiStable 层的 PromptDeclaration,列出所有可用模式及其说明,包含: +- `block_id`: "mode.available_modes" +- `content`: 从 ModeCatalog.list_modes() 生成的模式描述 +- `layer`: SystemPromptLayer::SemiStable + +#### Scenario: 模式地图包含三个 builtin 模式 +- **WHEN** ModeCatalog 包含 plan/execute/review 三个模式 +- **THEN** 生成的 PromptDeclaration content 包含这三个模式的名称和描述 + +### Requirement: 编译结果集成到 TurnExecutionResources +系统 SHALL 在 turn 开始时调用 compile_mode_spec,将 visible_tools 替换 TurnExecutionResources 中的 tools 字段。 + +#### Scenario: Turn 开始时工具按模式编译 +- **WHEN** 新 turn 以 session_mode=Plan 启动 +- **THEN** TurnExecutionResources.tools 仅包含 Plan 模式授予的工具 diff --git a/openspec/changes/collaboration-mode-system/specs/mode-prompt/spec.md b/openspec/changes/collaboration-mode-system/specs/mode-prompt/spec.md new file mode 100644 index 00000000..47ada2b1 --- /dev/null +++ b/openspec/changes/collaboration-mode-system/specs/mode-prompt/spec.md @@ -0,0 +1,35 @@ +## ADDED Requirements + +### Requirement: ModeMap prompt block 注入可用模式描述 +系统 SHALL 在每 turn 的 prompt 中注入一个 "Available Modes" block(SemiStable 层),内容包含: +- 每个可用模式的名称和简短描述 +- 每个模式的适用场景 +- 哪些模式 LLM 可自行进入,哪些需要用户操作 +- 切换方式说明 + +#### Scenario: ModeMap 包含三个 builtin 模式 +- **WHEN** BuiltinModeCatalog 包含 plan/execute/review +- **THEN** ModeMap prompt block 包含这三个模式的名称、描述和进入策略 + +#### Scenario: LLM 能理解何时进入 Plan 模式 +- **WHEN** ModeMap prompt block 被注入到 system prompt +- **THEN** 内容包含"当任务复杂、涉及多文件、或不确定最佳方案时"类似描述 + +### Requirement: CurrentMode prompt block 注入当前约束 +系统 SHALL 在每 turn 的 prompt 中注入一个 "Current Mode" block(Dynamic 层),内容包含: +- 当前模式名称 +- 当前模式的核心约束 +- 如果有 active_artifacts,引用其 summary + +#### Scenario: Plan 模式注入只读约束 +- **WHEN** 当前模式为 Plan +- **THEN** CurrentMode block 内容包含"只使用只读工具"、"不修改文件"等约束 + +#### Scenario: Execute 模式引用已接受的 plan artifact +- **WHEN** 当前模式为 Execute +- **AND** active_artifacts 中存在一个 status=Accepted 的 plan artifact +- **THEN** CurrentMode block 引用该 plan 的 summary + +#### Scenario: 模式切换后 prompt 自动更新 +- **WHEN** session_mode 从 Plan 切换到 Execute +- **THEN** 下一个 step 的 prompt 中 CurrentMode block 内容更新为 Execute 的约束 diff --git a/openspec/changes/collaboration-mode-system/specs/mode-spec/spec.md b/openspec/changes/collaboration-mode-system/specs/mode-spec/spec.md new file mode 100644 index 00000000..17ae6e89 --- /dev/null +++ b/openspec/changes/collaboration-mode-system/specs/mode-spec/spec.md @@ -0,0 +1,74 @@ +## ADDED Requirements + +### Requirement: CollaborationMode 枚举定义协作阶段 +系统 SHALL 定义 `CollaborationMode` 枚举,包含 `Plan`、`Execute`、`Review` 三个变体,作为跨 crate 稳定成立的语义词汇。 + +#### Scenario: 默认模式为 Execute +- **WHEN** 新会话创建时 +- **THEN** `CollaborationMode` 默认值为 `Execute` + +#### Scenario: 枚举可序列化为 camelCase +- **WHEN** `CollaborationMode::Plan` 被序列化为 JSON +- **THEN** 输出为 `"plan"` + +### Requirement: ModeSpec 声明式定义模式规格 +系统 SHALL 定义 `ModeSpec` 结构体,包含以下字段: +- `id`: 唯一标识(如 "plan") +- `name`: 人类可读名称 +- `description`: 模式说明(供 LLM 理解何时使用) +- `tool_grants`: `Vec` 工具授予规则 +- `system_directive`: 模式约束提示词 +- `entry_policy`: `ModeEntryPolicy` 进入策略 +- `transitions`: `Vec` 合法转换规则 +- `output_artifact_kind`: `Option` 产出 artifact 的 kind + +#### Scenario: ModeSpec 完整序列化 +- **WHEN** 一个包含所有字段的 ModeSpec 被序列化 +- **THEN** JSON 输出包含 toolGrants、systemDirective、entryPolicy、transitions、outputArtifactKind 等字段 + +#### Scenario: ModeSpec 缺失可选字段 +- **WHEN** 一个没有 output_artifact_kind 的 ModeSpec 被序列化 +- **THEN** JSON 中不包含 outputArtifactKind 字段 + +### Requirement: ToolGrantRule 定义工具授予策略 +系统 SHALL 定义 `ToolGrantRule` 枚举,包含三个变体: +- `Named(String)`: 按工具名称精确匹配 +- `SideEffect(SideEffect)`: 按 CapabilitySpec 的 side_effect 类别授予 +- `All`: 授予全部工具 + +#### Scenario: SideEffect(None) 授予所有只读工具 +- **WHEN** ToolGrantRule::SideEffect(None) 被用于编译工具列表 +- **AND** 注册表中存在 readFile(side_effect=None)和 writeFile(side_effect=Workspace) +- **THEN** readFile 被授予,writeFile 被排除 + +#### Scenario: Named 授予指定工具 +- **WHEN** ToolGrantRule::Named("readFile") 被用于编译工具列表 +- **AND** 注册表中存在 readFile +- **THEN** readFile 被授予 + +### Requirement: ModeEntryPolicy 定义进入策略 +系统 SHALL 定义 `ModeEntryPolicy` 枚举,包含三个变体: +- `LlmCanEnter`: LLM 可自行进入 +- `UserOnly`: 仅用户可触发 +- `LlmSuggestWithConfirmation`: LLM 可建议但需用户确认 + +#### Scenario: LlmCanEnter 模式下 LLM 调用 switchMode +- **WHEN** LLM 通过 switchMode tool 请求进入一个 entry_policy 为 LlmCanEnter 的模式 +- **THEN** 切换被允许,无需用户确认 + +#### Scenario: UserOnly 模式下 LLM 调用 switchMode +- **WHEN** LLM 通过 switchMode tool 请求进入一个 entry_policy 为 UserOnly 的模式 +- **THEN** 切换被拒绝,tool 返回错误信息"此模式需要用户手动切换" + +### Requirement: ModeTransition 定义合法转换规则 +系统 SHALL 定义 `ModeTransition` 结构体,包含: +- `target_mode`: 目标模式 ID +- `requires_confirmation`: 是否需要确认 + +#### Scenario: Plan → Execute 转换需要确认 +- **WHEN** ModeSpec("plan") 的 transitions 包含 `{ target_mode: "execute", requires_confirmation: true }` +- **THEN** 从 plan 切换到 execute 需要用户确认 + +#### Scenario: Execute → Plan 转换不需要确认 +- **WHEN** ModeSpec("execute") 的 transitions 包含 `{ target_mode: "plan", requires_confirmation: false }` +- **THEN** LLM 可直接从 execute 切换到 plan diff --git a/openspec/changes/collaboration-mode-system/specs/mode-switch-command/spec.md b/openspec/changes/collaboration-mode-system/specs/mode-switch-command/spec.md new file mode 100644 index 00000000..4c11b968 --- /dev/null +++ b/openspec/changes/collaboration-mode-system/specs/mode-switch-command/spec.md @@ -0,0 +1,31 @@ +## ADDED Requirements + +### Requirement: /mode 命令切换协作模式 +系统 SHALL 支持用户通过 `/mode ` 命令切换当前会话的协作模式。 + +#### Scenario: 用户成功切换到 plan 模式 +- **WHEN** 用户输入 "/mode plan" +- **AND** 当前模式为 execute +- **THEN** session_mode 切换到 Plan +- **AND** 一条 ModeChanged 事件被持久化,source 为 User + +#### Scenario: 用户切换到当前已处于的模式 +- **WHEN** 用户输入 "/mode execute" +- **AND** 当前模式已经是 execute +- **THEN** 系统返回提示"已处于 execute 模式",不产生事件 + +#### Scenario: 用户切换到不存在的模式 +- **WHEN** 用户输入 "/mode nonexistent" +- **THEN** 系统返回错误"未知模式: nonexistent" + +#### Scenario: /mode 不带参数显示当前模式 +- **WHEN** 用户输入 "/mode" 不带参数 +- **THEN** 系统返回当前模式名称和描述 + +### Requirement: /mode 命令绕过 entry_policy 检查 +系统 SHALL 让用户通过 /mode 命令的切换不受 ModeEntryPolicy 限制。用户始终可以切换到任何可用模式。 + +#### Scenario: 用户直接切换到 UserOnly 模式 +- **WHEN** 用户输入 "/mode plan" +- **AND** plan 模式的 entry_policy 是 UserOnly(假设) +- **THEN** 切换成功,因为用户手动操作绕过 entry_policy diff --git a/openspec/changes/collaboration-mode-system/specs/mode-switch-tool/spec.md b/openspec/changes/collaboration-mode-system/specs/mode-switch-tool/spec.md new file mode 100644 index 00000000..80a71831 --- /dev/null +++ b/openspec/changes/collaboration-mode-system/specs/mode-switch-tool/spec.md @@ -0,0 +1,33 @@ +## ADDED Requirements + +### Requirement: switchMode builtin tool +系统 SHALL 提供 `switchMode` builtin tool,允许 LLM 在 step 中请求切换模式。工具参数: +- `mode`: 目标模式名称(字符串) +- `reason`: 切换原因(可选) + +#### Scenario: LLM 成功切换到允许的模式 +- **WHEN** LLM 调用 switchMode("plan", "任务复杂,需要先做方案") +- **AND** plan 模式的 entry_policy 为 LlmCanEnter +- **THEN** tool 返回成功,内容为"模式已切换到 plan,下一 turn 将使用新工具集" + +#### Scenario: LLM 请求切换到 UserOnly 模式被拒绝 +- **WHEN** LLM 调用 switchMode("execute", "准备执行") +- **AND** execute 模式从 plan 切换的 transition requires_confirmation=true +- **THEN** tool 返回错误"此切换需要用户确认,请提示用户使用 /mode execute" + +#### Scenario: LLM 请求切换到不存在的模式 +- **WHEN** LLM 调用 switchMode("nonexistent", ...) +- **THEN** tool 返回错误"未知模式: nonexistent" + +#### Scenario: switchMode 产生 StorageEvent +- **WHEN** switchMode 成功执行 +- **THEN** 一条 ModeChanged 事件被持久化,source 为 Tool + +### Requirement: switchMode 不改变当前 step 的工具 +系统 SHALL 保证 switchMode 在当前 step 内不改变工具集。工具切换在下一个 turn 开始时生效。 + +#### Scenario: Plan 模式下 switchMode("execute") 后当前 step 工具不变 +- **WHEN** LLM 在 Plan 模式下的 step 3 调用 switchMode("execute") +- **THEN** step 3 后续的工具调用仍然只使用 Plan 模式的工具 +- **AND** session_mode 已更新为 Execute +- **AND** 下一个 turn 开始时编译出 Execute 模式的完整工具集 diff --git a/openspec/changes/collaboration-mode-system/specs/mode-transition/spec.md b/openspec/changes/collaboration-mode-system/specs/mode-transition/spec.md new file mode 100644 index 00000000..e165cce6 --- /dev/null +++ b/openspec/changes/collaboration-mode-system/specs/mode-transition/spec.md @@ -0,0 +1,55 @@ +## ADDED Requirements + +### Requirement: apply_mode_transition 统一模式切换入口 +系统 SHALL 提供 `apply_mode_transition()` 函数作为所有模式切换的统一入口,参数包含: +- `session_state`: 目标会话状态 +- `target_mode`: 目标 CollaborationMode +- `source`: ModeTransitionSource(Tool / User / UI) +- `translator`: EventTranslator + +#### Scenario: 内部执行流程 +- **WHEN** apply_mode_transition 被调用 +- **THEN** 按序执行:验证转换合法性 → 检查 entry_policy → 更新 session_mode → 广播 ModeChanged 事件 + +### Requirement: 转换合法性验证 +系统 SHALL 验证目标模式在当前模式的 ModeSpec.transitions 中是否合法。 + +#### Scenario: 合法转换通过 +- **WHEN** 当前模式为 Plan,目标为 Execute +- **AND** ModeSpec("plan").transitions 包含 target_mode="execute" +- **THEN** 验证通过 + +#### Scenario: 非法转换被拒绝 +- **WHEN** 当前模式为 Plan,目标为某个不在 transitions 列表中的模式 +- **THEN** 返回错误"不允许从 plan 切换到 " + +### Requirement: entry_policy 检查 +系统 SHALL 根据目标模式的 entry_policy 和 source 判断是否允许切换。 + +#### Scenario: LlmCanEnter + source=Tool 允许 +- **WHEN** 目标模式 entry_policy 为 LlmCanEnter +- **AND** source 为 Tool(LLM 调用) +- **THEN** 允许切换 + +#### Scenario: UserOnly + source=Tool 拒绝 +- **WHEN** 目标模式 entry_policy 为 UserOnly +- **AND** source 为 Tool(LLM 调用) +- **THEN** 拒绝切换,返回"需要用户手动切换" + +#### Scenario: UserOnly + source=User 允许 +- **WHEN** 目标模式 entry_policy 为 UserOnly +- **AND** source 为 User(/mode 命令或 UI) +- **THEN** 允许切换 + +### Requirement: transition requires_confirmation 检查 +系统 SHALL 检查当前模式到目标模式的 transition 是否标记 requires_confirmation=true。如果是且 source=Tool,要求 LLM 提示用户确认。 + +#### Scenario: requires_confirmation=true + source=Tool 需要提示 +- **WHEN** Plan → Execute 的 transition 标记 requires_confirmation=true +- **AND** source 为 Tool +- **THEN** 返回提示"此切换需要用户确认" + +#### Scenario: requires_confirmation=true + source=User 直接通过 +- **WHEN** Plan → Execute 的 transition 标记 requires_confirmation=true +- **AND** source 为 User +- **THEN** 直接通过,用户操作隐含确认 diff --git a/openspec/changes/collaboration-mode-system/specs/mode-truth/spec.md b/openspec/changes/collaboration-mode-system/specs/mode-truth/spec.md new file mode 100644 index 00000000..0e12839d --- /dev/null +++ b/openspec/changes/collaboration-mode-system/specs/mode-truth/spec.md @@ -0,0 +1,34 @@ +## ADDED Requirements + +### Requirement: SessionState 持有当前协作模式 +系统 SHALL 在 `SessionState` 中新增 `session_mode: StdMutex` 字段,默认值为 `Execute`。 + +#### Scenario: 新会话默认 Execute 模式 +- **WHEN** SessionState::new() 被调用 +- **THEN** session_mode 值为 CollaborationMode::Execute + +#### Scenario: 读取当前模式 +- **WHEN** 调用 session_state.current_mode() +- **THEN** 返回当前 session_mode 的值 + +### Requirement: 模式切换通过 StorageEvent 持久化 +系统 SHALL 在 `StorageEventPayload` 中新增 `ModeChanged` 变体,包含: +- `from`: 切换前的 CollaborationMode +- `to`: 切换后的 CollaborationMode +- `source`: ModeTransitionSource(Tool / User | UI) +- `timestamp` + +#### Scenario: 模式切换产生事件 +- **WHEN** session_mode 从 Execute 切换到 Plan +- **THEN** 一条 `ModeChanged { from: Execute, to: Plan, source: Tool }` 事件被写入 storage + +#### Scenario: 旧会话 replay 不受影响 +- **WHEN** replay 不包含 ModeChanged 事件的旧会话 +- **THEN** session_mode 保持默认值 Execute,不报错 + +### Requirement: 模式切换是原子操作 +系统 SHALL 保证模式切换过程中,session_mode 的更新和事件的持久化在同一个锁范围内完成。 + +#### Scenario: 并发切换请求的串行化 +- **WHEN** 两个并发的 switchMode 请求同时到达 +- **THEN** 第二个请求 MUST 等待第一个完成后再执行,不会出现中间状态 diff --git a/openspec/changes/collaboration-mode-system/tasks.md b/openspec/changes/collaboration-mode-system/tasks.md new file mode 100644 index 00000000..6f333a79 --- /dev/null +++ b/openspec/changes/collaboration-mode-system/tasks.md @@ -0,0 +1,98 @@ +## 1. Core 类型基础 + +- [ ] 1.1 在 `crates/core/src/mode/mod.rs` 创建 mode 模块,定义 `CollaborationMode` 枚举(Plan/Execute/Review)、`ModeEntryPolicy`、`ModeTransition`、`ArtifactStatus`、`RenderLevel`、`ModeTransitionSource` +- [ ] 1.2 在 `crates/core/src/mode/mod.rs` 定义 `ToolGrantRule` 枚举(Named/SideEffect/All) +- [ ] 1.3 在 `crates/core/src/mode/mod.rs` 定义 `ModeSpec` 结构体 +- [ ] 1.4 在 `crates/core/src/mode/mod.rs` 定义 `PlanContent`、`ReviewContent`、`ModeArtifactBody`、`ModeArtifactRef` 结构体 +- [ ] 1.5 在 `crates/core/src/mode/mod.rs` 定义 `ArtifactRenderer` trait 和 `ModeCatalog` trait +- [ ] 1.6 在 `crates/core/src/lib.rs` 注册 mode 模块并 re-export 公共类型 +- [ ] 1.7 在 `crates/core/src/policy/engine.rs` 的 `StorageEventPayload` 中新增 `ModeChanged`、`ModeArtifactCreated`、`ModeArtifactStatusChanged` 变体 + +**验证:** `cargo check -p astrcode-core` 通过 + +## 2. Session-Runtime 模式真相 + +- [ ] 2.1 在 `crates/session-runtime/src/state/mod.rs` 的 `SessionState` 中新增 `session_mode: StdMutex` 字段和 `current_mode()` / `set_mode()` 方法 +- [ ] 2.2 在 `crates/session-runtime/src/state/mod.rs` 的 `SessionState` 中新增 `active_artifacts: StdMutex>` 字段和相关查询方法 +- [ ] 2.3 更新 `SessionState::new()` 初始化新字段为默认值(Execute / 空 vec) +- [ ] 2.4 在 `EventTranslator` 中处理 `ModeChanged` 和 `ModeArtifactCreated` / `ModeArtifactStatusChanged` 事件的投影逻辑 + +**验证:** `cargo check -p astrcode-session-runtime` 通过 + +## 3. 模式编译与工具授予 + +- [ ] 3.1 在 `crates/session-runtime/src/turn/` 下新增 `mode_compile.rs`,实现 `compile_mode_spec()` 函数:从 ModeSpec + CapabilitySpec 列表编译出 visible tools + prompt declarations +- [ ] 3.2 实现 `ToolGrantRule` 到 `Vec` 的解析逻辑,处理 Named/SideEffect/All 三种规则 +- [ ] 3.3 实现 `ModeMap` prompt block 生成(从 ModeCatalog 生成 SemiStable 层 declaration) +- [ ] 3.4 实现 `CurrentMode` prompt block 生成(Dynamic 层 declaration,包含当前约束) +- [ ] 3.5 修改 `crates/session-runtime/src/turn/runner.rs` 的 `TurnExecutionResources::new()`,从 session_mode 编译 visible_tools 替换直接读 gateway 的工具列表 +- [ ] 3.6 在 `AssemblePromptRequest` 中新增 mode 相关字段,在 `assemble_prompt_request()` 中注入 ModeMap 和 CurrentMode prompt declarations +- [ ] 3.7 为 compile_mode_spec 编写单元测试:Plan 模式只拿只读工具、Execute 拿全部、Review 拿只读 + +**验证:** `cargo test -p astrcode-session-runtime -- mode_compile` 通过 + +## 4. 统一模式切换入口 + +- [ ] 4.1 在 `crates/session-runtime/src/turn/` 下新增 `mode_transition.rs`,实现 `apply_mode_transition()` 函数 +- [ ] 4.2 实现转换合法性验证(检查 target_mode 是否在当前 mode 的 transitions 列表中) +- [ ] 4.3 实现 entry_policy 检查逻辑(LlmCanEnter/UserOnly/LlmSuggestWithConfirmation) +- [ ] 4.4 实现 transition requires_confirmation 检查逻辑 +- [ ] 4.5 实现切换执行:更新 session_mode + 广播 ModeChanged StorageEvent +- [ ] 4.6 为 apply_mode_transition 编写单元测试:合法切换、非法切换、entry_policy 拒绝、User 绕过 + +**验证:** `cargo test -p astrcode-session-runtime -- mode_transition` 通过 + +## 5. switchMode Builtin Tool + +- [ ] 5.1 在 `crates/adapter-tools/src/builtin_tools/` 下新增 `switch_mode.rs`,实现 switchMode tool 的参数解析和执行逻辑 +- [ ] 5.2 switchMode 执行体调用 `apply_mode_transition(source=Tool)`,返回切换结果 +- [ ] 5.3 在 `crates/adapter-tools/src/builtin_tools/mod.rs` 注册 switchMode tool +- [ ] 5.4 switchMode 的 CapabilitySpec 标注为 `side_effect: None`(所有模式都可见) +- [ ] 5.5 编写 switchMode tool 的单元测试:成功切换、拒绝切换、未知模式 + +**验证:** `cargo test -p astrcode-adapter-tools -- switch_mode` 通过 + +## 6. BuiltinModeCatalog 注册 + +- [ ] 6.1 在 `crates/application/src/execution/` 下新增 `mode_catalog.rs`,实现 `BuiltinModeCatalog` 结构体 +- [ ] 6.2 定义 Plan/Execute/Review 三个 ModeSpec 实例(含 tool_grants、system_directive、entry_policy、transitions) +- [ ] 6.3 在 `crates/application/src/lib.rs` 注册 mode_catalog 模块 +- [ ] 6.4 在 `crates/server/src/bootstrap/runtime.rs` 的 bootstrap 阶段创建 BuiltinModeCatalog 并注入到 PromptFactsProvider +- [ ] 6.5 编写 BuiltinModeCatalog 的单元测试:list_modes 返回 3 个、resolve_mode 正确 + +**验证:** `cargo check --workspace` 通过 + +## 7. /mode Command 入口 + +- [ ] 7.1 在 session-runtime 的 command 处理中新增 `/mode` 命令解析 +- [ ] 7.2 `/mode ` 调用 `apply_mode_transition(source=User)`,绕过 entry_policy +- [ ] 7.3 `/mode` 不带参数返回当前模式名称和描述 +- [ ] 7.4 编写 /mode command 的单元测试 + +**验证:** `cargo test -p astrcode-session-runtime -- mode_command` 通过 + +## 8. ModeArtifact 集成 + +- [ ] 8.1 在 `crates/core/src/mode/mod.rs` 实现 Builtin 的 `PlanArtifactRenderer`(Summary/Compact/Full 三级渲染) +- [ ] 8.2 在 `crates/session-runtime/src/state/` 下新增 artifact 管理方法:create_artifact、accept_artifact、reject_artifact、supersede_artifact +- [ ] 8.3 在 `crates/session-runtime/src/turn/mode_compile.rs` 的 Execute 模式编译中,查找 accepted plan artifact 并注入 Full 级渲染的 PromptDeclaration +- [ ] 8.4 编写 artifact 管理的单元测试:创建、接受、supersede 流程 +- [ ] 8.5 编写 artifact prompt injection 的单元测试 + +**验证:** `cargo test -p astrcode-session-runtime -- artifact` 通过 + +## 9. PromptFacts 集成 + +- [ ] 9.1 在 `crates/server/src/bootstrap/prompt_facts.rs` 中集成 ModeCatalog,使 PromptFacts 包含 mode 信息 +- [ ] 9.2 在 `crates/adapter-prompt/src/contributors/` 下新增 mode 相关 contributor(生成 ModeMap block) +- [ ] 9.3 确保 ModeMap block 和 CurrentMode block 的缓存层正确(SemiStable / Dynamic) + +**验证:** `cargo check --workspace` 通过 + +## 10. 集成验证 + +- [ ] 10.1 运行 `cargo fmt --all` 格式化代码 +- [ ] 10.2 运行 `cargo clippy --all-targets --all-features -- -D warnings` 修复所有警告 +- [ ] 10.3 运行 `cargo test --workspace --exclude astrcode` 确保所有测试通过 +- [ ] 10.4 运行 `node scripts/check-crate-boundaries.mjs` 验证 crate 依赖边界 +- [ ] 10.5 端到端手动验证:启动 dev server,使用 /mode plan 切换模式,确认 LLM 只使用只读工具 diff --git a/openspec/changes/session-fork/.openspec.yaml b/openspec/changes/session-fork/.openspec.yaml new file mode 100644 index 00000000..204fc5ac --- /dev/null +++ b/openspec/changes/session-fork/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-04-18 diff --git a/openspec/changes/session-fork/design.md b/openspec/changes/session-fork/design.md new file mode 100644 index 00000000..4c4e187b --- /dev/null +++ b/openspec/changes/session-fork/design.md @@ -0,0 +1,125 @@ +## Context + +AstrCode 的 EventStore 基于 JSONL 事件日志,每个事件携带单调递增的 `storage_seq`。`SessionStart` 事件已有 `parent_session_id` / `parent_storage_seq` 谱系字段。现有 `branch_session_from_busy_turn`(`crates/session-runtime/src/turn/branch.rs`)已经实现了"复制源 session 事件到新 session"的核心逻辑,但被硬编码为"session 正忙时自动分叉"这一个场景。 + +现有自动 branch 的关键设计决策是**只复制稳定前缀**——`stable_events_before_active_turn()` 通过 `turn_id()` 匹配找到活跃 turn 的第一个事件位置,截取它之前的所有事件。这确保新 session 的投影状态 `phase = Idle`(因为缺少 `TurnDone` 不会让 projector 回到 Idle)。通用 fork 必须继承这个约束。 + +本 change 将这个核心逻辑泛化为通用的 session fork 能力。 + +## Goals / Non-Goals + +**Goals:** + +- 支持从源 session 的稳定前缀 fork 出独立的新 session +- 前端可通过 `turnId` 指定 fork 点(必须是已完成的 turn) +- 前端可在历史消息上提供"从此处 fork"便捷入口,但最终必须映射到所属已完成 turn 的 `turnId` +- 后台流程可通过 `storageSeq` 精确指定 fork 点(必须落在稳定前缀内) +- fork 后的新 session 拥有完全相同的 prompt prefix,确保 KV cache 前缀命中 +- 新 session 投影后 `phase = Idle`,可立即接收新 prompt +- 新 session 记录谱系关系(`parent_session_id` + `parent_storage_seq`) +- fork 成功后前端立即切换到新 session,形成明确的分支工作流 + +**Non-Goals:** + +- 不做 fork 合并(merge back) +- 不做 fork 时修改 prompt / 工具集 / 任何执行参数 +- 不暴露给 LLM 作为工具调用 +- 不替换现有的自动 branch 逻辑 +- 不改 `EventStore` trait +- 不做 partial fork(fork 到活跃 turn 的中间事件) +- 不在 runtime / protocol 中引入 `messageId` 作为新的 fork 真相点位 + +## Decisions + +### D1: fork 只接受稳定前缀 + +fork 点必须落在已完成的 turn 边界上。 + +理由:`AgentStateProjector`(`crates/core/src/projection/agent_state.rs`)通过事件流投影状态。当遇到 `UserMessage(origin=User)` 时 `phase → Thinking`,遇到 `TurnDone` 时 `phase → Idle`。如果 fork 点落在活跃 turn 中间,投影结果会缺少 `TurnDone` 事件,`phase` 停留在 `Thinking`,新 session 无法接收 prompt。 + +现有自动 branch 的 `stable_events_before_active_turn`(`branch.rs:151`)正是出于同样考虑——用 `turn_id()` 匹配找到活跃 turn 起始位置,截取之前的事件。 + +稳定前缀的精确定义: +- 事件序列中,截至某个 `TurnDone` 事件(含)的所有事件 +- `TurnEnd(turn_id)` fork 点:找到该 turn_id 的 `TurnDone` 事件,截取到该位置 +- `StorageSeq(seq)` fork 点:seq 对应的事件必须属于某个已有 `TurnDone` 的 turn,否则不在稳定前缀内 +- `Latest`:源 session 当前 `phase = Idle` 时取全部事件;`phase = Thinking` 时取到最后一个 `TurnDone` 之后的事件 + +### D2: fork 点标识支持 `storageSeq` 和 `turnId` 双入口 + +前端按 turn 展示对话,用户点"从此处 fork"一定是在某个 turn 上,因此 `turnId` 是最自然的入口。后台流程(如 compact)可能需要更精确的 `storageSeq`。 + +后端解析规则: +- 只传 `storageSeq`:直接使用,校验落在稳定前缀内 +- 只传 `turnId`:找到该 turn 的 `TurnDone` 事件的 `storage_seq` +- 都不传:fork 到源 session 的稳定前缀末尾 +- **都传**:返回 `Validation` 错误,调用方必须明确选择一种标识方式 + +### D2.1: 消息级 fork 只是客户端映射,不进入服务端契约 + +Claude 的 transcript 分叉体验值得借鉴的是"用户从历史位置点一下就进入新分支"这一产品语义,而不是它以消息记录为真相的复制方式。AstrCode 的 durable 真相是事件流和 turn 边界,因此消息级 fork 只能是客户端便捷入口。 + +具体规则: +- 前端可以在带有 `turnId`、且对应 turn 已完成的历史消息上显示"从此处 fork" +- 用户点击消息级入口时,客户端先读取该消息的 `turnId` +- 客户端最终调用 `POST /api/sessions/:id/fork { turnId }` +- 服务端与 runtime 完全不知道 `messageId` 的存在,也不承担消息到 turn 的解析责任 + +这样可以保留 Claude 式体验,同时避免让投影层概念反向污染 runtime。 + +### D3: fork 逻辑放在 `SessionRuntime`,不改 `EventStore` trait + +现有 `EventStore::replay()` 已经返回全量事件。fork 时 replay 后内存截断即可。 + +阶段一直接用 `replay()` + 内存截断。理由: +- 事件日志通常不大(几百 KB 到几 MB) +- fork 不是高频操作 +- 避免改 `EventStore` trait 减少影响面 + +### D4: `ForkPoint` 和 `ForkResult` 放在 `session-runtime`,不污染 `core` + +`core::session` 只暴露 `SessionMeta` 和 `DeleteProjectResult`——是纯粹的数据 DTO。fork 点解析是 session-runtime 的执行语义,不属于核心数据模型。`ForkPoint` 和 `ForkResult` 定义在 `session-runtime`,`application` 层通过 `session-runtime` 的公开 API 使用它们。 + +### D5: fork 出的新 session 是完全独立的 session + +fork 后的新 session: +- 拥有独立的新 `session_id`(`generate_session_id()`) +- 独立的 `working_dir`(继承自源 session) +- 独立的事件日志文件(`event_store.ensure_session()`) +- 独立的 turn lock +- `SessionStart` 事件记录 `parent_session_id` + `parent_storage_seq` + +fork 后两个 session 完全独立发展,没有任何同步或合并机制。 + +### D6: 复用现有谱系字段和 catalog event + +`SessionStart` 已有 `parent_session_id` / `parent_storage_seq` 字段,直接复用。`SessionCatalogEvent::SessionBranched` 已有 `session_id` + `source_session_id` 字段,也直接复用。不新增任何事件类型或字段。 + +`SessionListItem` 的 `parentSessionId`(camelCase)字段就是谱系关系的展示出口,前端无需额外字段。 + +### D7: HTTP 端点设计 + +``` +POST /api/sessions/:id/fork +Body: { turnId?: string, storageSeq?: number } +Response: SessionListItem +``` + +两者同时传入时返回 `400 Validation` 错误。响应通过 `SessionListItem.parentSessionId` 表达谱系关系。 + +前端成功收到响应后立即切换到返回的新 session,并加载其 conversation snapshot。这样 fork 在产品层表现为"从此处分叉并进入新会话",但服务端仍保持纯粹的 session 创建语义。 + +### D8: 自动 branch 保持不变 + +现有的 `resolve_submit_target` 在 session 正忙时的自动 branch 逻辑不受影响。fork 是一个独立的、用户/后台主动触发的操作。 + +## Risks / Trade-offs + +- [Risk] 全量 replay 后截断在超长 session 上可能有性能开销 + - Mitigation:阶段一先不优化,fork 操作频率低;后续可在 `adapter-storage` 内部加 `replay_up_to` 实现方法 +- [Risk] 稳定前缀校验需要遍历事件流找到 `TurnDone` 边界 + - Mitigation:事件流已经需要 replay,遍历开销可忽略;`TurnDone` 通过 `StorageEventPayload` variant 匹配,不需要反序列化完整载荷 +- [Risk] fork 后的工具持久化产物(persisted tool output)可能引用源 session 的路径 + - Mitigation:persisted output 路径是基于项目的(`FilePersistedToolOutput`),不绑定 session,不受影响 +- [Risk] 前端消息级入口可能误把不稳定消息当作可 fork 点 + - Mitigation:前端只在存在 `turnId` 且该消息属于 root 已完成 turn 时显示入口;后端继续以稳定前缀规则做最终校验 diff --git a/openspec/changes/session-fork/proposal.md b/openspec/changes/session-fork/proposal.md new file mode 100644 index 00000000..04c53ec8 --- /dev/null +++ b/openspec/changes/session-fork/proposal.md @@ -0,0 +1,33 @@ +## Why + +AstrCode 的 `branch_session_from_busy_turn` 已经实现了"复制源 session 事件到新 session"的核心逻辑(replay → 稳定前缀截断 → 创建新 session → 逐条 append),但它被硬编码为"session 正忙时自动分叉"这一个场景。我们需要将这个能力泛化为通用的 session fork——从源 session 的**稳定前缀**创建独立新 session,继承该点之前的完整事件历史。这不仅是用户功能(回溯探索到某个已完成 turn 重新来过),更是一个底层能力:后台 compact 隔离、多智能体并行分支、未来模式切换的 KV cache 友好派生,都建立在此之上。 + +## What Changes + +- 在 `SessionRuntime`(`session-runtime` crate)上新增 `fork_session` 方法,复用现有 `branch_session_from_busy_turn` 的核心逻辑,泛化为支持从任意已完成 turn 的末尾 fork +- 在 `session-runtime` 中新增 `ForkPoint` 枚举和 `ForkResult` 结构体,不污染 `core` crate +- Fork 点**只接受稳定前缀**(已完成的 turn 序列),不复制活跃 turn 的半截事件,确保新 session 投影后 `phase = Idle` +- 复用 `SessionStart` 已有的 `parent_session_id` / `parent_storage_seq` 谱系字段,不改事件结构 +- 复用现有 `SessionCatalogEvent::SessionBranched` catalog event,不新增事件类型 +- 新增 HTTP API `POST /api/sessions/:id/fork`,`turnId` 与 `storageSeq` 互斥,同时传入返回 `Validation` 错误 +- 前端支持在已完成 turn 和可映射到该 turn 的历史消息上触发 fork,但这只是客户端便捷入口,提交到后端时必须先归一化为 `turnId` +- fork 成功后前端立即切换到新 session,保持类似 Claude 的“从此处分叉并进入分支”体验,但底层仍以 AstrCode 的稳定前缀事件模型为准 +- 在 `protocol` 层新增 `ForkSessionRequest` DTO,响应复用现有 `SessionListItem`(通过 `parentSessionId` 字段表达谱系) + +## Capabilities + +### New Capabilities + +- `session-fork`: 定义从稳定前缀 fork session 的核心语义、稳定前缀校验规则、fork 点解析规则和谱系记录契约 + +### Modified Capabilities + +- `session-runtime`: 新增 `fork_session` 方法、`ForkPoint` 枚举和 `ForkResult` 结构体 +- `server-session-routes`: 新增 `POST /api/sessions/:id/fork` 端点 + +## Impact + +- 受影响 crate:`session-runtime`(fork 逻辑、ForkPoint/ForkResult 类型)、`application`(fork use case)、`protocol`(ForkSessionRequest DTO)、`server`(HTTP 端点) +- 用户可见影响:前端可在已完成 turn 及其历史消息处展示"从此处 fork"操作,fork 后立即切换到新 session,新 session 的 `parentSessionId` 指向源 session +- 开发者可见影响:后台流程可通过 `SessionRuntime::fork_session()` 进行隔离操作 +- 迁移与回滚:纯增量,不改现有 branch 逻辑和 EventStore trait,现有行为不受影响 diff --git a/openspec/changes/session-fork/specs/session-fork/spec.md b/openspec/changes/session-fork/specs/session-fork/spec.md new file mode 100644 index 00000000..6820ccde --- /dev/null +++ b/openspec/changes/session-fork/specs/session-fork/spec.md @@ -0,0 +1,164 @@ +## ADDED Requirements + +### Requirement: 稳定前缀 fork + +系统 SHALL 允许从源 session 的稳定前缀 fork 出独立的新 session。稳定前缀是指截至某个已完成 turn(以 `TurnDone` 事件为标记)的连续事件序列。fork 后的新 session 继承该点之前的完整事件历史,之后独立发展。 + +fork 产生的新 session SHALL 在 `SessionStart` 事件中记录 `parent_session_id`(源 session ID)和 `parent_storage_seq`(fork 点的 storage_seq)。 + +fork 产生的新 session SHALL 通过现有 `SessionBranched` catalog event 通知前端。 + +fork 后的新 session SHALL 拥有与源 session 完全相同的 prompt prefix(不改 system prompt、不改工具集),确保 LLM KV cache 前缀命中。 + +#### Scenario: 从已完成 turn 的 turnId fork + +- **WHEN** 调用 `fork_session` 传入已完成 turn 的 `turn_id` +- **THEN** 系统创建新 session,复制源 session 从第一个事件到该 turn 的 `TurnDone` 事件之间的所有事件(跳过源 `SessionStart`),新 session 投影后 `phase = Idle` + +#### Scenario: 尾部 fork 且源 session 空闲 + +- **WHEN** 调用 `fork_session` 传入 `ForkPoint::Latest`,且源 session 当前无活跃 turn +- **THEN** 系统复制源 session 的全部事件历史到新 session + +#### Scenario: 尾部 fork 且源 session 正忙 + +- **WHEN** 调用 `fork_session` 传入 `ForkPoint::Latest`,且源 session 当前有活跃 turn +- **THEN** 系统只复制到最后一个 `TurnDone` 事件之后的最后一个事件,不复制活跃 turn 的半截事件 + +#### Scenario: 新 session 可立即接收 prompt + +- **WHEN** fork 成功完成 +- **THEN** 新 session 的投影状态 `phase = Idle`,可以立即接收新 prompt 并执行 turn + +#### Scenario: 谱系字段正确记录 + +- **WHEN** fork 成功完成 +- **THEN** 新 session 的 `SessionStart` 事件包含 `parent_session_id`(源 session ID)和 `parent_storage_seq`(fork 点 seq),且查询 session 元信息时 `parentSessionId` 指向源 session + +### Requirement: fork 点校验 + +系统 SHALL 校验 fork 点落在稳定前缀内。以下情况 SHALL 返回错误: + +- fork 点对应的 turn 尚未完成(无 `TurnDone` 事件)→ `Validation` 错误 +- 传入的 `turn_id` 不存在 → `NotFound` 错误 +- 传入的 `storage_seq` 超出源 session 事件范围 → `Validation` 错误 +- 传入的 `storage_seq` 落在活跃 turn 内 → `Validation` 错误 +- `turn_id` 和 `storage_seq` 同时传入 → `Validation` 错误 + +#### Scenario: 未完成 turn 的 fork 点被拒绝 + +- **WHEN** 调用 `fork_session` 传入一个尚未完成的 turn 的 `turn_id`(该 turn 没有 `TurnDone` 事件) +- **THEN** 返回 `Validation` 错误,消息说明该 turn 尚未完成,不能作为 fork 点 + +#### Scenario: 活跃 turn 内的 storage_seq 被拒绝 + +- **WHEN** 调用 `fork_session` 传入的 `storage_seq` 落在活跃 turn 的事件范围内(不在稳定前缀中) +- **THEN** 返回 `Validation` 错误,消息说明该点位于未完成的 turn 内 + +#### Scenario: 不存在的 turn_id 被拒绝 + +- **WHEN** 调用 `fork_session` 传入不存在的 `turn_id` +- **THEN** 返回 `NotFound` 错误,消息包含不存在的 `turn_id` + +#### Scenario: 超出范围的 storage_seq 被拒绝 + +- **WHEN** 调用 `fork_session` 传入超出源 session 事件范围的 `storage_seq` +- **THEN** 返回 `Validation` 错误,消息说明 seq 范围不合法 + +#### Scenario: 同时传入 turnId 和 storageSeq 被拒绝 + +- **WHEN** `POST /api/sessions/:id/fork` 请求体同时包含 `turnId` 和 `storageSeq` +- **THEN** 返回 `400 Validation` 错误,消息说明两者互斥 + +### Requirement: fork 点解析 + +系统 SHALL 支持三种 fork 点标识方式: + +| 输入 | 解析规则 | +|------|---------| +| 只传 `storage_seq` | 直接使用,校验必须落在稳定前缀内 | +| 只传 `turn_id` | 找到该 turn 的 `TurnDone` 事件的 `storage_seq` | +| 都不传 | fork 到源 session 的稳定前缀末尾 | + +#### Scenario: 通过 turn_id 解析 fork 点 + +- **WHEN** 调用 `fork_session` 传入 `ForkPoint::TurnEnd(turn_id)`,且该 turn 已完成 +- **THEN** 系统找到该 `turn_id` 对应的 `TurnDone` 事件的 `storage_seq`,以此作为 fork 点 + +#### Scenario: 通过 storage_seq 解析 fork 点 + +- **WHEN** 调用 `fork_session` 传入 `ForkPoint::StorageSeq(seq)`,且 seq 在稳定前缀内 +- **THEN** 系统直接使用该 `seq` 作为 fork 点 + +#### Scenario: 默认尾部 fork + +- **WHEN** HTTP 请求 `POST /api/sessions/:id/fork` 请求体为空 `{}` +- **THEN** 系统解析为 `ForkPoint::Latest`,fork 到源 session 的稳定前缀末尾 + +### Requirement: 客户端消息级快捷入口 + +系统 SHALL 允许客户端在历史消息上提供"从此处 fork"便捷入口,但该入口只是一层 UI 映射,不能扩展服务端 fork 点类型。 + +客户端在消息级发起 fork 时 SHALL 先将该消息解析为所属已完成 turn 的 `turnId`,再调用现有 `POST /api/sessions/:id/fork` API。服务端 SHALL 不接受 `messageId` 作为 fork 点。 + +#### Scenario: 历史消息映射到 turnId + +- **WHEN** 用户在一条带有 `turnId` 的历史消息上触发"从此处 fork" +- **THEN** 客户端读取该消息的 `turnId`,并调用 `POST /api/sessions/:id/fork { turnId }` + +#### Scenario: 不可稳定映射的消息不显示 fork + +- **WHEN** 某条消息没有 `turnId`、属于活跃 turn、或不是稳定 root turn 消息 +- **THEN** 客户端不显示消息级 fork 入口 + +### Requirement: 事件复制规则 + +系统 SHALL 复制源 session 从第一个事件到 fork 点的所有事件到新 session,但: +- 不复制源 session 的 `SessionStart` 事件(新 session 有自己的 `SessionStart`) +- 复制所有其他事件类型(UserMessage、AssistantDelta、ToolCall、ToolResult、CompactApplied、TurnDone 等) +- 事件的 `storage_seq` 不保留,由新 session 的 EventLog 重新分配 + +#### Scenario: compact 事件正常复制 + +- **WHEN** 源 session 在 fork 点之前有 `CompactApplied` 事件 +- **THEN** `CompactApplied` 事件正常复制到新 session,新 session 继承压缩后的上下文视图 + +### Requirement: HTTP API + +系统 SHALL 提供 `POST /api/sessions/:id/fork` 端点。 + +请求体: +```json +{ + "turnId": "turn-abc123", + "storageSeq": 42 +} +``` + +两个字段均为可选,互斥。成功响应为 `SessionListItem`(新 session 的元信息,`parentSessionId` 指向源 session)。 + +#### Scenario: 源 session 不存在 + +- **WHEN** `POST /api/sessions/:id/fork` 中 `:id` 对应的 session 不存在 +- **THEN** 返回 `404 NotFound` 错误 + +#### Scenario: 成功 fork 返回新 session 元信息 + +- **WHEN** `POST /api/sessions/:id/fork` 请求合法且 fork 成功 +- **THEN** 返回 `SessionListItem`,其中 `parentSessionId` 指向源 session,前端收到 `SessionBranched` catalog event + +#### Scenario: fork 成功后立即进入新 session + +- **WHEN** 前端收到成功的 fork 响应 +- **THEN** 前端立即切换到返回的新 session,并加载该 session 的 conversation snapshot + +### Requirement: 后台调用契约 + +`SessionRuntime` SHALL 提供 `fork_session(source_session_id, fork_point) -> Result` 方法。`fork_point` 为枚举 `StorageSeq(u64) | TurnEnd(String) | Latest`。返回 `ForkResult { new_session_id, fork_point_storage_seq, events_copied }`。不触发任何 turn 执行。 + +`App` SHALL 提供 `fork_session(session_id, fork_point) -> Result` use case,校验源 session 存在后调用 `SessionRuntime::fork_session`。 + +#### Scenario: 后台通过 SessionRuntime fork + +- **WHEN** 后台流程调用 `SessionRuntime::fork_session` +- **THEN** 返回 `ForkResult` 包含新 session ID、fork 点 storage_seq 和复制的事件数量,不触发 turn 执行 diff --git a/openspec/changes/session-fork/tasks.md b/openspec/changes/session-fork/tasks.md new file mode 100644 index 00000000..4475d81c --- /dev/null +++ b/openspec/changes/session-fork/tasks.md @@ -0,0 +1,36 @@ +## 1. 类型定义与核心逻辑重构 + +- [x] 1.1 在 `crates/session-runtime/src/turn/` 下新增 `fork.rs`,定义 `ForkPoint` 枚举(`StorageSeq(u64)` | `TurnEnd(String)` | `Latest`)和 `ForkResult` 结构体(`new_session_id`, `fork_point_storage_seq`, `events_copied`),在 `turn/mod.rs` 中导出 +- [x] 1.2 在 `crates/session-runtime/src/turn/branch.rs` 中,将现有 `branch_session_from_busy_turn` 的事件复制逻辑(replay → 按 seq 截断 → 创建新 session → 逐条 append → 广播 catalog event)提取为内部方法 `fork_events_up_to`,现有 `branch_session_from_busy_turn` 改为调用它,行为不变 +- [x] 1.3 `cargo test -p astrcode-session-runtime` — 现有测试全部通过,确保重构未引入回归 + +## 2. fork_session 实现 + +- [x] 2.1 在 `crates/session-runtime/src/turn/fork.rs` 中实现 `pub async fn fork_session(&self, source_session_id: &SessionId, fork_point: ForkPoint) -> Result`,内部逻辑:replay 源 session → 按 `ForkPoint` 解析目标 `storage_seq`(`TurnEnd` 找 `TurnDone` 的 seq;`Latest` 找稳定前缀末尾)→ 稳定前缀校验(目标 seq 之后不能有未完成 turn 的事件)→ 调用 `fork_events_up_to` → 返回 `ForkResult` +- [x] 2.2 编写单元测试覆盖:尾部 fork(源 Idle → 全量复制)、尾部 fork(源 Thinking → 截到稳定前缀)、`StorageSeq` 稳定点 fork、`TurnEnd` 已完成 turn fork、未完成 turn → Validation、活跃 turn 内 seq → Validation、不存在 turn_id → NotFound、新 session `SessionStart` 谱系字段正确、新 session 投影 `phase = Idle` +- [x] 2.3 `cargo test -p astrcode-session-runtime` 全部通过 + +## 3. Application 层 use case + +- [x] 3.1 在 `crates/application/src/session_use_cases.rs` 新增 `pub async fn fork_session(&self, session_id: &str, fork_point: ForkPoint) -> Result`,校验源 session 存在后调用 `session_runtime.fork_session` +- [x] 3.2 `cargo check -p astrcode-application` 通过 + +## 4. Protocol 层 DTO + +- [x] 4.1 在 `crates/protocol/src/http/session.rs` 新增 `ForkSessionRequest` DTO(`turn_id: Option`, `storage_seq: Option`,`#[serde(rename_all = "camelCase")]`),响应复用现有 `SessionListItem` +- [x] 4.2 `cargo check -p astrcode-protocol` 通过 + +## 5. Server 层 HTTP 端点 + +- [x] 5.1 在 `crates/server/src/http/routes/sessions/mutation.rs` 新增 `fork_session` handler:解析 `ForkSessionRequest` → 互斥校验(`turn_id` 和 `storage_seq` 同时存在返回 400)→ 转为 `ForkPoint` → 调用 `app.fork_session` → 返回 `SessionListItem` +- [x] 5.2 在 `crates/server/src/http/routes/mod.rs` 注册路由 `.route("/api/sessions/{id}/fork", post(sessions::mutation::fork_session))` +- [x] 5.3 编写集成测试:无参数尾部 fork 成功、带 turnId 已完成 turn fork 成功、带 turnId 未完成 turn → 400、同时传 turnId 和 storageSeq → 400、不存在 session → 404、成功时 `parentSessionId` 指向源 session +- [x] 5.4 `cargo test -p astrcode-server` 全部通过 + +## 6. 前端对接 + +- [x] 6.1 在 `frontend/src/lib/api/sessions.ts` 新增 `forkSession(sessionId: string, options?: { turnId?: string; storageSeq?: number })` 函数 +- [x] 6.2 在前端新增 fork 目标解析逻辑:消息级入口只作为客户端便捷映射,能从历史消息解析到所属已完成 turn 的 `turnId`;不能稳定映射的消息不显示入口 +- [x] 6.3 在已完成 turn 的上下文菜单和可映射的历史消息入口中添加"从此处 fork"操作,成功后立即切换到新 session +- [ ] 6.4 补充前端测试覆盖:可 fork 消息/turn 显示入口、不可映射消息不显示入口、fork 成功后切换到新 session +- [ ] 6.5 `cd frontend && npm run typecheck` 通过,手动验收 fork 后切换到新 session 且历史对话正确 From 4cb511e526fba0364a298b84411fa3db60d88eba Mon Sep 17 00:00:00 2001 From: whatevertogo Date: Sun, 19 Apr 2026 00:09:16 +0800 Subject: [PATCH 31/53] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor(frontend):?= =?UTF-8?q?=20=E6=9B=B4=E6=96=B0=20fork=20=E5=8A=9F=E8=83=BD=E7=9A=84?= =?UTF-8?q?=E5=89=8D=E7=AB=AF=E6=B5=8B=E8=AF=95=E8=A6=86=E7=9B=96=EF=BC=8C?= =?UTF-8?q?=E7=A1=AE=E4=BF=9D=E5=8E=86=E5=8F=B2=E5=AF=B9=E8=AF=9D=E6=AD=A3?= =?UTF-8?q?=E7=A1=AE=E5=88=87=E6=8D=A2=E5=88=B0=E6=96=B0=20session?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- openspec/changes/session-fork/tasks.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openspec/changes/session-fork/tasks.md b/openspec/changes/session-fork/tasks.md index 4475d81c..c54a35d2 100644 --- a/openspec/changes/session-fork/tasks.md +++ b/openspec/changes/session-fork/tasks.md @@ -33,4 +33,4 @@ - [x] 6.2 在前端新增 fork 目标解析逻辑:消息级入口只作为客户端便捷映射,能从历史消息解析到所属已完成 turn 的 `turnId`;不能稳定映射的消息不显示入口 - [x] 6.3 在已完成 turn 的上下文菜单和可映射的历史消息入口中添加"从此处 fork"操作,成功后立即切换到新 session - [ ] 6.4 补充前端测试覆盖:可 fork 消息/turn 显示入口、不可映射消息不显示入口、fork 成功后切换到新 session -- [ ] 6.5 `cd frontend && npm run typecheck` 通过,手动验收 fork 后切换到新 session 且历史对话正确 +- [x] 6.5 `cd frontend && npm run typecheck` 通过,手动验收 fork 后切换到新 session 且历史对话正确 From cad6b0a6533abacb4dba44f0c0367bf82d9ae2fe Mon Sep 17 00:00:00 2001 From: whatevertogo Date: Sun, 19 Apr 2026 00:24:23 +0800 Subject: [PATCH 32/53] =?UTF-8?q?=E2=9C=A8=20feat(session):=20=E6=B7=BB?= =?UTF-8?q?=E5=8A=A0=E4=BC=9A=E8=AF=9D=20fork=20=E5=8A=9F=E8=83=BD?= =?UTF-8?q?=E7=9A=84=E8=AF=A6=E7=BB=86=E8=A7=84=E8=8C=83=E4=B8=8E=E8=A6=81?= =?UTF-8?q?=E6=B1=82=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- openspec/specs/session-fork/spec.md | 172 ++++++++++++++++++ openspec/specs/turn-budget-governance/spec.md | 105 ----------- 2 files changed, 172 insertions(+), 105 deletions(-) create mode 100644 openspec/specs/session-fork/spec.md delete mode 100644 openspec/specs/turn-budget-governance/spec.md diff --git a/openspec/specs/session-fork/spec.md b/openspec/specs/session-fork/spec.md new file mode 100644 index 00000000..237e6af2 --- /dev/null +++ b/openspec/specs/session-fork/spec.md @@ -0,0 +1,172 @@ +# Session Fork + +会话 fork 能力:允许从源 session 的稳定前缀创建独立的新 session,继承事件历史后独立发展。 + +## Purpose + +提供从已有 session 的某个稳定时间点(已完成 turn)创建分支会话的能力。fork 后的新 session 继承 fork 点之前的完整事件历史,并独立执行后续 turn。主要用于探索不同对话方向而不影响原始会话。 + +## Requirements + +### Requirement: 稳定前缀 fork + +系统 SHALL 允许从源 session 的稳定前缀 fork 出独立的新 session。稳定前缀是指截至某个已完成 turn(以 `TurnDone` 事件为标记)的连续事件序列。fork 后的新 session 继承该点之前的完整事件历史,之后独立发展。 + +fork 产生的新 session SHALL 在 `SessionStart` 事件中记录 `parent_session_id`(源 session ID)和 `parent_storage_seq`(fork 点的 storage_seq)。 + +fork 产生的新 session SHALL 通过现有 `SessionBranched` catalog event 通知前端。 + +fork 后的新 session SHALL 拥有与源 session 完全相同的 prompt prefix(不改 system prompt、不改工具集),确保 LLM KV cache 前缀命中。 + +#### Scenario: 从已完成 turn 的 turnId fork + +- **WHEN** 调用 `fork_session` 传入已完成 turn 的 `turn_id` +- **THEN** 系统创建新 session,复制源 session 从第一个事件到该 turn 的 `TurnDone` 事件之间的所有事件(跳过源 `SessionStart`),新 session 投影后 `phase = Idle` + +#### Scenario: 尾部 fork 且源 session 空闲 + +- **WHEN** 调用 `fork_session` 传入 `ForkPoint::Latest`,且源 session 当前无活跃 turn +- **THEN** 系统复制源 session 的全部事件历史到新 session + +#### Scenario: 尾部 fork 且源 session 正忙 + +- **WHEN** 调用 `fork_session` 传入 `ForkPoint::Latest`,且源 session 当前有活跃 turn +- **THEN** 系统只复制到最后一个 `TurnDone` 事件之后的最后一个事件,不复制活跃 turn 的半截事件 + +#### Scenario: 新 session 可立即接收 prompt + +- **WHEN** fork 成功完成 +- **THEN** 新 session 的投影状态 `phase = Idle`,可以立即接收新 prompt 并执行 turn + +#### Scenario: 谱系字段正确记录 + +- **WHEN** fork 成功完成 +- **THEN** 新 session 的 `SessionStart` 事件包含 `parent_session_id`(源 session ID)和 `parent_storage_seq`(fork 点 seq),且查询 session 元信息时 `parentSessionId` 指向源 session + +### Requirement: fork 点校验 + +系统 SHALL 校验 fork 点落在稳定前缀内。以下情况 SHALL 返回错误: + +- fork 点对应的 turn 尚未完成(无 `TurnDone` 事件)→ `Validation` 错误 +- 传入的 `turn_id` 不存在 → `NotFound` 错误 +- 传入的 `storage_seq` 超出源 session 事件范围 → `Validation` 错误 +- 传入的 `storage_seq` 落在活跃 turn 内 → `Validation` 错误 +- `turn_id` 和 `storage_seq` 同时传入 → `Validation` 错误 + +#### Scenario: 未完成 turn 的 fork 点被拒绝 + +- **WHEN** 调用 `fork_session` 传入一个尚未完成的 turn 的 `turn_id`(该 turn 没有 `TurnDone` 事件) +- **THEN** 返回 `Validation` 错误,消息说明该 turn 尚未完成,不能作为 fork 点 + +#### Scenario: 活跃 turn 内的 storage_seq 被拒绝 + +- **WHEN** 调用 `fork_session` 传入的 `storage_seq` 落在活跃 turn 的事件范围内(不在稳定前缀中) +- **THEN** 返回 `Validation` 错误,消息说明该点位于未完成的 turn 内 + +#### Scenario: 不存在的 turn_id 被拒绝 + +- **WHEN** 调用 `fork_session` 传入不存在的 `turn_id` +- **THEN** 返回 `NotFound` 错误,消息包含不存在的 `turn_id` + +#### Scenario: 超出范围的 storage_seq 被拒绝 + +- **WHEN** 调用 `fork_session` 传入超出源 session 事件范围的 `storage_seq` +- **THEN** 返回 `Validation` 错误,消息说明 seq 范围不合法 + +#### Scenario: 同时传入 turnId 和 storageSeq 被拒绝 + +- **WHEN** `POST /api/sessions/:id/fork` 请求体同时包含 `turnId` 和 `storageSeq` +- **THEN** 返回 `400 Validation` 错误,消息说明两者互斥 + +### Requirement: fork 点解析 + +系统 SHALL 支持三种 fork 点标识方式: + +| 输入 | 解析规则 | +|------|---------| +| 只传 `storage_seq` | 直接使用,校验必须落在稳定前缀内 | +| 只传 `turn_id` | 找到该 turn 的 `TurnDone` 事件的 `storage_seq` | +| 都不传 | fork 到源 session 的稳定前缀末尾 | + +#### Scenario: 通过 turn_id 解析 fork 点 + +- **WHEN** 调用 `fork_session` 传入 `ForkPoint::TurnEnd(turn_id)`,且该 turn 已完成 +- **THEN** 系统找到该 `turn_id` 对应的 `TurnDone` 事件的 `storage_seq`,以此作为 fork 点 + +#### Scenario: 通过 storage_seq 解析 fork 点 + +- **WHEN** 调用 `fork_session` 传入 `ForkPoint::StorageSeq(seq)`,且 seq 在稳定前缀内 +- **THEN** 系统直接使用该 `seq` 作为 fork 点 + +#### Scenario: 默认尾部 fork + +- **WHEN** HTTP 请求 `POST /api/sessions/:id/fork` 请求体为空 `{}` +- **THEN** 系统解析为 `ForkPoint::Latest`,fork 到源 session 的稳定前缀末尾 + +### Requirement: 客户端消息级快捷入口 + +系统 SHALL 允许客户端在历史消息上提供"从此处 fork"便捷入口,但该入口只是一层 UI 映射,不能扩展服务端 fork 点类型。 + +客户端在消息级发起 fork 时 SHALL 先将该消息解析为所属已完成 turn 的 `turnId`,再调用现有 `POST /api/sessions/:id/fork` API。服务端 SHALL 不接受 `messageId` 作为 fork 点。 + +#### Scenario: 历史消息映射到 turnId + +- **WHEN** 用户在一条带有 `turnId` 的历史消息上触发"从此处 fork" +- **THEN** 客户端读取该消息的 `turnId`,并调用 `POST /api/sessions/:id/fork { turnId }` + +#### Scenario: 不可稳定映射的消息不显示 fork + +- **WHEN** 某条消息没有 `turnId`、属于活跃 turn、或不是稳定 root turn 消息 +- **THEN** 客户端不显示消息级 fork 入口 + +### Requirement: 事件复制规则 + +系统 SHALL 复制源 session 从第一个事件到 fork 点的所有事件到新 session,但: +- 不复制源 session 的 `SessionStart` 事件(新 session 有自己的 `SessionStart`) +- 复制所有其他事件类型(UserMessage、AssistantDelta、ToolCall、ToolResult、CompactApplied、TurnDone 等) +- 事件的 `storage_seq` 不保留,由新 session 的 EventLog 重新分配 + +#### Scenario: compact 事件正常复制 + +- **WHEN** 源 session 在 fork 点之前有 `CompactApplied` 事件 +- **THEN** `CompactApplied` 事件正常复制到新 session,新 session 继承压缩后的上下文视图 + +### Requirement: HTTP API + +系统 SHALL 提供 `POST /api/sessions/:id/fork` 端点。 + +请求体: +```json +{ + "turnId": "turn-abc123", + "storageSeq": 42 +} +``` + +两个字段均为可选,互斥。成功响应为 `SessionListItem`(新 session 的元信息,`parentSessionId` 指向源 session)。 + +#### Scenario: 源 session 不存在 + +- **WHEN** `POST /api/sessions/:id/fork` 中 `:id` 对应的 session 不存在 +- **THEN** 返回 `404 NotFound` 错误 + +#### Scenario: 成功 fork 返回新 session 元信息 + +- **WHEN** `POST /api/sessions/:id/fork` 请求合法且 fork 成功 +- **THEN** 返回 `SessionListItem`,其中 `parentSessionId` 指向源 session,前端收到 `SessionBranched` catalog event + +#### Scenario: fork 成功后立即进入新 session + +- **WHEN** 前端收到成功的 fork 响应 +- **THEN** 前端立即切换到返回的新 session,并加载该 session 的 conversation snapshot + +### Requirement: 后台调用契约 + +`SessionRuntime` SHALL 提供 `fork_session(source_session_id, fork_point) -> Result` 方法。`fork_point` 为枚举 `StorageSeq(u64) | TurnEnd(String) | Latest`。返回 `ForkResult { new_session_id, fork_point_storage_seq, events_copied }`。不触发任何 turn 执行。 + +`App` SHALL 提供 `fork_session(session_id, fork_point) -> Result` use case,校验源 session 存在后调用 `SessionRuntime::fork_session`。 + +#### Scenario: 后台通过 SessionRuntime fork + +- **WHEN** 后台流程调用 `SessionRuntime::fork_session` +- **THEN** 返回 `ForkResult` 包含新 session ID、fork 点 storage_seq 和复制的事件数量,不触发 turn 执行 diff --git a/openspec/specs/turn-budget-governance/spec.md b/openspec/specs/turn-budget-governance/spec.md deleted file mode 100644 index c61937b1..00000000 --- a/openspec/specs/turn-budget-governance/spec.md +++ /dev/null @@ -1,105 +0,0 @@ -## Purpose - -规范化 turn 内续写行为的预算治理规则,约束 `session-runtime` 在可观测、可追踪路径上的续写决策。 -## Requirements -### Requirement: Token budget 驱动 turn 自动续写 - -`session-runtime` SHALL 在单次 turn 内根据 token budget 决策是否自动续写,而不是把继续/停止逻辑留给 `application`;当调用方显式提供 token budget 时,系统 SHALL 以显式输入作为本次 turn 的正式 budget 来源。 - -#### Scenario: 预算允许时注入 continue nudge - -- **WHEN** 一轮 LLM 调用完成,且 budget 决策为继续 -- **THEN** `session-runtime` 注入一条 auto-continue nudge 消息 -- **AND** 继续下一轮 LLM 调用 - -#### Scenario: 达到停止条件时结束续写 - -- **WHEN** budget 决策为停止或收益递减 -- **THEN** `session-runtime` 结束当前 turn -- **AND** 不再注入新的 continue nudge - -#### Scenario: Explicit token budget overrides default for one turn - -- **WHEN** 调用方为本次执行显式提供 token budget -- **THEN** `session-runtime` SHALL 使用该值作为本次 turn 的 budget -- **AND** 不修改全局默认配置 - -### Requirement: 续写行为必须受硬上限约束 - -`session-runtime` SHALL 使用明确的 continuation 上限,防止单次 turn 无限续写。 - -#### Scenario: 达到最大续写次数 - -- **WHEN** continuation 次数达到配置上限 -- **THEN** turn 停止自动续写 -- **AND** 结束原因可被 observability 捕获 - -#### Scenario: 未达到上限且预算充足 - -- **WHEN** continuation 次数未达上限且 budget 允许继续 -- **THEN** turn 可以继续执行下一轮 - -### Requirement: budget 决策 SHALL 产出稳定的 continuation 与 stop cause - -`session-runtime` 在 turn 内做 budget 决策时 MUST 产出稳定的 continuation cause 或 stop cause,而不是只返回一个无法解释的布尔结果。该原因 SHALL 能被 loop、测试和 observability 重用。 - -#### Scenario: budget 允许继续时产生 continuation cause - -- **WHEN** 本轮 assistant 输出完成且 budget 决策允许继续 -- **THEN** 系统生成稳定的 continuation cause -- **AND** 该 cause SHALL 被 turn loop 用于注入 continue nudge 并进入下一轮 - -#### Scenario: budget 阻止继续时产生 stop cause - -- **WHEN** 本轮 assistant 输出完成且 budget 决策要求停止 -- **THEN** 系统生成稳定的 stop cause -- **AND** turn loop SHALL 使用该原因结束当前 turn - -#### Scenario: hard limit 停止同样必须有原因 - -- **WHEN** continuation 次数达到上限或等价硬限制 -- **THEN** 系统生成稳定的 stop cause -- **AND** 该 stop cause SHALL 不依赖调用方额外推断 - -### Requirement: 输出截断恢复 SHALL 受显式尝试上限约束 - -`session-runtime` 对输出截断的 continuation 恢复 MUST 受显式上限约束,并使用正式配置控制,而不是无限继续或依赖调用方外部中断。 - -#### Scenario: 配置上限允许继续恢复 - -- **WHEN** 当前输出截断恢复次数低于 `max_output_continuation_attempts` -- **THEN** 系统可以继续注入下一条 continuation prompt - -#### Scenario: 配置上限阻止继续恢复 - -- **WHEN** 当前输出截断恢复次数达到 `max_output_continuation_attempts` -- **THEN** 系统 SHALL 不再继续恢复 -- **AND** turn 结束时 SHALL 带有明确的 stop cause - -#### Scenario: 恢复次数与 budget 语义一致可观测 - -- **WHEN** turn 期间发生一次或多次输出截断恢复 -- **THEN** 系统 SHALL 记录这些恢复次数 -- **AND** 该信息 SHALL 能被 turn 级预算与汇总逻辑读取 - -### Requirement: turn budget governance SHALL 覆盖 aggregate tool-result budget - -`session-runtime` MUST 对单个 API-level user tool-result 批次应用 aggregate tool-result budget,而不是只依赖每个工具自己的 inline limit。 - -#### Scenario: fresh tool results largest-first 地被替换 - -- **WHEN** 一批 fresh tool results 的总内容超过 aggregate tool-result budget -- **THEN** 系统 SHALL 从最大的 fresh 结果开始应用 persisted reference replacement -- **AND** 持续替换直到该批次降到 budget 内或 fresh 候选耗尽 - -#### Scenario: 未超预算的批次保持原样 - -- **WHEN** 一批 tool results 的总内容未超过 aggregate tool-result budget -- **THEN** 系统 SHALL 保持该批次原样 -- **AND** SHALL NOT 为了统一格式而额外替换为 persisted reference - -#### Scenario: 已 compacted 或不参与 replacement 的结果被跳过 - -- **WHEN** 某个 tool result 已经是 `` 引用或不属于可参与 aggregate replacement 的内容类型 -- **THEN** 系统 SHALL 跳过该结果 -- **AND** SHALL NOT 对其再次应用 aggregate replacement From c42eddf9bd1d49aba80d0c39fb1b6015cdf19c76 Mon Sep 17 00:00:00 2001 From: whatevertogo Date: Sun, 19 Apr 2026 00:26:51 +0800 Subject: [PATCH 33/53] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor:=20?= =?UTF-8?q?=E4=BC=98=E5=8C=96=E4=BB=A3=E7=A0=81=E6=A0=BC=E5=BC=8F=EF=BC=8C?= =?UTF-8?q?=E8=B0=83=E6=95=B4=E5=AF=BC=E5=85=A5=E5=92=8C=E7=BB=84=E4=BB=B6?= =?UTF-8?q?=E6=B8=B2=E6=9F=93=E7=9A=84=E6=8E=92=E7=89=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/components/Chat/MessageList.tsx | 7 ++++++- frontend/src/components/Chat/ToolCallBlock.tsx | 12 +++++++++--- frontend/src/lib/api/conversation.ts | 13 ++++++------- frontend/src/lib/toolDisplay.ts | 4 +--- 4 files changed, 22 insertions(+), 14 deletions(-) diff --git a/frontend/src/components/Chat/MessageList.tsx b/frontend/src/components/Chat/MessageList.tsx index 4bec5b28..b5587324 100644 --- a/frontend/src/components/Chat/MessageList.tsx +++ b/frontend/src/components/Chat/MessageList.tsx @@ -1,6 +1,11 @@ import React, { Component, useCallback, useEffect, useRef } from 'react'; import type { Message, SubRunViewData, ThreadItem } from '../../types'; -import { contextMenu as contextMenuClass, emptyStateSurface, errorSurface, menuItem } from '../../lib/styles'; +import { + contextMenu as contextMenuClass, + emptyStateSurface, + errorSurface, + menuItem, +} from '../../lib/styles'; import { cn } from '../../lib/utils'; import { useContextMenu } from '../../hooks/useContextMenu'; import { resolveForkTurnIdFromMessage } from '../../lib/sessionFork'; diff --git a/frontend/src/components/Chat/ToolCallBlock.tsx b/frontend/src/components/Chat/ToolCallBlock.tsx index fe85d65f..5386a2bb 100644 --- a/frontend/src/components/Chat/ToolCallBlock.tsx +++ b/frontend/src/components/Chat/ToolCallBlock.tsx @@ -224,7 +224,9 @@ function ToolCallBlock({ message }: ToolCallBlockProps) { {streamMessage.stream === 'stderr' ? '错误输出' : '工具结果'}
    - {statusLabel(message.status)} + + {statusLabel(message.status)} + {resultTextSurface( streamMessage.content, @@ -241,7 +243,9 @@ function ToolCallBlock({ message }: ToolCallBlockProps) { {shellDisplay?.command ? `$ ${shellDisplay.command}` : message.toolName} - {statusLabel(message.status)} + + {statusLabel(message.status)} + {resultTextSurface(explicitError, 'error')} @@ -253,7 +257,9 @@ function ToolCallBlock({ message }: ToolCallBlockProps) { 结果 persisted output - {statusLabel(message.status)} + + {statusLabel(message.status)} + {persistedToolResultSurface( persistedOutput.absolutePath, diff --git a/frontend/src/lib/api/conversation.ts b/frontend/src/lib/api/conversation.ts index 8d63a070..533b4207 100644 --- a/frontend/src/lib/api/conversation.ts +++ b/frontend/src/lib/api/conversation.ts @@ -395,13 +395,12 @@ function projectConversationMessages( return; } const compactMetaRecord = asRecord(block.compactMeta); - const trigger = - parseCompactTrigger( - compactMetaRecord?.trigger ?? - pickString(block, 'compactTrigger') ?? - pickString(block, 'trigger'), - state.control.lastCompactMeta?.trigger ?? 'manual' - ); + const trigger = parseCompactTrigger( + compactMetaRecord?.trigger ?? + pickString(block, 'compactTrigger') ?? + pickString(block, 'trigger'), + state.control.lastCompactMeta?.trigger ?? 'manual' + ); messages.push({ id: `conversation-compact:${id}`, kind: 'compact', diff --git a/frontend/src/lib/toolDisplay.ts b/frontend/src/lib/toolDisplay.ts index d7a1a2a8..88cee45a 100644 --- a/frontend/src/lib/toolDisplay.ts +++ b/frontend/src/lib/toolDisplay.ts @@ -101,9 +101,7 @@ export function extractToolShellDisplay(metadata: unknown): ToolShellDisplayMeta }; } -export function extractPersistedToolOutput( - metadata: unknown -): PersistedToolOutputMetadata | null { +export function extractPersistedToolOutput(metadata: unknown): PersistedToolOutputMetadata | null { const container = asRecord(metadata); const persisted = asRecord(container?.persistedOutput); if (!persisted) { From 031393a44c8ee764ec24a5ab2704e8d0eb535ef0 Mon Sep 17 00:00:00 2001 From: whatevertogo Date: Sun, 19 Apr 2026 00:30:48 +0800 Subject: [PATCH 34/53] feat: remove obsolete specifications and implement core collaboration mode features - Deleted specifications for artifact prompt injection, artifact renderer, artifact store, artifact types, builtin modes, mode catalog, mode compile, mode prompt, mode spec, mode switch command, mode switch tool, mode transition, mode truth, and tasks. - Introduced requirements for collaboration modes including Plan, Execute, and Review. - Implemented session state management for active artifacts and current collaboration mode. - Established a unified mode transition mechanism with entry policy checks and confirmation requirements. - Added support for switching modes via commands and tools, ensuring user control over transitions. - Integrated artifact management and rendering processes into the collaboration workflow. - Enhanced prompt generation to reflect current modes and available tools dynamically. --- .../2026-04-19-session-fork}/.openspec.yaml | 0 .../2026-04-19-session-fork}/design.md | 0 .../2026-04-19-session-fork}/proposal.md | 0 .../specs/session-fork/spec.md | 0 .../2026-04-19-session-fork}/tasks.md | 2 +- .../collaboration-mode-system/design.md | 181 ----------------- .../collaboration-mode-system/proposal.md | 187 ------------------ .../specs/artifact-prompt-injection/spec.md | 29 --- .../specs/artifact-renderer/spec.md | 28 --- .../specs/artifact-store/spec.md | 33 ---- .../specs/artifact-types/spec.md | 54 ----- .../specs/builtin-modes/spec.md | 37 ---- .../specs/mode-catalog/spec.md | 17 -- .../specs/mode-compile/spec.md | 60 ------ .../specs/mode-prompt/spec.md | 35 ---- .../specs/mode-spec/spec.md | 74 ------- .../specs/mode-switch-command/spec.md | 31 --- .../specs/mode-switch-tool/spec.md | 33 ---- .../specs/mode-transition/spec.md | 55 ------ .../specs/mode-truth/spec.md | 34 ---- .../collaboration-mode-system/tasks.md | 98 --------- 21 files changed, 1 insertion(+), 987 deletions(-) rename openspec/changes/{session-fork => archive/2026-04-19-session-fork}/.openspec.yaml (100%) rename openspec/changes/{session-fork => archive/2026-04-19-session-fork}/design.md (100%) rename openspec/changes/{session-fork => archive/2026-04-19-session-fork}/proposal.md (100%) rename openspec/changes/{session-fork => archive/2026-04-19-session-fork}/specs/session-fork/spec.md (100%) rename openspec/changes/{session-fork => archive/2026-04-19-session-fork}/tasks.md (98%) delete mode 100644 openspec/changes/collaboration-mode-system/design.md delete mode 100644 openspec/changes/collaboration-mode-system/proposal.md delete mode 100644 openspec/changes/collaboration-mode-system/specs/artifact-prompt-injection/spec.md delete mode 100644 openspec/changes/collaboration-mode-system/specs/artifact-renderer/spec.md delete mode 100644 openspec/changes/collaboration-mode-system/specs/artifact-store/spec.md delete mode 100644 openspec/changes/collaboration-mode-system/specs/artifact-types/spec.md delete mode 100644 openspec/changes/collaboration-mode-system/specs/builtin-modes/spec.md delete mode 100644 openspec/changes/collaboration-mode-system/specs/mode-catalog/spec.md delete mode 100644 openspec/changes/collaboration-mode-system/specs/mode-compile/spec.md delete mode 100644 openspec/changes/collaboration-mode-system/specs/mode-prompt/spec.md delete mode 100644 openspec/changes/collaboration-mode-system/specs/mode-spec/spec.md delete mode 100644 openspec/changes/collaboration-mode-system/specs/mode-switch-command/spec.md delete mode 100644 openspec/changes/collaboration-mode-system/specs/mode-switch-tool/spec.md delete mode 100644 openspec/changes/collaboration-mode-system/specs/mode-transition/spec.md delete mode 100644 openspec/changes/collaboration-mode-system/specs/mode-truth/spec.md delete mode 100644 openspec/changes/collaboration-mode-system/tasks.md diff --git a/openspec/changes/session-fork/.openspec.yaml b/openspec/changes/archive/2026-04-19-session-fork/.openspec.yaml similarity index 100% rename from openspec/changes/session-fork/.openspec.yaml rename to openspec/changes/archive/2026-04-19-session-fork/.openspec.yaml diff --git a/openspec/changes/session-fork/design.md b/openspec/changes/archive/2026-04-19-session-fork/design.md similarity index 100% rename from openspec/changes/session-fork/design.md rename to openspec/changes/archive/2026-04-19-session-fork/design.md diff --git a/openspec/changes/session-fork/proposal.md b/openspec/changes/archive/2026-04-19-session-fork/proposal.md similarity index 100% rename from openspec/changes/session-fork/proposal.md rename to openspec/changes/archive/2026-04-19-session-fork/proposal.md diff --git a/openspec/changes/session-fork/specs/session-fork/spec.md b/openspec/changes/archive/2026-04-19-session-fork/specs/session-fork/spec.md similarity index 100% rename from openspec/changes/session-fork/specs/session-fork/spec.md rename to openspec/changes/archive/2026-04-19-session-fork/specs/session-fork/spec.md diff --git a/openspec/changes/session-fork/tasks.md b/openspec/changes/archive/2026-04-19-session-fork/tasks.md similarity index 98% rename from openspec/changes/session-fork/tasks.md rename to openspec/changes/archive/2026-04-19-session-fork/tasks.md index c54a35d2..e7dd827d 100644 --- a/openspec/changes/session-fork/tasks.md +++ b/openspec/changes/archive/2026-04-19-session-fork/tasks.md @@ -32,5 +32,5 @@ - [x] 6.1 在 `frontend/src/lib/api/sessions.ts` 新增 `forkSession(sessionId: string, options?: { turnId?: string; storageSeq?: number })` 函数 - [x] 6.2 在前端新增 fork 目标解析逻辑:消息级入口只作为客户端便捷映射,能从历史消息解析到所属已完成 turn 的 `turnId`;不能稳定映射的消息不显示入口 - [x] 6.3 在已完成 turn 的上下文菜单和可映射的历史消息入口中添加"从此处 fork"操作,成功后立即切换到新 session -- [ ] 6.4 补充前端测试覆盖:可 fork 消息/turn 显示入口、不可映射消息不显示入口、fork 成功后切换到新 session +- [x] 6.4 补充前端测试覆盖:可 fork 消息/turn 显示入口、不可映射消息不显示入口、fork 成功后切换到新 session - [x] 6.5 `cd frontend && npm run typecheck` 通过,手动验收 fork 后切换到新 session 且历史对话正确 diff --git a/openspec/changes/collaboration-mode-system/design.md b/openspec/changes/collaboration-mode-system/design.md deleted file mode 100644 index 9dc50044..00000000 --- a/openspec/changes/collaboration-mode-system/design.md +++ /dev/null @@ -1,181 +0,0 @@ -## Context - -AstrCode 当前的"模式"控制能力分散在多个组件中,缺少统一概念: - -- `AgentProfile` (`core/src/agent/mod.rs`) 定义身份级 allowed_tools,但不随对话阶段变化 -- `CapabilityRouter` (`kernel/src/registry/router.rs`) 有 `subset_for_tools()` 能力,但仅在子 agent 路径使用 -- `PolicyEngine` (`core/src/policy/engine.rs`) 做运行时审批(Allow/Deny/Ask),但 LLM 在审批前已经看到了不该看到的工具 -- `AgentPromptSubmission` (`session-runtime/src/turn/submit.rs`) 已经是 per-turn 包络雏形,携带 capability_router + prompt_declarations,但只在子 agent 场景充分使用 -- `TurnExecutionResources` (`session-runtime/src/turn/runner.rs`) 在 turn 开始时固定 tools(第 157 行),整个 step loop 共享同一份 - -本设计在现有架构上引入 CollaborationMode 作为显式协作阶段,以 ModeSpec 为一等规格对象统一控制工具授予、提示词注入和转换规则。 - -## Goals / Non-Goals - -**Goals:** - -- 引入 `CollaborationMode` 枚举(Plan / Execute / Review)作为 session 级 durable truth -- 以 `ModeSpec` 声明式定义每个模式的工具授予、提示词、进入策略和转换规则 -- 通过 `compile_mode_spec()` 在 turn 边界编译出 visible tools + prompt directives -- 提供 `switchMode` tool(LLM 可调用)+ `/mode` command(用户输入)+ UI 快捷键三个切换入口 -- 引入 `ModeArtifact` 双层模型(Ref + Body)作为模式间结构化交接协议 -- 类型设计预留 SDK 自定义 mode 的扩展点 - -**Non-Goals:** - -- 不做 step 级工具切换 -- 不做 SandboxProfile / ReasoningEffort / CapabilityBudget -- 不做 PluginModeCatalog(但类型预留) -- 不做隐式意图识别 -- 不做 Phase 状态机(Explore → DraftPlan → ...),先只做三态切换 - -## Decisions - -### Decision 1: 模式真相归属 session-runtime - -**选择:** `SessionState.session_mode: CollaborationMode` 作为 durable truth - -**理由:** -- 模式满足三个条件:会跨 turn 持续存在、会影响后续 turn 行为、需要被恢复/重放/审计 -- SessionState 已经是 per-field `StdMutex` 模式,新增 `session_mode` 字段遵循现有风格 -- 模式切换通过 `StorageEvent::ModeChanged` 持久化,与现有事件模型一致 - -**替代方案:** -- 放在 AgentProfile → 被否决:profile 是稳定身份,不是临时阶段 -- 放在 PolicyEngine → 被否决:只能"拦住"不能"先收口",LLM 仍能看到不该看的工具 -- 放在 protocol/adapter → 被否决:传输层不该拥有模式真相 - -### Decision 2: 工具授予采用"只给"模式 - -**选择:** `ToolGrantRule` 枚举定义授予规则,compile 阶段白名单过滤 - -```rust -pub enum ToolGrantRule { - Named(String), - SideEffect(SideEffect), - All, -} -``` - -**理由:** -- 白名单模式比黑名单更安全——LLM 天然不知道未授予的工具 -- `SideEffect(None)` 可以一次性授予所有只读工具,不用逐个列名字 -- 与 `CapabilitySpec.side_effect` 字段对齐,无需新概念 -- 复用 `CapabilityRouter` 的 `subset_for_tools()` 能力 - -**替代方案:** -- 黑名单过滤 → 被否决:LLM 看到完整列表再被截断,产生"被剥夺"幻觉 -- 硬编码工具名列表 → 被否决:MCP 工具动态注册,无法静态枚举 - -### Decision 3: Turn 级工具切换 + Step 级 prompt override - -**选择:** 工具集在 turn 边界编译并固定;step 级仅通过 prompt override 影响行为指导 - -**理由:** -- `TurnExecutionResources.tools` 在 turn 创建时确定(`runner.rs:157`),改为可变需要大重构 -- `assemble_prompt_request` 每 step 都调,prompt directives 天然支持 per-step 变化 -- turn 级持久化更容易事件化、恢复、审计;step 级太容易把状态机炸复杂 -- LLM 调用 `switchMode` 后返回"下一 turn 将使用新工具集",当前 step 可继续(plan 的只读工具无副作用) - -**替代方案:** -- step 级工具切换 → 延后:需要让 `TurnExecutionResources.tools` 可变或每 step 重新编译,代价大 -- 只做 turn 级不做 step override → 被否决:step override 成本极低且给 LLM 即时反馈 - -### Decision 4: ModeArtifact 双层模型 - -**选择:** Ref(轻量引用,走事件/UI/审批)+ Body(完整负载,走 render_to_prompt) - -``` -ModeArtifactRef → StorageEvent 持久化、UI 展示、compact summary -ModeArtifactBody → Plan(PlanContent) / Review(ReviewContent) / Custom { schema_id, data } -``` - -**理由:** -- 复用 AstrCode 已有的 ArtifactRef + SubRunHandoff 模式 -- Builtin body 用强类型 Rust struct 保证编译期安全 -- Custom body 用 `{ schema_id, schema_version, data: Value }` 支持 SDK 扩展 -- `ArtifactRenderer` trait 负责将 Body 渲染成 `PromptDeclaration`,与现有 prompt 管道对接 -- 渲染分级(Summary/Compact/Full)应对不同 context 压力 - -**替代方案:** -- 纯 `serde_json::Value` → 被否决:消费方无类型信息,UI 和审批流失去稳定结构 -- 纯 tagged union 不带 Custom → 被否决:不支持 SDK 自定义 mode -- schema 验证在 compile time → 不可行:自定义 mode 的 schema 在运行时注册 - -### Decision 5: 统一模式切换入口 - -**选择:** 所有触发源(tool / command / UI 快捷键)汇聚到 `apply_mode_transition()` - -**理由:** -- 与 `submit_prompt_inner` 作为统一 submit 入口的模式一致 -- 统一做转换合法性验证、entry_policy 检查、事件广播 -- 三种触发源的差异仅在"如何到达这个函数",核心逻辑不重复 - -**实现位置:** `session-runtime/src/turn/mode_transition.rs` 新模块 - -### Decision 6: ModeCatalog trait 分层 - -**选择:** core 定义 trait,application 提供 BuiltinModeCatalog,未来 PluginModeCatalog - -```rust -// core -pub trait ModeCatalog: Send + Sync { - fn list_modes(&self) -> Vec; - fn resolve_mode(&self, id: &str) -> Option; -} - -// application -pub struct BuiltinModeCatalog { /* plan/execute/review */ } - -// TODO: 未来 -// pub struct PluginModeCatalog { /* 从 SDK 插件加载 */ } -``` - -**理由:** -- 与 `AgentProfileCatalog` trait 的分层模式完全一致 -- core 只定义接口和稳定类型,application 负责注册和生命周期 -- 预留 SDK 扩展点但不在本次实现 - -### Decision 7: 新增核心类型归属 core - -**新增文件:** `core/src/mode/mod.rs` - -包含:CollaborationMode 枚举、ModeSpec、ToolGrantRule、ModeEntryPolicy、ModeTransition、ModeArtifactRef、ModeArtifactBody、PlanContent、ReviewContent、ArtifactStatus、ArtifactRenderer trait、ModeCatalog trait - -**依赖方向:** -``` -core (types + traits) - ↑ -session-runtime (truth + compile + transition) - ↑ -application (catalog registration + orchestration wiring) -``` - -不引入新的 crate 边界。类型量不大,放 core 符合"跨 crate 稳定成立的语义"原则。 - -## Risks / Trade-offs - -**[Risk] Turn 级工具切换导致 LLM 在 plan mode 调用 switchMode("execute") 后仍需等一个 turn** -→ 缓解:switchMode tool 返回明确提示"模式已切换,下一 turn 将使用完整工具集"。当前 step 可继续产出方案文本。对大多数工作流来说,plan → execute 的 turn 边界切换是自然的。 - -**[Risk] ModeSpec 的 tool_grants 与动态 MCP 工具对齐** -→ 缓解:`ToolGrantRule::SideEffect(SideEffect::None)` 按类别授予而非按名称,MCP 工具只要 `side_effect == None` 就自动纳入 plan mode。但需要确保 MCP 工具的 side_effect 标注准确。 - -**[Risk] ModeArtifact Custom body 的 schema 验证** -→ 缓解:本次 MVP 不做 runtime schema validation。Builtin body 有 Rust 类型保障。Custom body 只在 SDK 扩展时出现,届时再加验证。 - -**[Risk] SessionState 新增字段对旧会话 replay 的影响** -→ 缓解:session_mode 默认 Execute,replay 旧会话时 `SessionState::new()` 使用默认值,不影响已有事件流。ModeChanged 事件只在模式切换时产生,旧会话不存在此事件。 - -**[Trade-off] 双层 Artifact 增加了序列化/反序列化复杂度** -→ 接受:Ref 和 Body 的使用场景确实不同(事件流 vs LLM 消费),合并成一个结构会导致"为了事件轻量而限制 Body 内容"或"为了 Body 丰富而让事件流膨胀"。拆开后各司其职。 - -## Open Questions - -1. **switchMode tool 的返回格式**:当 LLM 在 plan mode 调用 `switchMode("execute")` 时,tool result 应该返回什么?纯文本提示?还是结构化的"等待用户确认"状态?如果 execute 的 entry_policy 是 UserOnly,LLM 调用被拒绝时怎么反馈? - -2. **PlanArtifact 的 PlanContent schema**:steps 的结构化程度——是自由文本步骤列表,还是强类型 `{ description, files, risk }` 数组?强类型方便 UI 渲染,但 LLM 生成的准确性存疑。 - -3. **ModeMap prompt block 的缓存策略**:ModeMap(列出所有可用 mode)属于 SemiStable 层还是 Dynamic 层?如果自定义 mode 动态注册/卸载,ModeMap 需要每次 turn 重新生成。 - -4. **子 agent 是否继承父 agent 的 mode**:当父 agent 在 plan mode 下 spawn 子 agent,子 agent 应该是 execute mode(默认)还是继承 plan mode?建议默认 execute(子 agent 有自己的 scoped router),但需确认。 diff --git a/openspec/changes/collaboration-mode-system/proposal.md b/openspec/changes/collaboration-mode-system/proposal.md deleted file mode 100644 index 1e85bfcc..00000000 --- a/openspec/changes/collaboration-mode-system/proposal.md +++ /dev/null @@ -1,187 +0,0 @@ ---- -name: collaboration-mode-system -created: "2026-04-18" -status: proposal ---- - -## Why - -AstrCode 目前缺少显式的"协作阶段"建模。当用户说"先别动代码,只做方案"时,系统无法在结构层面响应——只能靠提示词软约束。与此同时,Claude Code 已将 Plan Mode 做成权限模式之一(只读分析 + 先给方案),Codex 也以 approval modes 分层控制执行摩擦。 - -当前代码里"模式"能力实际散落在多处: -- `AgentProfile.allowed_tools` — 工具授权(身份级) -- `CapabilityRouter.subset_for_tools()` — 工具过滤(子 agent 用) -- `PolicyEngine.check_capability_call()` — 运行时审批(Allow/Deny/Ask) -- `PromptDeclaration` — 提示词指导 -- `AgentPromptSubmission` — 每 turn 的工具 + prompt + 执行限制(子 agent 包络雏形) - -这些能力存在但缺少一个统一的概念:**协作模式(CollaborationMode)**。 - -核心问题: -1. **没有显式模式概念** — 无法区分"正在规划"和"正在执行" -2. **工具可见性和可执行性分裂** — LLM 能看到写工具,调用时才被 PolicyEngine 拒绝,浪费 token -3. **方案不是结构化对象** — Plan 只是 LLM 输出的一段文本,不可审批、不可版本化、不可失效 -4. **模式不可扩展** — 无法让用户通过 SDK 自定义新模式 - -## What Changes - -引入 **Mode System**,以 `ModeSpec` 为一等规格对象,统一控制工具授予、提示词注入、转换规则和产出协议。 - -### 核心概念 - -**五条正交轴(本次实现前三条):** - -| 轴 | 含义 | 真相归属 | -|---|---|---| -| Profile | 我是谁(稳定身份) | `AgentProfile` | -| **CollaborationMode** | **处于什么阶段** | **`SessionState.session_mode`** | -| ModeArtifact | 模式间的结构化交接 | `ModeArtifactRef` (durable) + `ModeArtifactBody` | -| ApprovalPolicy | 哪些动作要审批 | `PolicyEngine`(已有,渐进增强) | -| SandboxProfile | 进程/文件/网络边界 | 未来 | - -### 三层架构 - -``` -core ModeSpec / CollaborationMode 枚举 / ModeArtifact / ToolGrantRule / ArtifactRenderer trait - ← 只放稳定语义词汇,不放 runtime 编排细节 - -session-runtime - SessionState.session_mode ← durable 真相 - compile_mode_spec() ← 编译 ModeSpec → visible_tools + prompt_directives - apply_mode_transition() ← 统一切换入口(tool/command/UI 快捷键汇聚) - ModeArtifactStore ← artifact 持久化与查询 - -application BuiltinModeCatalog ← plan/execute/review 的 ModeSpec 注册 - // TODO: PluginModeCatalog ← 未来 SDK 自定义 mode -``` - -### Builtin Modes - -**Plan 模式:** -- 工具授予:只读工具(readFile, grep, findFiles, listDir, toolSearch, webSearch) -- 提示词:强调只读分析、结构化方案、不修改代码 -- 进入策略:`LlmCanEnter` — LLM 遇到复杂/不确定任务时可自行进入 -- 产出:`ModeArtifact { kind: "plan" }` — 结构化方案对象 - -**Execute 模式(默认):** -- 工具授予:全部 -- 提示词:完整执行 -- 进入策略:默认模式 / 用户确认后切换 -- 产出:无 - -**Review 模式:** -- 工具授予:只读工具 -- 提示词:代码审查 -- 进入策略:`LlmCanEnter` — 用户要求 review 时自动进入 -- 产出:`ModeArtifact { kind: "review" }` - -### 模式切换机制 - -- **switchMode tool**:LLM 在 step 中调用,请求切换模式 -- **/mode \ command**:用户终端输入 -- **Shift+Tab 快捷键**:UI 操作 -- 全部汇聚到 `apply_mode_transition()`,验证转换合法性 + entry_policy - -### 模式粒度:Turn 持久 / Step Runtime Override - -- **Turn 级**:`SessionState.session_mode` 持久化,工具集在 turn 开始时编译,不可变 -- **Step 级**:`TurnExecutionContext.step_mode_override` 仅影响 prompt directives,不改变工具集,不持久化 -- 理由:`TurnExecutionResources.tools` 在 turn 开始时确定(`runner.rs:157`),改它代价大;step 级 prompt override 已经可行(每 step 都调 `assemble_prompt_request`) - -### ModeArtifact 双层模型 - -``` -ModeArtifactRef ← 轻量引用,走事件流、UI、审批 - artifact_id, source_mode, kind, status, summary - -ModeArtifactBody ← 完整负载,走 render_to_prompt() 给 LLM - Plan(PlanContent) ← 强类型 - Review(ReviewContent) ← 强类型 - Custom { schema_id, schema_version, data: Value } ← SDK 扩展 -``` - -- **Ref** 用于事件持久化、UI 展示、compact summary -- **Body** 通过 `ArtifactRenderer` trait 渲染成 `PromptDeclaration` 给 LLM 消费 -- 渲染分级:Summary(UI/compact)→ Compact(context 紧张)→ Full(context 充裕) - -### 工具授予策略 - -采用"只给"模式而非"过滤"模式——LLM 只看到当前 mode 授予的工具,天然不知道其他工具存在。 - -```rust -pub enum ToolGrantRule { - Named(String), // 按名称精确匹配 - SideEffect(SideEffect), // 按 side_effect 类别授予 - All, // 授予全部 -} -``` - -Plan 模式可声明 `SideEffect(None)` 只拿纯只读工具,不用逐个列名字。与 `CapabilitySpec.side_effect` 字段对齐。 - -## Non-goals - -- **不做** step 级工具切换(只在 turn 边界切换工具集,step 级仅影响 prompt) -- **不做** Phase 状态机(Explore → DraftPlan → AwaitPlanApproval → Execute → Verify → Done),先只做 Plan / Execute / Review 三态 -- **不做** SandboxProfile(进程/文件/网络沙箱边界),留 TODO -- **不做** ReasoningEffort(思考深度旋钮),留 TODO -- **不做** CapabilityBudget(文件数/命令数上限),留 TODO -- **不做** PluginModeCatalog(SDK 自定义 mode),但类型设计预留扩展点 -- **不做** 隐式意图识别("先别动代码"自动切 plan),先只做显式切换 - -## Capabilities - -### P1 — 核心模式系统 - -- `mode-spec`:core 新增 CollaborationMode 枚举、ModeSpec 结构、ToolGrantRule、ModeEntryPolicy、ModeTransition -- `mode-truth`:session-runtime SessionState 新增 session_mode 字段,通过 StorageEvent 持久化切换历史 -- `mode-compile`:session-runtime 新增 `compile_mode_spec()` 编译 ModeSpec → visible tools + prompt directives -- `mode-switch-tool`:新增 switchMode builtin tool,LLM 可调用请求切换 -- `mode-switch-command`:新增 /mode command 入口 -- `mode-prompt`:新增 ModeMap prompt block(告诉 LLM 有哪些 mode、何时使用)+ CurrentMode prompt block(当前约束) -- `builtin-modes`:application 注册 plan/execute/review 三个 builtin ModeSpec -- `mode-catalog`:core 新增 ModeCatalog trait + BuiltinModeCatalog 实现 - -### P2 — ModeArtifact - -- `artifact-types`:core 新增 ModeArtifactRef、ModeArtifactBody(含 PlanContent/ReviewContent/Custom)、ArtifactStatus -- `artifact-renderer`:core 新增 ArtifactRenderer trait + builtin PlanArtifactRenderer 实现 -- `artifact-store`:session-runtime 管理 active_artifacts,通过 StorageEvent 持久化 -- `artifact-prompt-injection`:execute mode 从 active_artifacts 中查找 plan artifact,注入 prompt - -### P3 — 统一切换入口 + 审批流 - -- `mode-transition`:session-runtime 新增 `apply_mode_transition()` 统一入口 -- `mode-transition-validation`:验证转换合法性(ModeSpec.transitions)+ entry_policy 检查 -- `mode-ui-integration`:前端 Shift+Tab 快捷键 → API → apply_mode_transition -- `artifact-accept-flow`:plan artifact 的 Accept/Reject 状态转换 - -## Impact - -**用户可见影响:** -- 新增 `/mode plan`、`/mode execute`、`/mode review` 命令 -- Plan 模式下 LLM 只做分析不做修改,体验更安全 -- LLM 可以在复杂任务时自行进入 Plan 模式 -- 方案产出后用户可在 UI 中审批,确认后切换 Execute 执行 - -**开发者可见影响:** -- core 新增 `mode` 模块(CollaborationMode, ModeSpec, ToolGrantRule 等) -- session-runtime 的 `SessionState` 新增 `session_mode` 字段 -- session-runtime 新增 `mode_transition.rs` 模块 -- `TurnExecutionResources` 的 tools 编译逻辑从直接读 gateway 改为经 mode compile -- `AssemblePromptRequest` 新增 mode 相关 prompt declarations -- `StorageEventPayload` 新增 `ModeChanged`、`ModeArtifactCreated` 变体 -- 新增 `switchMode` builtin tool - -**架构影响:** -- 模式真相落在 session-runtime(符合"会随对话推进变化、影响后续 turn 行为、需要恢复/重放/审计"的判定标准) -- Profile 保持稳定身份不变,不被临时阶段污染 -- PolicyEngine 保持现有职责,未来可渐进增强为 per-mode 策略 -- 类型设计预留 SDK 扩展点(Custom artifact body、ModeCatalog trait) - -## Migration - -无破坏性迁移: -- 默认 mode 为 Execute,现有行为完全不受影响 -- session_mode 字段为新增,旧会话 replay 时默认 Execute -- switchMode tool 为新增 builtin tool,不影响现有工具注册 -- ModeArtifact 为新增存储类型,不影响现有事件流 diff --git a/openspec/changes/collaboration-mode-system/specs/artifact-prompt-injection/spec.md b/openspec/changes/collaboration-mode-system/specs/artifact-prompt-injection/spec.md deleted file mode 100644 index 9859bf3f..00000000 --- a/openspec/changes/collaboration-mode-system/specs/artifact-prompt-injection/spec.md +++ /dev/null @@ -1,29 +0,0 @@ -## ADDED Requirements - -### Requirement: Execute 模式自动引用 accepted plan artifact -系统 SHALL 在 Execute 模式的 prompt 编译阶段,自动查找 active_artifacts 中 status=Accepted 且 kind="plan" 的 artifact,并将其 Full 级渲染注入 prompt。 - -#### Scenario: 存在 accepted plan 时注入 -- **WHEN** 当前模式为 Execute -- **AND** active_artifacts 包含 kind="plan", status=Accepted 的 artifact -- **THEN** prompt 中包含该 plan 的完整步骤、假设和风险说明 - -#### Scenario: 不存在 accepted plan 时不注入 -- **WHEN** 当前模式为 Execute -- **AND** active_artifacts 中没有 accepted 的 plan artifact -- **THEN** prompt 中不包含 plan 引用块 - -#### Scenario: 多个 accepted plan 只注入最新的 -- **WHEN** active_artifacts 包含多个 accepted plan artifact -- **THEN** 只注入 artifact_id 最大的(最新创建的)那个 - -### Requirement: Plan artifact 注入为 Dynamic 层 PromptDeclaration -系统 SHALL 将 plan artifact 渲染为 `PromptDeclaration`,属性为: -- `block_id`: "mode.artifact.plan" -- `layer`: SystemPromptLayer::Dynamic -- `kind`: PromptDeclarationKind::ExtensionInstruction -- `always_include`: true - -#### Scenario: 渲染内容包含步骤列表 -- **WHEN** plan artifact 的 PlanContent 有 3 个步骤 -- **THEN** 注入的 PromptDeclaration content 包含这 3 个步骤的描述 diff --git a/openspec/changes/collaboration-mode-system/specs/artifact-renderer/spec.md b/openspec/changes/collaboration-mode-system/specs/artifact-renderer/spec.md deleted file mode 100644 index 22a4482c..00000000 --- a/openspec/changes/collaboration-mode-system/specs/artifact-renderer/spec.md +++ /dev/null @@ -1,28 +0,0 @@ -## ADDED Requirements - -### Requirement: ArtifactRenderer trait 定义渲染接口 -系统 SHALL 定义 `ArtifactRenderer` trait,包含: -- `render(body: &ModeArtifactBody, level: RenderLevel) -> String` - -`RenderLevel` 枚举包含:Summary、Compact、Full。 - -#### Scenario: Plan artifact Summary 级渲染 -- **WHEN** PlanArtifactRenderer 以 RenderLevel::Summary 渲染一个 PlanContent -- **THEN** 输出为 1-2 句摘要文本 - -#### Scenario: Plan artifact Full 级渲染 -- **WHEN** PlanArtifactRenderer 以 RenderLevel::Full 渲染一个 PlanContent -- **THEN** 输出包含完整步骤列表、假设、风险说明 - -### Requirement: 渲染结果可作为 PromptDeclaration 注入 -系统 SHALL 将 ArtifactRenderer 的输出封装为 PromptDeclaration,注入到 consuming mode 的 prompt 中。 - -#### Scenario: Accepted plan artifact 注入到 Execute mode prompt -- **WHEN** 当前模式为 Execute -- **AND** active_artifacts 中存在一个 status=Accepted 的 plan artifact -- **THEN** plan artifact 的 Full 级渲染作为 PromptDeclaration 注入到 system prompt - -#### Scenario: Compact 级渲染用于 auto compact 后 -- **WHEN** 会话经历 auto compact -- **AND** active_artifacts 中存在 plan artifact -- **THEN** compact summary 使用 Summary 级渲染引用 plan artifact diff --git a/openspec/changes/collaboration-mode-system/specs/artifact-store/spec.md b/openspec/changes/collaboration-mode-system/specs/artifact-store/spec.md deleted file mode 100644 index 1b2901c4..00000000 --- a/openspec/changes/collaboration-mode-system/specs/artifact-store/spec.md +++ /dev/null @@ -1,33 +0,0 @@ -## ADDED Requirements - -### Requirement: SessionState 管理 active_artifacts -系统 SHALL 在 `SessionState` 中新增 `active_artifacts: StdMutex>` 字段,跟踪当前会话的活跃 artifact。 - -#### Scenario: artifact 创建后加入 active_artifacts -- **WHEN** Plan 模式产出一个新的 ModeArtifact -- **THEN** 其 ModeArtifactRef 被添加到 active_artifacts - -#### Scenario: artifact 状态变更同步更新 -- **WHEN** 一个 active artifact 从 Draft 变为 Accepted -- **THEN** active_artifacts 中对应的 ref 的 status 更新为 Accepted - -#### Scenario: artifact 被 Superseded 后保留但标记 -- **WHEN** 新 plan 产出后旧 plan 被标记为 Superseded -- **THEN** 旧 plan 仍在 active_artifacts 中,但 status 为 Superseded - -### Requirement: Artifact 变更通过 StorageEvent 持久化 -系统 SHALL 在 `StorageEventPayload` 中新增以下变体: -- `ModeArtifactCreated { ref: ModeArtifactRef, body: ModeArtifactBody, timestamp }` -- `ModeArtifactStatusChanged { artifact_id, from_status, to_status, timestamp }` - -#### Scenario: artifact 创建产生事件 -- **WHEN** Plan 模式产出 ModeArtifact -- **THEN** 一条 ModeArtifactCreated 事件被持久化 - -#### Scenario: artifact 状态变更产生事件 -- **WHEN** 用户接受一个 plan artifact -- **THEN** 一条 ModeArtifactStatusChanged { from: Draft, to: Accepted } 事件被持久化 - -#### Scenario: 旧会话 replay 不受影响 -- **WHEN** replay 不包含 artifact 事件的旧会话 -- **THEN** active_artifacts 为空列表,不报错 diff --git a/openspec/changes/collaboration-mode-system/specs/artifact-types/spec.md b/openspec/changes/collaboration-mode-system/specs/artifact-types/spec.md deleted file mode 100644 index 62065db2..00000000 --- a/openspec/changes/collaboration-mode-system/specs/artifact-types/spec.md +++ /dev/null @@ -1,54 +0,0 @@ -## ADDED Requirements - -### Requirement: ModeArtifactRef 轻量引用 -系统 SHALL 定义 `ModeArtifactRef` 结构体,包含: -- `artifact_id`: String -- `source_mode`: String(产出模式 ID) -- `kind`: String(artifact 类型) -- `status`: ArtifactStatus(Draft | Accepted | Rejected | Superseded) -- `summary`: String(人可读摘要) - -#### Scenario: ModeArtifactRef 可序列化为 JSON -- **WHEN** 一个 ModeArtifactRef 被序列化 -- **THEN** JSON 包含 artifactId、sourceMode、kind、status、summary 字段 - -#### Scenario: ModeArtifactRef 可嵌入 StorageEvent -- **WHEN** artifact 创建或状态变更 -- **THEN** ModeArtifactRef 作为 StorageEvent payload 的一部分被持久化 - -### Requirement: ModeArtifactBody 完整负载 -系统 SHALL 定义 `ModeArtifactBody` 枚举: -- `Plan(PlanContent)`: 方案内容 -- `Review(ReviewContent)`: 审查内容 -- `Custom { schema_id, schema_version, data: Value }`: SDK 扩展 - -#### Scenario: Plan body 包含结构化步骤 -- **WHEN** Plan(PlanContent) 被构造 -- **THEN** PlanContent 包含 steps、assumptions、open_questions、touched_paths、risk_notes 字段 - -#### Scenario: Custom body 携带 schema 信息 -- **WHEN** Custom body 被构造 -- **THEN** 包含 schema_id(标识内容格式)、schema_version、data(自由 JSON) - -### Requirement: PlanContent 强类型方案结构 -系统 SHALL 定义 `PlanContent` 结构体,包含: -- `steps: Vec`(步骤列表) -- `assumptions: Vec`(假设条件) -- `open_questions: Vec`(待确认问题) -- `touched_paths: Vec`(涉及文件路径) -- `risk_notes: Vec`(风险说明) - -#### Scenario: PlanStep 包含描述和风险等级 -- **WHEN** PlanStep 被构造 -- **THEN** 包含 description 字段 - -### Requirement: ArtifactStatus 状态枚举 -系统 SHALL 定义 `ArtifactStatus` 枚举:Draft | Accepted | Rejected | Superseded。 - -#### Scenario: 新建 artifact 默认 Draft -- **WHEN** artifact 被创建 -- **THEN** status 为 Draft - -#### Scenario: 状态转换是单向的 -- **WHEN** artifact 从 Draft 转为 Accepted -- **THEN** 后续不能再改回 Draft diff --git a/openspec/changes/collaboration-mode-system/specs/builtin-modes/spec.md b/openspec/changes/collaboration-mode-system/specs/builtin-modes/spec.md deleted file mode 100644 index 06742f74..00000000 --- a/openspec/changes/collaboration-mode-system/specs/builtin-modes/spec.md +++ /dev/null @@ -1,37 +0,0 @@ -## ADDED Requirements - -### Requirement: BuiltinModeCatalog 注册三个预定义模式 -系统 SHALL 提供 `BuiltinModeCatalog` 实现,注册以下三个 ModeSpec: - -**Plan 模式:** -- tool_grants: SideEffect(None) + Named("toolSearch") -- system_directive: 只读分析、结构化方案、不修改代码 -- entry_policy: LlmCanEnter -- transitions: → execute (requires_confirmation=true), → review (requires_confirmation=false) -- output_artifact_kind: Some("plan") - -**Execute 模式:** -- tool_grants: All -- system_directive: 完整执行权限 -- entry_policy: 默认模式 -- transitions: → plan (requires_confirmation=false), → review (requires_confirmation=false) -- output_artifact_kind: None - -**Review 模式:** -- tool_grants: SideEffect(None) + Named("toolSearch") -- system_directive: 代码审查、质量检查 -- entry_policy: LlmCanEnter -- transitions: → execute (requires_confirmation=true), → plan (requires_confirmation=false) -- output_artifact_kind: Some("review") - -#### Scenario: list_modes 返回三个模式 -- **WHEN** BuiltinModeCatalog.list_modes() 被调用 -- **THEN** 返回包含 plan、execute、review 三个 ModeSpec 的列表 - -#### Scenario: resolve_mode 找到指定模式 -- **WHEN** BuiltinModeCatalog.resolve_mode("plan") 被调用 -- **THEN** 返回 Some(ModeSpec { id: "plan", ... }) - -#### Scenario: resolve_mode 找不到不存在的模式 -- **WHEN** BuiltinModeCatalog.resolve_mode("nonexistent") 被调用 -- **THEN** 返回 None diff --git a/openspec/changes/collaboration-mode-system/specs/mode-catalog/spec.md b/openspec/changes/collaboration-mode-system/specs/mode-catalog/spec.md deleted file mode 100644 index f19b1433..00000000 --- a/openspec/changes/collaboration-mode-system/specs/mode-catalog/spec.md +++ /dev/null @@ -1,17 +0,0 @@ -## ADDED Requirements - -### Requirement: ModeCatalog trait 定义模式发现接口 -系统 SHALL 在 core 中定义 `ModeCatalog` trait,包含以下方法: -- `list_modes() -> Vec`:列出所有可用模式 -- `resolve_mode(id: &str) -> Option`:按 ID 查找模式 - -#### Scenario: ModeCatalog 可被 Arc 包装共享 -- **WHEN** BuiltinModeCatalog 被 Arc 包装 -- **THEN** 可跨线程安全地调用 list_modes 和 resolve_mode - -### Requirement: ModeCatalog 在 bootstrap 阶段注册 -系统 SHALL 在 server bootstrap 阶段创建 BuiltinModeCatalog 并将其注入到需要消费模式信息的组件中。 - -#### Scenario: ModeCatalog 通过 PromptFactsProvider 消费 -- **WHEN** PromptFactsProvider 需要生成 ModeMap prompt block -- **THEN** 它通过 ModeCatalog trait 获取可用模式列表,不依赖具体实现 diff --git a/openspec/changes/collaboration-mode-system/specs/mode-compile/spec.md b/openspec/changes/collaboration-mode-system/specs/mode-compile/spec.md deleted file mode 100644 index e3656bcf..00000000 --- a/openspec/changes/collaboration-mode-system/specs/mode-compile/spec.md +++ /dev/null @@ -1,60 +0,0 @@ -## ADDED Requirements - -### Requirement: compile_mode_spec 编译模式执行规格 -系统 SHALL 提供 `compile_mode_spec()` 函数,将 ModeSpec + 全部注册工具编译为 `ModeExecutionSpec`,包含: -- `visible_tools`: 当前模式可见的工具定义列表 -- `mode_prompt`: 当前模式的约束 PromptDeclaration -- `mode_map_prompt`: 所有可用模式描述的 PromptDeclaration - -#### Scenario: Plan 模式编译只读工具 -- **WHEN** compile_mode_spec 以 ModeSpec("plan") 和包含 readFile + writeFile + shell 的工具注册表调用 -- **THEN** visible_tools 仅包含 readFile,不包含 writeFile 和 shell - -#### Scenario: Execute 模式编译全部工具 -- **WHEN** compile_mode_spec 以 ModeSpec("execute") 调用 -- **THEN** visible_tools 包含所有注册的工具 - -### Requirement: 工具编译使用授予白名单 -系统 SHALL 通过 ToolGrantRule 白名单机制过滤工具。不在白名单中的工具不会出现在 visible_tools 中。 - -#### Scenario: Named 规则精确匹配 -- **WHEN** tool_grants 包含 ToolGrantRule::Named("grep") -- **AND** 注册表中存在 grep 工具 -- **THEN** grep 出现在 visible_tools 中 - -#### Scenario: Named 规则匹配不存在的工具 -- **WHEN** tool_grants 包含 ToolGrantRule::Named("nonexistent") -- **AND** 注册表中不存在该工具 -- **THEN** 编译不报错,该规则被忽略 - -#### Scenario: SideEffect 规则按类别过滤 -- **WHEN** tool_grants 包含 ToolGrantRule::SideEffect(None) -- **AND** 注册表中 readFile 的 side_effect 为 None,writeFile 的 side_effect 为 Workspace -- **THEN** visible_tools 包含 readFile,不包含 writeFile - -### Requirement: 编译注入当前模式约束 prompt -系统 SHALL 为当前模式生成一个 Dynamic 层的 PromptDeclaration,包含: -- `block_id`: "mode.current_constraint" -- `content`: ModeSpec.system_directive 的内容 -- `layer`: SystemPromptLayer::Dynamic - -#### Scenario: Plan 模式注入只读约束 -- **WHEN** 当前模式为 Plan -- **THEN** 生成的 PromptDeclaration content 包含"只读分析"相关约束文本 - -### Requirement: 编译注入模式地图 prompt -系统 SHALL 生成一个 SemiStable 层的 PromptDeclaration,列出所有可用模式及其说明,包含: -- `block_id`: "mode.available_modes" -- `content`: 从 ModeCatalog.list_modes() 生成的模式描述 -- `layer`: SystemPromptLayer::SemiStable - -#### Scenario: 模式地图包含三个 builtin 模式 -- **WHEN** ModeCatalog 包含 plan/execute/review 三个模式 -- **THEN** 生成的 PromptDeclaration content 包含这三个模式的名称和描述 - -### Requirement: 编译结果集成到 TurnExecutionResources -系统 SHALL 在 turn 开始时调用 compile_mode_spec,将 visible_tools 替换 TurnExecutionResources 中的 tools 字段。 - -#### Scenario: Turn 开始时工具按模式编译 -- **WHEN** 新 turn 以 session_mode=Plan 启动 -- **THEN** TurnExecutionResources.tools 仅包含 Plan 模式授予的工具 diff --git a/openspec/changes/collaboration-mode-system/specs/mode-prompt/spec.md b/openspec/changes/collaboration-mode-system/specs/mode-prompt/spec.md deleted file mode 100644 index 47ada2b1..00000000 --- a/openspec/changes/collaboration-mode-system/specs/mode-prompt/spec.md +++ /dev/null @@ -1,35 +0,0 @@ -## ADDED Requirements - -### Requirement: ModeMap prompt block 注入可用模式描述 -系统 SHALL 在每 turn 的 prompt 中注入一个 "Available Modes" block(SemiStable 层),内容包含: -- 每个可用模式的名称和简短描述 -- 每个模式的适用场景 -- 哪些模式 LLM 可自行进入,哪些需要用户操作 -- 切换方式说明 - -#### Scenario: ModeMap 包含三个 builtin 模式 -- **WHEN** BuiltinModeCatalog 包含 plan/execute/review -- **THEN** ModeMap prompt block 包含这三个模式的名称、描述和进入策略 - -#### Scenario: LLM 能理解何时进入 Plan 模式 -- **WHEN** ModeMap prompt block 被注入到 system prompt -- **THEN** 内容包含"当任务复杂、涉及多文件、或不确定最佳方案时"类似描述 - -### Requirement: CurrentMode prompt block 注入当前约束 -系统 SHALL 在每 turn 的 prompt 中注入一个 "Current Mode" block(Dynamic 层),内容包含: -- 当前模式名称 -- 当前模式的核心约束 -- 如果有 active_artifacts,引用其 summary - -#### Scenario: Plan 模式注入只读约束 -- **WHEN** 当前模式为 Plan -- **THEN** CurrentMode block 内容包含"只使用只读工具"、"不修改文件"等约束 - -#### Scenario: Execute 模式引用已接受的 plan artifact -- **WHEN** 当前模式为 Execute -- **AND** active_artifacts 中存在一个 status=Accepted 的 plan artifact -- **THEN** CurrentMode block 引用该 plan 的 summary - -#### Scenario: 模式切换后 prompt 自动更新 -- **WHEN** session_mode 从 Plan 切换到 Execute -- **THEN** 下一个 step 的 prompt 中 CurrentMode block 内容更新为 Execute 的约束 diff --git a/openspec/changes/collaboration-mode-system/specs/mode-spec/spec.md b/openspec/changes/collaboration-mode-system/specs/mode-spec/spec.md deleted file mode 100644 index 17ae6e89..00000000 --- a/openspec/changes/collaboration-mode-system/specs/mode-spec/spec.md +++ /dev/null @@ -1,74 +0,0 @@ -## ADDED Requirements - -### Requirement: CollaborationMode 枚举定义协作阶段 -系统 SHALL 定义 `CollaborationMode` 枚举,包含 `Plan`、`Execute`、`Review` 三个变体,作为跨 crate 稳定成立的语义词汇。 - -#### Scenario: 默认模式为 Execute -- **WHEN** 新会话创建时 -- **THEN** `CollaborationMode` 默认值为 `Execute` - -#### Scenario: 枚举可序列化为 camelCase -- **WHEN** `CollaborationMode::Plan` 被序列化为 JSON -- **THEN** 输出为 `"plan"` - -### Requirement: ModeSpec 声明式定义模式规格 -系统 SHALL 定义 `ModeSpec` 结构体,包含以下字段: -- `id`: 唯一标识(如 "plan") -- `name`: 人类可读名称 -- `description`: 模式说明(供 LLM 理解何时使用) -- `tool_grants`: `Vec` 工具授予规则 -- `system_directive`: 模式约束提示词 -- `entry_policy`: `ModeEntryPolicy` 进入策略 -- `transitions`: `Vec` 合法转换规则 -- `output_artifact_kind`: `Option` 产出 artifact 的 kind - -#### Scenario: ModeSpec 完整序列化 -- **WHEN** 一个包含所有字段的 ModeSpec 被序列化 -- **THEN** JSON 输出包含 toolGrants、systemDirective、entryPolicy、transitions、outputArtifactKind 等字段 - -#### Scenario: ModeSpec 缺失可选字段 -- **WHEN** 一个没有 output_artifact_kind 的 ModeSpec 被序列化 -- **THEN** JSON 中不包含 outputArtifactKind 字段 - -### Requirement: ToolGrantRule 定义工具授予策略 -系统 SHALL 定义 `ToolGrantRule` 枚举,包含三个变体: -- `Named(String)`: 按工具名称精确匹配 -- `SideEffect(SideEffect)`: 按 CapabilitySpec 的 side_effect 类别授予 -- `All`: 授予全部工具 - -#### Scenario: SideEffect(None) 授予所有只读工具 -- **WHEN** ToolGrantRule::SideEffect(None) 被用于编译工具列表 -- **AND** 注册表中存在 readFile(side_effect=None)和 writeFile(side_effect=Workspace) -- **THEN** readFile 被授予,writeFile 被排除 - -#### Scenario: Named 授予指定工具 -- **WHEN** ToolGrantRule::Named("readFile") 被用于编译工具列表 -- **AND** 注册表中存在 readFile -- **THEN** readFile 被授予 - -### Requirement: ModeEntryPolicy 定义进入策略 -系统 SHALL 定义 `ModeEntryPolicy` 枚举,包含三个变体: -- `LlmCanEnter`: LLM 可自行进入 -- `UserOnly`: 仅用户可触发 -- `LlmSuggestWithConfirmation`: LLM 可建议但需用户确认 - -#### Scenario: LlmCanEnter 模式下 LLM 调用 switchMode -- **WHEN** LLM 通过 switchMode tool 请求进入一个 entry_policy 为 LlmCanEnter 的模式 -- **THEN** 切换被允许,无需用户确认 - -#### Scenario: UserOnly 模式下 LLM 调用 switchMode -- **WHEN** LLM 通过 switchMode tool 请求进入一个 entry_policy 为 UserOnly 的模式 -- **THEN** 切换被拒绝,tool 返回错误信息"此模式需要用户手动切换" - -### Requirement: ModeTransition 定义合法转换规则 -系统 SHALL 定义 `ModeTransition` 结构体,包含: -- `target_mode`: 目标模式 ID -- `requires_confirmation`: 是否需要确认 - -#### Scenario: Plan → Execute 转换需要确认 -- **WHEN** ModeSpec("plan") 的 transitions 包含 `{ target_mode: "execute", requires_confirmation: true }` -- **THEN** 从 plan 切换到 execute 需要用户确认 - -#### Scenario: Execute → Plan 转换不需要确认 -- **WHEN** ModeSpec("execute") 的 transitions 包含 `{ target_mode: "plan", requires_confirmation: false }` -- **THEN** LLM 可直接从 execute 切换到 plan diff --git a/openspec/changes/collaboration-mode-system/specs/mode-switch-command/spec.md b/openspec/changes/collaboration-mode-system/specs/mode-switch-command/spec.md deleted file mode 100644 index 4c11b968..00000000 --- a/openspec/changes/collaboration-mode-system/specs/mode-switch-command/spec.md +++ /dev/null @@ -1,31 +0,0 @@ -## ADDED Requirements - -### Requirement: /mode 命令切换协作模式 -系统 SHALL 支持用户通过 `/mode ` 命令切换当前会话的协作模式。 - -#### Scenario: 用户成功切换到 plan 模式 -- **WHEN** 用户输入 "/mode plan" -- **AND** 当前模式为 execute -- **THEN** session_mode 切换到 Plan -- **AND** 一条 ModeChanged 事件被持久化,source 为 User - -#### Scenario: 用户切换到当前已处于的模式 -- **WHEN** 用户输入 "/mode execute" -- **AND** 当前模式已经是 execute -- **THEN** 系统返回提示"已处于 execute 模式",不产生事件 - -#### Scenario: 用户切换到不存在的模式 -- **WHEN** 用户输入 "/mode nonexistent" -- **THEN** 系统返回错误"未知模式: nonexistent" - -#### Scenario: /mode 不带参数显示当前模式 -- **WHEN** 用户输入 "/mode" 不带参数 -- **THEN** 系统返回当前模式名称和描述 - -### Requirement: /mode 命令绕过 entry_policy 检查 -系统 SHALL 让用户通过 /mode 命令的切换不受 ModeEntryPolicy 限制。用户始终可以切换到任何可用模式。 - -#### Scenario: 用户直接切换到 UserOnly 模式 -- **WHEN** 用户输入 "/mode plan" -- **AND** plan 模式的 entry_policy 是 UserOnly(假设) -- **THEN** 切换成功,因为用户手动操作绕过 entry_policy diff --git a/openspec/changes/collaboration-mode-system/specs/mode-switch-tool/spec.md b/openspec/changes/collaboration-mode-system/specs/mode-switch-tool/spec.md deleted file mode 100644 index 80a71831..00000000 --- a/openspec/changes/collaboration-mode-system/specs/mode-switch-tool/spec.md +++ /dev/null @@ -1,33 +0,0 @@ -## ADDED Requirements - -### Requirement: switchMode builtin tool -系统 SHALL 提供 `switchMode` builtin tool,允许 LLM 在 step 中请求切换模式。工具参数: -- `mode`: 目标模式名称(字符串) -- `reason`: 切换原因(可选) - -#### Scenario: LLM 成功切换到允许的模式 -- **WHEN** LLM 调用 switchMode("plan", "任务复杂,需要先做方案") -- **AND** plan 模式的 entry_policy 为 LlmCanEnter -- **THEN** tool 返回成功,内容为"模式已切换到 plan,下一 turn 将使用新工具集" - -#### Scenario: LLM 请求切换到 UserOnly 模式被拒绝 -- **WHEN** LLM 调用 switchMode("execute", "准备执行") -- **AND** execute 模式从 plan 切换的 transition requires_confirmation=true -- **THEN** tool 返回错误"此切换需要用户确认,请提示用户使用 /mode execute" - -#### Scenario: LLM 请求切换到不存在的模式 -- **WHEN** LLM 调用 switchMode("nonexistent", ...) -- **THEN** tool 返回错误"未知模式: nonexistent" - -#### Scenario: switchMode 产生 StorageEvent -- **WHEN** switchMode 成功执行 -- **THEN** 一条 ModeChanged 事件被持久化,source 为 Tool - -### Requirement: switchMode 不改变当前 step 的工具 -系统 SHALL 保证 switchMode 在当前 step 内不改变工具集。工具切换在下一个 turn 开始时生效。 - -#### Scenario: Plan 模式下 switchMode("execute") 后当前 step 工具不变 -- **WHEN** LLM 在 Plan 模式下的 step 3 调用 switchMode("execute") -- **THEN** step 3 后续的工具调用仍然只使用 Plan 模式的工具 -- **AND** session_mode 已更新为 Execute -- **AND** 下一个 turn 开始时编译出 Execute 模式的完整工具集 diff --git a/openspec/changes/collaboration-mode-system/specs/mode-transition/spec.md b/openspec/changes/collaboration-mode-system/specs/mode-transition/spec.md deleted file mode 100644 index e165cce6..00000000 --- a/openspec/changes/collaboration-mode-system/specs/mode-transition/spec.md +++ /dev/null @@ -1,55 +0,0 @@ -## ADDED Requirements - -### Requirement: apply_mode_transition 统一模式切换入口 -系统 SHALL 提供 `apply_mode_transition()` 函数作为所有模式切换的统一入口,参数包含: -- `session_state`: 目标会话状态 -- `target_mode`: 目标 CollaborationMode -- `source`: ModeTransitionSource(Tool / User / UI) -- `translator`: EventTranslator - -#### Scenario: 内部执行流程 -- **WHEN** apply_mode_transition 被调用 -- **THEN** 按序执行:验证转换合法性 → 检查 entry_policy → 更新 session_mode → 广播 ModeChanged 事件 - -### Requirement: 转换合法性验证 -系统 SHALL 验证目标模式在当前模式的 ModeSpec.transitions 中是否合法。 - -#### Scenario: 合法转换通过 -- **WHEN** 当前模式为 Plan,目标为 Execute -- **AND** ModeSpec("plan").transitions 包含 target_mode="execute" -- **THEN** 验证通过 - -#### Scenario: 非法转换被拒绝 -- **WHEN** 当前模式为 Plan,目标为某个不在 transitions 列表中的模式 -- **THEN** 返回错误"不允许从 plan 切换到 " - -### Requirement: entry_policy 检查 -系统 SHALL 根据目标模式的 entry_policy 和 source 判断是否允许切换。 - -#### Scenario: LlmCanEnter + source=Tool 允许 -- **WHEN** 目标模式 entry_policy 为 LlmCanEnter -- **AND** source 为 Tool(LLM 调用) -- **THEN** 允许切换 - -#### Scenario: UserOnly + source=Tool 拒绝 -- **WHEN** 目标模式 entry_policy 为 UserOnly -- **AND** source 为 Tool(LLM 调用) -- **THEN** 拒绝切换,返回"需要用户手动切换" - -#### Scenario: UserOnly + source=User 允许 -- **WHEN** 目标模式 entry_policy 为 UserOnly -- **AND** source 为 User(/mode 命令或 UI) -- **THEN** 允许切换 - -### Requirement: transition requires_confirmation 检查 -系统 SHALL 检查当前模式到目标模式的 transition 是否标记 requires_confirmation=true。如果是且 source=Tool,要求 LLM 提示用户确认。 - -#### Scenario: requires_confirmation=true + source=Tool 需要提示 -- **WHEN** Plan → Execute 的 transition 标记 requires_confirmation=true -- **AND** source 为 Tool -- **THEN** 返回提示"此切换需要用户确认" - -#### Scenario: requires_confirmation=true + source=User 直接通过 -- **WHEN** Plan → Execute 的 transition 标记 requires_confirmation=true -- **AND** source 为 User -- **THEN** 直接通过,用户操作隐含确认 diff --git a/openspec/changes/collaboration-mode-system/specs/mode-truth/spec.md b/openspec/changes/collaboration-mode-system/specs/mode-truth/spec.md deleted file mode 100644 index 0e12839d..00000000 --- a/openspec/changes/collaboration-mode-system/specs/mode-truth/spec.md +++ /dev/null @@ -1,34 +0,0 @@ -## ADDED Requirements - -### Requirement: SessionState 持有当前协作模式 -系统 SHALL 在 `SessionState` 中新增 `session_mode: StdMutex` 字段,默认值为 `Execute`。 - -#### Scenario: 新会话默认 Execute 模式 -- **WHEN** SessionState::new() 被调用 -- **THEN** session_mode 值为 CollaborationMode::Execute - -#### Scenario: 读取当前模式 -- **WHEN** 调用 session_state.current_mode() -- **THEN** 返回当前 session_mode 的值 - -### Requirement: 模式切换通过 StorageEvent 持久化 -系统 SHALL 在 `StorageEventPayload` 中新增 `ModeChanged` 变体,包含: -- `from`: 切换前的 CollaborationMode -- `to`: 切换后的 CollaborationMode -- `source`: ModeTransitionSource(Tool / User | UI) -- `timestamp` - -#### Scenario: 模式切换产生事件 -- **WHEN** session_mode 从 Execute 切换到 Plan -- **THEN** 一条 `ModeChanged { from: Execute, to: Plan, source: Tool }` 事件被写入 storage - -#### Scenario: 旧会话 replay 不受影响 -- **WHEN** replay 不包含 ModeChanged 事件的旧会话 -- **THEN** session_mode 保持默认值 Execute,不报错 - -### Requirement: 模式切换是原子操作 -系统 SHALL 保证模式切换过程中,session_mode 的更新和事件的持久化在同一个锁范围内完成。 - -#### Scenario: 并发切换请求的串行化 -- **WHEN** 两个并发的 switchMode 请求同时到达 -- **THEN** 第二个请求 MUST 等待第一个完成后再执行,不会出现中间状态 diff --git a/openspec/changes/collaboration-mode-system/tasks.md b/openspec/changes/collaboration-mode-system/tasks.md deleted file mode 100644 index 6f333a79..00000000 --- a/openspec/changes/collaboration-mode-system/tasks.md +++ /dev/null @@ -1,98 +0,0 @@ -## 1. Core 类型基础 - -- [ ] 1.1 在 `crates/core/src/mode/mod.rs` 创建 mode 模块,定义 `CollaborationMode` 枚举(Plan/Execute/Review)、`ModeEntryPolicy`、`ModeTransition`、`ArtifactStatus`、`RenderLevel`、`ModeTransitionSource` -- [ ] 1.2 在 `crates/core/src/mode/mod.rs` 定义 `ToolGrantRule` 枚举(Named/SideEffect/All) -- [ ] 1.3 在 `crates/core/src/mode/mod.rs` 定义 `ModeSpec` 结构体 -- [ ] 1.4 在 `crates/core/src/mode/mod.rs` 定义 `PlanContent`、`ReviewContent`、`ModeArtifactBody`、`ModeArtifactRef` 结构体 -- [ ] 1.5 在 `crates/core/src/mode/mod.rs` 定义 `ArtifactRenderer` trait 和 `ModeCatalog` trait -- [ ] 1.6 在 `crates/core/src/lib.rs` 注册 mode 模块并 re-export 公共类型 -- [ ] 1.7 在 `crates/core/src/policy/engine.rs` 的 `StorageEventPayload` 中新增 `ModeChanged`、`ModeArtifactCreated`、`ModeArtifactStatusChanged` 变体 - -**验证:** `cargo check -p astrcode-core` 通过 - -## 2. Session-Runtime 模式真相 - -- [ ] 2.1 在 `crates/session-runtime/src/state/mod.rs` 的 `SessionState` 中新增 `session_mode: StdMutex` 字段和 `current_mode()` / `set_mode()` 方法 -- [ ] 2.2 在 `crates/session-runtime/src/state/mod.rs` 的 `SessionState` 中新增 `active_artifacts: StdMutex>` 字段和相关查询方法 -- [ ] 2.3 更新 `SessionState::new()` 初始化新字段为默认值(Execute / 空 vec) -- [ ] 2.4 在 `EventTranslator` 中处理 `ModeChanged` 和 `ModeArtifactCreated` / `ModeArtifactStatusChanged` 事件的投影逻辑 - -**验证:** `cargo check -p astrcode-session-runtime` 通过 - -## 3. 模式编译与工具授予 - -- [ ] 3.1 在 `crates/session-runtime/src/turn/` 下新增 `mode_compile.rs`,实现 `compile_mode_spec()` 函数:从 ModeSpec + CapabilitySpec 列表编译出 visible tools + prompt declarations -- [ ] 3.2 实现 `ToolGrantRule` 到 `Vec` 的解析逻辑,处理 Named/SideEffect/All 三种规则 -- [ ] 3.3 实现 `ModeMap` prompt block 生成(从 ModeCatalog 生成 SemiStable 层 declaration) -- [ ] 3.4 实现 `CurrentMode` prompt block 生成(Dynamic 层 declaration,包含当前约束) -- [ ] 3.5 修改 `crates/session-runtime/src/turn/runner.rs` 的 `TurnExecutionResources::new()`,从 session_mode 编译 visible_tools 替换直接读 gateway 的工具列表 -- [ ] 3.6 在 `AssemblePromptRequest` 中新增 mode 相关字段,在 `assemble_prompt_request()` 中注入 ModeMap 和 CurrentMode prompt declarations -- [ ] 3.7 为 compile_mode_spec 编写单元测试:Plan 模式只拿只读工具、Execute 拿全部、Review 拿只读 - -**验证:** `cargo test -p astrcode-session-runtime -- mode_compile` 通过 - -## 4. 统一模式切换入口 - -- [ ] 4.1 在 `crates/session-runtime/src/turn/` 下新增 `mode_transition.rs`,实现 `apply_mode_transition()` 函数 -- [ ] 4.2 实现转换合法性验证(检查 target_mode 是否在当前 mode 的 transitions 列表中) -- [ ] 4.3 实现 entry_policy 检查逻辑(LlmCanEnter/UserOnly/LlmSuggestWithConfirmation) -- [ ] 4.4 实现 transition requires_confirmation 检查逻辑 -- [ ] 4.5 实现切换执行:更新 session_mode + 广播 ModeChanged StorageEvent -- [ ] 4.6 为 apply_mode_transition 编写单元测试:合法切换、非法切换、entry_policy 拒绝、User 绕过 - -**验证:** `cargo test -p astrcode-session-runtime -- mode_transition` 通过 - -## 5. switchMode Builtin Tool - -- [ ] 5.1 在 `crates/adapter-tools/src/builtin_tools/` 下新增 `switch_mode.rs`,实现 switchMode tool 的参数解析和执行逻辑 -- [ ] 5.2 switchMode 执行体调用 `apply_mode_transition(source=Tool)`,返回切换结果 -- [ ] 5.3 在 `crates/adapter-tools/src/builtin_tools/mod.rs` 注册 switchMode tool -- [ ] 5.4 switchMode 的 CapabilitySpec 标注为 `side_effect: None`(所有模式都可见) -- [ ] 5.5 编写 switchMode tool 的单元测试:成功切换、拒绝切换、未知模式 - -**验证:** `cargo test -p astrcode-adapter-tools -- switch_mode` 通过 - -## 6. BuiltinModeCatalog 注册 - -- [ ] 6.1 在 `crates/application/src/execution/` 下新增 `mode_catalog.rs`,实现 `BuiltinModeCatalog` 结构体 -- [ ] 6.2 定义 Plan/Execute/Review 三个 ModeSpec 实例(含 tool_grants、system_directive、entry_policy、transitions) -- [ ] 6.3 在 `crates/application/src/lib.rs` 注册 mode_catalog 模块 -- [ ] 6.4 在 `crates/server/src/bootstrap/runtime.rs` 的 bootstrap 阶段创建 BuiltinModeCatalog 并注入到 PromptFactsProvider -- [ ] 6.5 编写 BuiltinModeCatalog 的单元测试:list_modes 返回 3 个、resolve_mode 正确 - -**验证:** `cargo check --workspace` 通过 - -## 7. /mode Command 入口 - -- [ ] 7.1 在 session-runtime 的 command 处理中新增 `/mode` 命令解析 -- [ ] 7.2 `/mode ` 调用 `apply_mode_transition(source=User)`,绕过 entry_policy -- [ ] 7.3 `/mode` 不带参数返回当前模式名称和描述 -- [ ] 7.4 编写 /mode command 的单元测试 - -**验证:** `cargo test -p astrcode-session-runtime -- mode_command` 通过 - -## 8. ModeArtifact 集成 - -- [ ] 8.1 在 `crates/core/src/mode/mod.rs` 实现 Builtin 的 `PlanArtifactRenderer`(Summary/Compact/Full 三级渲染) -- [ ] 8.2 在 `crates/session-runtime/src/state/` 下新增 artifact 管理方法:create_artifact、accept_artifact、reject_artifact、supersede_artifact -- [ ] 8.3 在 `crates/session-runtime/src/turn/mode_compile.rs` 的 Execute 模式编译中,查找 accepted plan artifact 并注入 Full 级渲染的 PromptDeclaration -- [ ] 8.4 编写 artifact 管理的单元测试:创建、接受、supersede 流程 -- [ ] 8.5 编写 artifact prompt injection 的单元测试 - -**验证:** `cargo test -p astrcode-session-runtime -- artifact` 通过 - -## 9. PromptFacts 集成 - -- [ ] 9.1 在 `crates/server/src/bootstrap/prompt_facts.rs` 中集成 ModeCatalog,使 PromptFacts 包含 mode 信息 -- [ ] 9.2 在 `crates/adapter-prompt/src/contributors/` 下新增 mode 相关 contributor(生成 ModeMap block) -- [ ] 9.3 确保 ModeMap block 和 CurrentMode block 的缓存层正确(SemiStable / Dynamic) - -**验证:** `cargo check --workspace` 通过 - -## 10. 集成验证 - -- [ ] 10.1 运行 `cargo fmt --all` 格式化代码 -- [ ] 10.2 运行 `cargo clippy --all-targets --all-features -- -D warnings` 修复所有警告 -- [ ] 10.3 运行 `cargo test --workspace --exclude astrcode` 确保所有测试通过 -- [ ] 10.4 运行 `node scripts/check-crate-boundaries.mjs` 验证 crate 依赖边界 -- [ ] 10.5 端到端手动验证:启动 dev server,使用 /mode plan 切换模式,确认 LLM 只使用只读工具 From 20af2e1ecde7c8d3ae40654aef49dfc4865c607e Mon Sep 17 00:00:00 2001 From: whatevertogo Date: Sun, 19 Apr 2026 11:06:54 +0800 Subject: [PATCH 35/53] feat(governance): implement unified governance surface cleanup - Introduced new specifications for agent delegation surface, agent tool governance, capability router assembly, delegation policy surface, execution limits control, governance surface assembly, policy engine integration, and prompt facts governance linkage. - Established requirements ensuring all governance inputs are derived from a unified governance envelope, eliminating redundant assembly paths and ensuring consistency across different execution entry points. - Enhanced collaboration audit facts to include governance context, ensuring traceability and alignment with governance policies. - Cleaned up existing code to ensure that all governance-related parameters are sourced from the governance envelope, improving maintainability and clarity. - Added tasks for further cleanup and validation of the governance assembly process, ensuring adherence to architectural principles and project guidelines. --- .codex/skills/openspec-onboard/SKILL.md | 554 ------------------ .codex/skills/openspec-propose/SKILL.md | 110 ++++ .../collaboration-mode-system/design.md | 338 +++++++++++ .../collaboration-mode-system/proposal.md | 50 ++ .../specs/agent-delegation-surface/spec.md | 81 +++ .../specs/agent-tool-governance/spec.md | 65 ++ .../specs/governance-mode-system/spec.md | 208 +++++++ .../specs/mode-capability-compilation/spec.md | 86 +++ .../specs/mode-command-surface/spec.md | 99 ++++ .../specs/mode-execution-policy/spec.md | 83 +++ .../specs/mode-policy-engine/spec.md | 76 +++ .../specs/mode-prompt-program/spec.md | 99 ++++ .../collaboration-mode-system/tasks.md | 88 +++ .../governance-surface-cleanup/.openspec.yaml | 2 + .../governance-surface-cleanup/design.md | 306 ++++++++++ .../governance-surface-cleanup/proposal.md | 50 ++ .../specs/agent-delegation-surface/spec.md | 53 ++ .../specs/agent-tool-governance/spec.md | 17 + .../specs/capability-router-assembly/spec.md | 39 ++ .../specs/delegation-policy-surface/spec.md | 57 ++ .../specs/execution-limits-control/spec.md | 59 ++ .../specs/governance-surface-assembly/spec.md | 90 +++ .../specs/policy-engine-integration/spec.md | 59 ++ .../prompt-facts-governance-linkage/spec.md | 59 ++ .../governance-surface-cleanup/tasks.md | 77 +++ openspec/config.yaml | 133 +++-- 26 files changed, 2337 insertions(+), 601 deletions(-) delete mode 100644 .codex/skills/openspec-onboard/SKILL.md create mode 100644 .codex/skills/openspec-propose/SKILL.md create mode 100644 openspec/changes/collaboration-mode-system/design.md create mode 100644 openspec/changes/collaboration-mode-system/proposal.md create mode 100644 openspec/changes/collaboration-mode-system/specs/agent-delegation-surface/spec.md create mode 100644 openspec/changes/collaboration-mode-system/specs/agent-tool-governance/spec.md create mode 100644 openspec/changes/collaboration-mode-system/specs/governance-mode-system/spec.md create mode 100644 openspec/changes/collaboration-mode-system/specs/mode-capability-compilation/spec.md create mode 100644 openspec/changes/collaboration-mode-system/specs/mode-command-surface/spec.md create mode 100644 openspec/changes/collaboration-mode-system/specs/mode-execution-policy/spec.md create mode 100644 openspec/changes/collaboration-mode-system/specs/mode-policy-engine/spec.md create mode 100644 openspec/changes/collaboration-mode-system/specs/mode-prompt-program/spec.md create mode 100644 openspec/changes/collaboration-mode-system/tasks.md create mode 100644 openspec/changes/governance-surface-cleanup/.openspec.yaml create mode 100644 openspec/changes/governance-surface-cleanup/design.md create mode 100644 openspec/changes/governance-surface-cleanup/proposal.md create mode 100644 openspec/changes/governance-surface-cleanup/specs/agent-delegation-surface/spec.md create mode 100644 openspec/changes/governance-surface-cleanup/specs/agent-tool-governance/spec.md create mode 100644 openspec/changes/governance-surface-cleanup/specs/capability-router-assembly/spec.md create mode 100644 openspec/changes/governance-surface-cleanup/specs/delegation-policy-surface/spec.md create mode 100644 openspec/changes/governance-surface-cleanup/specs/execution-limits-control/spec.md create mode 100644 openspec/changes/governance-surface-cleanup/specs/governance-surface-assembly/spec.md create mode 100644 openspec/changes/governance-surface-cleanup/specs/policy-engine-integration/spec.md create mode 100644 openspec/changes/governance-surface-cleanup/specs/prompt-facts-governance-linkage/spec.md create mode 100644 openspec/changes/governance-surface-cleanup/tasks.md diff --git a/.codex/skills/openspec-onboard/SKILL.md b/.codex/skills/openspec-onboard/SKILL.md deleted file mode 100644 index 014e4017..00000000 --- a/.codex/skills/openspec-onboard/SKILL.md +++ /dev/null @@ -1,554 +0,0 @@ ---- -name: openspec-onboard -description: Guided onboarding for OpenSpec - walk through a complete workflow cycle with narration and real codebase work. -license: MIT -compatibility: Requires openspec CLI. -metadata: - author: openspec - version: "1.0" - generatedBy: "1.3.0" ---- - -Guide the user through their first complete OpenSpec workflow cycle. This is a teaching experience—you'll do real work in their codebase while explaining each step. - ---- - -## Preflight - -Before starting, check if the OpenSpec CLI is installed: - -```bash -# Unix/macOS -openspec --version 2>&1 || echo "CLI_NOT_INSTALLED" -# Windows (PowerShell) -# if (Get-Command openspec -ErrorAction SilentlyContinue) { openspec --version } else { echo "CLI_NOT_INSTALLED" } -``` - -**If CLI not installed:** -> OpenSpec CLI is not installed. Install it first, then come back to `/opsx:onboard`. - -Stop here if not installed. - ---- - -## Phase 1: Welcome - -Display: - -``` -## Welcome to OpenSpec! - -I'll walk you through a complete change cycle—from idea to implementation—using a real task in your codebase. Along the way, you'll learn the workflow by doing it. - -**What we'll do:** -1. Pick a small, real task in your codebase -2. Explore the problem briefly -3. Create a change (the container for our work) -4. Build the artifacts: proposal → specs → design → tasks -5. Implement the tasks -6. Archive the completed change - -**Time:** ~15-20 minutes - -Let's start by finding something to work on. -``` - ---- - -## Phase 2: Task Selection - -### Codebase Analysis - -Scan the codebase for small improvement opportunities. Look for: - -1. **TODO/FIXME comments** - Search for `TODO`, `FIXME`, `HACK`, `XXX` in code files -2. **Missing error handling** - `catch` blocks that swallow errors, risky operations without try-catch -3. **Functions without tests** - Cross-reference `src/` with test directories -4. **Type issues** - `any` types in TypeScript files (`: any`, `as any`) -5. **Debug artifacts** - `console.log`, `console.debug`, `debugger` statements in non-debug code -6. **Missing validation** - User input handlers without validation - -Also check recent git activity: -```bash -# Unix/macOS -git log --oneline -10 2>/dev/null || echo "No git history" -# Windows (PowerShell) -# git log --oneline -10 2>$null; if ($LASTEXITCODE -ne 0) { echo "No git history" } -``` - -### Present Suggestions - -From your analysis, present 3-4 specific suggestions: - -``` -## Task Suggestions - -Based on scanning your codebase, here are some good starter tasks: - -**1. [Most promising task]** - Location: `src/path/to/file.ts:42` - Scope: ~1-2 files, ~20-30 lines - Why it's good: [brief reason] - -**2. [Second task]** - Location: `src/another/file.ts` - Scope: ~1 file, ~15 lines - Why it's good: [brief reason] - -**3. [Third task]** - Location: [location] - Scope: [estimate] - Why it's good: [brief reason] - -**4. Something else?** - Tell me what you'd like to work on. - -Which task interests you? (Pick a number or describe your own) -``` - -**If nothing found:** Fall back to asking what the user wants to build: -> I didn't find obvious quick wins in your codebase. What's something small you've been meaning to add or fix? - -### Scope Guardrail - -If the user picks or describes something too large (major feature, multi-day work): - -``` -That's a valuable task, but it's probably larger than ideal for your first OpenSpec run-through. - -For learning the workflow, smaller is better—it lets you see the full cycle without getting stuck in implementation details. - -**Options:** -1. **Slice it smaller** - What's the smallest useful piece of [their task]? Maybe just [specific slice]? -2. **Pick something else** - One of the other suggestions, or a different small task? -3. **Do it anyway** - If you really want to tackle this, we can. Just know it'll take longer. - -What would you prefer? -``` - -Let the user override if they insist—this is a soft guardrail. - ---- - -## Phase 3: Explore Demo - -Once a task is selected, briefly demonstrate explore mode: - -``` -Before we create a change, let me quickly show you **explore mode**—it's how you think through problems before committing to a direction. -``` - -Spend 1-2 minutes investigating the relevant code: -- Read the file(s) involved -- Draw a quick ASCII diagram if it helps -- Note any considerations - -``` -## Quick Exploration - -[Your brief analysis—what you found, any considerations] - -┌─────────────────────────────────────────┐ -│ [Optional: ASCII diagram if helpful] │ -└─────────────────────────────────────────┘ - -Explore mode (`/opsx:explore`) is for this kind of thinking—investigating before implementing. You can use it anytime you need to think through a problem. - -Now let's create a change to hold our work. -``` - -**PAUSE** - Wait for user acknowledgment before proceeding. - ---- - -## Phase 4: Create the Change - -**EXPLAIN:** -``` -## Creating a Change - -A "change" in OpenSpec is a container for all the thinking and planning around a piece of work. It lives in `openspec/changes//` and holds your artifacts—proposal, specs, design, tasks. - -Let me create one for our task. -``` - -**DO:** Create the change with a derived kebab-case name: -```bash -openspec new change "" -``` - -**SHOW:** -``` -Created: `openspec/changes//` - -The folder structure: -``` -openspec/changes// -├── proposal.md ← Why we're doing this (empty, we'll fill it) -├── design.md ← How we'll build it (empty) -├── specs/ ← Detailed requirements (empty) -└── tasks.md ← Implementation checklist (empty) -``` - -Now let's fill in the first artifact—the proposal. -``` - ---- - -## Phase 5: Proposal - -**EXPLAIN:** -``` -## The Proposal - -The proposal captures **why** we're making this change and **what** it involves at a high level. It's the "elevator pitch" for the work. - -I'll draft one based on our task. -``` - -**DO:** Draft the proposal content (don't save yet): - -``` -Here's a draft proposal: - ---- - -## Why - -[1-2 sentences explaining the problem/opportunity] - -## What Changes - -[Bullet points of what will be different] - -## Capabilities - -### New Capabilities -- ``: [brief description] - -### Modified Capabilities - - -## Impact - -- `src/path/to/file.ts`: [what changes] -- [other files if applicable] - ---- - -Does this capture the intent? I can adjust before we save it. -``` - -**PAUSE** - Wait for user approval/feedback. - -After approval, save the proposal: -```bash -openspec instructions proposal --change "" --json -``` -Then write the content to `openspec/changes//proposal.md`. - -``` -Proposal saved. This is your "why" document—you can always come back and refine it as understanding evolves. - -Next up: specs. -``` - ---- - -## Phase 6: Specs - -**EXPLAIN:** -``` -## Specs - -Specs define **what** we're building in precise, testable terms. They use a requirement/scenario format that makes expected behavior crystal clear. - -For a small task like this, we might only need one spec file. -``` - -**DO:** Create the spec file: -```bash -# Unix/macOS -mkdir -p openspec/changes//specs/ -# Windows (PowerShell) -# New-Item -ItemType Directory -Force -Path "openspec/changes//specs/" -``` - -Draft the spec content: - -``` -Here's the spec: - ---- - -## ADDED Requirements - -### Requirement: - - - -#### Scenario: - -- **WHEN** -- **THEN** -- **AND** - ---- - -This format—WHEN/THEN/AND—makes requirements testable. You can literally read them as test cases. -``` - -Save to `openspec/changes//specs//spec.md`. - ---- - -## Phase 7: Design - -**EXPLAIN:** -``` -## Design - -The design captures **how** we'll build it—technical decisions, tradeoffs, approach. - -For small changes, this might be brief. That's fine—not every change needs deep design discussion. -``` - -**DO:** Draft design.md: - -``` -Here's the design: - ---- - -## Context - -[Brief context about the current state] - -## Goals / Non-Goals - -**Goals:** -- [What we're trying to achieve] - -**Non-Goals:** -- [What's explicitly out of scope] - -## Decisions - -### Decision 1: [Key decision] - -[Explanation of approach and rationale] - ---- - -For a small task, this captures the key decisions without over-engineering. -``` - -Save to `openspec/changes//design.md`. - ---- - -## Phase 8: Tasks - -**EXPLAIN:** -``` -## Tasks - -Finally, we break the work into implementation tasks—checkboxes that drive the apply phase. - -These should be small, clear, and in logical order. -``` - -**DO:** Generate tasks based on specs and design: - -``` -Here are the implementation tasks: - ---- - -## 1. [Category or file] - -- [ ] 1.1 [Specific task] -- [ ] 1.2 [Specific task] - -## 2. Verify - -- [ ] 2.1 [Verification step] - ---- - -Each checkbox becomes a unit of work in the apply phase. Ready to implement? -``` - -**PAUSE** - Wait for user to confirm they're ready to implement. - -Save to `openspec/changes//tasks.md`. - ---- - -## Phase 9: Apply (Implementation) - -**EXPLAIN:** -``` -## Implementation - -Now we implement each task, checking them off as we go. I'll announce each one and occasionally note how the specs/design informed the approach. -``` - -**DO:** For each task: - -1. Announce: "Working on task N: [description]" -2. Implement the change in the codebase -3. Reference specs/design naturally: "The spec says X, so I'm doing Y" -4. Mark complete in tasks.md: `- [ ]` → `- [x]` -5. Brief status: "✓ Task N complete" - -Keep narration light—don't over-explain every line of code. - -After all tasks: - -``` -## Implementation Complete - -All tasks done: -- [x] Task 1 -- [x] Task 2 -- [x] ... - -The change is implemented! One more step—let's archive it. -``` - ---- - -## Phase 10: Archive - -**EXPLAIN:** -``` -## Archiving - -When a change is complete, we archive it. This moves it from `openspec/changes/` to `openspec/changes/archive/YYYY-MM-DD-/`. - -Archived changes become your project's decision history—you can always find them later to understand why something was built a certain way. -``` - -**DO:** -```bash -openspec archive "" -``` - -**SHOW:** -``` -Archived to: `openspec/changes/archive/YYYY-MM-DD-/` - -The change is now part of your project's history. The code is in your codebase, the decision record is preserved. -``` - ---- - -## Phase 11: Recap & Next Steps - -``` -## Congratulations! - -You just completed a full OpenSpec cycle: - -1. **Explore** - Thought through the problem -2. **New** - Created a change container -3. **Proposal** - Captured WHY -4. **Specs** - Defined WHAT in detail -5. **Design** - Decided HOW -6. **Tasks** - Broke it into steps -7. **Apply** - Implemented the work -8. **Archive** - Preserved the record - -This same rhythm works for any size change—a small fix or a major feature. - ---- - -## Command Reference - -**Core workflow:** - - | Command | What it does | - |-------------------|--------------------------------------------| - | `/opsx:propose` | Create a change and generate all artifacts | - | `/opsx:explore` | Think through problems before/during work | - | `/opsx:apply` | Implement tasks from a change | - | `/opsx:archive` | Archive a completed change | - -**Additional commands:** - - | Command | What it does | - |--------------------|----------------------------------------------------------| - | `/opsx:new` | Start a new change, step through artifacts one at a time | - | `/opsx:continue` | Continue working on an existing change | - | `/opsx:ff` | Fast-forward: create all artifacts at once | - | `/opsx:verify` | Verify implementation matches artifacts | - ---- - -## What's Next? - -Try `/opsx:propose` on something you actually want to build. You've got the rhythm now! -``` - ---- - -## Graceful Exit Handling - -### User wants to stop mid-way - -If the user says they need to stop, want to pause, or seem disengaged: - -``` -No problem! Your change is saved at `openspec/changes//`. - -To pick up where we left off later: -- `/opsx:continue ` - Resume artifact creation -- `/opsx:apply ` - Jump to implementation (if tasks exist) - -The work won't be lost. Come back whenever you're ready. -``` - -Exit gracefully without pressure. - -### User just wants command reference - -If the user says they just want to see the commands or skip the tutorial: - -``` -## OpenSpec Quick Reference - -**Core workflow:** - - | Command | What it does | - |--------------------------|--------------------------------------------| - | `/opsx:propose ` | Create a change and generate all artifacts | - | `/opsx:explore` | Think through problems (no code changes) | - | `/opsx:apply ` | Implement tasks | - | `/opsx:archive ` | Archive when done | - -**Additional commands:** - - | Command | What it does | - |---------------------------|-------------------------------------| - | `/opsx:new ` | Start a new change, step by step | - | `/opsx:continue ` | Continue an existing change | - | `/opsx:ff ` | Fast-forward: all artifacts at once | - | `/opsx:verify ` | Verify implementation | - -Try `/opsx:propose` to start your first change. -``` - -Exit gracefully. - ---- - -## Guardrails - -- **Follow the EXPLAIN → DO → SHOW → PAUSE pattern** at key transitions (after explore, after proposal draft, after tasks, after archive) -- **Keep narration light** during implementation—teach without lecturing -- **Don't skip phases** even if the change is small—the goal is teaching the workflow -- **Pause for acknowledgment** at marked points, but don't over-pause -- **Handle exits gracefully**—never pressure the user to continue -- **Use real codebase tasks**—don't simulate or use fake examples -- **Adjust scope gently**—guide toward smaller tasks but respect user choice diff --git a/.codex/skills/openspec-propose/SKILL.md b/.codex/skills/openspec-propose/SKILL.md new file mode 100644 index 00000000..4b57621a --- /dev/null +++ b/.codex/skills/openspec-propose/SKILL.md @@ -0,0 +1,110 @@ +--- +name: openspec-propose +description: Propose a new change with all artifacts generated in one step. Use when the user wants to quickly describe what they want to build and get a complete proposal with design, specs, and tasks ready for implementation. +license: MIT +compatibility: Requires openspec CLI. +metadata: + author: openspec + version: "1.0" + generatedBy: "1.3.0" +--- + +Propose a new change - create the change and generate all artifacts in one step. + +I'll create a change with artifacts: +- proposal.md (what & why) +- design.md (how) +- tasks.md (implementation steps) + +When ready to implement, run /opsx:apply + +--- + +**Input**: The user's request should include a change name (kebab-case) OR a description of what they want to build. + +**Steps** + +1. **If no clear input provided, ask what they want to build** + + Use the **AskUserQuestion tool** (open-ended, no preset options) to ask: + > "What change do you want to work on? Describe what you want to build or fix." + + From their description, derive a kebab-case name (e.g., "add user authentication" → `add-user-auth`). + + **IMPORTANT**: Do NOT proceed without understanding what the user wants to build. + +2. **Create the change directory** + ```bash + openspec new change "" + ``` + This creates a scaffolded change at `openspec/changes//` with `.openspec.yaml`. + +3. **Get the artifact build order** + ```bash + openspec status --change "" --json + ``` + Parse the JSON to get: + - `applyRequires`: array of artifact IDs needed before implementation (e.g., `["tasks"]`) + - `artifacts`: list of all artifacts with their status and dependencies + +4. **Create artifacts in sequence until apply-ready** + + Use the **TodoWrite tool** to track progress through the artifacts. + + Loop through artifacts in dependency order (artifacts with no pending dependencies first): + + a. **For each artifact that is `ready` (dependencies satisfied)**: + - Get instructions: + ```bash + openspec instructions --change "" --json + ``` + - The instructions JSON includes: + - `context`: Project background (constraints for you - do NOT include in output) + - `rules`: Artifact-specific rules (constraints for you - do NOT include in output) + - `template`: The structure to use for your output file + - `instruction`: Schema-specific guidance for this artifact type + - `outputPath`: Where to write the artifact + - `dependencies`: Completed artifacts to read for context + - Read any completed dependency files for context + - Create the artifact file using `template` as the structure + - Apply `context` and `rules` as constraints - but do NOT copy them into the file + - Show brief progress: "Created " + + b. **Continue until all `applyRequires` artifacts are complete** + - After creating each artifact, re-run `openspec status --change "" --json` + - Check if every artifact ID in `applyRequires` has `status: "done"` in the artifacts array + - Stop when all `applyRequires` artifacts are done + + c. **If an artifact requires user input** (unclear context): + - Use **AskUserQuestion tool** to clarify + - Then continue with creation + +5. **Show final status** + ```bash + openspec status --change "" + ``` + +**Output** + +After completing all artifacts, summarize: +- Change name and location +- List of artifacts created with brief descriptions +- What's ready: "All artifacts created! Ready for implementation." +- Prompt: "Run `/opsx:apply` or ask me to implement to start working on the tasks." + +**Artifact Creation Guidelines** + +- Follow the `instruction` field from `openspec instructions` for each artifact type +- The schema defines what each artifact should contain - follow it +- Read dependency artifacts for context before creating new ones +- Use `template` as the structure for your output file - fill in its sections +- **IMPORTANT**: `context` and `rules` are constraints for YOU, not content for the file + - Do NOT copy ``, ``, `` blocks into the artifact + - These guide what you write, but should never appear in the output + +**Guardrails** +- Create ALL artifacts needed for implementation (as defined by schema's `apply.requires`) +- Always read dependency artifacts before creating a new one +- If context is critically unclear, ask the user - but prefer making reasonable decisions to keep momentum +- If a change with that name already exists, ask if user wants to continue it or create a new one +- Verify each artifact file exists after writing before proceeding to next diff --git a/openspec/changes/collaboration-mode-system/design.md b/openspec/changes/collaboration-mode-system/design.md new file mode 100644 index 00000000..839e685f --- /dev/null +++ b/openspec/changes/collaboration-mode-system/design.md @@ -0,0 +1,338 @@ +## Context + +AstrCode 当前已经有几条与 mode 高度相关、但尚未统一收口的执行链路: + +- `session-runtime` 在 turn 开始时固定工具面,[runner.rs](/D:/GitObjectsOwn/Astrcode/crates/session-runtime/src/turn/runner.rs:157) 直接从 `gateway.capabilities().tool_definitions()` 取可见工具。 +- `PromptFactsProvider` 与 `PromptDeclaration` 已经是稳定的 prompt 注入入口,[request.rs](/D:/GitObjectsOwn/Astrcode/crates/session-runtime/src/turn/request.rs:256) 会基于当前 capability surface 解析 prompt facts。 +- `AgentPromptSubmission` 已经具备 turn-scoped execution envelope 雏形,可同时携带 scoped router、prompt declarations、resolved limits 和 inherited messages,[submit.rs](/D:/GitObjectsOwn/Astrcode/crates/session-runtime/src/turn/submit.rs:36)。 +- 子 agent 路径已经能生成 capability-aware child contract,[agent/mod.rs](/D:/GitObjectsOwn/Astrcode/crates/application/src/agent/mod.rs:314),但当前仍是单独硬编码逻辑。 +- 协作 guidance 仍以全局固定 prompt block 形式存在,[workflow_examples.rs](/D:/GitObjectsOwn/Astrcode/crates/adapter-prompt/src/contributors/workflow_examples.rs:94),尚未纳入统一治理配置。 +- `PolicyEngine` 已有完整三态策略框架(Allow/Deny/Ask)和审批流类型,但只有 `AllowAllPolicyEngine` 实现,无真实消费者,[engine.rs](/D:/GitObjectsOwn/Astrcode/crates/core/src/policy/engine.rs:289)。 +- `CapabilityPromptContributor` 和 `AgentProfileSummaryContributor` 通过 `PromptContext.tool_names` / `capability_specs` 间接感知工具面变化,无需自身了解 mode 概念,[capability_prompt.rs](/D:/GitObjectsOwn/Astrcode/crates/adapter-prompt/src/contributors/capability_prompt.rs)、[agent_profile_summary.rs](/D:/GitObjectsOwn/Astrcode/crates/adapter-prompt/src/contributors/agent_profile_summary.rs)。 +- `StorageEventPayload` 已有完整的 tagged event 体系,[event/types.rs](/D:/GitObjectsOwn/Astrcode/crates/core/src/event/types.rs:101),但无 mode 变更事件。 +- `AgentStateProjector` 提供了增量事件投影模式,[agent_state.rs](/D:/GitObjectsOwn/Astrcode/crates/core/src/projection/agent_state.rs),可作为 mode 投影的扩展参考。 +- `Command` enum 已有 `/new`、`/resume`、`/model`、`/compact` 等 slash 命令,[command/mod.rs](/D:/GitObjectsOwn/Astrcode/crates/cli/src/command/mod.rs:14),可扩展 `/mode`。 + +经 governance-surface-cleanup 后,上述散落逻辑已被收口为统一治理装配路径,mode system 在此基础上实现。 + +因此,这次设计的关键不是"再发明一个模式概念",而是把 mode 提升为统一的执行治理配置,并让它复用 cleanup 后的 envelope、prompt 与 capability surface 事实源。 + +## Goals / Non-Goals + +**Goals:** + +- 定义开放式 governance mode 模型,让 builtin 与未来插件 mode 都能通过 catalog 注册。 +- 在 turn 边界把当前 mode 编译为 `ResolvedTurnEnvelope`,统一承载 capability router、prompt declarations、execution limits、action policy 与 child policy。 +- mode 的能力选择通过 `CapabilitySelector` 从当前 `CapabilitySpec` 投影,支持组合操作。 +- mode 的执行限制与用户 `ExecutionControl` 取交集,更严格者生效。 +- mode 的 action policies 驱动 `PolicyEngine` 三态裁决,使策略引擎成为实际治理检查点。 +- mode 的 prompt program 通过 `PromptDeclaration` 注入标准 prompt 管线。 +- 保持 `run_turn` / tool cycle / compaction / streaming 只有一套通用实现,避免 mode 分叉 runtime engine。 +- 让 session 的当前 mode 通过 durable event 驱动投影保存,支持恢复、回放与审计。 +- 提供 `/mode` slash 命令,支持切换、补全和状态显示。 +- 让 child delegation surface 与协作 guidance 受当前 mode 约束。 +- 让 `DelegationMetadata`、`SpawnCapabilityGrant` 从 mode child policy 推导。 +- 让协作审计事实关联 mode 上下文。 +- 为未来插件扩展 mode 留出正式入口,同时限制插件只能扩展治理输入,不能接管 loop 本身。 + +**Non-Goals:** + +- 不新增独立 `mode-system` crate。 +- 不引入 `run_plan_turn` / `run_review_turn` / `run_execute_turn` 等多套 loop。 +- 不在本轮实现通用 artifact 平台;plan/review 输出契约只预留治理接口,不先做重量级产物系统。 +- 不让插件直接替换 `run_turn`、tool cycle、streaming path 或 compaction 算法。 +- 不在本轮把所有 builtin prompt contributor 全部删除;首批只把与治理强相关的协作/委派路径收口。 + +## Decisions + +### Decision 1:不新增 crate,沿现有边界分层落地 + +**选择:** + +- `core`:定义 `ModeId`、`GovernanceModeSpec`、`CapabilitySelector`、`ActionPolicies`、`ChildPolicySpec`、`ResolvedTurnEnvelope` +- `application`:提供 builtin / plugin mode catalog、transition validator、envelope compiler +- `session-runtime`:保存当前 mode 投影,并在 submit 边界应用 envelope +- `server`:在 bootstrap / reload 中装配 mode catalog + +**理由:** + +- 当前仓库已经明确 `core` 承载稳定语义、`application` 承载治理编排、`session-runtime` 承载单 session 真相,不需要再拆一层新的 mode facade。 +- 如果现在新增 crate,反而会逼迫它跨边界持有 `CapabilityRouter`、`PromptDeclaration`、session 投影与 plugin 注册等多重职责,迅速形成第二个 orchestration 中心。 + +**替代方案:** + +- 新建 `mode-system` crate:被拒绝。收益主要是"名字更显眼",代价是边界更差。 + +### Decision 2:mode 使用开放式 ID + catalog,不使用封闭枚举 + +**选择:** + +- 不使用 `CollaborationMode enum` +- 使用开放式 `ModeId(String)` + `GovernanceModeSpec` +- builtin `execute` / `plan` / `review` 只是 catalog 中的内置条目 + +**理由:** + +- 未来要支持插件自定义 mode,封闭枚举会直接把扩展点焊死。 +- 当前真正稳定的不是"只有三个 mode",而是"所有 mode 都要能编译成同一类治理包络"。 + +**替代方案:** + +- 先用 enum,未来再迁移:被拒绝。迁移成本高,还会污染协议、事件与测试。 + +### Decision 3:mode 编译为治理包络,而不是直接控制 loop + +**选择:** + +在 turn 提交时解析当前 mode,编译出: + +- `capability_router`(通过 CapabilitySelector) +- `prompt_declarations`(通过 prompt program) +- `execution_limits`(max_steps、ForkMode、SubmitBusyPolicy) +- `action_policies`(供 PolicyEngine 消费) +- `child_policy` + +并把这些作为 `ResolvedTurnEnvelope` 传入统一的 turn 执行链路。 + +**理由:** + +- 现有 `AgentPromptSubmission` 已经证明 "turn-scoped envelope" 是天然适配点。 +- `session-runtime` 的 turn runner 目前只需要一个已收敛的工具面和 prompt 输入,不需要知道 mode 名称。 +- 这样可以让 mode 真正控制执行治理,又不会把 mode 侵入到 tool cycle、compaction、streaming 等内部算法。 + +**替代方案:** + +- 为每种 mode 定制不同的 `agent_loop`:被拒绝。会把治理策略与执行引擎耦合,后续插件 mode 不可控。 +- 只生成 prompt,不收口 capability 与 child policy:被拒绝。太弱,无法支撑治理配置目标。 + +### Decision 4:mode capability 选择严格建立在现有 capability surface 之上 + +**选择:** + +mode 不维护平行工具目录,而是通过 `CapabilitySelector` 从当前 `CapabilitySpec` / capability router 投影能力面。首批支持: + +- `Name`:精确匹配工具名 +- `Kind`:匹配 CapabilityKind +- `SideEffect`:匹配副作用级别 +- `Tag`:匹配元数据标签 +- `AllTools`:选择全部工具 +- 组合操作:交集(Intersection)、并集(Union)、差集(Difference) + +**理由:** + +- 与现有 `capability-semantic-model` 要求一致,避免重新长出第二事实源。 +- 组合操作使 mode spec 能表达复杂约束(如"所有 Tool 类但排除 External 副作用")。 + +**替代方案:** + +- 继续只靠工具名字白名单:可实现但过脆,不利于插件扩展。 +- 继续把 `side_effect` 当万能选择器:被拒绝,治理语义和资源副作用语义不是一回事。 + +### Decision 5:session 只保存当前 mode 投影,durable truth 来自事件 + +**选择:** + +- 新增 `ModeChanged` durable event(`StorageEventPayload` 新变体) +- `AgentState` 增加 `mode_id` 字段 +- `SessionState` 增加 per-field mutex 缓存当前 mode +- replay 旧会话时默认回退为 builtin `execute` + +**理由:** + +- 项目架构已经明确 durable truth 优先来自 event log,`SessionState` 只是 projection cache + live control。 +- mode 会跨 turn 影响行为,也需要恢复与审计,必须进入事件流。 +- 现有 `AgentStateProjector` 的增量 apply 模式提供了清晰的扩展参考。 + +**替代方案:** + +- 直接把 `session_mode` 视为内存真相:被拒绝。与当前架构方向冲突。 + +### Decision 6:mode transition 由 application 治理,session-runtime 只负责应用 + +**选择:** + +- `application` 负责校验 target mode、entry policy、transition policy +- `session-runtime` 只接收已验证的 transition command,追加 durable event 并更新投影 +- `/mode`、UI 快捷键、工具调用都映射到统一应用用例,不在 `session-runtime` 中解析壳命令语法 + +**理由:** + +- 仓库明确规定 slash command 只是输入壳,语义要映射到稳定 server/application contract。 +- 这样可以把 mode transition 与未来 approval / governance 策略对齐,而不是在 runtime 内再造一套权限系统。 + +**替代方案:** + +- 直接在 `session-runtime` 里解析 `/mode`:被拒绝,会把输入壳语义沉到底层。 + +### Decision 7:child session 的初始 mode 由 child policy 推导,而不是简单继承 + +**选择:** + +- 父 mode 不直接把自己的 `mode_id` 原封不动传给 child +- 父 mode 的 `child_policy` 决定 child 是否允许 delegation、默认 child mode、是否允许 child 再委派 +- child 的 `SpawnCapabilityGrant` 和 `DelegationMetadata` 从 child policy 推导 + +**理由:** + +- 真正需要传递的是治理策略,而不是标签本身。 +- 这能自然兼容 fresh / resumed / restricted child 的既有 contract 语义。 +- `SpawnCapabilityGrant` 与 mode capability selector 的交集确保 child 能力面不超过 mode 约束。 + +**替代方案:** + +- 默认子 agent 继承父 mode:太粗糙,无法表达"父在 plan,但子可 execute"之类治理规则。 + +### Decision 8:协作 guidance 与 child contract 改为消费 governance prompt program + +**选择:** + +- 保留 `PromptDeclaration` 作为唯一 prompt 注入格式 +- 当前 mode 编译生成 prompt program,再映射为 declaration +- `workflow_examples` 与 child execution contract 逐步从硬编码文本改为消费 mode policy +- `CapabilityPromptContributor` 和 `AgentProfileSummaryContributor` 无需感知 mode——它们通过 `PromptContext` 间接响应能力面变化 + +**理由:** + +- 现有 prompt 管线已经稳定,不需要再开旁路。 +- `PromptDeclarationContributor` 已能渲染任意 declaration,mode 生成的 declarations 无需特殊处理。 +- contributor 的自动响应模式(依赖 tool_names 守卫条件)是最小侵入的实现方式。 +- 这也是未来插件自定义编排提示词的最小侵入接入点。 + +**替代方案:** + +- 允许插件直接替换 prompt composer 或注入任意 loop hooks:被拒绝,风险过高。 + +### Decision 9:mode 编译 action policies 供 PolicyEngine 消费 + +**选择:** + +- mode spec 定义 action policies(允许/拒绝/需审批的能力调用规则) +- envelope 编译时将 action policies 作为 `PolicyEngine` 的配置 +- `PolicyContext` 从治理包络派生,不再独立组装 +- 本轮 builtin mode 只使用 Allow/Deny(不触发 Ask 审批流) +- 插件 mode 可定义需要审批的 action policies + +**理由:** + +- `PolicyEngine` 已有完整框架但无消费者。mode 是让它发挥价值的自然时机。 +- 通过 action policies 而非直接实现 `PolicyEngine` trait,保持了 mode 与策略引擎的解耦。 +- 不需要修改 turn loop,策略检查在现有检查点位置执行。 + +**替代方案:** + +- mode 不连接 PolicyEngine,另建治理检查机制:被拒绝,会制造第二套策略系统。 +- 本轮实现完整审批流:被拒绝,scope 过大。 + +### Decision 10:mode 执行限制与 ExecutionControl 取交集 + +**选择:** + +- mode spec 可指定 max_steps 上限、ForkMode 约束、SubmitBusyPolicy 偏好 +- 用户通过 `ExecutionControl` 指定的限制与 mode 限制取交集(更严格者生效) +- mode 未指定的参数使用 runtime config 默认值 + +**理由:** + +- mode 代表治理约束,用户控制代表即时需求,两者应该叠加而非覆盖。 +- 这避免了 mode 限制被用户参数绕过,也避免了 mode 限制过度约束用户的合理需求。 + +**替代方案:** + +- mode 限制覆盖用户参数:被拒绝,用户失去即时控制能力。 +- 用户参数覆盖 mode 限制:被拒绝,治理约束可被绕过。 + +### Decision 11:/mode 命令集成到现有 CLI 命令体系 + +**选择:** + +- 在 `Command` enum 增加 `Mode { query: Option }` 变体 +- `parse_command` 增加 `"/mode"` arm +- tab 补全从 mode catalog 获取候选 +- 命令路由到 application 统一治理入口 + +**理由:** + +- 与 `/model`、`/compact` 等命令遵循相同模式,学习成本低。 +- 通过 slash candidates 机制自然支持 tab 补全。 + +**替代方案:** + +- 仅通过工具调用切换 mode:被拒绝,用户无法直接控制。 +- 在 session-runtime 中解析命令:被拒绝,违反"命令壳语义上移"原则。 + +### Decision 12:协作审计事实关联 mode 上下文 + +**选择:** + +- `AgentCollaborationFact` 增加可选的 `mode_id` 字段 +- 审计事实记录当前 turn 开始时的 mode(不受 turn 内 mode 变更影响) +- observability 快照包含当前 mode 和变更时间戳 + +**理由:** + +- 协作审计是治理闭环的重要环节。mode 上下文使审计能追溯到治理决策。 +- 低成本增加字段,不影响现有审计逻辑。 + +**替代方案:** + +- 不在审计中增加 mode 上下文:被接受作为备选,但增加调试和审计难度。 + +## Risks / Trade-offs + +- **[Risk] mode 选择器语义不够精确,导致 builtin / plugin capability 分类漂移** + → Mitigation:首批只支持少量稳定 selector,并优先复用现有 `CapabilitySpec` 字段;新增语义时扩展 semantic model,而不是旁路建表。 + +- **[Risk] 把太多治理逻辑压到 mode,导致 action policy 与 runtime approval 发生重复** + → Mitigation:mode 只表达"默认治理输入与可见行为边界",最终高风险动作的批准仍保留给统一治理入口。 + +- **[Risk] 现有静态协作 guidance 重构后出现行为漂移** + → Mitigation:先保留 builtin 默认 prompt program,重构时做等价测试,确保 execute mode 下行为近似当前默认行为。 + +- **[Risk] 插件 mode 破坏稳定性** + → Mitigation:插件 mode 只能注册 catalog/spec 和 prompt program,不能替换 runtime engine;reload 继续沿现有原子替换 capability surface 的治理链路。 + +- **[Risk] PolicyEngine 接入后审批流过于复杂** + → Mitigation:首轮 builtin mode 只使用 Allow/Deny,不触发 Ask 审批。审批流管线存在但不启用。 + +- **[Risk] /mode 命令在 turn 执行中触发竞态** + → Mitigation:mode transition next-turn 生效语义确保当前 turn 不受影响;application 层校验在 turn 开始时而非中途执行。 + +- **[Risk] CapabilitySelector 组合操作性能开销** + → Mitigation:capability surface 规模有限(通常 <100),组合操作在 turn 边界一次性执行,不影响 tool cycle 性能。 + +- **[Trade-off] 首轮不做通用 artifact 平台** + → 接受:先把治理输入、transition 和 turn envelope 站稳,比过早引入 plan/review 通用产物平台更重要。 + +- **[Trade-off] builtin mode 的 prompt program 初期可能仍是文本块** + → 接受:先确保管线正确,再逐步将硬编码文本迁移为结构化 prompt program。 + +## Migration Plan + +1. 在 `core` 引入 mode 稳定类型,但默认 builtin `execute` mode 与现有行为等价。 +2. 在 `core` 增加 `ModeChanged` 事件载荷和 `CapabilitySelector` 类型。 +3. 在 `application` 装配 builtin mode catalog,并实现 envelope compiler(包含 capability selector 编译和 action policy 编译)。 +4. 在 `session-runtime` 引入 `ModeChanged` 事件与当前 mode 投影;旧会话回放默认视为 `execute`。 +5. 将 turn 提交改为先编译并应用 `ResolvedTurnEnvelope`,但保持现有 `run_turn` 主循环不变。 +6. 将 PolicyEngine 的 PolicyContext 改为从治理包络派生。 +7. 逐步把协作 guidance 与 child contract 改为消费 mode policy。 +8. 在 CLI 增加 `/mode` 命令和 tab 补全。 +9. 让 DelegationMetadata 和 SpawnCapabilityGrant 从 mode child policy 推导。 +10. 在 bootstrap/reload 中装配 mode catalog。 +11. 为协作审计事实增加 mode 上下文。 +12. 后续再把插件 mode 接到 bootstrap / reload,不需要回滚 turn engine。 + +回滚策略: + +- 若实现中断或行为不稳定,可临时只保留 builtin `execute` mode,并让 envelope compiler 退化为"当前默认行为的等价编译"。 +- 因为 `run_turn` 不改成多实现,回滚只需关闭 mode compile / transition 接线,不需撤销整个 turn 引擎。 + +## Open Questions + +1. mode transition 是否需要与现有 approval / policy engine 做统一结果模型,而不是返回简单文本错误? +2. 插件 mode 的 schema 校验放在 bootstrap 期还是 reload 期统一校验,失败时是否整批拒绝? +3. governance prompt program 是否需要支持"覆盖 builtin block"还是只支持追加/排序? +4. child policy 的默认项是否要区分 root session 与 child session,以避免过度委派链条? +5. CapabilitySelector 的组合操作是否需要支持嵌套(如 `Union(Name("a"), Intersection(Kind(Tool), NotTag("experimental")))`),还是限制为一层? +6. mode 执行限制的 max_steps 是否需要区分"硬上限"(不可超过)和"建议值"(用户可覆盖)? +7. `/mode` 命令是否需要支持 mode 参数化(如 `/mode plan --focus=frontend`),还是首轮只支持 ID 切换? +8. PolicyEngine 的 Ask 审批流在插件 mode 场景下的超时和默认行为是什么? diff --git a/openspec/changes/collaboration-mode-system/proposal.md b/openspec/changes/collaboration-mode-system/proposal.md new file mode 100644 index 00000000..dd7e1034 --- /dev/null +++ b/openspec/changes/collaboration-mode-system/proposal.md @@ -0,0 +1,50 @@ +## Why + +AstrCode 目前缺少一套显式的执行治理配置模型。现有"模式"相关语义分散在工具可见性、prompt guidance、子 agent capability grant、profile 与运行时策略之间,导致系统只能用零散提示词和局部白名单近似表达"先规划再执行""限制委派""只允许审查"等行为,既不利于统一治理,也无法为未来的插件自定义 agent 编排、提示词与能力边界提供稳定扩展点。 + +同时,策略引擎(`PolicyEngine`)已定义三态裁决和审批流框架但未接入实际执行路径,能力路由器(`CapabilityRouter`)在 root/subagent/resume 三条路径中各自构建,执行限制参数散落无统一信封,委派策略元数据由局部 helper 拼装,prompt 事实中存在隐式治理联动,启动与运行时治理缺少 mode 接入点,协作审计事实缺少 mode 上下文。如果继续把 mode 当成临时 prompt 技巧处理,后续插件扩展会被迫绕过既有边界,重新长出第二套 orchestration 真相。 + +现在推进这项变更,是因为仓库已经具备统一 capability surface、`PromptDeclaration` 注入链路、child execution contract、基于事件日志的 session/runtime 架构、以及经 cleanup 收口的统一治理装配路径基础。在此基础上引入 mode 系统是自然且低风险的。 + +## What Changes + +- 引入面向执行治理的 mode 系统,把 mode 定义为可编译的治理配置,而不是仅仅是协作阶段标签。 +- 新增开放式 `mode id` / mode catalog / turn-envelope 编译链路,在 turn 边界把当前 mode 解析为可执行包络,统一收口 capability surface、prompt program、action policy、execution limits 与 child policy。 +- mode 的能力选择通过 `CapabilitySelector` 从当前 `CapabilitySpec` / capability router 编译出 scoped `CapabilityRouter`,支持基于 name、kind、side_effect、tag 的投影和组合操作。 +- mode 编译的执行限制(max_steps、ForkMode、SubmitBusyPolicy、AgentConfig 治理参数)与用户指定的 `ExecutionControl` 取交集。 +- mode 编译的 action policies 驱动 `PolicyEngine` 的三态裁决,使策略引擎从悬空框架变为实际消费治理包络的检查点。 +- mode 编译的 prompt program 生成 `PromptDeclaration`,通过现有注入路径进入 prompt 组装,控制 contributor 行为。 +- `PromptFactsProvider` 的 metadata 和 declaration 过滤与 mode 编译的 envelope 保持一致。 +- 新增 `/mode` slash 命令,支持 mode 切换、tab 补全、状态显示,通过统一 application 治理入口校验。 +- 让 session 持有当前 mode 的 durable 投影(`ModeChanged` 事件),所有切换经统一治理入口校验,在下一 turn 生效。 +- mode catalog 在 bootstrap 阶段装配,reload 时与能力面原子替换,插件 mode 走同一注册路径。 +- 让内置 mode 与未来插件 mode 走同一条注册和编译路径;插件可扩展提示词、能力选择和委派策略,但不能直接替换 runtime engine。 +- 收口现有静态协作 guidance 与 child execution contract,让其受当前 mode 的 governance program 约束。 +- `DelegationMetadata`、`SpawnCapabilityGrant` 从 mode child policy 推导,协作审计事实关联 mode 上下文。 +- **BREAKING** mode 的内部建模不再使用封闭枚举假设;实现将改为面向开放 catalog 的稳定 ID + spec 结构。 + +## Capabilities + +### New Capabilities +- `governance-mode-system`: 定义 mode catalog、mode transition、session 当前 mode 投影(事件驱动)、turn 边界的治理包络编译与应用、bootstrap/reload 集成、协作审计 mode 上下文、mode 可观测性。 +- `mode-capability-compilation`: mode 通过 CapabilitySelector 编译 scoped CapabilityRouter,支持组合选择器,child capability 从 parent mode child policy 推导。 +- `mode-execution-policy`: mode 编译执行限制(max_steps、ForkMode、SubmitBusyPolicy)和 AgentConfig 治理参数覆盖,与 ExecutionControl 取交集。 +- `mode-policy-engine`: mode 编译 action policies 驱动 PolicyEngine 三态裁决,PolicyContext 从治理包络派生,context strategy 受 mode 影响。 +- `mode-prompt-program`: mode 编译 prompt program 生成 PromptDeclaration,通过标准路径注入,控制 contributor 行为,PromptFactsProvider 与 envelope 一致。 +- `mode-command-surface`: `/mode` slash 命令、tab 补全、状态显示、transition 拒绝反馈、统一 application 治理入口。 + +### Modified Capabilities +- `agent-tool-governance`: 协作工具 guidance 从静态 builtin 规则升级为由当前 governance mode 驱动的 action policy / prompt program。Contributor 自动反映 mode 能力面。 +- `agent-delegation-surface`: child delegation catalog 与 child execution contract 体现当前 governance mode 的 child policy。DelegationMetadata、SpawnCapabilityGrant 从 mode child policy 推导。 + +## Impact + +- `crates/core`: 新增 mode 相关稳定类型(ModeId、GovernanceModeSpec、CapabilitySelector、ActionPolicies、ChildPolicySpec、ResolvedTurnEnvelope)和 `ModeChanged` 事件载荷。 +- `crates/application`: 新增 builtin/plugin mode catalog、transition 校验、envelope compiler、`/mode` 命令处理用例。 +- `crates/session-runtime`: 新增当前 mode 的 durable 投影(AgentState 扩展、SessionState per-field mutex)、mode transition 命令入口、submit 边界的 envelope 应用。 +- `crates/cli`: 新增 `Command::Mode` 变体、`/mode` 命令解析和 tab 补全。 +- `crates/server`: 在 bootstrap / reload 链路中装配 mode catalog,与能力面替换保持原子性。 +- `crates/adapter-prompt` 与现有 child contract 生成路径:从静态 guidance 转为消费 mode 编译结果。 +- `crates/core/src/policy`: PolicyEngine 通过 mode action policies 获得真实消费者。 +- 用户可见影响:可显式切换执行治理模式,mode 稳定影响工具可见性、委派约束、执行限制和提示词。 +- 开发者可见影响:实现必须遵守"mode 扩展 governance input,不扩展 runtime engine"的边界;本次不新增独立 crate,也不引入多套 turn loop。 diff --git a/openspec/changes/collaboration-mode-system/specs/agent-delegation-surface/spec.md b/openspec/changes/collaboration-mode-system/specs/agent-delegation-surface/spec.md new file mode 100644 index 00000000..9e110844 --- /dev/null +++ b/openspec/changes/collaboration-mode-system/specs/agent-delegation-surface/spec.md @@ -0,0 +1,81 @@ +## ADDED Requirements + +### Requirement: delegation surface SHALL reflect the resolved governance envelope + +模型可见的 child delegation catalog 与 child-scoped execution contract MUST 受当前 turn 的 resolved governance envelope 约束,而不是只根据静态 profile 列表或全局默认行为生成。 + +#### Scenario: delegation catalog is omitted when current mode forbids child delegation + +- **WHEN** 当前 turn 的 governance envelope 禁止创建新的 child 分支 +- **THEN** 系统 SHALL 不渲染可供选择的 child delegation catalog +- **AND** SHALL NOT 让模型先看到不可用条目再依赖 runtime 事后拒绝 + +#### Scenario: governance envelope narrows visible child templates + +- **WHEN** 当前 mode 只允许一部分 behavior template 用于 child delegation +- **THEN** delegation catalog SHALL 仅展示这些允许的 template +- **AND** SHALL 继续保持“profile 是行为模板,而非权限目录”的表达边界 + +### Requirement: child execution contract SHALL include governance-derived branch constraints + +child execution contract MUST 体现启动该 child 时生效的 governance child policy,包括 child 初始 mode、capability-aware 约束与是否允许继续委派。 + +#### Scenario: fresh child contract includes initial mode summary + +- **WHEN** 系统首次启动一个新的 child session +- **THEN** child execution contract SHALL 明确该 child 当前使用的治理模式或等价治理摘要 +- **AND** SHALL 说明该分支的责任边界与允许动作 + +#### Scenario: restricted child contract includes delegation boundary + +- **WHEN** child 由当前 governance mode 以受限 delegation policy 启动 +- **THEN** child execution contract SHALL 明确该 child 不应承担超出当前治理边界的工作 +- **AND** SHALL 在需要更宽能力面或更宽 delegation 权限时要求回退到父级重新决策 + +### Requirement: DelegationMetadata SHALL reflect mode-compiled child policy + +`DelegationMetadata`(responsibility_summary、reuse_scope_summary、restricted、capability_limit_summary)MUST 由 mode 编译的 child policy 驱动生成,而不是由局部 helper 独立构建。 + +#### Scenario: restricted flag comes from mode child policy + +- **WHEN** 当前 mode 的 child policy 指定 child 为 restricted delegation +- **THEN** `DelegationMetadata.restricted` SHALL 为 true +- **AND** responsibility_summary 和 capability_limit_summary SHALL 反映 child policy 的约束 + +#### Scenario: reuse scope aligns with mode delegation constraints + +- **WHEN** mode 限制 child reuse 的条件 +- **THEN** `DelegationMetadata.reuse_scope_summary` SHALL 体现 mode 定义的复用边界 +- **AND** SHALL NOT 使用与 mode 无关的默认复用策略 + +### Requirement: SpawnCapabilityGrant SHALL be derived from mode capability selector and child policy + +child 的 `SpawnCapabilityGrant.allowed_tools` MUST 由 mode 的 capability selector 与 child policy 联合计算,而不是从 spawn 参数直接构造。 + +#### Scenario: grant is intersection of mode selector and spawn parameters + +- **WHEN** mode 的 child policy 指定了 capability selector,同时 spawn 调用传入了 allowed_tools +- **THEN** 最终 `SpawnCapabilityGrant.allowed_tools` SHALL 为两者交集 +- **AND** 空交集 SHALL 导致 spawn 被拒绝并返回明确错误 + +#### Scenario: mode with no child policy uses spawn parameters directly + +- **WHEN** mode 未指定 child policy 的 capability selector +- **THEN** `SpawnCapabilityGrant` SHALL 使用 spawn 调用传入的 allowed_tools +- **AND** 行为与当前默认等价 + +### Requirement: delegation catalog SHALL be filtered by mode child policy + +`AgentProfileSummaryContributor` 渲染的 child profile 列表 MUST 受 mode child policy 约束。mode 可以限制可用于 delegation 的 profile 范围。 + +#### Scenario: mode limits available profiles + +- **WHEN** mode 的 child policy 仅允许部分 profile 用于 delegation +- **THEN** delegation catalog SHALL 仅展示这些允许的 profile +- **AND** 不可用 profile SHALL 不出现在列表中 + +#### Scenario: mode forbids delegation entirely + +- **WHEN** mode 的 child policy 禁止所有 delegation +- **THEN** spawn 工具 SHALL 不在可见能力面中 +- **AND** `AgentProfileSummaryContributor` SHALL 因 spawn 不可用而不渲染(通过现有守卫条件自动生效) diff --git a/openspec/changes/collaboration-mode-system/specs/agent-tool-governance/spec.md b/openspec/changes/collaboration-mode-system/specs/agent-tool-governance/spec.md new file mode 100644 index 00000000..20d50d06 --- /dev/null +++ b/openspec/changes/collaboration-mode-system/specs/agent-tool-governance/spec.md @@ -0,0 +1,65 @@ +## ADDED Requirements + +### Requirement: collaboration guidance SHALL be generated from the current governance mode + +当当前 session 可使用协作工具时,系统渲染给模型的协作 guidance MUST 来自当前 governance mode 编译得到的 action policy 与 prompt program,而不是固定的全局静态文本。 + +#### Scenario: execute mode renders the default collaboration protocol + +- **WHEN** 当前 session 处于 builtin `execute` mode +- **THEN** 系统 SHALL 继续渲染默认的四工具协作协议 +- **AND** 其行为语义 SHALL 与当前默认 guidance 保持等价 + +#### Scenario: restricted mode hides forbidden collaboration actions + +- **WHEN** 当前 governance mode 禁止某类协作动作,例如新的 child delegation +- **THEN** 系统 SHALL 不向模型渲染鼓励该动作的 guidance +- **AND** SHALL 只保留当前 mode 允许的协作决策协议 + +### Requirement: collaboration guidance SHALL reflect mode-specific delegation constraints + +协作 guidance MUST 体现当前 governance mode 对委派行为的额外约束,例如 child policy、reuse-first 限制与 capability mismatch 处置规则。 + +#### Scenario: mode narrows child reuse conditions + +- **WHEN** 当前 mode 对 child reuse 设置了更严格的责任边界或能力前提 +- **THEN** guidance SHALL 明确这些更严格的继续复用条件 +- **AND** SHALL NOT 继续沿用更宽松的默认文案 + +#### Scenario: mode disables recursive delegation + +- **WHEN** 当前 mode 的 child policy 禁止 child 再向下继续委派 +- **THEN** guidance SHALL 明确当前分支的 delegation boundary +- **AND** SHALL NOT 鼓励模型继续 fan-out 新的 child 层级 + +### Requirement: CapabilityPromptContributor SHALL automatically reflect mode capability surface + +`CapabilityPromptContributor` 通过 `PromptContext.tool_names` 和 `capability_specs` 渲染工具摘要和详细指南。mode 对工具面的约束 SHALL 自动反映在 contributor 的输出中,无需 contributor 自身感知 mode。 + +#### Scenario: mode removes collaboration tools from tool summary + +- **WHEN** mode 编译的 capability router 移除了 spawn/send/close/observe 工具 +- **THEN** `build_tool_summary_block` 的 "Agent Collaboration Tools" 分组 SHALL 为空 +- **AND** 详细指南 SHALL 不包含被移除工具的条目 + +#### Scenario: mode restricts external tools + +- **WHEN** mode 的 capability selector 排除了 source:mcp 或 source:plugin 工具 +- **THEN** "External MCP / Plugin Tools" 分组 SHALL 仅包含未被排除的工具 +- **AND** SHALL NOT 显示已被 mode 限制的工具 + +### Requirement: workflow_examples contributor SHALL delegate governance content to mode prompt program + +`WorkflowExamplesContributor` 中与治理强相关的内容(协作协议、delegation modes、spawn 限制等)MUST 由 mode prompt program 生成的 PromptDeclarations 替代。contributor SHALL 仅保留非治理的 few-shot 教学内容。 + +#### Scenario: execute mode guidance is served from mode prompt program + +- **WHEN** 当前 mode 为 `code` +- **THEN** 协作协议 guidance SHALL 来自 mode 编译的 PromptDeclarations +- **AND** `WorkflowExamplesContributor` 的 `child-collaboration-guidance` block SHALL 不再包含治理真相 + +#### Scenario: plan mode provides different collaboration guidance + +- **WHEN** 当前 mode 为 `plan` 且允许有限 delegation +- **THEN** 协作 guidance SHALL 来自 plan mode 的 prompt program +- **AND** SHALL 包含 plan-specific 的委派策略说明 diff --git a/openspec/changes/collaboration-mode-system/specs/governance-mode-system/spec.md b/openspec/changes/collaboration-mode-system/specs/governance-mode-system/spec.md new file mode 100644 index 00000000..6088533e --- /dev/null +++ b/openspec/changes/collaboration-mode-system/specs/governance-mode-system/spec.md @@ -0,0 +1,208 @@ +## ADDED Requirements + +### Requirement: governance mode catalog SHALL support builtin and plugin-defined modes through stable IDs + +系统 SHALL 通过开放式 mode catalog 管理执行治理模式,而不是依赖封闭枚举。每个 mode MUST 由稳定 `mode id` 标识,并可由 builtin 或插件注册。 + +#### Scenario: builtin execute mode is available by default + +- **WHEN** 系统创建一个新 session,且没有显式 mode 事件 +- **THEN** 系统 SHALL 解析到 builtin `code` mode +- **AND** 该默认 mode 的治理行为 SHALL 与当前默认执行行为保持等价 + +#### Scenario: plugin-defined mode joins the same catalog + +- **WHEN** 一个插件在 bootstrap 或 reload 成功注册自定义 mode +- **THEN** 该 mode SHALL 出现在统一的 mode catalog 中 +- **AND** 系统 SHALL 继续用与 builtin mode 相同的解析与编译流程消费它 + +### Requirement: governance mode SHALL compile to a turn-scoped execution envelope + +系统 SHALL 在 turn 边界把当前 mode 编译为 `ResolvedTurnEnvelope`。该 envelope MUST 至少包含当前 turn 的 capability surface、prompt declarations、execution limits、action policies 与 child policy。 + +#### Scenario: plan mode compiles a restricted capability surface + +- **WHEN** 当前 session 的 mode 为一个只读规划型 mode +- **THEN** 系统 SHALL 为该 turn 编译出收缩后的 capability router +- **AND** 当前 turn 模型可见的工具集合 SHALL 与该 router 保持一致 + +#### Scenario: execute mode compiles the full default envelope + +- **WHEN** 当前 session 的 mode 为 builtin `code` +- **THEN** 系统 SHALL 编译出与当前默认执行行为等价的 envelope +- **AND** SHALL NOT 因引入 mode 而额外改变 turn loop 语义 + +### Requirement: mode capability selection SHALL be resolved against the current capability semantic model + +mode 的能力选择 MUST 建立在当前 `CapabilitySpec` / capability router 之上,而不是维护平行工具注册表。mode selector SHALL 至少支持基于名称、kind、side effect 或 tag 的投影。 + +#### Scenario: selector filters against current capability surface + +- **WHEN** 某个 mode 使用 tag 或 side-effect selector 选择能力 +- **THEN** 系统 SHALL 基于当前 capability surface 中的 `CapabilitySpec` 解析可见能力 +- **AND** SHALL NOT 通过独立 mode 工具目录重建另一份真相 + +#### Scenario: plugin capability is governed by the same selectors + +- **WHEN** 当前 capability surface 同时包含 builtin 与插件工具 +- **THEN** mode selector SHALL 对它们一视同仁地解析 +- **AND** SHALL NOT 因来源不同而走不同治理路径 + +### Requirement: session SHALL persist the current mode as an event-driven projection + +系统 SHALL 通过 durable 事件记录 session 当前 mode 的变更,并在 `session-runtime` 内维护当前 mode 的投影缓存。 + +#### Scenario: new session replays without mode events + +- **WHEN** 一个旧 session 的事件流中不存在 mode 变更事件 +- **THEN** replay 结果 SHALL 回退到 builtin `execute` mode + +#### Scenario: mode change survives replay + +- **WHEN** session 已经追加过一次有效的 mode 变更事件 +- **THEN** 会话重载或回放后 SHALL 恢复为该最新 mode +- **AND** 后续 turn SHALL 继续使用该 mode 的 envelope 编译结果 + +### Requirement: mode transition SHALL be validated through a unified governance entrypoint + +所有 mode 切换请求 MUST 经过统一治理入口校验 target mode、transition policy 与 entry policy,然后再由 `session-runtime` 应用 durable 变更。 + +#### Scenario: invalid transition is rejected before runtime mutation + +- **WHEN** 一个切换请求的目标 mode 不满足当前 mode 的 transition policy +- **THEN** 系统 SHALL 在追加任何 durable 事件前拒绝该请求 + +#### Scenario: valid transition applies on the next turn + +- **WHEN** 当前 turn 中途发生一次合法 mode 切换 +- **THEN** 当前 turn 的执行 envelope SHALL 保持不变 +- **AND** 新 mode SHALL 从下一次 turn 开始生效 + +### Requirement: governance mode SHALL constrain orchestration inputs without replacing the runtime engine + +governance mode 可以约束 prompt program、能力面、委派策略与行为入口,但 MUST NOT 直接替换 `run_turn`、tool cycle、streaming path 或 compaction 算法。 + +#### Scenario: plugin mode customizes prompt without replacing loop + +- **WHEN** 一个插件 mode 定义了自定义 prompt program +- **THEN** 系统 SHALL 只把它编译为 envelope 中的 prompt declarations +- **AND** SHALL 继续使用同一套通用 turn loop 执行该 turn + +#### Scenario: mode-specific loop implementations are forbidden + +- **WHEN** 实现一个新的 governance mode +- **THEN** 系统 SHALL NOT 要求新增独立的 `run__turn` 或等价 loop 实现 + +### Requirement: child sessions SHALL derive their initial governance mode from child policy + +子 session 的初始治理模式 MUST 由父 turn 的 resolved child policy 推导,而不是简单继承父 session 的 mode 标签。 + +#### Scenario: parent plan mode launches an execute-capable child by policy + +- **WHEN** 父 session 当前处于规划型 mode,且其 child policy 允许子分支使用执行型 mode +- **THEN** 新 child session SHALL 按 child policy 初始化为对应 child mode +- **AND** SHALL NOT 因父 mode 是 plan 而被强制继承为同名 mode + +#### Scenario: child delegation is disabled by current mode + +- **WHEN** 当前 mode 的 child policy 禁止创建新的 child 分支 +- **THEN** 当前 turn SHALL 不向模型暴露新的 child delegation 行为入口 +- **AND** SHALL 在 delegation surface 中反映该约束 + +### Requirement: mode catalog SHALL be assembled during bootstrap and updated on reload + +builtin mode catalog MUST 在 server bootstrap 阶段通过 `GovernanceBuildInput` 装配,插件 mode 在 bootstrap 或 reload 时注册到同一 catalog。reload 时,mode catalog 的替换 SHALL 与能力面替换保持原子性。 + +#### Scenario: builtin modes are available after bootstrap + +- **WHEN** server bootstrap 完成 +- **THEN** `execute`、`plan`、`review` 等 builtin mode SHALL 已在 catalog 中注册 +- **AND** 无需任何额外配置即可使用 + +#### Scenario: plugin mode joins catalog during bootstrap + +- **WHEN** 一个插件在 bootstrap 握手阶段声明了自定义 mode +- **THEN** 该 mode SHALL 出现在统一 catalog 中 +- **AND** 如果 mode spec 校验失败,SHALL 整批拒绝该插件的所有 mode + +#### Scenario: reload atomically swaps mode catalog and capability surface + +- **WHEN** runtime reload 触发 +- **THEN** mode catalog 替换 SHALL 与 capability surface 替换在同一原子操作中完成 +- **AND** reload 失败时 SHALL 继续使用旧的 mode catalog(与当前能力面回滚策略一致) + +#### Scenario: running sessions are unaffected by catalog reload + +- **WHEN** reload 发生时有 session 正在执行 +- **THEN** 已在执行的 turn SHALL 使用 reload 前的 envelope +- **AND** 仅在下一 turn 开始时使用新的 catalog 编译 envelope + +### Requirement: mode change SHALL be recorded as a durable event in session event log + +mode 变更 MUST 通过 durable 事件记录到 session 事件流。`StorageEventPayload` SHALL 增加对应的变体(如 `ModeChanged`),确保 mode 变更可回放、可审计。 + +#### Scenario: mode change appends a ModeChanged event + +- **WHEN** session 的 mode 成功切换 +- **THEN** 系统 SHALL 追加一个 `ModeChanged { from: ModeId, to: ModeId }` 事件到 session event log +- **AND** 该事件 SHALL 包含足够信息用于回放和审计 + +#### Scenario: old session replay falls back to default mode + +- **WHEN** 一个旧 session 的事件流中不包含 `ModeChanged` 事件 +- **THEN** replay 结果 SHALL 回退到 builtin `execute` mode +- **AND** 行为 SHALL 与当前默认行为等价 + +#### Scenario: mode change survives replay + +- **WHEN** session 事件流包含一个或多个 `ModeChanged` 事件 +- **THEN** replay 后 SHALL 恢复为最新事件指定的 mode +- **AND** 后续 turn SHALL 使用该 mode 编译 envelope + +### Requirement: session state SHALL maintain current mode as a projection from event log + +`SessionState` SHALL 维护当前 mode 的投影缓存,该投影从事件流中 `ModeChanged` 事件增量计算得出。 + +#### Scenario: SessionState exposes current mode projection + +- **WHEN** session state 需要知道当前 mode +- **THEN** 它 SHALL 从投影缓存中读取当前 `ModeId` +- **AND** 投影更新 SHALL 在 `translate_store_and_cache` 中通过事件驱动完成 + +#### Scenario: AgentState projector handles ModeChanged events + +- **WHEN** `AgentStateProjector.apply()` 接收到 `ModeChanged` 事件 +- **THEN** `AgentState` SHALL 更新其 mode 字段 +- **AND** 后续 `project()` 调用 SHALL 反映最新 mode + +### Requirement: collaboration audit facts SHALL include mode context + +`AgentCollaborationFact` 记录的协作审计事件 MUST 包含当前 mode 上下文,使审计链路能追溯到 mode 治理决策。 + +#### Scenario: collaboration fact records active mode at action time + +- **WHEN** 系统记录一个 `AgentCollaborationFact`(如 spawn 或 send) +- **THEN** 该事实 SHALL 包含当前 session 的 `mode_id` +- **AND** 审计查询 SHALL 能按 mode 过滤协作事实 + +#### Scenario: mode transition during turn does not affect audit context + +- **WHEN** turn 执行中途发生 mode 变更(下一 turn 生效) +- **THEN** 当前 turn 内的协作事实 SHALL 使用 turn 开始时的 mode +- **AND** SHALL NOT 因 mode 变更导致同一 turn 内的审计上下文不一致 + +### Requirement: mode observability SHALL support monitoring and diagnostics + +mode 系统的运行状态 SHALL 可观测,包括当前活跃 mode、mode 变更历史、以及 mode 编译的 envelope 摘要。 + +#### Scenario: observability snapshot includes current mode + +- **WHEN** `ObservabilitySnapshotProvider` 采集快照 +- **THEN** 快照 SHALL 包含 session 当前 mode ID +- **AND** SHALL 包含最近 mode 变更的时间戳 + +#### Scenario: envelope compilation diagnostics are available + +- **WHEN** envelope 编译产生异常结果(如空能力面) +- **THEN** 系统 SHALL 记录诊断信息 +- **AND** 该信息 SHALL 可通过 observability 接口查询 diff --git a/openspec/changes/collaboration-mode-system/specs/mode-capability-compilation/spec.md b/openspec/changes/collaboration-mode-system/specs/mode-capability-compilation/spec.md new file mode 100644 index 00000000..c49cca2f --- /dev/null +++ b/openspec/changes/collaboration-mode-system/specs/mode-capability-compilation/spec.md @@ -0,0 +1,86 @@ +## ADDED Requirements + +### Requirement: mode SHALL compile to a scoped CapabilityRouter through CapabilitySelector resolution + +每个 governance mode MUST 通过 `CapabilitySelector` 从当前 `CapabilitySpec` / capability router 投影出 mode-specific 的能力子集,编译为 scoped `CapabilityRouter`,并在 turn 开始时通过 `scoped_gateway()` 固定工具面。 + +#### Scenario: execute mode compiles the full capability surface + +- **WHEN** 当前 session 的 mode 为 builtin `code` +- **THEN** envelope 编译 SHALL 产生包含当前全部可见工具的 capability router +- **AND** `scoped_gateway()` (runner.rs:339) SHALL 传入该 router,结果与当前默认行为等价 + +#### Scenario: plan mode compiles a read-only capability subset + +- **WHEN** 当前 session 的 mode 为 builtin `plan` +- **THEN** envelope 编译 SHALL 基于 CapabilitySelector 筛除具有 `SideEffect::Workspace` 或 `SideEffect::External` 的工具 +- **AND** 保留 `SideEffect::None` 和 `SideEffect::Local` 的工具 +- **AND** 模型在该 turn 中 SHALL NOT 看到或调用被筛除的工具 + +#### Scenario: review mode compiles an observation-only subset + +- **WHEN** 当前 session 的 mode 为 builtin `review` +- **THEN** envelope 编译 SHALL 仅保留无副作用的工具(可能包括 observe、read-only 工具) +- **AND** SHALL 移除 spawn、send、close 等协作工具 + +### Requirement: CapabilitySelector SHALL resolve against current CapabilitySpec metadata + +CapabilitySelector 的投影 MUST 严格基于当前 `CapabilitySpec` 的字段(name、kind、side_effect、tags),MUST NOT 维护平行的工具注册表。 + +#### Scenario: NameSelector matches exact capability name + +- **WHEN** mode 使用 `Name("shell")` selector +- **THEN** 编译结果 SHALL 包含名称为 "shell" 的 capability(如果它在当前 surface 中存在) +- **AND** SHALL NOT 匹配名称不包含 "shell" 的 capability + +#### Scenario: KindSelector matches capability kind + +- **WHEN** mode 使用 `Kind(Tool)` selector +- **THEN** 编译结果 SHALL 包含所有 `CapabilityKind::Tool` 类型的 capability + +#### Scenario: SideEffectSelector matches side effect level + +- **WHEN** mode 使用 `SideEffect(None)` selector +- **THEN** 编译结果 SHALL 仅包含 `side_effect == SideEffect::None` 的 capability +- **AND** SHALL NOT 包含 `SideEffect::Local` 或更高级别的 capability + +#### Scenario: TagSelector matches capability tags + +- **WHEN** mode 使用 `Tag("source:mcp")` selector +- **THEN** 编译结果 SHALL 包含 tags 中含有 `"source:mcp"` 的 capability + +#### Scenario: selector operates uniformly on builtin and plugin capabilities + +- **WHEN** 当前 capability surface 同时包含 builtin 与插件工具 +- **THEN** CapabilitySelector SHALL 对它们一视同仁地解析 +- **AND** SHALL NOT 因来源不同而走不同选择路径 + +### Requirement: mode SHALL support compositional capability selectors + +mode 的能力选择 SHALL 支持组合操作(并集、交集、差集),使 mode spec 能表达复杂的能力面约束。 + +#### Scenario: mode uses intersection of selectors + +- **WHEN** mode 定义能力选择为 `Kind(Tool) ∩ NotSideEffect(External)` +- **THEN** 编译结果 SHALL 包含所有 Tool 类型且不具有 External 副作用的 capability + +#### Scenario: mode uses exclusion selector + +- **WHEN** mode 定义能力选择为 `AllTools - Name("spawn")` +- **THEN** 编译结果 SHALL 包含除 "spawn" 外的所有工具 + +### Requirement: child capability router SHALL be derived from parent mode's child policy + +child session 的能力路由 MUST 由父 mode 的 child policy 推导,而不是简单继承父 session 的完整能力面。推导过程 SHALL 复用 CapabilitySelector 机制。 + +#### Scenario: child policy specifies narrower capability selector + +- **WHEN** 父 mode 的 child policy 定义了 `capability_selector` 限制 child 可用工具 +- **THEN** child 的 capability router SHALL 先按 child policy 的 selector 从父能力面中筛选 +- **AND** SHALL NOT 直接继承父的完整能力面 + +#### Scenario: child policy intersects with SpawnCapabilityGrant + +- **WHEN** 父 mode 的 child policy 有 capability selector,同时 spawn 调用指定了 `SpawnCapabilityGrant` +- **THEN** child 的最终能力面 SHALL 为两者交集 +- **AND** 空交集 SHALL 导致 spawn 被拒绝 diff --git a/openspec/changes/collaboration-mode-system/specs/mode-command-surface/spec.md b/openspec/changes/collaboration-mode-system/specs/mode-command-surface/spec.md new file mode 100644 index 00000000..054c61ef --- /dev/null +++ b/openspec/changes/collaboration-mode-system/specs/mode-command-surface/spec.md @@ -0,0 +1,99 @@ +## ADDED Requirements + +### Requirement: mode switching SHALL be accessible through a /mode slash command + +用户 MUST 能通过 `/mode` slash 命令切换当前 session 的 governance mode。该命令 SHALL 集成到现有 `Command` enum(cli/src/command/mod.rs)中。 + +#### Scenario: /mode with no argument shows current mode and available modes + +- **WHEN** 用户输入 `/mode`(无参数) +- **THEN** 系统 SHALL 显示当前 session 的 mode 和 catalog 中可用的 mode 列表 +- **AND** 显示内容 SHALL 包括每个 mode 的名称和简短描述 + +#### Scenario: /mode with mode ID switches to target mode + +- **WHEN** 用户输入 `/mode plan` +- **THEN** 系统 SHALL 校验 `plan` 是有效的 mode ID +- **AND** 校验通过后 SHALL 在当前 turn 追加 mode 变更事件 +- **AND** 新 mode SHALL 从下一次 turn 开始生效 + +#### Scenario: /mode with invalid mode ID is rejected + +- **WHEN** 用户输入 `/mode nonexistent` +- **THEN** 系统 SHALL 返回错误提示,列出可用的 mode ID +- **AND** SHALL NOT 改变当前 mode + +### Requirement: /mode SHALL support tab completion from the mode catalog + +`/mode` 命令 SHALL 支持 tab 补全,补全候选来自当前 mode catalog 中可用的 mode ID。 + +#### Scenario: tab completion lists available modes + +- **WHEN** 用户在 `/mode ` 后按 tab +- **THEN** 系统 SHALL 显示当前 catalog 中所有可用 mode ID 作为候选 +- **AND** 候选列表 SHALL 排除当前已处于的 mode + +#### Scenario: tab completion filters by prefix + +- **WHEN** 用户输入 `/mode pl` 后按 tab +- **THEN** 系统 SHALL 过滤并显示以 "pl" 开头的 mode ID(如 "plan") +- **AND** 若无匹配 SHALL 不显示候选 + +### Requirement: /mode SHALL integrate with the existing slash command palette + +`/mode` 命令 SHALL 出现在 slash command palette 中,与 `/model`、`/compact` 等命令并列。 + +#### Scenario: /mode appears in slash candidates + +- **WHEN** 用户输入 `/` 触发 slash palette +- **THEN** `/mode` SHALL 出现在候选列表中 +- **AND** SHALL 附带描述文本(如 "Switch execution governance mode") + +### Requirement: mode command SHALL route through unified application governance entrypoint + +`/mode` 命令的解析和执行 MUST 走统一的 application 治理入口,而不是在 `session-runtime` 中解析命令语法。这与项目架构中 "slash command 只是输入壳,语义映射到稳定 server/application contract" 的要求一致。 + +#### Scenario: CLI sends mode transition request to application layer + +- **WHEN** CLI 收到 `/mode plan` 命令 +- **THEN** 它 SHALL 将 mode transition 请求发送到 application 的统一治理入口 +- **AND** application 层 SHALL 校验 target mode、entry policy 和 transition policy +- **AND** session-runtime SHALL 只接收已验证的 transition command + +#### Scenario: mode transition from tool call uses the same governance path + +- **WHEN** 模型通过工具调用请求 mode 切换 +- **THEN** 该请求 SHALL 走与 `/mode` 命令相同的治理入口 +- **AND** 校验逻辑 SHALL 完全一致 + +### Requirement: mode status SHALL be visible to the user + +用户 MUST 能在 UI/CLI 中看到当前 session 的 active mode。 + +#### Scenario: session status shows current mode + +- **WHEN** session 处于活跃状态 +- **THEN** CLI/UI SHALL 显示当前 session 的 mode ID(如 `[plan]` 或 `[code]`) +- **AND** mode 变更后 SHALL 即时更新显示 + +#### Scenario: mode change is reported to the user + +- **WHEN** mode 切换成功 +- **THEN** 系统 SHALL 向用户确认 mode 已变更 +- **AND** SHALL 提示新 mode 在下一 turn 生效 + +### Requirement: mode transition rejection SHALL provide actionable feedback + +当 mode 切换被拒绝时,系统 MUST 提供清晰的错误信息和可操作的建议。 + +#### Scenario: transition policy violation is explained + +- **WHEN** 当前 mode 的 transition policy 禁止切换到目标 mode +- **THEN** 系统 SHALL 解释拒绝原因(如 "Cannot switch from review to plan: transition not allowed") +- **AND** SHALL 列出从当前 mode 可以切换到的 mode 列表 + +#### Scenario: running session blocks certain mode transitions + +- **WHEN** 某些 mode 要求在无 running turn 时才能切换 +- **THEN** 系统 SHALL 提示用户等待当前 turn 完成后再切换 +- **AND** SHALL NOT 静默忽略切换请求 diff --git a/openspec/changes/collaboration-mode-system/specs/mode-execution-policy/spec.md b/openspec/changes/collaboration-mode-system/specs/mode-execution-policy/spec.md new file mode 100644 index 00000000..bff38e0d --- /dev/null +++ b/openspec/changes/collaboration-mode-system/specs/mode-execution-policy/spec.md @@ -0,0 +1,83 @@ +## ADDED Requirements + +### Requirement: mode SHALL resolve mode-specific execution limits into the turn envelope + +每个 governance mode MUST 在编译 envelope 时解析 mode-specific 的执行限制参数,包括 max_steps、ForkMode 策略、以及 turn 级并发策略(SubmitBusyPolicy),作为 `ResolvedTurnEnvelope` 的一部分。 + +#### Scenario: execute mode uses default execution limits + +- **WHEN** 当前 session 的 mode 为 builtin `code` +- **THEN** envelope 的 execution limits SHALL 与当前默认行为等价 +- **AND** max_steps SHALL 来自 runtime config 或用户 `ExecutionControl` + +#### Scenario: plan mode uses reduced max_steps + +- **WHEN** 当前 session 的 mode 为 builtin `plan` +- **THEN** envelope 的 max_steps SHALL 可由 mode spec 指定上限 +- **AND** 用户通过 `ExecutionControl.max_steps` 指定的值 SHALL NOT 超过 mode spec 的上限 + +#### Scenario: review mode uses minimal execution limits + +- **WHEN** 当前 session 的 mode 为 builtin `review` +- **THEN** envelope 的 max_steps SHALL 可限制为 1(仅观察,不执行多步) +- **AND** SHALL NOT 允许 tool chain 执行 + +### Requirement: mode SHALL determine ForkMode policy for child context inheritance + +ForkMode(FullHistory/LastNTurns)决定的上下文继承策略 MUST 受当前 mode 约束。mode spec 可以限制 child 可继承的上下文范围。 + +#### Scenario: execute mode allows default ForkMode + +- **WHEN** 当前 mode 对 child context inheritance 无特殊限制 +- **THEN** ForkMode SHALL 按 SpawnAgentParams 的调用参数决定(与当前行为等价) + +#### Scenario: restricted mode limits child context to LastNTurns + +- **WHEN** 当前 mode 的 child policy 规定 child 只能继承最近 N 个 turn 的上下文 +- **THEN** ForkMode SHALL 强制使用 `LastNTurns(N)` 而非 `FullHistory` +- **AND** 即使调用方指定 `FullHistory`,SHALL 被降级为 mode 允许的最大范围 + +### Requirement: mode SHALL influence SubmitBusyPolicy for turn concurrency + +不同 mode 可以有不同的 turn 并发治理策略。mode spec SHALL 能指定当已有 turn 执行时,新 submit 应使用 `BranchOnBusy` 还是 `RejectOnBusy`。 + +#### Scenario: execute mode uses BranchOnBusy + +- **WHEN** 当前 mode 对 turn 并发无特殊限制 +- **THEN** SubmitBusyPolicy SHALL 默认为 `BranchOnBusy`(与当前行为等价) + +#### Scenario: plan mode uses RejectOnBusy + +- **WHEN** 当前 mode 要求 turn 串行执行 +- **THEN** SubmitBusyPolicy SHALL 为 `RejectOnBusy` +- **AND** 已有 turn 在执行时的新 submit SHALL 被拒绝而非 branching + +### Requirement: mode execution limits SHALL compose with user-specified ExecutionControl + +mode 的执行限制 MUST 与用户通过 `ExecutionControl` 指定的限制取交集(更严格者生效),而不是简单覆盖。 + +#### Scenario: user max_steps is lower than mode limit + +- **WHEN** mode spec 允许 max_steps = 50,但用户指定 `ExecutionControl.max_steps = 10` +- **THEN** 实际 max_steps SHALL 为 10(用户限制更严格) + +#### Scenario: user max_steps exceeds mode limit + +- **WHEN** mode spec 限制 max_steps = 20,但用户指定 `ExecutionControl.max_steps = 50` +- **THEN** 实际 max_steps SHALL 为 20(mode 限制更严格) +- **AND** 系统 SHALL NOT 静默截断,可选择在 submit 响应中提示限制已生效 + +### Requirement: mode SHALL resolve AgentConfig governance parameters for the current turn + +`AgentConfig` 中的治理参数(max_subrun_depth、max_spawn_per_turn 等)MUST 可被 mode spec 覆盖或限制,使不同 mode 能表达不同的协作深度策略。 + +#### Scenario: plan mode reduces max_spawn_per_turn + +- **WHEN** 当前 mode 的 spec 指定 `max_spawn_per_turn = 0` +- **THEN** 该 turn SHALL 不允许 spawn 任何 child +- **AND** spawn 工具 SHALL 不在可见能力面中 + +#### Scenario: mode does not override AgentConfig by default + +- **WHEN** mode spec 未指定覆盖参数 +- **THEN** 这些参数 SHALL 使用 runtime config 中的值(与当前行为等价) diff --git a/openspec/changes/collaboration-mode-system/specs/mode-policy-engine/spec.md b/openspec/changes/collaboration-mode-system/specs/mode-policy-engine/spec.md new file mode 100644 index 00000000..51263ef4 --- /dev/null +++ b/openspec/changes/collaboration-mode-system/specs/mode-policy-engine/spec.md @@ -0,0 +1,76 @@ +## ADDED Requirements + +### Requirement: mode SHALL compile to action policies that the PolicyEngine enforces + +每个 governance mode MUST 编译为 action policies,作为 `ResolvedTurnEnvelope` 的一部分。`PolicyEngine` 的三个检查点 SHALL 在 turn 执行链路中消费这些 action policies。 + +#### Scenario: execute mode compiles permissive action policies + +- **WHEN** 当前 session 的 mode 为 builtin `code` +- **THEN** action policies SHALL 编译为默认允许所有能力调用 +- **AND** `check_capability_call` SHALL 返回 `PolicyVerdict::Allow`(与当前 `AllowAllPolicyEngine` 行为等价) + +#### Scenario: plan mode compiles restrictive action policies + +- **WHEN** 当前 session 的 mode 为 builtin `plan` +- **THEN** action policies SHALL 禁止具有 `SideEffect::Workspace` 或 `SideEffect::External` 的能力调用 +- **AND** `check_capability_call` SHALL 对这些调用返回 `PolicyVerdict::Deny` + +#### Scenario: custom mode compiles ask-on-high-risk policies + +- **WHEN** 一个插件 mode 定义了"高风险操作需审批"的 action policy +- **THEN** 对高风险能力调用 `check_capability_call` SHALL 返回 `PolicyVerdict::Ask` +- **AND** 系统 SHALL 发起审批流(通过治理包络建立的管线) + +### Requirement: PolicyContext SHALL be populated from the mode-compiled envelope + +`PolicyContext`(core/policy/engine.rs:108-124)的构建 MUST 从 mode 编译后的治理包络派生,确保策略引擎与 turn 执行链路使用同一事实源。 + +#### Scenario: PolicyContext session/turn identifiers come from envelope + +- **WHEN** PolicyEngine 需要构建 `PolicyContext` 用于裁决 +- **THEN** session_id、turn_id、step_index、working_dir、profile SHALL 从治理包络中获取 +- **AND** SHALL NOT 在调用点独立组装 + +#### Scenario: PolicyContext profile aligns with envelope capability surface + +- **WHEN** mode 编译后的 envelope 指定了特定的 capability surface +- **THEN** PolicyContext 可用的能力信息 SHALL 与 envelope 一致 +- **AND** SHALL NOT 出现 PolicyContext 认为某工具可用但 envelope 已移除的不一致 + +### Requirement: mode SHALL influence context strategy decisions + +`decide_context_strategy`(PolicyEngine 的上下文策略检查点)SHALL 能参考当前 mode 的上下文治理偏好,使不同 mode 可以有不同的 context pressure 处理策略。 + +#### Scenario: execute mode uses default context strategy + +- **WHEN** context pressure 触发策略裁决且当前 mode 为 `code` +- **THEN** 策略 SHALL 使用默认的 `ContextStrategy::Compact`(与当前行为等价) + +#### Scenario: review mode prefers truncate over compact + +- **WHEN** context pressure 触发策略裁决且当前 mode 为 `review` +- **THEN** 策略 MAY 优先使用 `ContextStrategy::Truncate` 而非 Compact +- **AND** SHALL NOT 丢失 review 对象的内容 + +#### Scenario: mode does not specify context strategy + +- **WHEN** mode spec 未定义上下文策略偏好 +- **THEN** 系统 SHALL 使用 runtime config 的默认策略 +- **AND** SHALL NOT 因缺少 mode 配置而无法裁决 + +### Requirement: mode-specific policy engine SHALL be swappable without modifying turn loop + +mode 变更后,后续 turn 的策略行为 SHALL 通过替换治理包络中的 action policies 实现,MUST NOT 要求修改 `run_turn`、tool cycle 或 streaming path。 + +#### Scenario: mode transition changes policy behavior at turn boundary + +- **WHEN** session 从 `code` mode 切换到 `plan` mode +- **THEN** 下一 turn 的 PolicyEngine 行为 SHALL 基于 plan mode 的 action policies +- **AND** 当前 turn 的执行 SHALL 不受影响(next-turn 生效语义) + +#### Scenario: plugin mode provides custom policy implementation + +- **WHEN** 一个插件 mode 定义了自定义的策略裁决逻辑 +- **THEN** 系统 SHALL 通过治理包络中的 action policies 传递该逻辑 +- **AND** SHALL NOT 要求插件直接实现 `PolicyEngine` trait 或修改 turn runner diff --git a/openspec/changes/collaboration-mode-system/specs/mode-prompt-program/spec.md b/openspec/changes/collaboration-mode-system/specs/mode-prompt-program/spec.md new file mode 100644 index 00000000..dc868957 --- /dev/null +++ b/openspec/changes/collaboration-mode-system/specs/mode-prompt-program/spec.md @@ -0,0 +1,99 @@ +## ADDED Requirements + +### Requirement: mode SHALL compile to a prompt program that generates PromptDeclarations + +每个 governance mode MUST 编译为一个 prompt program,该 program 在 turn 边界生成一组 `PromptDeclaration`,作为 `ResolvedTurnEnvelope` 的一部分注入 prompt 组装管线。 + +#### Scenario: execute mode compiles the default prompt program + +- **WHEN** 当前 session 的 mode 为 builtin `code` +- **THEN** prompt program SHALL 生成与当前默认协作 guidance 等价的 PromptDeclarations +- **AND** 渲染结果 SHALL 与现有 `WorkflowExamplesContributor` 的 `child-collaboration-guidance` block 行为等价 + +#### Scenario: plan mode compiles a planning-oriented prompt program + +- **WHEN** 当前 session 的 mode 为 builtin `plan` +- **THEN** prompt program SHALL 生成规划导向的 PromptDeclarations +- **AND** SHALL 包含规划方法论 guidance、输出格式约束、以及不允许直接执行的声明 + +#### Scenario: review mode compiles an observation-oriented prompt program + +- **WHEN** 当前 session 的 mode 为 builtin `review` +- **THEN** prompt program SHALL 生成审查导向的 PromptDeclarations +- **AND** SHALL 不包含 spawn/send 协作协议 guidance + +### Requirement: mode prompt program SHALL integrate through the existing PromptDeclaration injection path + +mode 生成的 PromptDeclarations MUST 通过现有注入路径进入 prompt 组装,即 `TurnRunRequest.prompt_declarations` -> `TurnExecutionResources` -> `AssemblePromptRequest` -> `PromptOutputRequest.submission_prompt_declarations` -> `build_prompt_output()`,MUST NOT 开辟新的 prompt 注入旁路。 + +#### Scenario: mode declarations travel the standard path + +- **WHEN** mode 编译生成 PromptDeclarations +- **THEN** 它们 SHALL 被放入 `AgentPromptSubmission.prompt_declarations` +- **AND** 通过 `submit_prompt_inner` -> `RunnerRequest` -> `TurnRunRequest` 标准路径进入 runner + +#### Scenario: mode declarations are visible to PromptDeclarationContributor + +- **WHEN** `PromptDeclarationContributor` (adapter-prompt) 渲染 prompt +- **THEN** 它 SHALL 能渲染 mode 生成的 declarations +- **AND** SHALL 对 mode declarations 和其他 declarations 使用相同的渲染逻辑 + +### Requirement: mode SHALL control which builtin prompt contributors are active + +不同 mode 可以要求禁用或替换某些 builtin prompt contributor。mode spec SHALL 能声明对 contributor 的约束。 + +#### Scenario: execute mode keeps all contributors active + +- **WHEN** 当前 mode 为 `code` +- **THEN** 所有现有 contributor(WorkflowExamplesContributor、AgentProfileSummaryContributor、CapabilityPromptContributor)SHALL 保持活跃 +- **AND** 行为与当前默认等价 + +#### Scenario: mode disables AgentProfileSummaryContributor when delegation is forbidden + +- **WHEN** 当前 mode 的 child policy 禁止创建 child 分支 +- **THEN** `AgentProfileSummaryContributor` SHALL 不渲染(因为它只在 spawn 可用时激活) +- **AND** 这一行为 SHALL 自动发生(因为 mode 编译的 capability router 已移除 spawn 工具) + +#### Scenario: mode replaces collaboration guidance content + +- **WHEN** 当前 mode 要求不同的协作 guidance +- **THEN** `WorkflowExamplesContributor` 的治理专属内容 SHALL 被 mode prompt program 的 declarations 替代 +- **AND** `WorkflowExamplesContributor` SHALL 仅保留非治理 few-shot 内容(如果有) + +### Requirement: PromptFactsProvider SHALL resolve prompt facts against the mode-compiled envelope + +`PromptFactsProvider.resolve_prompt_facts()` 构建的 `PromptFacts` MUST 与 mode 编译后的治理包络保持一致,包括 metadata 中的治理参数和 declarations 的可见性过滤。 + +#### Scenario: PromptFacts metadata reflects mode-resolved parameters + +- **WHEN** mode 编译后的 envelope 指定了 max_spawn_per_turn = 0 +- **THEN** `PromptFacts.metadata` 中的 `agentMaxSpawnPerTurn` SHALL 为 0 +- **AND** SHALL NOT 使用 runtime config 中的原始值 + +#### Scenario: prompt declaration visibility aligns with mode capability surface + +- **WHEN** mode 编译的 envelope 移除了某些工具 +- **THEN** `prompt_declaration_is_visible` 过滤 SHALL 使用 envelope 的能力面 +- **AND** 已被 mode 移除的工具对应的 declarations SHALL 不对模型可见 + +#### Scenario: profile context approvalMode reflects mode policy + +- **WHEN** mode 的 action policies 包含审批要求 +- **THEN** `build_profile_context` 中的 `approvalMode` SHALL 反映该模式 +- **AND** SHALL 与 PolicyEngine 的实际行为一致 + +### Requirement: plugin mode SHALL be able to contribute custom prompt blocks without replacing contributors + +插件 mode MUST 能通过 prompt program 注入自定义 prompt blocks,但 MUST NOT 直接替换、删除或修改现有 prompt contributor 的内部逻辑。 + +#### Scenario: plugin mode appends custom guidance + +- **WHEN** 一个插件 mode 定义了自定义协作 guidance +- **THEN** 系统 SHALL 将该 guidance 编译为额外的 PromptDeclaration +- **AND** 现有 contributor 的渲染逻辑 SHALL 不受影响 + +#### Scenario: plugin mode cannot bypass contributor pipeline + +- **WHEN** 一个插件 mode 尝试绕过 prompt 组装管线 +- **THEN** 系统 SHALL 仅通过 PromptDeclaration 注入路径接受 mode 的 prompt 输入 +- **AND** SHALL NOT 允许插件直接修改 prompt 组装中间产物 diff --git a/openspec/changes/collaboration-mode-system/tasks.md b/openspec/changes/collaboration-mode-system/tasks.md new file mode 100644 index 00000000..afeba304 --- /dev/null +++ b/openspec/changes/collaboration-mode-system/tasks.md @@ -0,0 +1,88 @@ +## 1. Core 治理模型 + +- [ ] 1.1 在 `crates/core/src/mode/mod.rs` 定义开放式 `ModeId`、`GovernanceModeSpec`、`CapabilitySelector`(含 Name/Kind/SideEffect/Tag/AllTools 及组合操作)、`ActionPolicies`、`ChildPolicySpec` 与 `ResolvedTurnEnvelope` +- [ ] 1.2 在 `crates/core/src/lib.rs` 导出 mode 模块,并为序列化、校验与默认 builtin mode ID 补充单元测试 +- [ ] 1.3 在 `crates/core/src/event/types.rs` 增加 `ModeChanged { from: ModeId, to: ModeId }` 事件载荷,并补充旧会话默认回退到 `execute` 的测试 + +## 2. Capability Selector 编译 + +- [ ] 2.1 在 `crates/application/src/mode/compiler.rs` 实现 `CapabilitySelector -> CapabilityRouter` 编译逻辑,从当前 `CapabilitySpec` / capability router 投影能力面 +- [ ] 2.2 实现组合选择器(交集、并集、差集)的编译逻辑 +- [ ] 2.3 确保 child capability router 从 parent mode child policy + SpawnCapabilityGrant 交集推导 +- [ ] 2.4 为 execute/plan/review 三个 builtin mode 的 capability 选择编写等价测试 + +## 3. Application Catalog 与 Compiler + +- [ ] 3.1 在 `crates/application/src/mode/catalog.rs` 实现 builtin mode catalog,定义 `execute`(code)、`plan`、`review` 三个首批 mode spec(含 CapabilitySelector、ActionPolicies、ChildPolicySpec、prompt program) +- [ ] 3.2 在 `crates/application/src/mode/compiler.rs` 实现 `GovernanceModeSpec -> ResolvedTurnEnvelope` 完整编译逻辑(capability router + prompt declarations + execution limits + action policies + child policy) +- [ ] 3.3 在 `crates/application/src/mode/validator.rs` 实现统一 transition / entry policy 校验入口,并补充非法切换、next-turn 生效等测试 + +## 4. Mode 执行限制 + +- [ ] 4.1 在 envelope 编译中实现 mode-specific max_steps 解析,与用户 ExecutionControl 取交集(更严格者生效) +- [ ] 4.2 在 envelope 编译中实现 mode-specific ForkMode 约束(如 restricted mode 强制 LastNTurns) +- [ ] 4.3 评估 SubmitBusyPolicy 是否需要由 mode 指定,如果是则在 envelope 编译中实现 +- [ ] 4.4 在 envelope 编译中实现 AgentConfig 治理参数覆盖(max_subrun_depth、max_spawn_per_turn) + +## 5. Mode Policy Engine 集成 + +- [ ] 5.1 将 `PolicyContext` 构建改为从治理包络派生,消除与治理包络字段的重复组装 +- [ ] 5.2 实现 mode action policies 到 `PolicyEngine` 行为的映射(Allow/Deny 裁决) +- [ ] 5.3 确保 `decide_context_strategy` 能参考 mode 的上下文治理偏好 +- [ ] 5.4 确保 builtin mode 使用 AllowAllPolicyEngine 等价行为,补充默认放行测试 + +## 6. Mode Prompt Program + +- [ ] 6.1 为 execute/plan/review 三个 builtin mode 定义 prompt program(生成 PromptDeclarations) +- [ ] 6.2 确保 mode declarations 通过标准注入路径(`TurnRunRequest.prompt_declarations` -> `PromptDeclarationContributor`)进入 prompt 组装 +- [ ] 6.3 重构 `WorkflowExamplesContributor`,让治理专属内容改为由 mode prompt program 提供 +- [ ] 6.4 确保 `PromptFactsProvider` 的 metadata 和 declaration 过滤与 mode envelope 保持一致 +- [ ] 6.5 验证 `CapabilityPromptContributor` 和 `AgentProfileSummaryContributor` 通过 PromptContext 自动响应 mode 能力面变化 + +## 7. Session Runtime 集成 + +- [ ] 7.1 在 `crates/core/src/projection/agent_state.rs` 的 `AgentState` 增加 `mode_id` 字段,在 `AgentStateProjector::apply()` 增加 `ModeChanged` 事件处理 +- [ ] 7.2 在 `crates/session-runtime/src/state/mod.rs` 的 `SessionState` 增加 `current_mode` per-field mutex +- [ ] 7.3 在 `crates/session-runtime/src/turn/submit.rs` 的 submit 边界解析当前 mode,并把 `ResolvedTurnEnvelope` 收口到 `AgentPromptSubmission` / `RunnerRequest` +- [ ] 7.4 修改 `crates/session-runtime/src/turn/runner.rs`,确保 turn 工具面从 envelope 的 capability router 读取 +- [ ] 7.5 确保旧 session replay 时 ModeChanged 事件缺失回退到 `execute` + +## 8. Delegation 与 Child Policy + +- [ ] 8.1 实现 mode child policy 到 `DelegationMetadata` 的推导逻辑 +- [ ] 8.2 实现 mode child policy 到 `SpawnCapabilityGrant` 的推导逻辑 +- [ ] 8.3 修改 `crates/application/src/execution/subagent.rs` 和 `agent/mod.rs`,让 child 初始 mode 和 execution contract 来自 resolved child policy +- [ ] 8.4 确保 `AgentProfileSummaryContributor` 在 mode 禁止 delegation 时不渲染(通过现有 spawn 守卫条件自动生效) + +## 9. /mode 命令 + +- [ ] 9.1 在 `crates/cli/src/command/mod.rs` 的 `Command` enum 增加 `Mode { query: Option }` 变体 +- [ ] 9.2 在 `parse_command` 中增加 `"/mode"` arm +- [ ] 9.3 实现 tab 补全,从 mode catalog 获取候选(集成到 slash_candidates 机制) +- [ ] 9.4 在 `coordinator.rs` 中实现 `/mode` 命令的执行调度 +- [ ] 9.5 实现 mode 状态显示(当前 mode、可用 mode 列表、transition 拒绝反馈) + +## 10. Bootstrap、Reload 与验证 + +- [ ] 10.1 在 `crates/server/src/bootstrap/governance.rs` 的 `GovernanceBuildInput` 中增加 mode catalog 参数 +- [ ] 10.2 在 `ServerRuntimeReloader` 的 reload 编排中增加 mode catalog 替换步骤(与能力面替换原子性) +- [ ] 10.3 在 `crates/server/src/bootstrap/runtime.rs` 中装配 builtin mode catalog +- [ ] 10.4 确保插件 mode 在 bootstrap 握手阶段可注册到同一 catalog + +## 11. 协作审计与可观测性 + +- [ ] 11.1 在 `AgentCollaborationFact` 增加可选的 `mode_id` 字段 +- [ ] 11.2 确保审计事实记录当前 turn 开始时的 mode(不受 turn 内 mode 变更影响) +- [ ] 11.3 在 `ObservabilitySnapshotProvider` 快照中增加当前 mode 和变更时间戳 +- [ ] 11.4 实现 envelope 编译的诊断信息记录(如空能力面警告) + +## 12. 集成测试与验证 + +- [ ] 12.1 为 mode-aware collaboration guidance、delegation catalog 和 restricted child contract 增加回归测试 +- [ ] 12.2 为 mode 切换的 next-turn 生效语义增加测试 +- [ ] 12.3 为 CapabilitySelector 编译的等价性增加测试 +- [ ] 12.4 为 PolicyEngine 与 mode action policies 的集成增加测试 +- [ ] 12.5 运行 `cargo fmt --all` +- [ ] 12.6 运行 `cargo test --workspace --exclude astrcode` +- [ ] 12.7 运行 `node scripts/check-crate-boundaries.mjs` +- [ ] 12.8 手动验证:切换 builtin mode、确认下一 turn 生效、确认 child delegation surface 与 prompt guidance 随 mode 收敛 diff --git a/openspec/changes/governance-surface-cleanup/.openspec.yaml b/openspec/changes/governance-surface-cleanup/.openspec.yaml new file mode 100644 index 00000000..204fc5ac --- /dev/null +++ b/openspec/changes/governance-surface-cleanup/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-04-18 diff --git a/openspec/changes/governance-surface-cleanup/design.md b/openspec/changes/governance-surface-cleanup/design.md new file mode 100644 index 00000000..4d6e5603 --- /dev/null +++ b/openspec/changes/governance-surface-cleanup/design.md @@ -0,0 +1,306 @@ +## Context + +当前治理相关逻辑至少散落在以下几条路径: + +- `session-runtime::AgentPromptSubmission` 同时承载 scoped router、prompt declarations、resolved limits、injected messages 等多类治理输入,但它只是一个字段集合,而不是显式的治理包络模型。[submit.rs](/D:/GitObjectsOwn/Astrcode/crates/session-runtime/src/turn/submit.rs:36) +- `application::execution::root`、`application::execution::subagent`、`application::session_use_cases` 以不同方式构造这些字段,造成 root / session / child 提交路径的装配方式不一致。[root.rs](/D:/GitObjectsOwn/Astrcode/crates/application/src/execution/root.rs)、[subagent.rs](/D:/GitObjectsOwn/Astrcode/crates/application/src/execution/subagent.rs)、[session_use_cases.rs](/D:/GitObjectsOwn/Astrcode/crates/application/src/session_use_cases.rs) +- child execution contract 由 `application::agent` 中的 helper 直接生成,但静态协作 guidance 又在 `adapter-prompt::WorkflowExamplesContributor` 内硬编码,authoritative 来源分裂。[agent/mod.rs](/D:/GitObjectsOwn/Astrcode/crates/application/src/agent/mod.rs:314)、[workflow_examples.rs](/D:/GitObjectsOwn/Astrcode/crates/adapter-prompt/src/contributors/workflow_examples.rs:17) +- `PromptFactsProvider` 已经是稳定的 prompt 事实入口,但当前治理相关 builtin 事实并未统一从这里注入。[prompt_facts.rs](/D:/GitObjectsOwn/Astrcode/crates/server/src/bootstrap/prompt_facts.rs) +- 三条路径各自独立构建 `CapabilityRouter`:root 从 kernel gateway 计算,subagent 做 allowed_tools 交集,resume 在 `routing.rs` 中独立构建。[root.rs:71](/D:/GitObjectsOwn/Astrcode/crates/application/src/execution/root.rs:71)、[subagent.rs:141](/D:/GitObjectsOwn/Astrcode/crates/application/src/execution/subagent.rs:141)、[routing.rs:571](/D:/GitObjectsOwn/Astrcode/crates/application/src/agent/routing.rs:571) +- `ResolvedExecutionLimitsSnapshot`、`ExecutionControl`、`ForkMode`、`SubmitBusyPolicy` 散落在不同层,没有统一信封。[agent/mod.rs:580](/D:/GitObjectsOwn/Astrcode/crates/core/src/agent/mod.rs:580)、[execution_control.rs](/D:/GitObjectsOwn/Astrcode/crates/core/src/execution_control.rs)、[submit.rs:23](/D:/GitObjectsOwn/Astrcode/crates/session-runtime/src/turn/submit.rs:23) +- `PolicyEngine` 已有完整的三态策略框架(Allow/Deny/Ask)和审批流类型,但当前只有 `AllowAllPolicyEngine` 且没有真实消费者,与执行路径完全脱钩。[engine.rs](/D:/GitObjectsOwn/Astrcode/crates/core/src/policy/engine.rs:289) +- `DelegationMetadata`、`SpawnCapabilityGrant`、`AgentCollaborationPolicyContext` 由 `agent/mod.rs` 中的局部 helper 各自拼装。[agent/mod.rs:287](/D:/GitObjectsOwn/Astrcode/crates/application/src/agent/mod.rs:287)、[agent/mod.rs:100](/D:/GitObjectsOwn/Astrcode/crates/core/src/agent/mod.rs:100) +- `PromptFactsProvider` 中存在隐式治理联动:vars dict 传递 `agentMaxSubrunDepth` 等参数,`prompt_declaration_is_visible` 用 capability name 做隐式过滤。[prompt_facts.rs:86](/D:/GitObjectsOwn/Astrcode/crates/server/src/bootstrap/prompt_facts.rs:86)、[prompt_facts.rs:200](/D:/GitObjectsOwn/Astrcode/crates/server/src/bootstrap/prompt_facts.rs:200) +- `AppGovernance` 和 `RuntimeCoordinator` 管理运行时治理生命周期,但缺少 mode catalog 的明确接入点。[governance.rs:84](/D:/GitObjectsOwn/Astrcode/crates/application/src/lifecycle/governance.rs:84)、[coordinator.rs:29](/D:/GitObjectsOwn/Astrcode/crates/core/src/runtime/coordinator.rs:29) +- `AgentCollaborationFact` 记录协作审计事件,但缺少与治理包络的上下文关联,导致审计链路无法追溯到治理决策依据。[agent/mod.rs:1129](/D:/GitObjectsOwn/Astrcode/crates/core/src/agent/mod.rs:1129) + +这导致一个问题:治理逻辑已经客观存在,却没有一条清晰的 authoritative assembly path。后续 governance mode system 如果直接叠加,会被迫同时接管 `application` helper、`session-runtime` ad-hoc submission 字段、`adapter-prompt` 静态 guidance、策略引擎接线、能力路由构建、执行限制计算、委派元数据生成、prompt 事实联动等多条路径。 + +## Goals / Non-Goals + +**Goals:** + +- 定义统一的 `ResolvedGovernanceSurface` / 等价 execution envelope,作为进入 `session-runtime` 前的标准治理输入。 +- 让 root execution、普通 session submit、subagent fresh/resumed launch 统一复用同一治理装配器。 +- 统一三条能力路由装配路径(root/subagent/resume),使 capability router 由治理装配器统一生成。 +- 把 `ResolvedExecutionLimitsSnapshot`、`ExecutionControl`、`ForkMode`、`SubmitBusyPolicy` 等执行限制收敛为治理包络的一部分。 +- 为 `PolicyEngine` 建立与治理包络的接入管线,使策略引擎能消费治理包络,同时本轮保持 `AllowAllPolicyEngine` 默认行为。 +- 将 `DelegationMetadata`、`SpawnCapabilityGrant`、`AgentCollaborationPolicyContext` 的生成收口到治理装配路径。 +- 把协作 guidance、delegation catalog、child execution contract 的 authoritative 来源收口到治理装配层。 +- 显式化 `PromptFactsProvider` 中的隐式治理联动,使 prompt 事实成为治理包络的消费者。 +- 让 `AppGovernance` 和 `RuntimeCoordinator` 为后续 mode catalog 接入预留明确入口。 +- 让协作审计事实(`AgentCollaborationFact`)能关联治理包络上下文。 +- 保持 `adapter-prompt` 的职责回到"渲染已有 prompt declaration / few-shot",而不是继续承载治理真相。 +- 为后续 governance mode system 预留稳定接入点,但本次不实现 mode 本身。 + +**Non-Goals:** + +- 不实现新的 governance mode catalog 或 mode transition。 +- 不改变 `run_turn`、tool cycle、streaming path 或 compaction 核心流程。 +- 不新增独立 crate。 +- 不在本轮实现完整的审批拦截逻辑;只建立策略引擎与治理包络的管线,默认行为保持放行。 +- 不在本轮追求完全删除所有 builtin prompt contributor;只收口与治理强相关的 authoritative 逻辑。 + +## Decisions + +### Decision 1:引入统一治理包络,而不是继续扩张 `AgentPromptSubmission` + +**选择:** + +新增一个显式治理包络类型,例如 `ResolvedGovernanceSurface`,承载: + +- scoped capability router +- prompt declarations +- resolved execution limits +- resolved context overrides +- inherited / injected messages +- child delegation metadata(如适用) +- resolved policy context(PolicyEngine 消费用) +- collaboration audit context(协作事实记录用) + +`AgentPromptSubmission` 退化为 transport/helper 形状,或直接被该类型替代。 + +**理由:** + +- 当前 `AgentPromptSubmission` 只是字段集合,难以表达"这是已经完成治理装配后的 turn 输入"。 +- governance mode system 后续也需要一个统一的 envelope 接入点,本次 cleanup 可以先把底座做对。 + +**替代方案:** + +- 继续往 `AgentPromptSubmission` 塞字段:被拒绝,会让提交 API 更混乱。 + +### Decision 2:治理装配器放在 `application`,`session-runtime` 只消费结果 + +**选择:** + +- `application` 新增治理装配服务,负责根据入口类型、profile、capability grant、delegation metadata 等解析最终治理包络。 +- `session-runtime` 只消费已经解析好的治理包络,不在底层重新做业务级策略判断。 + +**理由:** + +- 这符合仓库中 `application` 是治理入口、`session-runtime` 是单 session 真相面的边界。 +- 也符合后续 mode transition / mode compile 最终应在 `application` 完成决策的方向。 + +**替代方案:** + +- 在 `session-runtime` 内做治理装配:被拒绝,会把业务治理下沉到底层。 + +### Decision 3:把 authoritative governance prompt 迁移到 declaration 装配链路 + +**选择:** + +- 协作 guidance、child contract、delegation-specific builtin blocks 由治理装配器生成 `PromptDeclaration` +- `adapter-prompt` 只负责渲染这些 declaration +- `WorkflowExamplesContributor` 保留非治理 few-shot 内容,治理专属内容迁出 + +**理由:** + +- 当前 prompt declaration 已经是跨边界稳定 contract,适合承载 authoritative governance 事实。 +- 这样 mode 系统未来只需要改变 declaration 装配结果,不需要修改 adapter 里的硬编码逻辑。 + +**替代方案:** + +- 让 `adapter-prompt` 继续直接拼 governance 文本:被拒绝,会让 adapter 再次偷渡业务真相。 + +### Decision 4:fresh / resumed child contract 走同一治理装配路径 + +**选择:** + +- fresh child、resumed child、普通 session prompt submit 共用同一治理装配总入口 +- 允许入口参数不同,但输出形状一致 + +**理由:** + +- 现在 fresh/resumed child contract 虽然都在 `application::agent`,但仍是局部 helper,不是正式装配路径。 +- 统一后才容易在 mode 系统中替换"哪套 child policy 生效",而不是分别 patch 多条路径。 + +**替代方案:** + +- 继续保留 fresh/resumed 专属 helper,各自由调用方决定是否使用:被拒绝,容易再次分叉。 + +### Decision 5:cleanup 以行为等价为目标,不在本轮引入新的产品语义 + +**选择:** + +- 本轮重构以"authoritative 来源收口"和"模块职责清晰"为目标 +- execute 默认行为、child contract 语义、协作 guidance 语义尽量保持现状等价 + +**理由:** + +- 这是 governance mode system 的前置,不应该在同一轮里同时引入结构重构和新治理语义。 + +### Decision 6:CapabilityRouter 构建逻辑统一到治理装配器 + +**选择:** + +- root/subagent/resume 三条路径的 capability router 构建逻辑全部迁入治理装配器 +- 治理装配器接收 kernel gateway 的全局能力面和入口参数(如 SpawnCapabilityGrant),统一计算子集 +- `execution/root.rs:71-85`、`execution/subagent.rs:141-172`、`agent/routing.rs:571-722` 中的独立构建逻辑被替换 + +**理由:** + +- 三条路径的核心逻辑是"从全局能力面中选取当前 turn 可用的子集",语义相同但实现分散。 +- mode system 后续需要按 mode 改变能力面选择策略,统一后只需修改一处。 + +**替代方案:** + +- 保留三条路径各自构建,只在 mode 系统时统一:被拒绝,会在 mode 实现时同时修改三个文件。 + +### Decision 7:执行限制类型统一收口为治理包络的字段 + +**选择:** + +- `ResolvedExecutionLimitsSnapshot`、`ExecutionControl`、`ForkMode`、`SubmitBusyPolicy` 在治理装配阶段统一解析 +- 治理包络承载完整的执行限制信息,提交路径不再独立计算这些值 +- `AgentConfig` 中的治理参数(max_subrun_depth、max_spawn_per_turn 等)作为治理装配器的输入,不直接被消费方读取 + +**理由:** + +- 当前各消费方(root.rs、subagent.rs、session_use_cases.rs、agent/mod.rs)各自从 runtime config 或参数中读取限制值,容易不一致。 +- 治理包络作为唯一事实源,消除参数散落导致的不一致风险。 + +**替代方案:** + +- 保持各消费方独立读取 config:被拒绝,mode system 会需要在多处同时修改参数来源。 + +### Decision 8:PolicyEngine 接入治理包络,但本轮不实现非平凡策略 + +**选择:** + +- `PolicyContext` 从治理包络派生,不再独立组装 +- 策略引擎的三个检查点能读取治理包络中的能力面和执行限制 +- 本轮保持 `AllowAllPolicyEngine` 作为唯一实现,不引入审批拦截 +- 审批流类型(ApprovalRequest/ApprovalResolution/ApprovalPending)的管线建立但默认不触发 + +**理由:** + +- 策略引擎是 mode system 的核心执行检查点,如果本轮不接管线,mode 实现时需要同时做管线接入和策略逻辑。 +- 先建立管线但不改变默认行为,风险最小。 + +**替代方案:** + +- 完全跳过策略引擎集成:被拒绝,mode system 的审批能力无法复用已有框架。 +- 本轮实现完整审批逻辑:被拒绝,scope 过大,与 cleanup 目标冲突。 + +### Decision 9:DelegationMetadata 和 SpawnCapabilityGrant 从治理包络派生 + +**选择:** + +- `build_delegation_metadata`、`SpawnCapabilityGrant` 的构建逻辑迁入治理装配器 +- `AgentCollaborationPolicyContext` 从治理包络中获取 max_subrun_depth/max_spawn_per_turn +- `enforce_spawn_budget_for_turn` 使用治理包络中的参数 + +**理由:** + +- 委派策略元数据是治理决策的核心输出,由局部 helper 生成意味着治理逻辑分散。 +- mode system 后续需要按 mode 改变委派策略,统一后只需修改装配器。 + +**替代方案:** + +- 保留各 helper 独立构建,mode system 时统一:被拒绝,与 Decision 6 同理。 + +### Decision 10:PromptFactsProvider 退化为治理包络的消费者 + +**选择:** + +- `prompt_declaration_is_visible` 过滤逻辑上移到治理装配层 +- `PromptFacts.metadata` 中的治理参数(agentMaxSubrunDepth 等)从治理包络获取,不再通过 vars dict 传递 +- `build_profile_context` 中的 approvalMode 与治理包络中的策略配置保持一致 + +**理由:** + +- 当前 `PromptFactsProvider` 既是事实收集器又是隐式治理过滤器,职责不清。 +- 显式化后,prompt 事实与能力面/执行限制使用同一治理事实源,消除不一致风险。 + +**替代方案:** + +- 保持 vars dict 隐式传递:被拒绝,string-keyed dict 容易出错,且 mode system 需要更强的类型安全。 + +### Decision 11:AppGovernance 和 RuntimeCoordinator 预留 mode catalog 接入点 + +**选择:** + +- `GovernanceBuildInput` 增加可选的 mode catalog 参数(本次传 None) +- `AppGovernance.reload()` 编排中预留 mode catalog 替换步骤(本次为空操作) +- `RuntimeCoordinator.replace_runtime_surface` 后续 turn 使用更新后的治理包络 + +**理由:** + +- mode catalog 需要在 bootstrap/reload 阶段装配,如果不预留接入点,mode 实现时需要修改治理生命周期编排。 +- 预留但不实现,本轮不增加运行时开销。 + +**替代方案:** + +- mode system 实现时再加:被接受作为备选,但预留接口成本很低,且能减少 mode 系统的改动面。 + +### Decision 12:协作审计事实关联治理包络上下文 + +**选择:** + +- `AgentCollaborationFact` 增加可选的治理包络标识字段(如 governance_revision 或 envelope_hash) +- `CollaborationFactRecord` 的构建参数从治理包络获取 +- 本轮不要求审计事实改变语义,只增加关联能力 + +**理由:** + +- 协作审计是治理闭环的重要环节。如果审计事实无法追溯到治理决策,mode system 的治理验证会缺少关键数据。 +- 低成本增加字段,不影响现有审计逻辑。 + +**替代方案:** + +- 不在审计中增加治理上下文:被接受作为备选,但会增加 mode system 的调试难度。 + +## Risks / Trade-offs + +- **[Risk] 收口 authoritative prompt 来源时出现行为漂移** + → Mitigation:先用等价文本迁移,保留现有回归测试,并增加 root/session/subagent 三入口的一致性测试。 + +- **[Risk] 新治理包络类型与后续 mode envelope 重叠** + → Mitigation:本轮命名和字段设计直接按后续可扩展方向做,避免做一次性中间态 DTO。 + +- **[Risk] application 层装配器过重** + → Mitigation:装配器只负责治理输入解析与声明生成,不吞并 session-runtime 的执行控制逻辑。 + +- **[Risk] CapabilityRouter 构建逻辑统一后,各入口的差异化需求丢失** + → Mitigation:治理装配器支持入口类型参数化(root/subagent/resume),保留必要的差异化计算,只统一计算框架。 + +- **[Risk] PolicyEngine 管线建立后,后续 mode 系统的审批逻辑可能需要重构管线** + → Mitigation:本轮只建立最小管线(PolicyContext 从包络派生),不引入复杂的审批流编排,后续可增量扩展。 + +- **[Risk] PromptFactsProvider 退化后,prompt 事实的可测试性下降** + → Mitigation:治理装配器本身需要独立的单元测试,PromptFactsProvider 的测试改为验证它正确消费了治理包络。 + +- **[Trade-off] 短期内会同时存在旧 helper 与新装配路径的迁移代码** + → 接受:优先保证行为等价和渐进迁移,待 mode system 接入后再删残留 helper。 + +- **[Trade-off] 预留 mode catalog 接入点可能引入未使用的参数** + → 接受:参数为 Option 类型,不传时无运行时开销;mode 系统实现时直接填充即可。 + +## Migration Plan + +1. 定义统一治理包络类型与 application 装配服务。 +2. 先接 root execution、subagent launch,再接普通 session submit。 +3. 将三条 capability router 构建路径统一迁入治理装配器。 +4. 把 execution limits、ForkMode、SubmitBusyPolicy 的解析迁入治理装配器。 +5. 为 PolicyEngine 建立 PolicyContext 从治理包络派生的管线。 +6. 把 child contract 与协作 guidance 的 authoritative 来源迁到治理装配器。 +7. 将 DelegationMetadata、SpawnCapabilityGrant、AgentCollaborationPolicyContext 的生成迁入治理装配器。 +8. 显式化 PromptFactsProvider 中的治理联动,使其成为治理包络消费者。 +9. 在 AppGovernance/RuntimeCoordinator 中预留 mode catalog 接入点。 +10. 为协作审计事实增加治理包络上下文关联。 +11. 将 `session-runtime` submit 入口收敛为消费治理包络。 +12. 清理旧 helper 和分散命名,保留最小兼容桥接直到 mode system 接入。 + +回滚策略: + +- 若装配收口引发行为回归,可暂时保留旧调用路径,让入口回退到旧 helper;因为本轮不触碰 turn engine,回滚范围局限在治理装配层。 + +## Open Questions + +1. `ResolvedGovernanceSurface` 应下沉到 `core` 还是先放在 `application` / `session-runtime` 边界附近? +2. 普通 session prompt submit 是否也需要显式治理包络,还是只为 root/subagent 先收口? +3. `WorkflowExamplesContributor` 中哪些块属于"few-shot 教学",哪些属于"authoritative governance guidance",边界是否需要更明确的 contributor 切分? +4. PolicyEngine 的管线建立是否需要同步考虑 MCP 级别的 approval 管线(`adapter-mcp::config::approval`),还是先只关注 application/session-runtime 层? +5. 治理包络是否需要区分"编译时"(mode 编译为包络)和"提交时"(包络传入 session-runtime)两个阶段,还是合并为一个阶段? +6. ForkMode 的选择是否应该由治理包络中的策略决定,还是继续由 SpawnAgentParams 的调用方决定? +7. 协作审计事实的治理上下文关联,是用轻量级标识(如 hash)还是结构化摘要? diff --git a/openspec/changes/governance-surface-cleanup/proposal.md b/openspec/changes/governance-surface-cleanup/proposal.md new file mode 100644 index 00000000..faeca5a6 --- /dev/null +++ b/openspec/changes/governance-surface-cleanup/proposal.md @@ -0,0 +1,50 @@ +## Why + +AstrCode 当前与执行治理相关的逻辑分散在多个不同行为层:静态协作 guidance 写在 `adapter-prompt`,child execution contract 写在 `application` 的 agent 模块里,turn-scoped router 与 prompt declarations 又通过 `session-runtime::AgentPromptSubmission` 以临时包络形式传递。同时,策略引擎框架(`PolicyEngine`)已定义但未接入实际执行路径,能力路由器(`CapabilityRouter`)在三条入口路径中各自构建,执行限制参数(`ResolvedExecutionLimitsSnapshot`、`ExecutionControl`、`ForkMode`、`SubmitBusyPolicy`)散落在不同层,委派策略元数据(`DelegationMetadata`、`SpawnCapabilityGrant`)由局部 helper 各自拼装,prompt 事实注入中存在隐式治理联动(vars dict 传递治理参数、隐式 capability name 过滤),启动与运行时治理装配(`AppGovernance`、`RuntimeCoordinator`)缺少 mode catalog 接入点,协作审计事实(`AgentCollaborationFact`)缺少治理上下文关联。 + +这个结构虽然可工作,但职责边界不够清晰,也让即将落地的 governance mode system 缺少一个稳定、统一的治理装配入口。 + +现在先做这轮 cleanup,是为了在不改变 runtime engine 的前提下,把散落的治理输入收口成一条清晰的数据流。否则后续 mode 系统要么继续叠加在现有混乱接线上,要么被迫与当前实现并存,长期会更难维护。 + +## What Changes + +- 新增统一的 governance surface / execution envelope 装配路径,收口 turn-scoped capability router、prompt declarations、child contract 与其他治理输入。 +- 让 root execution、普通 session prompt submit、subagent launch 与 child resume 等入口复用同一套治理装配逻辑,而不是各自拼装 `AgentPromptSubmission`。 +- 把 builtin 协作 guidance 与 child execution contract 的 authoritative 来源从分散硬编码迁移到统一治理装配层,`adapter-prompt` 仅负责渲染 `PromptDeclaration`。 +- 将三条能力路由装配路径(root/subagent/resume)统一到治理装配器,消除各自独立构建 `CapabilityRouter` 的重复逻辑。 +- 把 `ResolvedExecutionLimitsSnapshot`、`ExecutionControl`、`ForkMode`、`SubmitBusyPolicy` 等执行限制与控制输入收敛为治理包络的一部分。 +- 为 `PolicyEngine` 建立与治理包络的接入管线,使策略引擎的三态裁决能基于治理包络做出,同时保持 `AllowAllPolicyEngine` 作为默认实现。 +- 将 `DelegationMetadata`、`SpawnCapabilityGrant`、`AgentCollaborationPolicyContext` 等委派策略元数据收口到治理装配路径。 +- 将 `PromptFactsProvider` 中的隐式治理联动(vars dict 参数传递、capability name 过滤)显式化,使 prompt 事实成为治理包络的消费者而非独立治理装配器。 +- 在 `AppGovernance` 和 `RuntimeCoordinator` 层为后续 mode catalog 接入预留明确入口。 +- 让协作审计事实(`AgentCollaborationFact`)能关联治理包络上下文,确保审计链路可追溯。 +- 整理与治理相关的命名和模块分布,减少 "同一概念在不同 crate 用不同形状表达" 的情况,为后续 governance mode system 提供稳定前置。 +- 保持现有默认用户行为尽量等价;本次不引入新的 mode 能力,也不改写 `run_turn` 主循环。 + +## Capabilities + +### New Capabilities +- `governance-surface-assembly`: 定义统一的治理装配入口,要求所有 turn / delegation 入口先解析治理包络,再进入 `session-runtime`。同时覆盖 bootstrap/runtime 治理生命周期边界的清晰化和 mode catalog 接入预留。 +- `capability-router-assembly`: 统一 root/subagent/resume 三条路径的 capability router 构建,使能力面收敛逻辑经过治理装配器。 +- `execution-limits-control`: 收敛 `ResolvedExecutionLimitsSnapshot`、`ExecutionControl`、`ForkMode`、`SubmitBusyPolicy`、`AgentConfig` 治理参数到治理包络。 +- `policy-engine-integration`: 为 `PolicyEngine` 建立与治理包络的接入管线,确保策略引擎能消费治理包络做出裁决。 +- `delegation-policy-surface`: 收口 `DelegationMetadata`、`SpawnCapabilityGrant`、`AgentCollaborationPolicyContext` 的生成到治理装配路径。 +- `prompt-facts-governance-linkage`: 显式化 `PromptFactsProvider` 中的隐式治理联动,使 prompt 事实成为治理包络的消费者。 + +### Modified Capabilities +- `agent-tool-governance`: 协作 guidance 必须来自 authoritative governance surface,而不是散落在 adapter 层的静态硬编码。 +- `agent-delegation-surface`: child delegation catalog 与 execution contract 必须通过同一治理装配路径生成,同时协作审计事实必须能关联治理包络上下文。 + +## Impact + +- `crates/core` 或 `crates/application` 中会新增统一的治理包络类型与装配接口。 +- `crates/application` 的 root/subagent/session 提交流程会改为复用同一治理装配器。 +- `crates/application/src/execution/subagent.rs` 的 `resolve_child_execution_limits` 逻辑会迁入治理装配器。 +- `crates/application/src/agent/routing.rs` 的 resume 路径 scoped router 构建会迁入治理装配器。 +- `crates/application/src/agent/mod.rs` 的 `build_delegation_metadata` 和 spawn budget enforcement 会改为消费治理包络参数。 +- `crates/session-runtime` 的 `AgentPromptSubmission` / submit API 形状会被收敛,减少 ad-hoc 字段扩散。 +- `crates/core/src/policy/engine.rs` 的 `PolicyContext` 会从治理包络派生,策略引擎会获得治理包络感知能力。 +- `crates/server/src/bootstrap/prompt_facts.rs` 的隐式治理联动会显式化,`PromptFactsProvider` 会退化为治理包络的消费者。 +- `crates/server/src/bootstrap/governance.rs` 和 `crates/application/src/lifecycle/governance.rs` 会增加 mode catalog 接入预留。 +- `crates/adapter-prompt` 的 `WorkflowExamplesContributor` 等治理相关逻辑会被瘦身,authoritative 治理事实转为来自上游 `PromptDeclaration`。 +- 用户可见影响应保持最小:本次主要是收口结构、清理职责,为后续 governance mode system 提供实现前置。 diff --git a/openspec/changes/governance-surface-cleanup/specs/agent-delegation-surface/spec.md b/openspec/changes/governance-surface-cleanup/specs/agent-delegation-surface/spec.md new file mode 100644 index 00000000..bbe494a7 --- /dev/null +++ b/openspec/changes/governance-surface-cleanup/specs/agent-delegation-surface/spec.md @@ -0,0 +1,53 @@ +## ADDED Requirements + +### Requirement: child execution contracts SHALL be emitted from the shared governance assembly path + +fresh child 与 resumed child 的 execution contract MUST 由统一治理装配路径生成,而不是由不同调用路径分别手工拼接。 + +#### Scenario: fresh child contract uses the shared assembly path + +- **WHEN** 系统首次启动一个承担新责任分支的 child +- **THEN** child execution contract SHALL 通过共享治理装配器生成 +- **AND** SHALL 与同一次提交中的其他治理声明保持同一事实源 + +#### Scenario: resumed child contract uses the same authoritative source + +- **WHEN** 父级复用已有 child 并发送 delta instruction +- **THEN** resumed child contract SHALL 由同一治理装配路径生成 +- **AND** SHALL NOT 退回到独立 helper 拼接的平行实现 + +### Requirement: delegation catalog and child contracts SHALL stay consistent under the same governance surface + +delegation catalog 可见的 behavior template、child execution contract 中的责任边界与 capability-aware 限制 MUST 来源于同一治理包络。 + +#### Scenario: catalog and contract agree on branch constraints + +- **WHEN** 某个 child template 在当前提交中可见且被用于启动 child +- **THEN** delegation catalog 与最终 child execution contract SHALL 体现一致的责任边界和限制摘要 +- **AND** SHALL NOT 让 catalog 与 contract 分别读取不同来源的治理事实 + +### Requirement: collaboration facts SHALL be recordable with governance envelope context + +`AgentCollaborationFact`(core/agent/mod.rs:1129-1155)记录 spawn/send/observe/close/delivery 等协作动作的审计事件。这些事实 MUST 能关联到生成该动作时的治理包络上下文,使审计链路可追溯。 + +#### Scenario: collaboration fact includes governance context + +- **WHEN** 系统记录一个 `AgentCollaborationFact`(如 spawn 或 send) +- **THEN** 该事实 SHALL 能关联到当前 turn 的治理包络标识或摘要 +- **AND** SHALL NOT 丢失治理上下文导致无法追溯决策依据 + +#### Scenario: policy revision aligns with governance envelope + +- **WHEN** `AGENT_COLLABORATION_POLICY_REVISION` 用于标记协作策略版本 +- **THEN** 该版本标识 SHALL 与治理包络中的策略版本一致 +- **AND** SHALL NOT 出现审计事实的策略版本与实际治理策略不同步 + +### Requirement: CollaborationFactRecord SHALL derive its parameters from the governance envelope + +`CollaborationFactRecord`(agent/mod.rs:96-166)跟踪每个协作动作的结果、原因码和延迟。其构建参数 MUST 来自治理包络,而不是各调用点独立组装。 + +#### Scenario: fact record uses governance-resolved child identity and limits + +- **WHEN** 系统为一个 spawn 或 send 动作构建 `CollaborationFactRecord` +- **THEN** child identity、capability limits 等字段 SHALL 从治理包络中获取 +- **AND** SHALL NOT 从不同参数源独立读取导致与治理包络不一致 diff --git a/openspec/changes/governance-surface-cleanup/specs/agent-tool-governance/spec.md b/openspec/changes/governance-surface-cleanup/specs/agent-tool-governance/spec.md new file mode 100644 index 00000000..a9883aff --- /dev/null +++ b/openspec/changes/governance-surface-cleanup/specs/agent-tool-governance/spec.md @@ -0,0 +1,17 @@ +## ADDED Requirements + +### Requirement: authoritative collaboration guidance SHALL be assembled outside adapter-owned static prompt code + +协作 guidance 的 authoritative 来源 MUST 来自统一治理装配路径,而不是继续散落在 adapter 层的静态 builtin prompt 代码中。 + +#### Scenario: adapter renders but does not own collaboration truth + +- **WHEN** 模型 prompt 中出现协作 guidance +- **THEN** `adapter-prompt` SHALL 只负责渲染该 guidance 对应的 `PromptDeclaration` +- **AND** SHALL NOT 继续把协作治理真相直接硬编码在 contributor 内作为唯一事实源 + +#### Scenario: multiple entrypoints receive consistent collaboration guidance + +- **WHEN** root execution、普通 session submit 与 child execution 都需要协作 guidance +- **THEN** 它们 SHALL 从同一治理装配路径获得一致的协作声明 +- **AND** SHALL NOT 因入口不同而依赖不同的硬编码文本来源 diff --git a/openspec/changes/governance-surface-cleanup/specs/capability-router-assembly/spec.md b/openspec/changes/governance-surface-cleanup/specs/capability-router-assembly/spec.md new file mode 100644 index 00000000..ebe49988 --- /dev/null +++ b/openspec/changes/governance-surface-cleanup/specs/capability-router-assembly/spec.md @@ -0,0 +1,39 @@ +## ADDED Requirements + +### Requirement: capability router assembly SHALL follow a unified path across all turn entrypoints + +root execution、subagent launch 与 resumed child 三条路径构建 capability router 的逻辑 MUST 统一经过治理装配器,而不是各自独立从不同来源计算能力面。 + +#### Scenario: root execution resolves capability surface through the governance assembler + +- **WHEN** 系统发起一次 root agent execution +- **THEN** root 路径 SHALL 通过统一治理装配器解析当前 turn 的 capability router +- **AND** SHALL NOT 直接在 `execution/root.rs` 中从 `kernel.gateway().capabilities().tool_names()` 独立计算工具面 + +#### Scenario: subagent launch resolves child-scoped router through the same assembler + +- **WHEN** 系统启动一个 fresh child session +- **THEN** subagent 路径 SHALL 通过统一治理装配器生成 child-scoped capability router +- **AND** SHALL NOT 独立在 `execution/subagent.rs:141-172` 中做 `parent_allowed_tools ∩ SpawnCapabilityGrant.allowed_tools` 交集计算 + +#### Scenario: resumed child resolves scoped router through the shared path + +- **WHEN** 父级通过 `send` 恢复一个 idle child +- **THEN** resume 路径 SHALL 通过统一治理装配器生成与 fresh child 一致的 scoped router +- **AND** SHALL NOT 在 `agent/routing.rs:571-722` 中独立构建 capability 子集 + +### Requirement: capability subset computation SHALL be parameterized by governance envelope, not hardcoded per call site + +能力子集的计算参数(parent_allowed_tools、SpawnCapabilityGrant、可见能力面)MUST 从治理包络中统一解析,而不是作为独立参数散落在各调用点。 + +#### Scenario: child capability grant comes from the governance envelope + +- **WHEN** 治理装配器为一个 child turn 生成 capability router +- **THEN** child 的 `SpawnCapabilityGrant` 与 parent 的 allowed_tools SHALL 从治理包络中统一读取 +- **AND** SHALL NOT 分别由 `subagent.rs` 和 `routing.rs` 各自从不同参数源构造 + +#### Scenario: kernel gateway capabilities feed into the governance assembler + +- **WHEN** 治理装配器需要当前全局能力面作为输入 +- **THEN** 它 SHALL 从 `kernel.gateway().capabilities()` 获取权威来源 +- **AND** root/child/resume 路径 SHALL NOT 各自直接调用 kernel gateway 获取能力列表 diff --git a/openspec/changes/governance-surface-cleanup/specs/delegation-policy-surface/spec.md b/openspec/changes/governance-surface-cleanup/specs/delegation-policy-surface/spec.md new file mode 100644 index 00000000..e3cdfd77 --- /dev/null +++ b/openspec/changes/governance-surface-cleanup/specs/delegation-policy-surface/spec.md @@ -0,0 +1,57 @@ +## ADDED Requirements + +### Requirement: delegation metadata SHALL be generated from the unified governance assembly path + +`DelegationMetadata`(responsibility_summary、reuse_scope_summary、restricted、capability_limit_summary)MUST 由统一治理装配器生成,而不是在 `agent/mod.rs:287-312` 的 `build_delegation_metadata` helper 中独立拼装。 + +#### Scenario: delegation metadata comes from the governance assembler + +- **WHEN** 系统启动或恢复一个 child session +- **THEN** `DelegationMetadata` SHALL 由治理装配器根据治理包络中的 child policy 统一生成 +- **AND** SHALL NOT 由 `build_delegation_metadata` helper 从局部参数独立构建 + +#### Scenario: delegation metadata is consistent across fresh and resumed child + +- **WHEN** fresh child 和 resumed child 各自生成 delegation metadata +- **THEN** 两者的 metadata 字段含义和来源 SHALL 一致 +- **AND** SHALL NOT 因 fresh/resumed 路径不同而使用不同的 metadata 生成逻辑 + +### Requirement: SpawnCapabilityGrant SHALL be resolved from the governance envelope, not passed as ad-hoc spawn parameters + +`SpawnCapabilityGrant` 当前作为 `SpawnAgentParams` 的字段由调用方直接构造。它 MUST 从治理包络中解析,使 child 的能力授权受统一治理决策约束。 + +#### Scenario: capability grant comes from governance-resolved child policy + +- **WHEN** 系统确定一个 child 允许使用的工具集合 +- **THEN** `SpawnCapabilityGrant.allowed_tools` SHALL 由治理装配器根据 child policy 与 parent capability surface 计算得出 +- **AND** SHALL NOT 由 spawn 调用方从模型参数中直接构造 + +### Requirement: AgentCollaborationPolicyContext SHALL be built from the governance envelope + +`AgentCollaborationPolicyContext`(policy_revision + max_subrun_depth + max_spawn_per_turn)MUST 从治理包络中获取参数,而不是在 `agent/mod.rs:741-749` 中独立从 runtime config 读取。 + +#### Scenario: policy context uses governance-resolved parameters + +- **WHEN** 系统构建 `AgentCollaborationPolicyContext` 用于协作事实记录 +- **THEN** `max_subrun_depth` 和 `max_spawn_per_turn` SHALL 来自治理包络 +- **AND** SHALL NOT 从 `ResolvedAgentConfig` 独立读取 + +### Requirement: spawn budget enforcement SHALL consume governance-resolved limits + +`enforce_spawn_budget_for_turn`(agent/mod.rs:992-1012)当前直接从 runtime config 读取 `max_spawn_per_turn`。它 MUST 使用治理包络中已解析的限制参数。 + +#### Scenario: spawn budget check uses envelope parameters + +- **WHEN** 系统 spawn 一个新 child 前检查 turn 内 spawn 预算 +- **THEN** 预算上限 SHALL 来自治理包络 +- **AND** SHALL NOT 从 runtime config 独立读取 `max_spawn_per_turn` + +### Requirement: delegation metadata persistence SHALL stay consistent with governance envelope + +`persist_delegation_for_handle`(agent/mod.rs:394-436)将 delegation metadata 持久化到 kernel 控制面。持久化的数据 MUST 与治理包络中的 delegation 信息保持一致。 + +#### Scenario: persisted delegation matches envelope + +- **WHEN** 系统持久化 child 的 delegation metadata +- **THEN** 持久化的数据 SHALL 与治理包络中生成的 delegation 信息一致 +- **AND** SHALL NOT 出现持久化数据与治理包络不同步的情况 diff --git a/openspec/changes/governance-surface-cleanup/specs/execution-limits-control/spec.md b/openspec/changes/governance-surface-cleanup/specs/execution-limits-control/spec.md new file mode 100644 index 00000000..9447bff9 --- /dev/null +++ b/openspec/changes/governance-surface-cleanup/specs/execution-limits-control/spec.md @@ -0,0 +1,59 @@ +## ADDED Requirements + +### Requirement: execution limits SHALL be resolved as part of the unified governance envelope + +`ResolvedExecutionLimitsSnapshot`、`ExecutionControl`、`ForkMode` 与 `SubmitBusyPolicy` 等执行限制与控制输入 MUST 在治理装配阶段统一解析为治理包络的一部分,而不是在提交路径中各自独立计算。 + +#### Scenario: root execution limits come from the governance assembler + +- **WHEN** 系统发起一次 root agent execution +- **THEN** `ResolvedExecutionLimitsSnapshot`(allowed_tools + max_steps)SHALL 由治理装配器生成 +- **AND** SHALL NOT 在 `execution/root.rs:71-85` 中独立从 kernel gateway 和 `ExecutionControl.max_steps` 计算 + +#### Scenario: child execution limits come from the governance assembler + +- **WHEN** 系统启动或恢复一个 child session +- **THEN** child 的 `ResolvedExecutionLimitsSnapshot` SHALL 由治理装配器根据 child policy 与 parent limits 统一计算 +- **AND** SHALL NOT 在 `execution/subagent.rs:141-172` 中独立做 allowed_tools 交集运算 + +#### Scenario: ExecutionControl feeds into the governance assembler, not directly into submission + +- **WHEN** 用户通过 `submit_prompt_with_control` 提交一个带 `ExecutionControl` 的请求 +- **THEN** `ExecutionControl` 的 max_steps 与 manual_compact SHALL 作为治理装配器的输入参数 +- **AND** SHALL NOT 直接在 `session_use_cases.rs:125-134` 中覆写 runtime config + +### Requirement: AgentConfig governance parameters SHALL flow through the governance assembly path + +`max_subrun_depth`、`max_spawn_per_turn`、`max_concurrent_agents` 等 `AgentConfig` 治理参数 MUST 通过治理装配路径统一传递到消费方,而不是通过 runtime config 在各消费点分散读取。 + +#### Scenario: spawn budget enforcement uses governance-resolved parameters + +- **WHEN** `enforce_spawn_budget_for_turn` 检查当前 turn 的 spawn 预算 +- **THEN** 它 SHALL 使用治理包络中已解析的 spawn 限制参数 +- **AND** SHALL NOT 直接从 `ResolvedAgentConfig` 中分散读取 `max_spawn_per_turn` + +#### Scenario: collaboration policy context is built from the governance envelope + +- **WHEN** `AgentCollaborationPolicyContext` 需要构建用于协作事实记录 +- **THEN** `max_subrun_depth` 和 `max_spawn_per_turn` SHALL 来自治理包络 +- **AND** SHALL NOT 在 `agent/mod.rs:741-749` 中独立从 runtime config 读取 + +### Requirement: ForkMode and context inheritance SHALL be governed by the unified assembly path + +`ForkMode`(FullHistory/LastNTurns)决定的上下文继承策略 MUST 作为治理包络的一部分,而不是在 `subagent.rs:247-297` 中作为独立逻辑处理。 + +#### Scenario: child context inheritance strategy comes from the governance envelope + +- **WHEN** 系统为 child 选择继承的父级上下文范围 +- **THEN** ForkMode 的选择与 recent tail 裁剪逻辑 SHALL 由治理装配器驱动 +- **AND** SHALL NOT 在 `select_inherited_recent_tail` 中独立实现 + +### Requirement: SubmitBusyPolicy SHALL be derivable from the governance envelope + +`SubmitBusyPolicy`(BranchOnBusy/RejectOnBusy)当前硬编码在 session-runtime,但其语义是 turn 级并发治理策略,MUST 可以被治理包络覆盖。 + +#### Scenario: default busy policy is derived from governance configuration + +- **WHEN** 系统 submit 一个 prompt 且已有 turn 在执行 +- **THEN** busy policy SHALL 可从治理包络中读取,而不是固定为 `BranchOnBusy` +- **AND** 不同入口类型(root vs subagent vs resumed)SHALL 可以有不同的默认 busy policy diff --git a/openspec/changes/governance-surface-cleanup/specs/governance-surface-assembly/spec.md b/openspec/changes/governance-surface-cleanup/specs/governance-surface-assembly/spec.md new file mode 100644 index 00000000..2f55d502 --- /dev/null +++ b/openspec/changes/governance-surface-cleanup/specs/governance-surface-assembly/spec.md @@ -0,0 +1,90 @@ +## ADDED Requirements + +### Requirement: all turn entrypoints SHALL resolve a shared governance surface before session-runtime submission + +系统 MUST 在 root execution、普通 session prompt 提交、fresh child launch 与 resumed child submit 等所有 turn 入口上,先解析一个统一的治理包络,再把它交给 `session-runtime`。 + +#### Scenario: root execution uses the shared governance assembly path + +- **WHEN** 系统发起一次 root agent execution +- **THEN** 它 SHALL 先解析统一治理包络 +- **AND** SHALL NOT 直接在调用点手工拼接 scoped router、prompt declarations 与其他治理输入 + +#### Scenario: subagent launch uses the same governance surface shape + +- **WHEN** 系统启动一个 fresh 或 resumed child session +- **THEN** 它 SHALL 通过相同的治理装配入口生成治理包络 +- **AND** 输出形状 SHALL 与其他 turn 入口一致 + +### Requirement: governance surface SHALL be the authoritative source for turn-scoped governance inputs + +治理包络 MUST 成为 turn-scoped capability router、prompt declarations、resolved limits、context inheritance 与 child contract 等治理输入的 authoritative 来源。 + +#### Scenario: session-runtime consumes a resolved governance envelope + +- **WHEN** `session-runtime` 接收一次 turn 提交 +- **THEN** 它 SHALL 读取已解析的治理包络作为治理输入 +- **AND** SHALL NOT 在底层重新推导业务级治理决策 + +#### Scenario: prompt declarations come from the governance surface + +- **WHEN** 当前 turn 需要内置协作 guidance、child contract 或其他治理声明 +- **THEN** 这些声明 SHALL 来源于治理包络 +- **AND** SHALL 通过统一的 `PromptDeclaration` 链路进入 prompt 组装 + +### Requirement: governance surface cleanup SHALL preserve current default behavior while removing duplicated assembly paths + +本轮治理收口重构 MUST 以行为等价为默认目标;在没有显式新治理配置的前提下,root/session/subagent 入口的默认执行行为 SHALL 与当前保持等价。 + +#### Scenario: default execute path remains behaviorally equivalent + +- **WHEN** 系统在未启用额外治理配置的情况下提交普通执行任务 +- **THEN** 模型可见工具、默认协作 guidance 与 child contract 语义 SHALL 与当前默认行为保持等价 + +#### Scenario: duplicate assembly logic is removed without changing runtime engine + +- **WHEN** 完成本轮 cleanup +- **THEN** turn 相关治理输入 SHALL 由统一装配路径生成 +- **AND** `run_turn`、tool cycle、streaming path 与 compaction engine SHALL 保持单一实现 + +### Requirement: bootstrap governance assembly SHALL provide a clear entrypoint for mode system integration + +`build_app_governance`(server/bootstrap/governance.rs:43-80)和 `GovernanceBuildInput` 是服务器级治理组合根。它们 MUST 为后续 mode system 提供明确的接入点,使 mode catalog 能在 bootstrap/reload 阶段被装配。 + +#### Scenario: GovernanceBuildInput exposes mode-catalog-ready assembly hooks + +- **WHEN** 后续 governance mode system 需要在 bootstrap 阶段注册 mode catalog +- **THEN** `GovernanceBuildInput` SHALL 已具备接入 mode catalog 的参数或接口 +- **AND** SHALL NOT 要求修改 bootstrap 编排流程的核心结构 + +#### Scenario: AppGovernance reload path supports mode catalog swap + +- **WHEN** 运行时 reload 触发能力面和配置的原子替换 +- **THEN** reload 编排 SHALL 能同时替换 mode catalog(如果存在) +- **AND** SHALL NOT 因缺少接入点而要求在 mode system 实现时重新编排 reload 流程 + +### Requirement: runtime governance lifecycle SHALL keep clear boundaries between governance assembly and runtime execution + +`AppGovernance`(application/lifecycle/governance.rs)负责 reload/shutdown 生命周期管理。治理装配与运行时执行的边界 MUST 保持清晰,治理装配层不吞并 runtime engine 的执行控制逻辑。 + +#### Scenario: reload governance check remains in application layer + +- **WHEN** `AppGovernance.reload()` 检查是否有 running session +- **THEN** 该检查 SHALL 继续在 application 层完成 +- **AND** SHALL NOT 下沉到 session-runtime 或 kernel 层 + +#### Scenario: capability surface replacement uses the governance assembly path + +- **WHEN** `RuntimeCoordinator.replace_runtime_surface` 执行原子化能力面替换 +- **THEN** 新能力面 SHALL 通过治理装配路径传递到后续 turn 提交 +- **AND** SHALL NOT 出现替换后的能力面与正在执行的 turn 使用的能力面不一致的竞态 + +### Requirement: CapabilitySurfaceSync and runtime coordinator SHALL be governance-surface-aware + +`CapabilitySurfaceSync`(server/bootstrap/capabilities.rs:108-156)管理 stable local + dynamic external 能力面的同步。`RuntimeCoordinator`(core/runtime/coordinator.rs)负责原子化运行时表面替换。两者 MUST 在治理面变更后能通知治理装配器刷新缓存。 + +#### Scenario: capability surface change triggers governance envelope refresh + +- **WHEN** MCP 连接变更或插件 reload 导致能力面发生改变 +- **THEN** 后续 turn 的治理包络 SHALL 使用更新后的能力面 +- **AND** SHALL NOT 使用 stale 的缓存能力面继续生成治理包络 diff --git a/openspec/changes/governance-surface-cleanup/specs/policy-engine-integration/spec.md b/openspec/changes/governance-surface-cleanup/specs/policy-engine-integration/spec.md new file mode 100644 index 00000000..ab2a4406 --- /dev/null +++ b/openspec/changes/governance-surface-cleanup/specs/policy-engine-integration/spec.md @@ -0,0 +1,59 @@ +## ADDED Requirements + +### Requirement: PolicyEngine SHALL consume the resolved governance envelope as its input context + +`PolicyEngine` 的三个检查点(`check_model_request`、`check_capability_call`、`decide_context_strategy`)MUST 基于已解析的治理包络做出裁决,而不是在执行路径中保持脱钩状态。 + +#### Scenario: capability call check uses governance-resolved limits + +- **WHEN** turn 执行链路中发生一次能力调用 +- **THEN** `check_capability_call` SHALL 能读取当前 turn 治理包络中的 capability surface 和 execution limits +- **AND** SHALL NOT 仅依赖 `PolicyContext` 中与治理包络重复或矛盾的元数据 + +#### Scenario: model request check is informed by governance envelope + +- **WHEN** turn 准备向 LLM 发送请求 +- **THEN** `check_model_request` SHALL 能参考治理包络中的 action policy 和 prompt declarations +- **AND** SHALL NOT 在缺少治理上下文的情况下做放行裁决 + +#### Scenario: context strategy decision aligns with governance envelope + +- **WHEN** context pressure 触发上下文策略裁决(compact/summarize/truncate/ignore) +- **THEN** `decide_context_strategy` SHALL 遵守治理包络中可能存在的上下文治理偏好 +- **AND** SHALL NOT 始终使用硬编码的默认策略而不考虑治理输入 + +### Requirement: PolicyContext SHALL be populated from the governance envelope, not independently assembled + +`PolicyContext` 当前独立组装 session_id/turn_id/step_index/working_dir/profile 等字段。这些字段 MUST 从治理包络中获取,确保策略引擎的输入与 turn 执行链路使用同一事实源。 + +#### Scenario: PolicyContext fields align with governance envelope + +- **WHEN** 策略引擎需要 `PolicyContext` 做裁决 +- **THEN** `PolicyContext` SHALL 从治理包络中派生,而不是在调用点重新组装 +- **AND** SHALL NOT 出现 PolicyContext 的 profile 与治理包络的 profile 来源不一致的情况 + +### Requirement: approval flow types SHALL be connected to the governance assembly path + +`ApprovalRequest`、`ApprovalResolution`、`ApprovalPending` 等审批流类型当前仅在 `core/policy/engine.rs` 中定义,没有真实消费者。治理装配路径 SHOULD 为审批流提供明确的接入点,使策略引擎的三态裁决(Allow/Deny/Ask)能在 turn 执行链路中生效。 + +#### Scenario: Ask verdict triggers approval through the governance path + +- **WHEN** 策略引擎对一次能力调用返回 `PolicyVerdict::Ask` +- **THEN** 系统 SHALL 能通过治理装配路径构建 `ApprovalRequest` 并发起审批流 +- **AND** SHALL NOT 因缺少接入点而始终回退到 `AllowAllPolicyEngine` + +### Requirement: the governance cleanup SHALL preserve AllowAllPolicyEngine as the default while establishing the integration plumbing + +本轮 cleanup 不要求实现完整的审批拦截逻辑,但 MUST 确保策略引擎与治理包络之间的接线存在,使得后续 mode system 能通过替换 PolicyEngine 实现来改变治理行为。 + +#### Scenario: AllowAllPolicyEngine remains the default after cleanup + +- **WHEN** 系统在未配置自定义策略引擎的情况下运行 +- **THEN** 默认行为 SHALL 继续使用 `AllowAllPolicyEngine` 放行所有请求 +- **AND** 治理包络到策略引擎的接线 SHALL 已存在但默认不改变裁决结果 + +#### Scenario: the integration plumbing allows future PolicyEngine swap without touching turn loop + +- **WHEN** 后续 governance mode system 需要实现模式感知的策略裁决 +- **THEN** 系统 SHALL 只需替换 PolicyEngine 实现或调整治理包络参数 +- **AND** SHALL NOT 需要修改 `run_turn`、tool cycle 或 streaming path diff --git a/openspec/changes/governance-surface-cleanup/specs/prompt-facts-governance-linkage/spec.md b/openspec/changes/governance-surface-cleanup/specs/prompt-facts-governance-linkage/spec.md new file mode 100644 index 00000000..2d551f45 --- /dev/null +++ b/openspec/changes/governance-surface-cleanup/specs/prompt-facts-governance-linkage/spec.md @@ -0,0 +1,59 @@ +## ADDED Requirements + +### Requirement: prompt declaration visibility filtering SHALL be driven by the governance envelope, not implicit capability name matching + +`prompt_declaration_is_visible`(server/bootstrap/prompt_facts.rs:200-213)当前通过 `allowed_capability_names` 过滤 prompt declaration 的可见性。这个联动 MUST 变为显式的治理包络驱动,而不是通过隐式的字符串集合匹配。 + +#### Scenario: declaration visibility uses governance-resolved capability surface + +- **WHEN** `PromptFactsProvider` 需要决定哪些 prompt declaration 对当前 turn 可见 +- **THEN** 过滤逻辑 SHALL 使用治理包络中已解析的 capability surface +- **AND** SHALL NOT 独立从 `ResolvedExecutionLimitsSnapshot.allowed_tools` 重建过滤集合 + +#### Scenario: visibility filtering is consistent across prompt facts and turn execution + +- **WHEN** turn 执行链路使用治理包络中的 capability router 决定工具可见性 +- **THEN** prompt facts 的 declaration 过滤 SHALL 使用同一能力面事实源 +- **AND** SHALL NOT 出现工具可见但 declaration 被过滤(或反之)的不一致 + +### Requirement: PromptFacts metadata governance parameters SHALL come from the governance envelope + +`PromptFacts.metadata` 当前通过 vars dict 注入 `agentMaxSubrunDepth` 和 `agentMaxSpawnPerTurn` 等治理参数。这些参数 MUST 从治理包络中显式获取,而不是通过松散的 string-keyed dict 传递。 + +#### Scenario: agent limits in prompt facts come from the envelope + +- **WHEN** `resolve_prompt_facts` 构建 `PromptFacts.metadata` +- **THEN** `agentMaxSubrunDepth` 和 `agentMaxSpawnPerTurn` SHALL 从治理包络中读取 +- **AND** SHALL NOT 从 `ResolvedAgentConfig` 独立读取并通过 vars dict 注入 + +#### Scenario: metadata keys are strongly typed through the governance path + +- **WHEN** 治理参数通过治理包络传递到 prompt facts +- **THEN** 参数传递 SHALL 使用结构化类型,而不是 string-keyed hashmap +- **AND** SHALL 减少因 key 名拼写错误或类型不匹配导致的隐式失败 + +### Requirement: profile context governance fields SHALL come from the governance envelope + +`build_profile_context`(prompt_facts.rs:107-136)当前注入 `approvalMode`、`sessionId`、`turnId` 等治理上下文字段。这些字段 MUST 与治理包络中的信息保持一致。 + +#### Scenario: approval mode in profile context aligns with envelope + +- **WHEN** `build_profile_context` 注入 `approvalMode` +- **THEN** approvalMode 的值 SHALL 与治理包络中的策略引擎配置一致 +- **AND** SHALL NOT 出现 profile context 中的 approvalMode 与实际策略引擎行为不一致的情况 + +#### Scenario: session and turn identifiers in profile context come from the governance path + +- **WHEN** profile context 包含 sessionId 和 turnId +- **THEN** 这些标识符 SHALL 与治理包络中记录的标识符一致 +- **AND** SHALL NOT 从独立的参数源重新获取 + +### Requirement: PromptFactsProvider SHALL be a consumer of the governance surface, not an independent governance assembler + +`PromptFactsProvider` 当前同时承担"收集 prompt 事实"和"做隐式治理过滤"两个职责。cleanup 后,它 MUST 只负责收集和渲染事实,治理过滤逻辑 MUST 上移到治理装配层。 + +#### Scenario: PromptFactsProvider delegates governance filtering to the assembler + +- **WHEN** `resolve_prompt_facts` 执行 +- **THEN** 它 SHALL 接收治理装配器已过滤的 prompt declarations 和 capability surface +- **AND** SHALL NOT 自行实现 `prompt_declaration_is_visible` 过滤逻辑 diff --git a/openspec/changes/governance-surface-cleanup/tasks.md b/openspec/changes/governance-surface-cleanup/tasks.md new file mode 100644 index 00000000..93a4294d --- /dev/null +++ b/openspec/changes/governance-surface-cleanup/tasks.md @@ -0,0 +1,77 @@ +## 1. 统一治理包络模型 + +- [ ] 1.1 在 `crates/core` 或 `crates/application` 中引入统一治理包络类型(如 `ResolvedGovernanceSurface`),覆盖 scoped router、prompt declarations、resolved limits、overrides、injected messages、policy context 与 collaboration audit context +- [ ] 1.2 梳理 `crates/session-runtime/src/turn/submit.rs` 中 `AgentPromptSubmission` 的职责,决定是替换还是瘦身为治理包络的 transport 形状 +- [ ] 1.3 为统一治理包络补充字段校验与基础单元测试,确保不同入口可复用同一输出形状 + +## 2. 收口入口装配路径 + +- [ ] 2.1 在 `crates/application` 新增治理装配服务,让 `execution/root.rs`、`execution/subagent.rs` 与 `session_use_cases.rs` 统一调用 +- [ ] 2.2 清理 root / session / subagent 三条路径里手工拼接 scoped router、prompt declarations、limits 的重复逻辑 +- [ ] 2.3 为 root、普通 session submit、fresh child、resumed child 四类入口补充一致性测试 + +## 3. Capability Router 统一装配 + +- [ ] 3.1 将 `execution/root.rs:71-85` 的 root capability router 构建迁入治理装配器 +- [ ] 3.2 将 `execution/subagent.rs:141-172` 的 child capability router 构建(parent_allowed_tools ∩ SpawnCapabilityGrant 交集)迁入治理装配器 +- [ ] 3.3 将 `agent/routing.rs:571-722` 的 resumed child scoped router 构建迁入治理装配器 +- [ ] 3.4 确保三条路径统一后各自保留必要的入口类型差异化参数,补充回归测试 + +## 4. 执行限制与控制收口 + +- [ ] 4.1 将 `ResolvedExecutionLimitsSnapshot` 的构建逻辑从各入口迁入治理装配器 +- [ ] 4.2 将 `ExecutionControl`(max_steps、manual_compact)作为治理装配器的输入参数,不再在 session_use_cases.rs 中直接覆写 runtime config +- [ ] 4.3 将 `ForkMode` 和上下文继承策略(`select_inherited_recent_tail`)作为治理包络的一部分 +- [ ] 4.4 评估 `SubmitBusyPolicy` 是否需要成为治理包络的可配置字段,还是保持固定策略 +- [ ] 4.5 将 `AgentConfig` 中 max_subrun_depth、max_spawn_per_turn 等治理参数改为治理装配器的输入源,不再被消费方直接读取 + +## 5. 策略引擎接入管线 + +- [ ] 5.1 将 `PolicyContext` 的构建改为从治理包络派生,消除与治理包络字段的重复组装 +- [ ] 5.2 确保 `PolicyEngine` 的三个检查点能读取治理包络中的 capability surface 和 execution limits +- [ ] 5.3 建立 `ApprovalRequest` / `ApprovalResolution` / `ApprovalPending` 的管线骨架,但默认不触发 +- [ ] 5.4 保持 `AllowAllPolicyEngine` 作为默认实现,补充管线存在但默认放行的测试 + +## 6. 委派策略元数据收口 + +- [ ] 6.1 将 `build_delegation_metadata`(agent/mod.rs:287-312)迁入治理装配器 +- [ ] 6.2 将 `SpawnCapabilityGrant` 的解析从 spawn 参数迁入治理包络 +- [ ] 6.3 将 `AgentCollaborationPolicyContext` 的构建改为从治理包络获取参数 +- [ ] 6.4 将 `enforce_spawn_budget_for_turn` 改为使用治理包络中的限制参数 +- [ ] 6.5 确保 `persist_delegation_for_handle` 持久化的数据与治理包络一致 + +## 7. Prompt 与 Delegation 真相收口 + +- [ ] 7.1 将 `crates/application/src/agent/mod.rs` 中 fresh/resumed child contract 生成逻辑迁入统一治理装配链路 +- [ ] 7.2 收口 `crates/adapter-prompt/src/contributors/workflow_examples.rs` 中 authoritative 协作 guidance,使 adapter 仅渲染上游声明 +- [ ] 7.3 确保 delegation catalog、child contract 与协作 guidance 使用同一治理事实源,并补充回归测试 + +## 8. Prompt 事实治理联动显式化 + +- [ ] 8.1 将 `prompt_declaration_is_visible`(prompt_facts.rs:200-213)的过滤逻辑上移到治理装配层 +- [ ] 8.2 将 `PromptFacts.metadata` 中 `agentMaxSubrunDepth` / `agentMaxSpawnPerTurn` 改为从治理包络获取,消除 vars dict 传递 +- [ ] 8.3 确保 `build_profile_context` 中的 approvalMode 与治理包络中的策略配置一致 +- [ ] 8.4 重构 `PromptFactsProvider` 为治理包络的消费者,不再独立实现治理过滤 + +## 9. Bootstrap/Runtime 治理生命周期 + +- [ ] 9.1 在 `GovernanceBuildInput`(server/bootstrap/governance.rs)中预留 mode catalog 参数(Option 类型) +- [ ] 9.2 在 `AppGovernance.reload()` 编排中预留 mode catalog 替换步骤(本轮为空操作) +- [ ] 9.3 确保 `RuntimeCoordinator.replace_runtime_surface` 后续 turn 使用更新后的治理包络 +- [ ] 9.4 确保 `CapabilitySurfaceSync` 能力面变更后通知治理装配器刷新缓存 + +## 10. 协作审计事实关联 + +- [ ] 10.1 为 `AgentCollaborationFact` 增加可选的治理包络标识字段(governance_revision 或 envelope_hash) +- [ ] 10.2 将 `CollaborationFactRecord` 的构建参数改为从治理包络获取 +- [ ] 10.3 确保 `AGENT_COLLABORATION_POLICY_REVISION` 与治理包络中的策略版本一致 + +## 11. 清理与验证 + +- [ ] 11.1 清理旧 helper、重复命名与临时桥接代码,保持模块职责与文件结构清晰一致 +- [ ] 11.2 运行 `cargo fmt --all` +- [ ] 11.3 运行 `cargo test --workspace --exclude astrcode` +- [ ] 11.4 运行 `node scripts/check-crate-boundaries.mjs` +- [ ] 11.5 手动验证 root/session/subagent 提交路径的默认行为等价,且治理声明来源已统一 +- [ ] 11.6 验证 PolicyEngine 管线存在但默认行为与当前等价 +- [ ] 11.7 验证 PromptFactsProvider 退化后 prompt 输出与当前等价 diff --git a/openspec/config.yaml b/openspec/config.yaml index a6440c41..5435487c 100644 --- a/openspec/config.yaml +++ b/openspec/config.yaml @@ -1,58 +1,97 @@ -schema: spec-driven # 默认 schema,推荐使用这个;也可以改成 minimalist 或自定义 schema +schema: spec-driven context: | - 必要: - - 用中文写所有文档 - - 技术栈: - - 后端: Rust, Axum, SSE - - 桌面端: Tauri - - 前端: TypeScript, React - - 数据与持久化: JSONL 事件日志 / 会话存储 - - 其他: 异步架构、流式响应、插件/工具系统 - - 架构约定: - - 采用清晰分层与边界明确的架构设计 - - Server is the truth:服务端作为业务事实唯一来源 - - 前后端分离,前端仅消费 HTTP / SSE API,不承载核心业务逻辑 - - DTO / 协议层保持纯数据结构,不混入运行时业务逻辑 - - PROJECT_ARCHITECTURE.md 是架构约定的主要文档,必须保持更新并作为所有设计决策的参考,这很重要你必须要深度理解并遵守其中的约定 - - 项目原则: - - 优先干净架构与长期可维护性,不为向后兼容牺牲设计质量 - - 重要改动必须说明边界变化、依赖变化与职责重分配 - - 性能敏感路径应避免不必要抽象和重复数据转换 - - 安全或高风险能力应有明确权限边界与默认保护 - - 所有设计应优先帮助开发者理解系统,而不是只追求形式上的分层 - - 约束: - - 会话持久化优先基于事件日志,而不是隐式内存状态 - - 子 agent / 子会话设计必须明确所有权、通信方式与恢复语义 - - 工具系统设计必须避免让 LLM 直接接触过多内部实现细节 + # 语言与文档规范 + - 所有文档、分析、设计说明、任务拆解、验收标准必须使用中文。 + - 术语应保持前后一致,优先使用项目既有命名;若引入新术语,必须先定义其含义与边界。 + - 文档应强调可执行性,避免空泛表述,优先给出明确约束、边界、输入输出与验收方式。 + + # 项目事实 + - 技术栈: + - 后端:Rust、Axum、SSE + - 桌面端:Tauri + - 前端:TypeScript、React + - 数据与持久化:JSONL 事件日志、会话存储 + - 系统特征:异步架构、流式响应、插件 / 工具系统 + + # 架构权威来源 + - PROJECT_ARCHITECTURE.md 是架构约定的最高参考文档,必须持续保持更新。 + - 任何 proposal、specs、design、tasks 都必须与 PROJECT_ARCHITECTURE.md 保持一致。 + - 若本次变更与 PROJECT_ARCHITECTURE.md 存在冲突,必须显式指出冲突点,并说明: + - 为什么冲突 + - 是否需要先更新 PROJECT_ARCHITECTURE.md + - 本次方案如何过渡、迁移或回滚 + + # 核心架构原则 + - 采用清晰分层、边界明确、依赖方向单向的架构设计。 + - Server is the truth:服务端是业务事实唯一来源。 + - 前后端分离:前端只消费 HTTP / SSE API,不承载核心业务逻辑。 + - DTO / 协议层必须保持纯数据结构,不承载运行时业务逻辑。 + - 存储、运行时、协议、界面适配层的职责必须清晰,避免边界漂移。 + - 所有设计优先提升系统可理解性与长期可维护性,而不是追求表面抽象层次。 + + # 设计与实现原则 + - 优先复用现有模式与稳定边界,避免无必要地引入新抽象,但是鼓励在必要时进行合理抽象以提升整体设计质量。 + - 不为向后兼容牺牲整体设计质量;若保留兼容层,必须明确其生命周期与退出路径。 + - 重要改动必须说明: + - 边界变化 + - 依赖变化 + - 职责重分配 + - 迁移方案 + - 回滚方案 + - 性能敏感路径应避免不必要抽象、重复序列化、重复数据转换与冗余状态同步。 + - 安全或高风险能力必须具备明确权限边界、默认保护与失败时的保守行为。 + - 任何跨模块设计都应说明数据流、控制流与错误传播路径。 + + # 硬性约束 + - 会话持久化优先基于事件日志,而不是隐式内存状态。 + - 任何会话 / 子会话 / 子 agent 设计,必须明确: + - 所有权 + - 生命周期 + - 通信方式 + - 恢复语义 + - 失败语义 + - 工具系统设计必须避免让 LLM 直接暴露于过多内部实现细节。 + - 流式场景必须考虑中断、取消、部分输出、重试与一致性问题。 + - 若涉及事件模型,必须说明事件定义、追加时机、回放行为、恢复策略与投影视图关系。 rules: proposal: - - 必须说明问题、目标、约束、取舍与最终方案 - - 必须说明用户可见影响与开发者可见影响 - - 明确列出 non-goals(本次不做的事) - - 如果涉及替换旧结构,必须说明迁移与回滚思路 + - 必须明确:问题、目标、约束、最终方案、关键取舍。 + - 必须说明:用户可见影响、开发者可见影响、系统边界影响。 + - 必须列出 non-goals,明确本次不解决的问题。 + - 若涉及替换旧结构,必须给出迁移路径、兼容策略与回滚思路。 + - 若涉及架构演进,必须说明是否需要同步更新 PROJECT_ARCHITECTURE.md。 + - 避免只描述“想做什么”,必须解释“为什么这样做而不是其他方案”。 specs: - - 验收条件优先使用清晰、可验证的表达 - - 优先复用现有模式,不重复发明抽象 - - 必须包含边界情况、失败路径与流式场景考虑 - - 若涉及架构边界,必须体现模块职责与交互约束 + - 验收条件必须具体、清晰、可验证,避免模糊词汇。 + - 必须覆盖正常路径、边界情况、失败路径与流式场景。 + - 优先复用现有模式;若新增抽象,必须说明其必要性与替代方案为何不足。 + - 若涉及架构边界,必须明确模块职责、交互约束与禁止事项。 + - 若涉及状态变化,必须说明状态来源、状态持久化方式与一致性要求。 + - 验收标准应尽量从外部行为出发,而不是只描述内部实现。 design: - - 明确 crate 边界、依赖方向与核心数据流 - - 明确持久化方案、事件模型与回放/恢复机制 - - 如果涉及 server / runtime / storage / frontend,必须说明各自职责变化 - - 如果涉及协议或 DTO,必须保证其不承载业务逻辑 - - 若涉及会话、子 agent、工具调用,必须说明生命周期与所有权边界 + - 必须明确依赖方向与核心数据流(重要)。 + - 必须明确持久化方案、事件模型以及回放 / 恢复机制。 + - 若涉及 server / runtime / storage / frontend,必须分别说明职责变化。 + - 若涉及协议或 DTO,必须保证其为纯数据结构,不承载业务逻辑。 + - 若涉及会话、子 agent、工具调用,必须说明生命周期、所有权边界、通信语义与失败恢复。 + - 必须指出新增文件、修改文件、删除文件及其原因。 + - 必须标注高风险点、兼容性影响、性能影响与可观测性需求。 + - 设计应优先服务实现,不要写成抽象口号。 tasks: - - 任务粒度保持小而可验证,每项尽量可独立实现与测试 - - 尽量包含准确文件路径 - - 包含必要的验证命令、测试要求或手动验收步骤 - - 优先按可独立交付的阶段拆分,而不是按技术层大块堆叠 - - 生成tasks时候应当注意修改文件或者生成文件后的文件夹结构和命名的合理性,保持项目结构的清晰和一致 \ No newline at end of file + - 每个任务应尽量可独立实现、独立验证、独立回滚。 + - 每个任务应尽量包含准确文件路径;若暂时不能确定,也应明确模块范围。 + - 每个任务必须附带至少一种验证方式: + - 自动化测试 + - 命令行验证 + - 手动验收步骤 + - 生成任务时必须考虑实现细节 + - 优先按可独立交付的阶段拆分,而不是按技术层大块堆叠。 + - 每个任务应避免同时跨越多个无强依赖的关注点。 + - 生成 tasks 时必须关注修改后目录结构、文件命名、模块归属的合理性,保持项目结构清晰、一致、可维护。 + - 若任务涉及迁移、重构或替换,必须包含清理旧结构或保留兼容层的后续任务。 + - 若任务较大,应进一步拆成可以在单次实现中稳定完成的小任务。 \ No newline at end of file From 1da8d7cf533c5a4f6e7849db1b22fc83411de7fa Mon Sep 17 00:00:00 2001 From: whatevertogo Date: Sun, 19 Apr 2026 12:08:56 +0800 Subject: [PATCH 36/53] =?UTF-8?q?=E2=9C=A8=20feat(governance):=20=E5=AE=9E?= =?UTF-8?q?=E7=8E=B0=E5=A3=B0=E6=98=8E=E5=BC=8F=E6=B2=BB=E7=90=86=E6=A8=A1?= =?UTF-8?q?=E5=BC=8F=E7=B3=BB=E7=BB=9F=EF=BC=8C=E6=9B=BF=E4=BB=A3=E7=A1=AC?= =?UTF-8?q?=E7=BC=96=E7=A0=81=E5=8D=8F=E4=BD=9C=E6=8C=87=E5=AF=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit core/mode - Why: 建立治理模式的领域模型基础,支持声明式能力选择、动作策略和子代理策略 - How: 新增 ModeId、GovernanceModeSpec、CapabilitySelector、ActionPolicies、ResolvedTurnEnvelope 等核心类型,内置 code/plan/review 三种模式 application/governance_surface - Why: 将运行时治理决策(工具白名单、审批策略、协作指导 prompt)统一到一个装配层 - How: 新增 GovernanceSurfaceAssembler,按 session/root/child 场景编译治理面,生成 ResolvedTurnEnvelope 和 prompt declarations application/mode - Why: 需要统一的 mode 注册、编译和校验能力,支持内置模式和插件扩展模式 - How: 新增 ModeCatalog(builtin + plugin 模式注册)、compile_mode_envelope(能力解析→工具白名单→策略编译)、validate_mode_transition(合法转换校验) application/agent/context - Why: agent/mod.rs 职责过重,协作事实记录和 agent 上下文构建应独立 - How: 提取 CollaborationFactRecord 和 agent 上下文构建逻辑到 context.rs,routing.rs 新增治理面感知的子代理分发 application/terminal_queries - Why: terminal_use_cases.rs 过于庞大且混杂查询与用例逻辑 - How: 拆分为 cursor/resume/snapshot/summary 四个查询模块,删除 terminal_use_cases.rs application/lib.rs - Why: App 需要持有 governance_surface 和 mode_catalog 以服务各用例 - How: App 新增两个字段及访问器,清理 config 批量 re-export,session_use_cases 接入治理面编译和 mode 切换用例 session-runtime - Why: session 层需要持久化 mode 切换事件并提供 mode 状态查询 - How: 新增 switch_mode/session_mode_state,submit 使用 ResolvedTurnEnvelope 替代原始 runtime 透传,新增 ModeChanged 存储事件 core/event - Why: ModeChanged 需要纳入事件流以驱动 phase tracker 和 projection - How: 新增 StorageEventPayload::ModeChanged,phase/translate/projection 统一处理 core/projection - Why: AgentState 需要追踪当前 mode 以支持状态投影 - How: AgentState 新增 mode_id 字段,projector 处理 ModeChanged 事件 protocol - Why: 前端和 CLI 需要 mode 列表、状态查询和切换的 HTTP 接口 - How: 新增 ModeSummaryDto/SessionModeStateDto/SwitchModeRequest,插件握手协议扩展 modes 字段 server - Why: 暴露 mode 相关 HTTP 端点,bootstrap 组装 governance_surface 和 mode_catalog - How: 新增 /api/modes、/api/sessions/:id/mode 路由,runtime bootstrap 注入 assembler client - Why: CLI 需要通过 HTTP 调用 mode 相关 API - How: 新增 list_modes/get_session_mode/switch_mode 方法 cli - Why: 终端用户需要通过命令切换 session 治理模式 - How: 新增 Command::Mode 和 Command::ModeList,coordinator 处理 mode 切换交互 plugin - Why: 插件应能声明自定义治理模式以扩展系统能力 - How: InitializeResultData 新增 modes 字段,invoker 新增 declared_modes(),peer 握手传递 modes adapter-prompt/workflow_examples - Why: 协作指导 prompt 不再硬编码,改由治理面装配层注入 - How: 移除 child-collaboration-guidance 硬编码块,更新测试 adapter-tools/collab_result_mapping - Why: 简化 advisory projection 逻辑 - How: 用 Option::as_ref()? 替代显式 None 检查 observability/collector - Why: 协作事实需要携带治理版本和模式 ID - How: AgentCollaborationFact 新增 governance_revision 和 mode_id 字段,测试同步更新 test_support - Why: application 层测试需要轻量级 stub 而非依赖真实 session-runtime - How: 新增 StubSessionPort 实现 AppSessionPort + AgentSessionPort openspec - Why: 同步任务状态 - How: 更新 collaboration-mode-system 和 governance-surface-cleanup 任务清单 --- .../src/contributors/workflow_examples.rs | 189 +-- .../src/agent_tools/collab_result_mapping.rs | 5 +- crates/application/src/agent/context.rs | 540 ++++++++ crates/application/src/agent/mod.rs | 682 +--------- crates/application/src/agent/observe.rs | 2 +- crates/application/src/agent/routing.rs | 146 ++- crates/application/src/agent/test_support.rs | 5 +- crates/application/src/execution/root.rs | 61 +- crates/application/src/execution/subagent.rs | 347 +----- .../src/governance_surface/assembler.rs | 367 ++++++ .../src/governance_surface/inherited.rs | 107 ++ .../application/src/governance_surface/mod.rs | 198 +++ .../src/governance_surface/policy.rs | 189 +++ .../src/governance_surface/prompt.rs | 216 ++++ .../src/governance_surface/tests.rs | 417 +++++++ crates/application/src/lib.rs | 125 +- crates/application/src/mode/catalog.rs | 279 +++++ crates/application/src/mode/compiler.rs | 410 ++++++ crates/application/src/mode/mod.rs | 13 + crates/application/src/mode/validator.rs | 69 + .../src/observability/collector.rs | 10 +- crates/application/src/ports/app_session.rs | 30 +- crates/application/src/session_use_cases.rs | 133 +- .../src/terminal_queries/cursor.rs | 38 + .../application/src/terminal_queries/mod.rs | 25 + .../src/terminal_queries/resume.rs | 274 ++++ .../src/terminal_queries/snapshot.rs | 116 ++ .../src/terminal_queries/summary.rs | 63 + .../application/src/terminal_queries/tests.rs | 608 +++++++++ crates/application/src/terminal_use_cases.rs | 1109 ----------------- crates/application/src/test_support.rs | 300 +++++ crates/cli/src/app/coordinator.rs | 66 +- crates/cli/src/app/mod.rs | 133 +- crates/cli/src/command/mod.rs | 10 + crates/cli/src/state/mod.rs | 10 +- crates/cli/src/state/shell.rs | 7 +- crates/client/src/lib.rs | 51 +- crates/core/src/agent/mod.rs | 4 + crates/core/src/event/phase.rs | 2 + crates/core/src/event/translate.rs | 1 + crates/core/src/event/types.rs | 11 +- crates/core/src/lib.rs | 11 +- crates/core/src/mode/mod.rs | 442 +++++++ crates/core/src/ports.rs | 19 + crates/core/src/projection/agent_state.rs | 10 +- crates/plugin/src/invoker.rs | 8 + crates/plugin/src/peer.rs | 2 + crates/protocol/src/http/mod.rs | 4 +- crates/protocol/src/http/session.rs | 25 + crates/protocol/src/plugin/handshake.rs | 12 + crates/protocol/src/plugin/tests.rs | 40 + crates/protocol/tests/conformance.rs | 1 + crates/server/src/bootstrap/governance.rs | 12 +- crates/server/src/bootstrap/mcp.rs | 4 +- crates/server/src/bootstrap/plugins.rs | 15 +- crates/server/src/bootstrap/prompt_facts.rs | 52 +- crates/server/src/bootstrap/providers.rs | 7 +- crates/server/src/bootstrap/runtime.rs | 14 +- crates/server/src/http/mapper.rs | 12 +- crates/server/src/http/routes/config.rs | 2 +- crates/server/src/http/routes/conversation.rs | 13 +- crates/server/src/http/routes/mod.rs | 8 + crates/server/src/http/routes/sessions/mod.rs | 4 +- .../src/http/routes/sessions/mutation.rs | 25 + .../server/src/http/routes/sessions/query.rs | 53 +- crates/server/src/http/terminal_projection.rs | 9 +- crates/session-runtime/src/command/mod.rs | 27 +- crates/session-runtime/src/lib.rs | 17 +- crates/session-runtime/src/query/agent.rs | 18 +- crates/session-runtime/src/query/mod.rs | 2 +- crates/session-runtime/src/query/service.rs | 15 +- crates/session-runtime/src/query/terminal.rs | 9 + crates/session-runtime/src/state/mod.rs | 31 +- .../src/turn/compaction_cycle.rs | 1 + .../src/turn/manual_compact.rs | 1 + crates/session-runtime/src/turn/request.rs | 11 +- crates/session-runtime/src/turn/runner.rs | 9 +- .../src/turn/runner/step/driver.rs | 1 + .../src/turn/runner/step/tests.rs | 1 + crates/session-runtime/src/turn/submit.rs | 189 +-- crates/session-runtime/src/turn/summary.rs | 4 +- .../collaboration-mode-system/tasks.md | 106 +- .../governance-surface-cleanup/tasks.md | 90 +- 83 files changed, 5953 insertions(+), 2751 deletions(-) create mode 100644 crates/application/src/agent/context.rs create mode 100644 crates/application/src/governance_surface/assembler.rs create mode 100644 crates/application/src/governance_surface/inherited.rs create mode 100644 crates/application/src/governance_surface/mod.rs create mode 100644 crates/application/src/governance_surface/policy.rs create mode 100644 crates/application/src/governance_surface/prompt.rs create mode 100644 crates/application/src/governance_surface/tests.rs create mode 100644 crates/application/src/mode/catalog.rs create mode 100644 crates/application/src/mode/compiler.rs create mode 100644 crates/application/src/mode/mod.rs create mode 100644 crates/application/src/mode/validator.rs create mode 100644 crates/application/src/terminal_queries/cursor.rs create mode 100644 crates/application/src/terminal_queries/mod.rs create mode 100644 crates/application/src/terminal_queries/resume.rs create mode 100644 crates/application/src/terminal_queries/snapshot.rs create mode 100644 crates/application/src/terminal_queries/summary.rs create mode 100644 crates/application/src/terminal_queries/tests.rs delete mode 100644 crates/application/src/terminal_use_cases.rs create mode 100644 crates/application/src/test_support.rs create mode 100644 crates/core/src/mode/mod.rs diff --git a/crates/adapter-prompt/src/contributors/workflow_examples.rs b/crates/adapter-prompt/src/contributors/workflow_examples.rs index d7e069b5..ab21673e 100644 --- a/crates/adapter-prompt/src/contributors/workflow_examples.rs +++ b/crates/adapter-prompt/src/contributors/workflow_examples.rs @@ -3,10 +3,7 @@ //! 提供 few-shot 示例对话,教导模型"先收集上下文再修改代码"的行为模式。 //! 仅在第一步(step_index == 0)时生效,以 prepend 方式插入到对话消息中。 //! -//! 同时提供子 Agent 协作决策指导:当父 Agent 收到子 Agent 交付结果后, -//! 指导模型如何决定关闭或保留子 Agent。 - -use astrcode_core::config::DEFAULT_MAX_SUBRUN_DEPTH; +//! 子 Agent 协作指导现在由上游治理声明注入;本 contributor 只保留 few-shot 示例。 use async_trait::async_trait; use crate::{ @@ -16,8 +13,6 @@ use crate::{ pub struct WorkflowExamplesContributor; -const AGENT_COLLABORATION_TOOLS: &[&str] = &["spawn", "send", "observe", "close"]; - #[async_trait] impl PromptContributor for WorkflowExamplesContributor { fn contributor_id(&self) -> &'static str { @@ -84,74 +79,6 @@ impl PromptContributor for WorkflowExamplesContributor { ); } - if has_agent_collaboration_tools(ctx) { - let max_depth = collaboration_depth_limit(ctx).unwrap_or(DEFAULT_MAX_SUBRUN_DEPTH); - let max_spawn_per_turn = collaboration_spawn_limit(ctx).unwrap_or(3); - blocks.push( - BlockSpec::system_text( - "child-collaboration-guidance", - BlockKind::CollaborationGuide, - "Child Agent Collaboration Guide", - format!( - "Use the child-agent tools as one decision protocol.\n\nKeep `agentId` \ - exact. Copy it byte-for-byte in later `send`, `observe`, and `close` \ - calls. Never renumber it, never zero-pad it, and never invent `agent-01` \ - when the tool result says `agent-1`.\n\nDefault protocol:\n1. `spawn` \ - only for a new isolated responsibility with real parallel or \ - context-isolation value.\n2. `send` when the same child should take one \ - concrete next step on the same responsibility branch.\n3. `observe` only \ - when the next decision depends on current child state.\n4. `close` when \ - the branch is done or no longer useful.\n\nDelegation modes:\n- Fresh \ - child: use `spawn` for a new responsibility branch. Give the child a \ - full briefing: task scope, boundaries, expected deliverable, and any \ - important focus or exclusion. Do not treat a short nudge like 'take a \ - look' as a sufficient fresh-child brief.\n- Resumed child: use `send` \ - when the same child should continue the same responsibility branch. Send \ - one concrete delta instruction or clarification, not a full re-briefing \ - of the original task.\n- Restricted child: when you narrow a child with \ - `capabilityGrant`, assign only work that fits that reduced capability \ - surface. If the next step needs tools the restricted child does not \ - have, choose a different child or do the work locally instead of forcing \ - a mismatch.\n\n`Idle` is normal and reusable. Do not respawn just \ - because a child finished one turn. Reuse an idle child with \ - `send(agentId, message)` when the responsibility stays the same. If you \ - are unsure whether the child is still running, idle, or terminated, call \ - `observe(agentId)` once and act on the result.\n\nSpawn sparingly. The \ - runtime enforces a maximum child depth of {max_depth} and at most \ - {max_spawn_per_turn} new children per turn. Start with one child unless \ - there are clearly separate workstreams. Do not blanket-spawn agents just \ - to explore a repo broadly.\n\nAvoid waste:\n- Do not loop on `observe` \ - with no decision attached.\n- If a child is still running and you are \ - simply waiting, prefer a brief shell sleep over spending another tool \ - call on `observe`.\n- Pick one wait mode per pause: either `observe` now \ - because you need a snapshot for the next decision, or sleep briefly \ - because you are only waiting. Do not alternate `shell` and `observe` in \ - a polling loop.\n- After a wait, call `observe` only when the next \ - decision depends on the child's current state.\n- Do not immediately \ - re-`observe` the same child after a fresh delivery unless the state is \ - genuinely ambiguous.\n- Do not stack speculative `send` calls.\n- Do not \ - spawn a new child when an existing idle child already owns the \ - responsibility.\n\nIf a delivery satisfies the request, `close` the \ - branch. If the same child should continue, `send` one precise follow-up. \ - If you see the same `deliveryId` again after recovery, treat it as the \ - same delivery, not a new task.\n\nWhen you are the child on a delegated \ - task, use upstream `send(kind + payload)` to deliver a formal message to \ - your direct parent. Report `progress`, `completed`, `failed`, or \ - `close_request` explicitly. Do not wait for the parent to infer state \ - from raw intermediate steps, and do not end with an open loop like \ - '继续观察中' unless you are also sending a non-terminal `progress` \ - delivery that keeps the branch alive.\n\nWhen you are the parent and \ - receive a child delivery, treat it as a decision point. Do not leave it \ - hanging and do not immediately re-observe the same child unless the \ - state is unclear. Decide immediately whether the result is complete \ - enough to `close` the branch, or whether the same child should continue \ - with one concrete `send` follow-up that names the exact next step." - ), - ) - .with_priority(600), - ); - } - PromptContribution { blocks, ..PromptContribution::default() @@ -159,30 +86,10 @@ impl PromptContributor for WorkflowExamplesContributor { } } -fn has_agent_collaboration_tools(ctx: &PromptContext) -> bool { - ctx.tool_names.iter().any(|tool_name| { - AGENT_COLLABORATION_TOOLS - .iter() - .any(|candidate| tool_name == candidate) - }) -} - fn should_add_tool_search_example(ctx: &PromptContext) -> bool { has_tool_search(ctx) && has_external_tools(ctx) } -fn collaboration_depth_limit(ctx: &PromptContext) -> Option { - ctx.vars - .get("agent.max_subrun_depth") - .and_then(|value| value.parse::().ok()) -} - -fn collaboration_spawn_limit(ctx: &PromptContext) -> Option { - ctx.vars - .get("agent.max_spawn_per_turn") - .and_then(|value| value.parse::().ok()) -} - fn has_tool_search(ctx: &PromptContext) -> bool { ctx.tool_names .iter() @@ -302,7 +209,7 @@ mod tests { } #[tokio::test] - async fn adds_collaboration_guidance_only_when_agent_tools_are_available() { + async fn does_not_add_collaboration_guidance_directly() { let _guard = TestEnvGuard::new(); let composer = PromptComposer::with_options(PromptComposerOptions { validation_level: ValidationLevel::Strict, @@ -327,94 +234,12 @@ mod tests { let output = composer.build(&ctx).await.expect("build should succeed"); - let collaboration_block = output - .plan - .system_blocks - .iter() - .find(|block| block.id == "child-collaboration-guidance") - .expect("collaboration guidance block should exist"); - assert!(collaboration_block.content.contains("Keep `agentId` exact")); - assert!(collaboration_block.content.contains(&format!( - "maximum child depth of {DEFAULT_MAX_SUBRUN_DEPTH}" - ))); - assert!(collaboration_block.content.contains("Default protocol:")); - assert!(collaboration_block.content.contains("Delegation modes:")); - assert!(collaboration_block.content.contains("Fresh child:")); - assert!(collaboration_block.content.contains("Resumed child:")); - assert!(collaboration_block.content.contains("Restricted child:")); assert!( - collaboration_block - .content - .contains("same `deliveryId` again") - ); - assert!( - collaboration_block - .content - .contains("`Idle` is normal and reusable") - ); - assert!( - collaboration_block - .content - .contains("Do not loop on `observe`") - ); - assert!( - collaboration_block.content.contains( - "prefer a brief shell sleep over spending another tool call on `observe`" - ) - ); - assert!( - collaboration_block - .content - .contains("Do not alternate `shell` and `observe` in a polling loop") - ); - assert!( - collaboration_block - .content - .contains("use upstream `send(kind + payload)`") - ); - assert!(collaboration_block.content.contains("`close_request`")); - assert!(collaboration_block.content.contains("exact next step")); - } - - #[tokio::test] - async fn collaboration_guidance_uses_configured_depth_limit() { - let _guard = TestEnvGuard::new(); - let composer = PromptComposer::with_options(PromptComposerOptions { - validation_level: ValidationLevel::Strict, - ..PromptComposerOptions::default() - }); - - let mut vars = std::collections::HashMap::new(); - vars.insert("agent.max_subrun_depth".to_string(), "5".to_string()); - let ctx = PromptContext { - working_dir: "/workspace/demo".to_string(), - tool_names: vec![ - "spawn".to_string(), - "send".to_string(), - "observe".to_string(), - "close".to_string(), - ], - capability_specs: Vec::new(), - prompt_declarations: Vec::new(), - agent_profiles: Vec::new(), - skills: Vec::new(), - step_index: 0, - turn_index: 0, - vars, - }; - - let output = composer.build(&ctx).await.expect("build should succeed"); - - let collaboration_block = output - .plan - .system_blocks - .iter() - .find(|block| block.id == "child-collaboration-guidance") - .expect("collaboration guidance block should exist"); - assert!( - collaboration_block - .content - .contains("maximum child depth of 5") + output + .plan + .system_blocks + .iter() + .all(|block| block.id != "child-collaboration-guidance") ); } } diff --git a/crates/adapter-tools/src/agent_tools/collab_result_mapping.rs b/crates/adapter-tools/src/agent_tools/collab_result_mapping.rs index b7117989..aeb2deda 100644 --- a/crates/adapter-tools/src/agent_tools/collab_result_mapping.rs +++ b/crates/adapter-tools/src/agent_tools/collab_result_mapping.rs @@ -98,10 +98,7 @@ fn inject_advisory_projection(metadata: &mut serde_json::Value, result: &Collabo fn build_advisory_projection(result: &CollaborationResult) -> Option { let delegation = result.delegation(); let branch = delegation.map(branch_advisory); - - if branch.is_none() { - return None; - } + branch.as_ref()?; Some(json!({ "branch": branch, diff --git a/crates/application/src/agent/context.rs b/crates/application/src/agent/context.rs new file mode 100644 index 00000000..f81f84a5 --- /dev/null +++ b/crates/application/src/agent/context.rs @@ -0,0 +1,540 @@ +use std::path::Path; + +use astrcode_core::{ + AgentCollaborationActionKind, AgentCollaborationFact, AgentCollaborationOutcomeKind, + AgentCollaborationPolicyContext, AgentEventContext, ChildExecutionIdentity, InvocationKind, + ModeId, ResolvedExecutionLimitsSnapshot, Result, SubRunHandle, ToolContext, +}; + +use super::{ + AgentOrchestrationError, AgentOrchestrationService, IMPLICIT_ROOT_PROFILE_ID, + root_execution_event_context, subrun_event_context, +}; +use crate::governance_surface::{GOVERNANCE_POLICY_REVISION, collaboration_policy_context}; + +pub(crate) struct CollaborationFactRecord<'a> { + pub(crate) action: AgentCollaborationActionKind, + pub(crate) outcome: AgentCollaborationOutcomeKind, + pub(crate) session_id: &'a str, + pub(crate) turn_id: &'a str, + pub(crate) parent_agent_id: Option, + pub(crate) child: Option<&'a SubRunHandle>, + pub(crate) delivery_id: Option, + pub(crate) reason_code: Option, + pub(crate) summary: Option, + pub(crate) latency_ms: Option, + pub(crate) source_tool_call_id: Option, + pub(crate) policy: Option, + pub(crate) governance_revision: Option, + pub(crate) mode_id: Option, +} + +impl<'a> CollaborationFactRecord<'a> { + pub(crate) fn new( + action: AgentCollaborationActionKind, + outcome: AgentCollaborationOutcomeKind, + session_id: &'a str, + turn_id: &'a str, + ) -> Self { + Self { + action, + outcome, + session_id, + turn_id, + parent_agent_id: None, + child: None, + delivery_id: None, + reason_code: None, + summary: None, + latency_ms: None, + source_tool_call_id: None, + policy: None, + governance_revision: None, + mode_id: None, + } + } + + pub(crate) fn parent_agent_id(mut self, parent_agent_id: Option) -> Self { + self.parent_agent_id = parent_agent_id; + self + } + + pub(crate) fn child(mut self, child: &'a SubRunHandle) -> Self { + self.child = Some(child); + self + } + + pub(crate) fn delivery_id(mut self, delivery_id: impl Into) -> Self { + self.delivery_id = Some(delivery_id.into()); + self + } + + pub(crate) fn reason_code(mut self, reason_code: impl Into) -> Self { + self.reason_code = Some(reason_code.into()); + self + } + + pub(crate) fn summary(mut self, summary: impl Into) -> Self { + self.summary = Some(summary.into()); + self + } + + pub(crate) fn latency_ms(mut self, latency_ms: u64) -> Self { + self.latency_ms = Some(latency_ms); + self + } + + pub(crate) fn source_tool_call_id(mut self, source_tool_call_id: Option) -> Self { + self.source_tool_call_id = source_tool_call_id; + self + } + + pub(crate) fn policy(mut self, policy: AgentCollaborationPolicyContext) -> Self { + self.policy = Some(policy); + self + } + + pub(crate) fn governance_revision(mut self, governance_revision: impl Into) -> Self { + self.governance_revision = Some(governance_revision.into()); + self + } + + pub(crate) fn mode_id(mut self, mode_id: Option) -> Self { + self.mode_id = mode_id; + self + } +} + +pub(crate) struct ToolCollaborationContext { + runtime: astrcode_core::ResolvedRuntimeConfig, + session_id: String, + turn_id: String, + parent_agent_id: Option, + source_tool_call_id: Option, + policy: AgentCollaborationPolicyContext, + governance_revision: String, + mode_id: ModeId, +} + +pub(crate) struct ToolCollaborationContextInput { + pub(crate) runtime: astrcode_core::ResolvedRuntimeConfig, + pub(crate) session_id: String, + pub(crate) turn_id: String, + pub(crate) parent_agent_id: Option, + pub(crate) source_tool_call_id: Option, + pub(crate) policy: AgentCollaborationPolicyContext, + pub(crate) governance_revision: String, + pub(crate) mode_id: ModeId, +} + +impl ToolCollaborationContext { + pub(crate) fn new(input: ToolCollaborationContextInput) -> Self { + Self { + runtime: input.runtime, + session_id: input.session_id, + turn_id: input.turn_id, + parent_agent_id: input.parent_agent_id, + source_tool_call_id: input.source_tool_call_id, + policy: input.policy, + governance_revision: input.governance_revision, + mode_id: input.mode_id, + } + } + + pub(crate) fn with_parent_agent_id(mut self, parent_agent_id: Option) -> Self { + self.parent_agent_id = parent_agent_id; + self + } + + pub(crate) fn runtime(&self) -> &astrcode_core::ResolvedRuntimeConfig { + &self.runtime + } + + pub(crate) fn session_id(&self) -> &str { + &self.session_id + } + + pub(crate) fn turn_id(&self) -> &str { + &self.turn_id + } + + pub(crate) fn parent_agent_id(&self) -> Option { + self.parent_agent_id.clone() + } + + pub(crate) fn source_tool_call_id(&self) -> Option { + self.source_tool_call_id.clone() + } + + pub(crate) fn policy(&self) -> AgentCollaborationPolicyContext { + self.policy.clone() + } + + pub(crate) fn governance_revision(&self) -> &str { + &self.governance_revision + } + + pub(crate) fn mode_id(&self) -> &ModeId { + &self.mode_id + } + + pub(crate) fn fact<'a>( + &'a self, + action: AgentCollaborationActionKind, + outcome: AgentCollaborationOutcomeKind, + ) -> CollaborationFactRecord<'a> { + CollaborationFactRecord::new(action, outcome, &self.session_id, &self.turn_id) + .parent_agent_id(self.parent_agent_id()) + .source_tool_call_id(self.source_tool_call_id()) + .policy(self.policy()) + .governance_revision(self.governance_revision()) + .mode_id(Some(self.mode_id().clone())) + } +} + +pub(crate) fn implicit_session_root_agent_id(session_id: &str) -> String { + format!( + "root-agent:{}", + astrcode_session_runtime::normalize_session_id(session_id) + ) +} + +fn default_resolved_limits_for_gateway( + gateway: &astrcode_kernel::KernelGateway, + max_steps: Option, +) -> ResolvedExecutionLimitsSnapshot { + ResolvedExecutionLimitsSnapshot { + allowed_tools: gateway.capabilities().tool_names(), + max_steps, + } +} + +async fn ensure_handle_has_resolved_limits( + kernel: &dyn crate::AgentKernelPort, + gateway: &astrcode_kernel::KernelGateway, + handle: SubRunHandle, + max_steps: Option, +) -> std::result::Result { + if !handle.resolved_limits.allowed_tools.is_empty() { + return Ok(handle); + } + + super::persist_resolved_limits_for_handle( + kernel, + handle, + default_resolved_limits_for_gateway(gateway, max_steps), + ) + .await +} + +impl AgentOrchestrationService { + pub(super) fn resolve_runtime_config_for_working_dir( + &self, + working_dir: &Path, + ) -> std::result::Result { + self.config_service + .load_resolved_runtime_config(Some(working_dir)) + .map_err(|error| AgentOrchestrationError::Internal(error.to_string())) + } + + pub(super) async fn resolve_runtime_config_for_session( + &self, + session_id: &str, + ) -> std::result::Result { + let working_dir = self + .session_runtime + .get_session_working_dir(session_id) + .await + .map_err(AgentOrchestrationError::from)?; + self.resolve_runtime_config_for_working_dir(Path::new(&working_dir)) + } + + pub(super) fn resolve_subagent_profile( + &self, + working_dir: &Path, + profile_id: &str, + ) -> std::result::Result { + self.profiles + .find_profile(working_dir, profile_id) + .map_err(|error| match error { + crate::ApplicationError::NotFound(message) => { + AgentOrchestrationError::NotFound(message) + }, + crate::ApplicationError::InvalidArgument(message) => { + AgentOrchestrationError::InvalidInput(message) + }, + other => AgentOrchestrationError::Internal(other.to_string()), + }) + } + + pub(super) async fn append_collaboration_fact( + &self, + fact: AgentCollaborationFact, + ) -> std::result::Result<(), AgentOrchestrationError> { + let turn_id = fact.turn_id.clone(); + let parent_session_id = fact.parent_session_id.clone(); + let event_agent = if let Some(parent_agent_id) = fact.parent_agent_id.as_deref() { + self.kernel + .get_handle(parent_agent_id) + .await + .map(|handle| { + if handle.depth == 0 { + root_execution_event_context(handle.agent_id, handle.agent_profile) + } else { + subrun_event_context(&handle) + } + }) + .unwrap_or_default() + } else { + AgentEventContext::default() + }; + self.session_runtime + .append_agent_collaboration_fact( + &parent_session_id, + &turn_id, + event_agent, + fact.clone(), + ) + .await + .map_err(AgentOrchestrationError::from)?; + self.metrics.record_agent_collaboration_fact(&fact); + Ok(()) + } + + pub(super) async fn record_collaboration_fact( + &self, + runtime: &astrcode_core::ResolvedRuntimeConfig, + record: CollaborationFactRecord<'_>, + ) -> std::result::Result<(), AgentOrchestrationError> { + let fact = AgentCollaborationFact { + fact_id: format!("acf-{}", uuid::Uuid::new_v4()).into(), + action: record.action, + outcome: record.outcome, + parent_session_id: record.session_id.to_string().into(), + turn_id: record.turn_id.to_string().into(), + parent_agent_id: record.parent_agent_id.map(Into::into), + child_identity: record.child.and_then(|handle| { + handle + .child_session_id + .clone() + .map(|child_session_id| ChildExecutionIdentity { + agent_id: handle.agent_id.clone(), + session_id: child_session_id, + sub_run_id: handle.sub_run_id.clone(), + }) + }), + delivery_id: record.delivery_id.map(Into::into), + reason_code: record.reason_code, + summary: record.summary, + latency_ms: record.latency_ms, + source_tool_call_id: record.source_tool_call_id.map(Into::into), + governance_revision: record.governance_revision, + mode_id: record.mode_id, + policy: record + .policy + .unwrap_or_else(|| collaboration_policy_context(runtime)), + }; + self.append_collaboration_fact(fact).await + } + + pub(super) async fn tool_collaboration_context( + &self, + ctx: &ToolContext, + ) -> std::result::Result { + let runtime = self.resolve_runtime_config_for_working_dir(ctx.working_dir())?; + let mode_id = self + .session_runtime + .session_mode_state(ctx.session_id()) + .await + .map_err(AgentOrchestrationError::from)? + .current_mode_id; + Ok(ToolCollaborationContext::new( + ToolCollaborationContextInput { + runtime: runtime.clone(), + session_id: ctx.session_id().to_string(), + turn_id: ctx.turn_id().unwrap_or("unknown-turn").to_string(), + parent_agent_id: ctx.agent_context().agent_id.clone().map(Into::into), + source_tool_call_id: ctx.tool_call_id().map(ToString::to_string), + policy: collaboration_policy_context(&runtime), + governance_revision: GOVERNANCE_POLICY_REVISION.to_string(), + mode_id, + }, + )) + } + + pub(super) async fn record_fact_best_effort( + &self, + runtime: &astrcode_core::ResolvedRuntimeConfig, + record: CollaborationFactRecord<'_>, + ) { + let _ = self.record_collaboration_fact(runtime, record).await; + } + + pub(super) async fn reject_with_fact( + &self, + runtime: &astrcode_core::ResolvedRuntimeConfig, + record: CollaborationFactRecord<'_>, + error: AgentOrchestrationError, + ) -> std::result::Result { + self.record_fact_best_effort(runtime, record).await; + Err(error) + } + + pub(super) async fn reject_spawn( + &self, + collaboration: &ToolCollaborationContext, + reason_code: &str, + error: AgentOrchestrationError, + ) -> Result { + self.record_fact_best_effort( + collaboration.runtime(), + collaboration + .fact( + AgentCollaborationActionKind::Spawn, + AgentCollaborationOutcomeKind::Rejected, + ) + .reason_code(reason_code) + .summary(error.to_string()), + ) + .await; + Err(super::map_orchestration_error(error)) + } + + pub(super) async fn fail_spawn_internal( + &self, + collaboration: &ToolCollaborationContext, + reason_code: &str, + message: String, + ) -> Result { + self.record_fact_best_effort( + collaboration.runtime(), + collaboration + .fact( + AgentCollaborationActionKind::Spawn, + AgentCollaborationOutcomeKind::Failed, + ) + .reason_code(reason_code) + .summary(message.clone()), + ) + .await; + Err(astrcode_core::AstrError::Internal(message)) + } + + pub(super) async fn ensure_parent_agent_handle( + &self, + ctx: &ToolContext, + ) -> std::result::Result { + let session_id = ctx.session_id().to_string(); + let explicit_agent_id = ctx + .agent_context() + .agent_id + .clone() + .filter(|agent_id| !agent_id.trim().is_empty()); + + if let Some(agent_id) = explicit_agent_id { + if let Some(handle) = self.kernel.get_handle(&agent_id).await { + if handle.depth == 0 && handle.resolved_limits.allowed_tools.is_empty() { + return ensure_handle_has_resolved_limits( + self.kernel.as_ref(), + &self.kernel.gateway(), + handle, + None, + ) + .await + .map_err(AgentOrchestrationError::Internal); + } + return Ok(handle); + } + + let is_root_execution = matches!( + ctx.agent_context().invocation_kind, + Some(InvocationKind::RootExecution) + ); + if is_root_execution { + let profile_id = ctx + .agent_context() + .agent_profile + .clone() + .filter(|profile_id| !profile_id.trim().is_empty()) + .unwrap_or_else(|| IMPLICIT_ROOT_PROFILE_ID.to_string()); + let handle = self + .kernel + .register_root_agent(agent_id.to_string(), session_id, profile_id) + .await + .map_err(|error| { + AgentOrchestrationError::Internal(format!( + "failed to register root agent for parent context: {error}" + )) + })?; + return ensure_handle_has_resolved_limits( + self.kernel.as_ref(), + &self.kernel.gateway(), + handle, + None, + ) + .await + .map_err(AgentOrchestrationError::Internal); + } + + return Err(AgentOrchestrationError::NotFound(format!( + "agent '{}' not found", + agent_id + ))); + } + + if let Some(handle) = self.kernel.find_root_handle_for_session(&session_id).await { + return ensure_handle_has_resolved_limits( + self.kernel.as_ref(), + &self.kernel.gateway(), + handle, + None, + ) + .await + .map_err(AgentOrchestrationError::Internal); + } + + let handle = self + .kernel + .register_root_agent( + implicit_session_root_agent_id(&session_id), + session_id, + IMPLICIT_ROOT_PROFILE_ID.to_string(), + ) + .await + .map_err(|error| { + AgentOrchestrationError::Internal(format!( + "failed to register implicit root agent for session parent context: {error}" + )) + })?; + ensure_handle_has_resolved_limits( + self.kernel.as_ref(), + &self.kernel.gateway(), + handle, + None, + ) + .await + .map_err(AgentOrchestrationError::Internal) + } + + pub(super) async fn enforce_spawn_budget_for_turn( + &self, + parent_agent_id: &str, + parent_turn_id: &str, + max_spawn_per_turn: usize, + ) -> std::result::Result<(), AgentOrchestrationError> { + let spawned_for_turn = self + .kernel + .count_children_spawned_for_turn(parent_agent_id, parent_turn_id) + .await; + + if spawned_for_turn >= max_spawn_per_turn { + return Err(AgentOrchestrationError::InvalidInput(format!( + "spawn budget exhausted for this turn ({spawned_for_turn}/{max_spawn_per_turn}); \ + reuse an existing child with send/observe/close, or continue the work in the \ + current agent" + ))); + } + + Ok(()) + } +} diff --git a/crates/application/src/agent/mod.rs b/crates/application/src/agent/mod.rs index 96ef0bf4..a027bd9c 100644 --- a/crates/application/src/agent/mod.rs +++ b/crates/application/src/agent/mod.rs @@ -12,6 +12,7 @@ //! - 不直接依赖 adapter-* //! - 不缓存 session 引用 +mod context; mod observe; mod routing; mod terminal; @@ -21,28 +22,32 @@ mod wake; use std::{ collections::HashMap, - path::Path, sync::{Arc, Mutex}, }; use astrcode_core::{ - AgentCollaborationActionKind, AgentCollaborationFact, AgentCollaborationOutcomeKind, - AgentCollaborationPolicyContext, AgentEventContext, AgentLifecycleStatus, AgentTurnOutcome, - ArtifactRef, ChildExecutionIdentity, CloseAgentParams, CollaborationResult, DelegationMetadata, - InvocationKind, ObserveParams, ParentDelivery, ParentDeliveryOrigin, ParentDeliveryPayload, - ParentDeliveryTerminalSemantics, ProgressParentDeliveryPayload, PromptDeclaration, - PromptDeclarationKind, PromptDeclarationRenderTarget, PromptDeclarationSource, - QueuedInputEnvelope, ResolvedExecutionLimitsSnapshot, Result, RuntimeMetricsRecorder, - SendAgentParams, SpawnAgentParams, SubRunHandle, SubRunHandoff, SubRunResult, - SystemPromptLayer, ToolContext, + AgentCollaborationActionKind, AgentCollaborationOutcomeKind, AgentEventContext, + AgentLifecycleStatus, AgentTurnOutcome, ArtifactRef, CloseAgentParams, CollaborationResult, + DelegationMetadata, ObserveParams, ParentDelivery, ParentDeliveryOrigin, ParentDeliveryPayload, + ParentDeliveryTerminalSemantics, ProgressParentDeliveryPayload, QueuedInputEnvelope, + ResolvedExecutionLimitsSnapshot, Result, RuntimeMetricsRecorder, SendAgentParams, + SpawnAgentParams, SubRunHandle, SubRunHandoff, SubRunResult, ToolContext, }; use async_trait::async_trait; +pub(crate) use context::{ + CollaborationFactRecord, ToolCollaborationContext, ToolCollaborationContextInput, + implicit_session_root_agent_id, +}; use thiserror::Error; use crate::{ AgentKernelPort, AgentSessionPort, config::ConfigService, execution::{ProfileResolutionService, SubagentExecutionRequest, launch_subagent}, + governance_surface::{ + GOVERNANCE_POLICY_REVISION, GovernanceSurfaceAssembler, build_delegation_metadata, + effective_allowed_tools_for_limits, + }, lifecycle::TaskRegistry, }; @@ -90,306 +95,9 @@ pub(crate) fn subrun_event_context_for_parent_turn( } pub(crate) const IMPLICIT_ROOT_PROFILE_ID: &str = "default"; -pub(crate) const AGENT_COLLABORATION_POLICY_REVISION: &str = "agent-collaboration-v1"; +pub(crate) const AGENT_COLLABORATION_POLICY_REVISION: &str = GOVERNANCE_POLICY_REVISION; const MAX_OBSERVE_GUARD_ENTRIES: usize = 1024; -pub(crate) struct CollaborationFactRecord<'a> { - pub(crate) action: AgentCollaborationActionKind, - pub(crate) outcome: AgentCollaborationOutcomeKind, - pub(crate) session_id: &'a str, - pub(crate) turn_id: &'a str, - pub(crate) parent_agent_id: Option, - pub(crate) child: Option<&'a SubRunHandle>, - pub(crate) delivery_id: Option, - pub(crate) reason_code: Option, - pub(crate) summary: Option, - pub(crate) latency_ms: Option, - pub(crate) source_tool_call_id: Option, -} - -impl<'a> CollaborationFactRecord<'a> { - pub(crate) fn new( - action: AgentCollaborationActionKind, - outcome: AgentCollaborationOutcomeKind, - session_id: &'a str, - turn_id: &'a str, - ) -> Self { - Self { - action, - outcome, - session_id, - turn_id, - parent_agent_id: None, - child: None, - delivery_id: None, - reason_code: None, - summary: None, - latency_ms: None, - source_tool_call_id: None, - } - } - - pub(crate) fn parent_agent_id(mut self, parent_agent_id: Option) -> Self { - self.parent_agent_id = parent_agent_id; - self - } - - pub(crate) fn child(mut self, child: &'a SubRunHandle) -> Self { - self.child = Some(child); - self - } - - pub(crate) fn delivery_id(mut self, delivery_id: impl Into) -> Self { - self.delivery_id = Some(delivery_id.into()); - self - } - - pub(crate) fn reason_code(mut self, reason_code: impl Into) -> Self { - self.reason_code = Some(reason_code.into()); - self - } - - pub(crate) fn summary(mut self, summary: impl Into) -> Self { - self.summary = Some(summary.into()); - self - } - - pub(crate) fn latency_ms(mut self, latency_ms: u64) -> Self { - self.latency_ms = Some(latency_ms); - self - } - - pub(crate) fn source_tool_call_id(mut self, source_tool_call_id: Option) -> Self { - self.source_tool_call_id = source_tool_call_id; - self - } -} - -pub(crate) struct ToolCollaborationContext { - runtime: astrcode_core::ResolvedRuntimeConfig, - session_id: String, - turn_id: String, - parent_agent_id: Option, - source_tool_call_id: Option, -} - -impl ToolCollaborationContext { - pub(crate) fn new( - runtime: astrcode_core::ResolvedRuntimeConfig, - session_id: String, - turn_id: String, - parent_agent_id: Option, - source_tool_call_id: Option, - ) -> Self { - Self { - runtime, - session_id, - turn_id, - parent_agent_id, - source_tool_call_id, - } - } - - pub(crate) fn with_parent_agent_id(mut self, parent_agent_id: Option) -> Self { - self.parent_agent_id = parent_agent_id; - self - } - - pub(crate) fn runtime(&self) -> &astrcode_core::ResolvedRuntimeConfig { - &self.runtime - } - - pub(crate) fn session_id(&self) -> &str { - &self.session_id - } - - pub(crate) fn turn_id(&self) -> &str { - &self.turn_id - } - - pub(crate) fn parent_agent_id(&self) -> Option { - self.parent_agent_id.clone() - } - - pub(crate) fn source_tool_call_id(&self) -> Option { - self.source_tool_call_id.clone() - } - - pub(crate) fn fact<'a>( - &'a self, - action: AgentCollaborationActionKind, - outcome: AgentCollaborationOutcomeKind, - ) -> CollaborationFactRecord<'a> { - CollaborationFactRecord::new(action, outcome, &self.session_id, &self.turn_id) - .parent_agent_id(self.parent_agent_id()) - .source_tool_call_id(self.source_tool_call_id()) - } -} - -pub(crate) fn implicit_session_root_agent_id(session_id: &str) -> String { - // 为什么按 session 生成 synthetic root id: - // `AgentControl` 以 agent_id 作为全局索引键,普通会话若都共用 `root-agent` - // 会把不同 session 的父子树混在一起。 - format!( - "root-agent:{}", - astrcode_session_runtime::normalize_session_id(session_id) - ) -} - -fn default_resolved_limits_for_gateway( - gateway: &astrcode_kernel::KernelGateway, - max_steps: Option, -) -> ResolvedExecutionLimitsSnapshot { - ResolvedExecutionLimitsSnapshot { - allowed_tools: gateway.capabilities().tool_names(), - max_steps, - } -} - -fn effective_tool_names_for_handle( - handle: &SubRunHandle, - gateway: &astrcode_kernel::KernelGateway, -) -> Vec { - if handle.resolved_limits.allowed_tools.is_empty() { - gateway.capabilities().tool_names() - } else { - handle.resolved_limits.allowed_tools.clone() - } -} - -fn compact_delegation_summary(description: &str, prompt: &str) -> String { - let candidate = if !description.trim().is_empty() { - description.trim() - } else { - prompt.trim() - }; - let normalized = candidate.split_whitespace().collect::>().join(" "); - let mut chars = normalized.chars(); - let truncated = chars.by_ref().take(160).collect::(); - if chars.next().is_some() { - format!("{truncated}…") - } else { - truncated - } -} - -fn capability_limit_summary(allowed_tools: &[String]) -> Option { - if allowed_tools.is_empty() { - return None; - } - - Some(format!( - "本分支当前只允许使用这些工具:{}。", - allowed_tools.join(", ") - )) -} - -pub(crate) fn build_delegation_metadata( - description: &str, - prompt: &str, - resolved_limits: &ResolvedExecutionLimitsSnapshot, - restricted: bool, -) -> DelegationMetadata { - let responsibility_summary = compact_delegation_summary(description, prompt); - let reuse_scope_summary = if restricted { - "只有当下一步仍属于同一责任分支,且所需操作仍落在当前收缩后的 capability surface \ - 内时,才应继续复用这个 child。" - .to_string() - } else { - "只有当下一步仍属于同一责任分支时,才应继续复用这个 child;若责任边界已经改变,应 close \ - 当前分支并重新选择更合适的执行主体。" - .to_string() - }; - - DelegationMetadata { - responsibility_summary, - reuse_scope_summary, - restricted, - capability_limit_summary: restricted - .then(|| capability_limit_summary(&resolved_limits.allowed_tools)) - .flatten(), - } -} - -pub(crate) fn build_fresh_child_contract(metadata: &DelegationMetadata) -> PromptDeclaration { - let mut content = format!( - "You are a delegated child responsible for one isolated branch.\n\nResponsibility \ - branch:\n- {}\n\nFresh-child rule:\n- Treat this as a new responsibility branch with its \ - own ownership boundary.\n- Do not expand into unrelated exploration or \ - implementation.\n\nUnified send contract:\n- Use downstream `send(agentId + message)` \ - only when you need a direct child to continue a more specific sub-branch.\n- When this \ - branch reaches progress, completion, failure, or a close request, use upstream \ - `send(kind + payload)` to report to your direct parent.\n- Do not wait for an extra \ - confirmation loop before reporting terminal state.\n\nReuse boundary:\n- {}", - metadata.responsibility_summary, metadata.reuse_scope_summary - ); - if let Some(limit_summary) = &metadata.capability_limit_summary { - content.push_str(&format!( - "\n\nCapability limit:\n- {limit_summary}\n- Do not take work that needs tools \ - outside this surface." - )); - } - - PromptDeclaration { - block_id: "child.execution.contract".to_string(), - title: "Child Execution Contract".to_string(), - content, - render_target: PromptDeclarationRenderTarget::System, - layer: SystemPromptLayer::Inherited, - kind: PromptDeclarationKind::ExtensionInstruction, - priority_hint: Some(585), - always_include: true, - source: PromptDeclarationSource::Builtin, - capability_name: None, - origin: Some("child-contract:fresh".to_string()), - } -} - -pub(crate) fn build_resumed_child_contract( - metadata: &DelegationMetadata, - message: &str, - context: Option<&str>, -) -> PromptDeclaration { - let mut content = format!( - "You are continuing an existing delegated child branch.\n\nResponsibility continuity:\n- \ - Keep ownership of the same branch: {}\n\nResumed-child rule:\n- Prioritize the latest \ - delta instruction from the parent.\n- Do not restate or reinterpret the whole original \ - brief unless the new delta requires it.\n\nDelta instruction:\n- {}", - metadata.responsibility_summary, - message.trim() - ); - if let Some(context) = context.filter(|value| !value.trim().is_empty()) { - content.push_str(&format!("\n- Supplementary context: {}", context.trim())); - } - content.push_str(&format!( - "\n\nUnified send contract:\n- Keep using downstream `send(agentId + message)` only for \ - direct child delegation inside the same branch.\n- Use upstream `send(kind + payload)` \ - to report concrete progress, completion, failure, or a close request back to your direct \ - parent.\n- Do not restate the whole branch transcript when reporting upward.\n\nReuse \ - boundary:\n- {}", - metadata.reuse_scope_summary - )); - if let Some(limit_summary) = &metadata.capability_limit_summary { - content.push_str(&format!( - "\n\nCapability limit:\n- {limit_summary}\n- If the delta now needs broader tools, \ - stop stretching this child and let the parent choose a different branch." - )); - } - - PromptDeclaration { - block_id: "child.execution.contract".to_string(), - title: "Child Execution Contract".to_string(), - content, - render_target: PromptDeclarationRenderTarget::System, - layer: SystemPromptLayer::Inherited, - kind: PromptDeclarationKind::ExtensionInstruction, - priority_hint: Some(585), - always_include: true, - source: PromptDeclarationSource::Builtin, - capability_name: None, - origin: Some("child-contract:resumed".to_string()), - } -} pub(crate) async fn persist_resolved_limits_for_handle( kernel: &dyn AgentKernelPort, @@ -435,24 +143,6 @@ pub(crate) async fn persist_delegation_for_handle( Ok(handle) } -async fn ensure_handle_has_resolved_limits( - kernel: &dyn AgentKernelPort, - gateway: &astrcode_kernel::KernelGateway, - handle: SubRunHandle, - max_steps: Option, -) -> std::result::Result { - if !handle.resolved_limits.allowed_tools.is_empty() { - return Ok(handle); - } - - persist_resolved_limits_for_handle( - kernel, - handle, - default_resolved_limits_for_gateway(gateway, max_steps), - ) - .await -} - pub(crate) fn child_delivery_input_queue_envelope( notification: &astrcode_core::ChildSessionNotification, target_agent_id: String, @@ -668,6 +358,7 @@ pub struct AgentOrchestrationService { session_runtime: Arc, config_service: Arc, profiles: Arc, + governance_surface: Arc, task_registry: Arc, metrics: Arc, observe_guard: Arc>, @@ -679,6 +370,7 @@ impl AgentOrchestrationService { session_runtime: Arc, config_service: Arc, profiles: Arc, + governance_surface: Arc, task_registry: Arc, metrics: Arc, ) -> Self { @@ -687,329 +379,12 @@ impl AgentOrchestrationService { session_runtime, config_service, profiles, + governance_surface, task_registry, metrics, observe_guard: Arc::new(Mutex::new(ObserveGuardState::default())), } } - - /// 解析指定工作目录的有效 RuntimeConfig。 - fn resolve_runtime_config_for_working_dir( - &self, - working_dir: &Path, - ) -> std::result::Result { - self.config_service - .load_resolved_runtime_config(Some(working_dir)) - .map_err(|error| AgentOrchestrationError::Internal(error.to_string())) - } - - /// 解析指定 session 对应工作目录的有效 RuntimeConfig。 - async fn resolve_runtime_config_for_session( - &self, - session_id: &str, - ) -> std::result::Result { - let working_dir = self - .session_runtime - .get_session_working_dir(session_id) - .await - .map_err(AgentOrchestrationError::from)?; - self.resolve_runtime_config_for_working_dir(Path::new(&working_dir)) - } - - /// 解析子 agent profile,将 ApplicationError 映射为编排层错误。 - /// NotFound → NotFound(提示用户检查 profile id), - /// InvalidArgument → InvalidInput(参数格式问题), - /// 其余一律降级为 Internal(避免泄露内部细节)。 - fn resolve_subagent_profile( - &self, - working_dir: &Path, - profile_id: &str, - ) -> std::result::Result { - self.profiles - .find_profile(working_dir, profile_id) - .map_err(|error| match error { - crate::ApplicationError::NotFound(message) => { - AgentOrchestrationError::NotFound(message) - }, - crate::ApplicationError::InvalidArgument(message) => { - AgentOrchestrationError::InvalidInput(message) - }, - other => AgentOrchestrationError::Internal(other.to_string()), - }) - } - - fn collaboration_policy_context( - &self, - runtime: &astrcode_core::ResolvedRuntimeConfig, - ) -> AgentCollaborationPolicyContext { - AgentCollaborationPolicyContext { - policy_revision: AGENT_COLLABORATION_POLICY_REVISION.to_string(), - max_subrun_depth: runtime.agent.max_subrun_depth, - max_spawn_per_turn: runtime.agent.max_spawn_per_turn, - } - } - - async fn append_collaboration_fact( - &self, - fact: AgentCollaborationFact, - ) -> std::result::Result<(), AgentOrchestrationError> { - let turn_id = fact.turn_id.clone(); - let parent_session_id = fact.parent_session_id.clone(); - // 根据父级 agent 的 depth 决定 event context 类型: - // depth == 0 是根级执行(用 root_execution_event_context), - // 否则是子运行(用 subrun_event_context 保持 child session 血缘)。 - let event_agent = if let Some(parent_agent_id) = fact.parent_agent_id.as_deref() { - self.kernel - .get_handle(parent_agent_id) - .await - .map(|handle| { - if handle.depth == 0 { - root_execution_event_context(handle.agent_id, handle.agent_profile) - } else { - subrun_event_context(&handle) - } - }) - .unwrap_or_default() - } else { - AgentEventContext::default() - }; - self.session_runtime - .append_agent_collaboration_fact( - &parent_session_id, - &turn_id, - event_agent, - fact.clone(), - ) - .await - .map_err(AgentOrchestrationError::from)?; - self.metrics.record_agent_collaboration_fact(&fact); - Ok(()) - } - - async fn record_collaboration_fact( - &self, - runtime: &astrcode_core::ResolvedRuntimeConfig, - record: CollaborationFactRecord<'_>, - ) -> std::result::Result<(), AgentOrchestrationError> { - let fact = AgentCollaborationFact { - fact_id: format!("acf-{}", uuid::Uuid::new_v4()).into(), - action: record.action, - outcome: record.outcome, - parent_session_id: record.session_id.to_string().into(), - turn_id: record.turn_id.to_string().into(), - parent_agent_id: record.parent_agent_id.map(Into::into), - child_identity: record.child.and_then(|handle| { - handle - .child_session_id - .clone() - .map(|child_session_id| ChildExecutionIdentity { - agent_id: handle.agent_id.clone(), - session_id: child_session_id, - sub_run_id: handle.sub_run_id.clone(), - }) - }), - delivery_id: record.delivery_id.map(Into::into), - reason_code: record.reason_code, - summary: record.summary, - latency_ms: record.latency_ms, - source_tool_call_id: record.source_tool_call_id.map(Into::into), - policy: self.collaboration_policy_context(runtime), - }; - self.append_collaboration_fact(fact).await - } - - fn tool_collaboration_context( - &self, - ctx: &ToolContext, - ) -> std::result::Result { - Ok(ToolCollaborationContext::new( - self.resolve_runtime_config_for_working_dir(ctx.working_dir())?, - ctx.session_id().to_string(), - ctx.turn_id().unwrap_or("unknown-turn").to_string(), - ctx.agent_context().agent_id.clone().map(Into::into), - ctx.tool_call_id().map(ToString::to_string), - )) - } - - async fn record_fact_best_effort( - &self, - runtime: &astrcode_core::ResolvedRuntimeConfig, - record: CollaborationFactRecord<'_>, - ) { - let _ = self.record_collaboration_fact(runtime, record).await; - } - - async fn reject_with_fact( - &self, - runtime: &astrcode_core::ResolvedRuntimeConfig, - record: CollaborationFactRecord<'_>, - error: AgentOrchestrationError, - ) -> std::result::Result { - self.record_fact_best_effort(runtime, record).await; - Err(error) - } - - async fn reject_spawn( - &self, - collaboration: &ToolCollaborationContext, - reason_code: &str, - error: AgentOrchestrationError, - ) -> Result { - self.record_fact_best_effort( - collaboration.runtime(), - collaboration - .fact( - AgentCollaborationActionKind::Spawn, - AgentCollaborationOutcomeKind::Rejected, - ) - .reason_code(reason_code) - .summary(error.to_string()), - ) - .await; - Err(map_orchestration_error(error)) - } - - async fn fail_spawn_internal( - &self, - collaboration: &ToolCollaborationContext, - reason_code: &str, - message: String, - ) -> Result { - self.record_fact_best_effort( - collaboration.runtime(), - collaboration - .fact( - AgentCollaborationActionKind::Spawn, - AgentCollaborationOutcomeKind::Failed, - ) - .reason_code(reason_code) - .summary(message.clone()), - ) - .await; - Err(astrcode_core::AstrError::Internal(message)) - } - - /// 三级解析确保父级 agent handle 存在: - /// 1. 显式 agent_id → 直接查找,未找到且为 RootExecution 则自动注册 - /// 2. 无显式 id → 按 session 查找已有 root agent - /// 3. 都没有 → 注册一个隐式 root agent(synthetic agent id) - async fn ensure_parent_agent_handle( - &self, - ctx: &ToolContext, - ) -> std::result::Result { - let session_id = ctx.session_id().to_string(); - let explicit_agent_id = ctx - .agent_context() - .agent_id - .clone() - .filter(|agent_id| !agent_id.trim().is_empty()); - - if let Some(agent_id) = explicit_agent_id { - if let Some(handle) = self.kernel.get_handle(&agent_id).await { - if handle.depth == 0 && handle.resolved_limits.allowed_tools.is_empty() { - return ensure_handle_has_resolved_limits( - self.kernel.as_ref(), - &self.kernel.gateway(), - handle, - None, - ) - .await - .map_err(AgentOrchestrationError::Internal); - } - return Ok(handle); - } - - let is_root_execution = matches!( - ctx.agent_context().invocation_kind, - Some(InvocationKind::RootExecution) - ); - if is_root_execution { - let profile_id = ctx - .agent_context() - .agent_profile - .clone() - .filter(|profile_id| !profile_id.trim().is_empty()) - .unwrap_or_else(|| IMPLICIT_ROOT_PROFILE_ID.to_string()); - let handle = self - .kernel - .register_root_agent(agent_id.to_string(), session_id, profile_id) - .await - .map_err(|error| { - AgentOrchestrationError::Internal(format!( - "failed to register root agent for parent context: {error}" - )) - })?; - return ensure_handle_has_resolved_limits( - self.kernel.as_ref(), - &self.kernel.gateway(), - handle, - None, - ) - .await - .map_err(AgentOrchestrationError::Internal); - } - - return Err(AgentOrchestrationError::NotFound(format!( - "agent '{}' not found", - agent_id - ))); - } - - if let Some(handle) = self.kernel.find_root_handle_for_session(&session_id).await { - return ensure_handle_has_resolved_limits( - self.kernel.as_ref(), - &self.kernel.gateway(), - handle, - None, - ) - .await - .map_err(AgentOrchestrationError::Internal); - } - - let handle = self - .kernel - .register_root_agent( - implicit_session_root_agent_id(&session_id), - session_id, - IMPLICIT_ROOT_PROFILE_ID.to_string(), - ) - .await - .map_err(|error| { - AgentOrchestrationError::Internal(format!( - "failed to register implicit root agent for session parent context: {error}" - )) - })?; - ensure_handle_has_resolved_limits( - self.kernel.as_ref(), - &self.kernel.gateway(), - handle, - None, - ) - .await - .map_err(AgentOrchestrationError::Internal) - } - - async fn enforce_spawn_budget_for_turn( - &self, - parent_agent_id: &str, - parent_turn_id: &str, - max_spawn_per_turn: usize, - ) -> std::result::Result<(), AgentOrchestrationError> { - let spawned_for_turn = self - .kernel - .count_children_spawned_for_turn(parent_agent_id, parent_turn_id) - .await; - - if spawned_for_turn >= max_spawn_per_turn { - return Err(AgentOrchestrationError::InvalidInput(format!( - "spawn budget exhausted for this turn ({spawned_for_turn}/{max_spawn_per_turn}); \ - reuse an existing child with send/observe/close, or continue the work in the \ - current agent" - ))); - } - - Ok(()) - } } #[derive(Debug, Clone, PartialEq, Eq)] @@ -1083,6 +458,7 @@ impl astrcode_core::SubAgentExecutor for AgentOrchestrationService { .map_err(map_orchestration_error)?; let collaboration = self .tool_collaboration_context(ctx) + .await .map_err(map_orchestration_error)? .with_parent_agent_id(Some(parent_handle.agent_id.to_string())); let parent_agent_id = parent_handle.agent_id.to_string(); @@ -1108,13 +484,14 @@ impl astrcode_core::SubAgentExecutor for AgentOrchestrationService { parent_agent_id: parent_agent_id.clone(), parent_turn_id: parent_turn_id.clone(), working_dir: ctx.working_dir().display().to_string(), + mode_id: collaboration.mode_id().clone(), profile, description: spawn_description.clone(), task: params.prompt, context: params.context, - parent_allowed_tools: effective_tool_names_for_handle( - &parent_handle, + parent_allowed_tools: effective_allowed_tools_for_limits( &self.kernel.gateway(), + &parent_handle.resolved_limits, ), capability_grant: params.capability_grant, source_tool_call_id: ctx.tool_call_id().map(ToString::to_string), @@ -1123,7 +500,7 @@ impl astrcode_core::SubAgentExecutor for AgentOrchestrationService { .enforce_spawn_budget_for_turn( &parent_agent_id, &request.parent_turn_id, - runtime_config.agent.max_spawn_per_turn, + collaboration.policy().max_spawn_per_turn, ) .await { @@ -1135,6 +512,7 @@ impl astrcode_core::SubAgentExecutor for AgentOrchestrationService { let accepted = match launch_subagent( self.kernel.as_ref(), self.session_runtime.as_ref(), + self.governance_surface.as_ref(), request, runtime_config.clone(), &self.metrics, @@ -1263,13 +641,15 @@ mod tests { }; use super::{ - IMPLICIT_ROOT_PROFILE_ID, build_delegation_metadata, build_fresh_child_contract, - build_resumed_child_contract, child_delivery_input_queue_envelope, + IMPLICIT_ROOT_PROFILE_ID, build_delegation_metadata, child_delivery_input_queue_envelope, implicit_session_root_agent_id, root_execution_event_context, terminal_notification_message, terminal_notification_turn_outcome, }; - use crate::agent::test_support::{ - TestLlmBehavior, build_agent_test_harness, build_agent_test_harness_with_agent_config, + use crate::{ + agent::test_support::{ + TestLlmBehavior, build_agent_test_harness, build_agent_test_harness_with_agent_config, + }, + governance_surface::{build_fresh_child_contract, build_resumed_child_contract}, }; fn assert_no_removed_kernel_agent_methods(source: &str, file: &str) { diff --git a/crates/application/src/agent/observe.rs b/crates/application/src/agent/observe.rs index 04755008..77206668 100644 --- a/crates/application/src/agent/observe.rs +++ b/crates/application/src/agent/observe.rs @@ -17,7 +17,7 @@ impl AgentOrchestrationService { params: ObserveParams, ctx: &astrcode_core::ToolContext, ) -> Result { - let collaboration = self.tool_collaboration_context(ctx)?; + let collaboration = self.tool_collaboration_context(ctx).await?; params .validate() .map_err(super::AgentOrchestrationError::from)?; diff --git a/crates/application/src/agent/routing.rs b/crates/application/src/agent/routing.rs index fa7bd457..b653f1d6 100644 --- a/crates/application/src/agent/routing.rs +++ b/crates/application/src/agent/routing.rs @@ -12,9 +12,10 @@ use astrcode_core::{ SubRunHandle, }; -use super::{ - AgentOrchestrationService, build_delegation_metadata, build_resumed_child_contract, - subrun_event_context, +use super::{AgentOrchestrationService, build_delegation_metadata, subrun_event_context}; +use crate::governance_surface::{ + GovernanceBusyPolicy, ResumedChildGovernanceInput, collaboration_policy_context, + effective_allowed_tools_for_limits, }; impl AgentOrchestrationService { @@ -126,7 +127,7 @@ impl AgentOrchestrationService { params: SendToChildParams, ctx: &astrcode_core::ToolContext, ) -> Result { - let collaboration = self.tool_collaboration_context(ctx)?; + let collaboration = self.tool_collaboration_context(ctx).await?; let child = self .require_direct_child_handle( @@ -170,7 +171,7 @@ impl AgentOrchestrationService { params: SendToParentParams, ctx: &astrcode_core::ToolContext, ) -> Result { - let fallback_collaboration = self.tool_collaboration_context(ctx)?; + let fallback_collaboration = self.tool_collaboration_context(ctx).await?; let Some(child_agent_id) = ctx.agent_context().agent_id.as_deref() else { let error = super::AgentOrchestrationError::InvalidInput( "upstream send requires a child agent execution context".to_string(), @@ -411,13 +412,27 @@ impl AgentOrchestrationService { }, }; + let runtime = self + .resolve_runtime_config_for_session(child.session_id.as_str()) + .await?; + let mode_id = self + .session_runtime + .session_mode_state(child.session_id.as_str()) + .await + .map_err(super::AgentOrchestrationError::from)? + .current_mode_id; + Ok(super::ToolCollaborationContext::new( - self.resolve_runtime_config_for_session(child.session_id.as_str()) - .await?, - child.session_id.to_string(), - parent_turn_id.to_string(), - child.parent_agent_id.clone().map(Into::into), - ctx.tool_call_id().map(ToString::to_string), + super::ToolCollaborationContextInput { + runtime: runtime.clone(), + session_id: child.session_id.to_string(), + turn_id: parent_turn_id.to_string(), + parent_agent_id: child.parent_agent_id.clone().map(Into::into), + source_tool_call_id: ctx.tool_call_id().map(ToString::to_string), + policy: collaboration_policy_context(&runtime), + governance_revision: super::AGENT_COLLABORATION_POLICY_REVISION.to_string(), + mode_id, + }, )) } @@ -461,7 +476,7 @@ impl AgentOrchestrationService { params: CloseAgentParams, ctx: &astrcode_core::ToolContext, ) -> Result { - let collaboration = self.tool_collaboration_context(ctx)?; + let collaboration = self.tool_collaboration_context(ctx).await?; params .validate() .map_err(super::AgentOrchestrationError::from)?; @@ -621,60 +636,97 @@ impl AgentOrchestrationService { .await; }; - let allowed_tools = - super::effective_tool_names_for_handle(&reused_handle, &self.kernel.gateway()); - let scoped_router = match self - .kernel - .gateway() - .capabilities() - .subset_for_tools_checked(&allowed_tools) + let fallback_delegation = build_delegation_metadata( + "", + params.message.as_str(), + &reused_handle.resolved_limits, + false, + ); + let resume_delegation = reused_handle + .delegation + .clone() + .unwrap_or(fallback_delegation); + let runtime = match self + .resolve_runtime_config_for_session(child_session_id) + .await { - Ok(router) => router, + Ok(runtime) => runtime, Err(error) => { return self .restore_pending_inbox_and_fail( &child.agent_id, pending, format!( - "agent '{}' resume capability resolution failed: {error}", + "agent '{}' resume runtime resolution failed: {error}", + params.agent_id + ), + ) + .await; + }, + }; + let working_dir = match self + .session_runtime + .get_session_working_dir(child_session_id) + .await + { + Ok(working_dir) => working_dir, + Err(error) => { + return self + .restore_pending_inbox_and_fail( + &child.agent_id, + pending, + format!( + "agent '{}' resume working directory resolution failed: {error}", + params.agent_id + ), + ) + .await; + }, + }; + let surface = match self.governance_surface.resumed_child_surface( + self.kernel.as_ref(), + ResumedChildGovernanceInput { + session_id: child.session_id.to_string(), + turn_id: current_parent_turn_id.clone(), + working_dir, + mode_id: collaboration.mode_id().clone(), + runtime, + allowed_tools: effective_allowed_tools_for_limits( + &self.kernel.gateway(), + &reused_handle.resolved_limits, + ), + resolved_limits: reused_handle.resolved_limits.clone(), + delegation: Some(resume_delegation.clone()), + message: params.message.clone(), + context: params.context.clone(), + busy_policy: GovernanceBusyPolicy::BranchOnBusy, + }, + ) { + Ok(surface) => surface, + Err(error) => { + return self + .restore_pending_inbox_and_fail( + &child.agent_id, + pending, + format!( + "agent '{}' resume governance surface failed: {error}", params.agent_id ), ) .await; }, }; - - let fallback_delegation = build_delegation_metadata( - "", - params.message.as_str(), - &reused_handle.resolved_limits, - false, - ); - let resume_delegation = reused_handle - .delegation - .clone() - .unwrap_or(fallback_delegation); let accepted = match self .session_runtime .submit_prompt_for_agent_with_submission( child_session_id, resume_message, - self.resolve_runtime_config_for_session(child_session_id) - .await?, - astrcode_session_runtime::AgentPromptSubmission { - agent: astrcode_core::AgentEventContext::from(&reused_handle), - capability_router: Some(scoped_router), - prompt_declarations: vec![build_resumed_child_contract( - &resume_delegation, - params.message.as_str(), - params.context.as_deref(), - )], - resolved_limits: Some(reused_handle.resolved_limits.clone()), - resolved_overrides: None, - injected_messages: Vec::new(), - source_tool_call_id: ctx.tool_call_id().map(ToString::to_string), - }, + surface.runtime.clone(), + surface.into_submission( + astrcode_core::AgentEventContext::from(&reused_handle), + ctx.tool_call_id().map(ToString::to_string), + ), ) .await { diff --git a/crates/application/src/agent/test_support.rs b/crates/application/src/agent/test_support.rs index 60b7b3f4..da7e4391 100644 --- a/crates/application/src/agent/test_support.rs +++ b/crates/application/src/agent/test_support.rs @@ -21,8 +21,8 @@ use serde_json::Value; use crate::{ AgentKernelPort, AgentOrchestrationService, AgentSessionPort, ApplicationError, ConfigService, - ProfileResolutionService, RuntimeObservabilityCollector, execution::ProfileProvider, - lifecycle::TaskRegistry, + GovernanceSurfaceAssembler, ProfileResolutionService, RuntimeObservabilityCollector, + execution::ProfileProvider, lifecycle::TaskRegistry, }; pub(crate) struct AgentTestHarness { @@ -83,6 +83,7 @@ pub(crate) fn build_agent_test_harness_with_agent_config( session_port, config_service.clone(), profiles.clone(), + Arc::new(GovernanceSurfaceAssembler::default()), task_registry, metrics.clone(), ); diff --git a/crates/application/src/execution/root.rs b/crates/application/src/execution/root.rs index c621aad1..efe2afba 100644 --- a/crates/application/src/execution/root.rs +++ b/crates/application/src/execution/root.rs @@ -8,8 +8,7 @@ use std::{path::Path, sync::Arc}; use astrcode_core::{ - AgentMode, ExecutionAccepted, ResolvedExecutionLimitsSnapshot, ResolvedRuntimeConfig, - SubagentContextOverrides, + AgentMode, ExecutionAccepted, ModeId, ResolvedRuntimeConfig, SubagentContextOverrides, }; use crate::{ @@ -19,6 +18,7 @@ use crate::{ execution::{ ExecutionControl, ProfileResolutionService, ensure_profile_mode, merge_task_with_context, }, + governance_surface::{GovernanceSurfaceAssembler, RootGovernanceInput}, }; /// 根代理执行请求。 @@ -44,11 +44,11 @@ pub async fn execute_root_agent( kernel: &dyn AppKernelPort, session_runtime: &dyn AppSessionPort, profiles: &Arc, + governance: &GovernanceSurfaceAssembler, request: RootExecutionRequest, - mut runtime_config: ResolvedRuntimeConfig, + runtime_config: ResolvedRuntimeConfig, ) -> Result { validate_root_request(&request)?; - apply_execution_control(&mut runtime_config, request.control.as_ref()); validate_root_context_overrides_supported(request.context_overrides.as_ref())?; let profile = profiles.find_profile(Path::new(&request.working_dir), &request.agent_id)?; @@ -68,10 +68,19 @@ pub async fn execute_root_agent( ) .await .map_err(|e| ApplicationError::Internal(format!("failed to register root agent: {e}")))?; - let resolved_limits = ResolvedExecutionLimitsSnapshot { - allowed_tools: kernel.gateway().capabilities().tool_names(), - max_steps: Some(runtime_config.max_steps as u32), - }; + let surface = governance.root_surface( + kernel, + RootGovernanceInput { + session_id: session.session_id.clone(), + turn_id: astrcode_core::generate_turn_id(), + working_dir: request.working_dir.clone(), + profile: profile_id.clone(), + mode_id: ModeId::default(), + runtime: runtime_config, + control: request.control.clone(), + }, + )?; + let resolved_limits = surface.resolved_limits.clone(); if kernel .set_resolved_limits(&handle.agent_id, resolved_limits.clone()) .await @@ -92,11 +101,11 @@ pub async fn execute_root_agent( .submit_prompt_for_agent( &session.session_id, merged_task, - runtime_config, - astrcode_session_runtime::AgentPromptSubmission { - agent: root_execution_event_context(handle.agent_id.clone(), profile_id), - ..Default::default() - }, + surface.runtime.clone(), + surface.into_submission( + root_execution_event_context(handle.agent_id.clone(), profile_id), + None, + ), ) .await .map_err(ApplicationError::from)?; @@ -157,18 +166,6 @@ fn ensure_root_profile_mode(profile: &astrcode_core::AgentProfile) -> Result<(), ) } -fn apply_execution_control( - runtime_config: &mut ResolvedRuntimeConfig, - control: Option<&ExecutionControl>, -) { - let Some(control) = control else { - return; - }; - if let Some(max_steps) = control.max_steps { - runtime_config.max_steps = max_steps as usize; - } -} - #[cfg(test)] mod tests { use super::*; @@ -259,20 +256,6 @@ mod tests { assert_eq!(merged, "main task"); } - #[test] - fn apply_execution_control_overrides_runtime_config() { - let mut runtime = ResolvedRuntimeConfig::default(); - apply_execution_control( - &mut runtime, - Some(&ExecutionControl { - max_steps: Some(5), - manual_compact: None, - }), - ); - - assert_eq!(runtime.max_steps, 5); - } - #[test] fn validate_root_context_overrides_accepts_empty_overrides() { validate_root_context_overrides_supported(Some(&SubagentContextOverrides::default())) diff --git a/crates/application/src/execution/subagent.rs b/crates/application/src/execution/subagent.rs index 21e74d99..ef9d762d 100644 --- a/crates/application/src/execution/subagent.rs +++ b/crates/application/src/execution/subagent.rs @@ -6,22 +6,22 @@ use std::sync::Arc; use astrcode_core::{ - AgentLifecycleStatus, AgentMode, AgentProfile, ExecutionAccepted, ForkMode, LlmMessage, - ResolvedExecutionLimitsSnapshot, ResolvedRuntimeConfig, ResolvedSubagentContextOverrides, - RuntimeMetricsRecorder, SessionId, SpawnCapabilityGrant, UserMessageOrigin, - normalize_non_empty_unique_string_list, project, + AgentLifecycleStatus, AgentMode, AgentProfile, ExecutionAccepted, ModeId, + ResolvedRuntimeConfig, RuntimeMetricsRecorder, SpawnCapabilityGrant, }; use astrcode_kernel::AgentControlError; -use astrcode_session_runtime::AgentPromptSubmission; use crate::{ AgentKernelPort, AgentSessionPort, agent::{ - build_delegation_metadata, build_fresh_child_contract, persist_delegation_for_handle, - persist_resolved_limits_for_handle, subrun_event_context, + persist_delegation_for_handle, persist_resolved_limits_for_handle, subrun_event_context, }, errors::ApplicationError, execution::{ensure_profile_mode, merge_task_with_context}, + governance_surface::{ + FreshChildGovernanceInput, GovernanceBusyPolicy, GovernanceSurfaceAssembler, + build_delegation_metadata, + }, }; /// 子代理执行请求。 @@ -30,6 +30,7 @@ pub struct SubagentExecutionRequest { pub parent_agent_id: String, pub parent_turn_id: String, pub working_dir: String, + pub mode_id: ModeId, pub profile: AgentProfile, pub description: String, pub task: String, @@ -50,36 +51,37 @@ pub struct SubagentExecutionRequest { pub async fn launch_subagent( kernel: &dyn AgentKernelPort, session_runtime: &dyn AgentSessionPort, + governance: &GovernanceSurfaceAssembler, request: SubagentExecutionRequest, runtime_config: ResolvedRuntimeConfig, metrics: &Arc, ) -> Result { validate_subagent_request(&request)?; ensure_subagent_profile_mode(&request.profile)?; - let resolved_limits = resolve_child_execution_limits( - &request.parent_allowed_tools, - request.capability_grant.as_ref(), - runtime_config.max_steps as u32, - )?; - let scoped_gateway = kernel - .gateway() - .subset_for_tools_checked(&resolved_limits.allowed_tools) - .map_err(|error| ApplicationError::InvalidArgument(error.to_string()))?; - let scoped_router = scoped_gateway.capabilities().clone(); - let resolved_overrides = ResolvedSubagentContextOverrides::default(); - let inherited_messages = resolve_inherited_parent_messages( - session_runtime, - &request.parent_session_id, - &resolved_overrides, - ) - .await?; + let surface = governance + .fresh_child_surface( + kernel, + session_runtime, + FreshChildGovernanceInput { + session_id: request.parent_session_id.clone(), + turn_id: request.parent_turn_id.clone(), + working_dir: request.working_dir.clone(), + mode_id: request.mode_id.clone(), + runtime: runtime_config, + parent_allowed_tools: request.parent_allowed_tools.clone(), + capability_grant: request.capability_grant.clone(), + description: request.description.clone(), + task: request.task.clone(), + busy_policy: GovernanceBusyPolicy::BranchOnBusy, + }, + ) + .await?; let delegation = build_delegation_metadata( request.description.as_str(), request.task.as_str(), - &resolved_limits, + &surface.resolved_limits, request.capability_grant.is_some(), ); - let contract = build_fresh_child_contract(&delegation); let child_session = session_runtime .create_child_session(&request.working_dir, &request.parent_session_id) @@ -107,29 +109,24 @@ pub async fn launch_subagent( handle.agent_id ))); } - let handle = persist_resolved_limits_for_handle(kernel, handle, resolved_limits.clone()) - .await - .map_err(ApplicationError::Internal)?; + let handle = + persist_resolved_limits_for_handle(kernel, handle, surface.resolved_limits.clone()) + .await + .map_err(ApplicationError::Internal)?; let handle = persist_delegation_for_handle(kernel, handle, delegation) .await .map_err(ApplicationError::Internal)?; - let merged_task = merge_task_with_context(&request.task, request.context.as_deref()); let mut accepted = session_runtime .submit_prompt_for_agent_with_submission( &child_session.session_id, merged_task, - runtime_config, - AgentPromptSubmission { - agent: subrun_event_context(&handle), - capability_router: Some(scoped_router), - prompt_declarations: vec![contract], - resolved_limits: Some(resolved_limits), - resolved_overrides: Some(resolved_overrides), - injected_messages: inherited_messages, - source_tool_call_id: request.source_tool_call_id, - }, + surface.runtime.clone(), + surface.into_submission( + subrun_event_context(&handle), + request.source_tool_call_id.clone(), + ), ) .await .map_err(ApplicationError::from)?; @@ -138,39 +135,6 @@ pub async fn launch_subagent( Ok(accepted) } -fn resolve_child_execution_limits( - parent_allowed_tools: &[String], - capability_grant: Option<&SpawnCapabilityGrant>, - max_steps: u32, -) -> Result { - let parent_allowed_tools = - normalize_non_empty_unique_string_list(parent_allowed_tools, "parentAllowedTools") - .map_err(ApplicationError::from)?; - let allowed_tools = match capability_grant { - Some(grant) => { - let requested_tools = grant - .normalized_allowed_tools() - .map_err(ApplicationError::from)?; - let allowed_tools = requested_tools - .into_iter() - .filter(|tool| parent_allowed_tools.iter().any(|parent| parent == tool)) - .collect::>(); - if allowed_tools.is_empty() { - return Err(ApplicationError::InvalidArgument( - "capability grant 与父级当前可继承工具无交集".to_string(), - )); - } - allowed_tools - }, - None => parent_allowed_tools, - }; - - Ok(ResolvedExecutionLimitsSnapshot { - allowed_tools, - max_steps: Some(max_steps), - }) -} - fn validate_subagent_request(request: &SubagentExecutionRequest) -> Result<(), ApplicationError> { if request.parent_session_id.trim().is_empty() { return Err(ApplicationError::InvalidArgument( @@ -195,107 +159,6 @@ fn validate_subagent_request(request: &SubagentExecutionRequest) -> Result<(), A Ok(()) } -async fn resolve_inherited_parent_messages( - session_runtime: &dyn AgentSessionPort, - parent_session_id: &str, - overrides: &ResolvedSubagentContextOverrides, -) -> Result, ApplicationError> { - let parent_events = session_runtime - .session_stored_events(&SessionId::from(parent_session_id.to_string())) - .await - .map_err(ApplicationError::from)?; - let projected = project( - &parent_events - .iter() - .map(|stored| stored.event.clone()) - .collect::>(), - ); - - Ok(build_inherited_messages(&projected.messages, overrides)) -} - -fn build_inherited_messages( - parent_messages: &[LlmMessage], - overrides: &ResolvedSubagentContextOverrides, -) -> Vec { - let mut inherited = Vec::new(); - - if overrides.include_compact_summary { - if let Some(summary) = parent_messages.iter().find(|message| { - matches!( - message, - LlmMessage::User { - origin: UserMessageOrigin::CompactSummary, - .. - } - ) - }) { - inherited.push(summary.clone()); - } - } - - if overrides.include_recent_tail { - inherited.extend(select_inherited_recent_tail( - parent_messages, - overrides.fork_mode.as_ref(), - )); - } - - inherited -} - -fn select_inherited_recent_tail( - parent_messages: &[LlmMessage], - fork_mode: Option<&ForkMode>, -) -> Vec { - let non_summary_messages = parent_messages - .iter() - .filter(|message| { - !matches!( - message, - LlmMessage::User { - origin: UserMessageOrigin::CompactSummary, - .. - } - ) - }) - .cloned() - .collect::>(); - - match fork_mode { - Some(ForkMode::LastNTurns(turns)) => { - tail_messages_for_last_n_turns(&non_summary_messages, *turns) - }, - Some(ForkMode::FullHistory) | None => non_summary_messages, - } -} - -fn tail_messages_for_last_n_turns(messages: &[LlmMessage], turns: usize) -> Vec { - if turns == 0 || messages.is_empty() { - return Vec::new(); - } - - let mut remaining_turns = turns; - let mut start_index = 0usize; - for (index, message) in messages.iter().enumerate().rev() { - if matches!( - message, - LlmMessage::User { - origin: UserMessageOrigin::User, - .. - } - ) { - remaining_turns = remaining_turns.saturating_sub(1); - start_index = index; - if remaining_turns == 0 { - break; - } - } - } - - messages[start_index..].to_vec() -} - fn ensure_subagent_profile_mode(profile: &AgentProfile) -> Result<(), ApplicationError> { ensure_profile_mode(profile, &[AgentMode::SubAgent, AgentMode::All], "subagent") } @@ -323,9 +186,10 @@ fn map_spawn_error(error: AgentControlError) -> ApplicationError { #[cfg(test)] mod tests { - use astrcode_core::{AgentMode, AgentProfile, ForkMode, LlmMessage, UserMessageOrigin}; + use astrcode_core::{AgentMode, AgentProfile}; use super::*; + use crate::governance_surface::GovernanceSurfaceAssembler; fn test_profile() -> AgentProfile { AgentProfile { @@ -346,6 +210,7 @@ mod tests { parent_agent_id: "root-agent".to_string(), parent_turn_id: "turn-1".to_string(), working_dir: "/tmp/project".to_string(), + mode_id: ModeId::default(), profile: test_profile(), description: "探索代码".to_string(), task: "explore the code".to_string(), @@ -438,135 +303,7 @@ mod tests { } #[test] - fn resolve_child_execution_limits_inherits_parent_tools_when_no_grant() { - let limits = resolve_child_execution_limits( - &["read_file".to_string(), "grep".to_string()], - None, - 12, - ) - .expect("limits should resolve"); - - assert_eq!(limits.allowed_tools, vec!["read_file", "grep"]); - assert_eq!(limits.max_steps, Some(12)); - } - - #[test] - fn resolve_child_execution_limits_intersects_parent_and_grant() { - let limits = resolve_child_execution_limits( - &["read_file".to_string(), "grep".to_string()], - Some(&SpawnCapabilityGrant { - allowed_tools: vec!["grep".to_string(), "write_file".to_string()], - }), - 8, - ) - .expect("limits should resolve"); - - assert_eq!(limits.allowed_tools, vec!["grep"]); - assert_eq!(limits.max_steps, Some(8)); - } - - #[test] - fn resolve_child_execution_limits_rejects_disjoint_grant() { - let error = resolve_child_execution_limits( - &["read_file".to_string(), "grep".to_string()], - Some(&SpawnCapabilityGrant { - allowed_tools: vec!["write_file".to_string()], - }), - 8, - ) - .expect_err("disjoint grants should fail fast"); - - assert!(error.to_string().contains("无交集")); - } - - #[test] - fn build_inherited_messages_uses_compact_summary_and_last_n_turn_tail() { - let inherited = build_inherited_messages( - &[ - LlmMessage::User { - content: "older summary".to_string(), - origin: UserMessageOrigin::CompactSummary, - }, - LlmMessage::User { - content: "turn-1".to_string(), - origin: UserMessageOrigin::User, - }, - LlmMessage::Assistant { - content: "answer-1".to_string(), - tool_calls: Vec::new(), - reasoning: None, - }, - LlmMessage::User { - content: "turn-2".to_string(), - origin: UserMessageOrigin::User, - }, - LlmMessage::Assistant { - content: "answer-2".to_string(), - tool_calls: Vec::new(), - reasoning: None, - }, - ], - &ResolvedSubagentContextOverrides { - include_compact_summary: true, - include_recent_tail: true, - fork_mode: Some(ForkMode::LastNTurns(1)), - ..ResolvedSubagentContextOverrides::default() - }, - ); - - assert_eq!(inherited.len(), 3); - assert!(matches!( - &inherited[0], - LlmMessage::User { - origin: UserMessageOrigin::CompactSummary, - .. - } - )); - assert!(matches!( - &inherited[1], - LlmMessage::User { - content, - origin: UserMessageOrigin::User, - } if content == "turn-2" - )); - assert!(matches!( - &inherited[2], - LlmMessage::Assistant { content, .. } if content == "answer-2" - )); - } - - #[test] - fn select_inherited_recent_tail_uses_full_history_by_default() { - let inherited = select_inherited_recent_tail( - &[ - LlmMessage::User { - content: "older summary".to_string(), - origin: UserMessageOrigin::CompactSummary, - }, - LlmMessage::User { - content: "turn-1".to_string(), - origin: UserMessageOrigin::User, - }, - LlmMessage::Assistant { - content: "answer-1".to_string(), - tool_calls: Vec::new(), - reasoning: None, - }, - ], - None, - ); - - assert_eq!(inherited.len(), 2); - assert!(matches!( - &inherited[0], - LlmMessage::User { - content, - origin: UserMessageOrigin::User, - } if content == "turn-1" - )); - assert!(matches!( - &inherited[1], - LlmMessage::Assistant { content, .. } if content == "answer-1" - )); + fn governance_surface_builder_exists_for_subagent_paths() { + let _assembler = GovernanceSurfaceAssembler::default(); } } diff --git a/crates/application/src/governance_surface/assembler.rs b/crates/application/src/governance_surface/assembler.rs new file mode 100644 index 00000000..2677e3d0 --- /dev/null +++ b/crates/application/src/governance_surface/assembler.rs @@ -0,0 +1,367 @@ +use astrcode_core::{ + ResolvedExecutionLimitsSnapshot, ResolvedRuntimeConfig, ResolvedSubagentContextOverrides, + ResolvedTurnEnvelope, +}; +use astrcode_kernel::CapabilityRouter; + +use super::{ + BuildSurfaceInput, FreshChildGovernanceInput, GovernanceBusyPolicy, ResolvedGovernanceSurface, + ResumedChildGovernanceInput, RootGovernanceInput, SessionGovernanceInput, +}; +use crate::{ + AgentSessionPort, AppKernelPort, ApplicationError, CompiledModeEnvelope, ComposerResolvedSkill, + ExecutionControl, ModeCatalog, compile_mode_envelope, compile_mode_envelope_for_child, +}; + +#[derive(Debug, Clone)] +pub struct GovernanceSurfaceAssembler { + mode_catalog: ModeCatalog, +} + +impl GovernanceSurfaceAssembler { + pub fn new(mode_catalog: ModeCatalog) -> Self { + Self { mode_catalog } + } + + pub fn runtime_with_control( + &self, + mut runtime: ResolvedRuntimeConfig, + control: Option<&ExecutionControl>, + allow_manual_compact: bool, + ) -> Result { + if let Some(control) = control { + control.validate()?; + if let Some(max_steps) = control.max_steps { + runtime.max_steps = max_steps as usize; + } + if !allow_manual_compact && control.manual_compact.is_some() { + return Err(ApplicationError::InvalidArgument( + "manualCompact is not valid for prompt submission".to_string(), + )); + } + } + Ok(runtime) + } + + pub fn build_submission_skill_declaration( + &self, + skill: &ComposerResolvedSkill, + user_prompt: Option<&str>, + ) -> astrcode_core::PromptDeclaration { + let mut content = format!( + "The user explicitly selected the `{}` skill for this turn.\n\nSelected skill:\n- id: \ + {}\n- description: {}\n\nTurn contract:\n- Call the `Skill` tool for `{}` before \ + continuing.\n- Treat the user's message as the task-specific instruction for this \ + skill.\n- If the user message is empty, follow the skill's default workflow and ask \ + only if blocked.\n- Do not silently substitute a different skill unless `{}` is \ + unavailable.", + skill.id, + skill.id, + skill.description.trim(), + skill.id, + skill.id + ); + if let Some(user_prompt) = user_prompt.map(str::trim).filter(|value| !value.is_empty()) { + content.push_str(&format!("\n- User prompt focus: {user_prompt}")); + } + astrcode_core::PromptDeclaration { + block_id: format!("submission.skill.{}", skill.id), + title: format!("Selected Skill: {}", skill.id), + content, + render_target: astrcode_core::PromptDeclarationRenderTarget::System, + layer: astrcode_core::SystemPromptLayer::Dynamic, + kind: astrcode_core::PromptDeclarationKind::ExtensionInstruction, + priority_hint: Some(590), + always_include: true, + source: astrcode_core::PromptDeclarationSource::Builtin, + capability_name: None, + origin: Some(format!("skill-slash:{}", skill.id)), + } + } + + fn compile_mode_surface( + &self, + kernel: &dyn AppKernelPort, + mode_id: &astrcode_core::ModeId, + extra_prompt_declarations: Vec, + ) -> Result { + let spec = self.mode_catalog.get(mode_id).ok_or_else(|| { + ApplicationError::InvalidArgument(format!("unknown mode '{}'", mode_id)) + })?; + compile_mode_envelope( + kernel.gateway().capabilities(), + &spec, + extra_prompt_declarations, + ) + .map_err(ApplicationError::from) + } + + fn compile_child_mode_surface( + &self, + kernel: &dyn AppKernelPort, + mode_id: &astrcode_core::ModeId, + parent_allowed_tools: &[String], + capability_grant: Option<&astrcode_core::SpawnCapabilityGrant>, + ) -> Result { + let spec = self.mode_catalog.get(mode_id).ok_or_else(|| { + ApplicationError::InvalidArgument(format!("unknown mode '{}'", mode_id)) + })?; + compile_mode_envelope_for_child( + kernel.gateway().capabilities(), + &spec, + parent_allowed_tools, + capability_grant, + ) + .map_err(ApplicationError::from) + } + + fn build_surface( + &self, + input: BuildSurfaceInput, + ) -> Result { + let BuildSurfaceInput { + session_id, + turn_id, + working_dir, + profile, + compiled, + runtime, + requested_busy_policy, + resolved_overrides, + injected_messages, + leading_prompt_declaration, + } = input; + let mut prompt_declarations = compiled.envelope.prompt_declarations.clone(); + if let Some(leading) = leading_prompt_declaration { + prompt_declarations.insert(0, leading); + } + prompt_declarations.extend(super::prompt::collaboration_prompt_declarations( + &compiled.envelope.allowed_tools, + runtime.agent.max_subrun_depth, + runtime.agent.max_spawn_per_turn, + )); + let busy_policy = super::policy::resolve_busy_policy( + compiled.envelope.submit_busy_policy, + requested_busy_policy, + ); + let surface = ResolvedGovernanceSurface { + mode_id: compiled.envelope.mode_id.clone(), + runtime: runtime.clone(), + capability_router: compiled.capability_router, + prompt_declarations, + resolved_limits: ResolvedExecutionLimitsSnapshot { + allowed_tools: compiled.envelope.allowed_tools.clone(), + max_steps: Some(runtime.max_steps as u32), + }, + resolved_overrides, + injected_messages, + policy_context: super::policy::build_policy_context( + &session_id, + &turn_id, + &working_dir, + &profile, + &compiled.envelope, + ), + collaboration_policy: super::collaboration_policy_context(&runtime), + approval: super::policy::default_approval_pipeline( + &session_id, + &turn_id, + &compiled.envelope, + ), + governance_revision: super::GOVERNANCE_POLICY_REVISION.to_string(), + busy_policy, + diagnostics: compiled.envelope.diagnostics.clone(), + }; + surface.validate()?; + Ok(surface) + } + + pub fn session_surface( + &self, + kernel: &dyn AppKernelPort, + input: SessionGovernanceInput, + ) -> Result { + let runtime = self.runtime_with_control(input.runtime, input.control.as_ref(), false)?; + let compiled = + self.compile_mode_surface(kernel, &input.mode_id, input.extra_prompt_declarations)?; + self.build_surface(BuildSurfaceInput { + session_id: input.session_id, + turn_id: input.turn_id, + working_dir: input.working_dir, + profile: input.profile, + compiled, + runtime, + requested_busy_policy: input.busy_policy, + resolved_overrides: None, + injected_messages: Vec::new(), + leading_prompt_declaration: None, + }) + } + + pub fn root_surface( + &self, + kernel: &dyn AppKernelPort, + input: RootGovernanceInput, + ) -> Result { + self.session_surface( + kernel, + SessionGovernanceInput { + session_id: input.session_id, + turn_id: input.turn_id, + working_dir: input.working_dir, + profile: input.profile, + mode_id: input.mode_id, + runtime: input.runtime, + control: input.control, + extra_prompt_declarations: Vec::new(), + busy_policy: GovernanceBusyPolicy::BranchOnBusy, + }, + ) + } + + pub async fn fresh_child_surface( + &self, + kernel: &dyn AppKernelPort, + session_runtime: &dyn AgentSessionPort, + input: FreshChildGovernanceInput, + ) -> Result { + let compiled = self.compile_child_mode_surface( + kernel, + &input.mode_id, + &input.parent_allowed_tools, + input.capability_grant.as_ref(), + )?; + let resolved_overrides = ResolvedSubagentContextOverrides { + fork_mode: compiled.envelope.fork_mode.clone(), + ..ResolvedSubagentContextOverrides::default() + }; + let injected_messages = super::resolve_inherited_parent_messages( + session_runtime, + &input.session_id, + &resolved_overrides, + ) + .await?; + let delegation = super::build_delegation_metadata( + input.description.as_str(), + input.task.as_str(), + &ResolvedExecutionLimitsSnapshot { + allowed_tools: compiled.envelope.allowed_tools.clone(), + max_steps: Some(input.runtime.max_steps as u32), + }, + compiled.envelope.child_policy.restricted, + ); + self.build_surface(BuildSurfaceInput { + session_id: input.session_id, + turn_id: input.turn_id, + working_dir: input.working_dir, + profile: "subagent".to_string(), + compiled, + runtime: input.runtime, + requested_busy_policy: input.busy_policy, + resolved_overrides: Some(resolved_overrides), + injected_messages, + leading_prompt_declaration: Some(super::build_fresh_child_contract(&delegation)), + }) + } + + pub fn resumed_child_surface( + &self, + kernel: &dyn AppKernelPort, + input: ResumedChildGovernanceInput, + ) -> Result { + let mut runtime = input.runtime; + if let Some(max_steps) = input.resolved_limits.max_steps { + runtime.max_steps = max_steps as usize; + } + let compiled = self.compile_mode_surface(kernel, &input.mode_id, Vec::new())?; + let allowed_tools = if input.allowed_tools.is_empty() { + if input.resolved_limits.allowed_tools.is_empty() { + compiled.envelope.allowed_tools.clone() + } else { + input.resolved_limits.allowed_tools.clone() + } + } else { + input.allowed_tools + }; + let delegation = input.delegation.unwrap_or_else(|| { + super::build_delegation_metadata( + "", + input.message.as_str(), + &input.resolved_limits, + false, + ) + }); + let compiled = CompiledModeEnvelope { + capability_router: if allowed_tools == compiled.envelope.allowed_tools { + compiled.capability_router + } else if allowed_tools.is_empty() { + Some(CapabilityRouter::empty()) + } else { + Some( + kernel + .gateway() + .capabilities() + .subset_for_tools_checked(&allowed_tools) + .map_err(|error| ApplicationError::InvalidArgument(error.to_string()))?, + ) + }, + envelope: ResolvedTurnEnvelope { + allowed_tools: allowed_tools.clone(), + ..compiled.envelope + }, + spec: compiled.spec, + }; + self.build_surface(BuildSurfaceInput { + session_id: input.session_id, + turn_id: input.turn_id, + working_dir: input.working_dir, + profile: "subagent".to_string(), + compiled, + runtime, + requested_busy_policy: input.busy_policy, + resolved_overrides: None, + injected_messages: Vec::new(), + leading_prompt_declaration: Some(super::build_resumed_child_contract( + &delegation, + input.message.as_str(), + input.context.as_deref(), + )), + }) + } + + pub fn tool_collaboration_context( + &self, + runtime: ResolvedRuntimeConfig, + session_id: String, + turn_id: String, + parent_agent_id: Option, + source_tool_call_id: Option, + mode_id: astrcode_core::ModeId, + ) -> super::ToolCollaborationGovernanceContext { + super::ToolCollaborationGovernanceContext::new( + super::ToolCollaborationGovernanceContextInput { + runtime: runtime.clone(), + session_id, + turn_id, + parent_agent_id, + source_tool_call_id, + policy: super::collaboration_policy_context(&runtime), + governance_revision: super::GOVERNANCE_POLICY_REVISION.to_string(), + mode_id, + }, + ) + } + + pub fn mode_catalog(&self) -> &ModeCatalog { + &self.mode_catalog + } +} + +impl Default for GovernanceSurfaceAssembler { + fn default() -> Self { + Self::new( + crate::mode::builtin_mode_catalog() + .expect("builtin governance mode catalog should build"), + ) + } +} diff --git a/crates/application/src/governance_surface/inherited.rs b/crates/application/src/governance_surface/inherited.rs new file mode 100644 index 00000000..3b728aa7 --- /dev/null +++ b/crates/application/src/governance_surface/inherited.rs @@ -0,0 +1,107 @@ +use astrcode_core::{ + ForkMode, LlmMessage, ResolvedSubagentContextOverrides, UserMessageOrigin, project, +}; + +use crate::{AgentSessionPort, ApplicationError}; + +pub(crate) async fn resolve_inherited_parent_messages( + session_runtime: &dyn AgentSessionPort, + parent_session_id: &str, + overrides: &ResolvedSubagentContextOverrides, +) -> Result, ApplicationError> { + let parent_events = session_runtime + .session_stored_events(&astrcode_core::SessionId::from( + parent_session_id.to_string(), + )) + .await + .map_err(ApplicationError::from)?; + let projected = project( + &parent_events + .iter() + .map(|stored| stored.event.clone()) + .collect::>(), + ); + Ok(build_inherited_messages(&projected.messages, overrides)) +} + +pub(crate) fn build_inherited_messages( + parent_messages: &[LlmMessage], + overrides: &ResolvedSubagentContextOverrides, +) -> Vec { + let mut inherited = Vec::new(); + + if overrides.include_compact_summary { + if let Some(summary) = parent_messages.iter().find(|message| { + matches!( + message, + LlmMessage::User { + origin: UserMessageOrigin::CompactSummary, + .. + } + ) + }) { + inherited.push(summary.clone()); + } + } + + if overrides.include_recent_tail { + inherited.extend(select_inherited_recent_tail( + parent_messages, + overrides.fork_mode.as_ref(), + )); + } + + inherited +} + +pub(crate) fn select_inherited_recent_tail( + parent_messages: &[LlmMessage], + fork_mode: Option<&ForkMode>, +) -> Vec { + let non_summary_messages = parent_messages + .iter() + .filter(|message| { + !matches!( + message, + LlmMessage::User { + origin: UserMessageOrigin::CompactSummary, + .. + } + ) + }) + .cloned() + .collect::>(); + + match fork_mode { + Some(ForkMode::LastNTurns(turns)) => { + tail_messages_for_last_n_turns(&non_summary_messages, *turns) + }, + Some(ForkMode::FullHistory) | None => non_summary_messages, + } +} + +fn tail_messages_for_last_n_turns(messages: &[LlmMessage], turns: usize) -> Vec { + if turns == 0 || messages.is_empty() { + return Vec::new(); + } + + let mut remaining_turns = turns; + let mut start_index = 0usize; + for (index, message) in messages.iter().enumerate().rev() { + if matches!( + message, + LlmMessage::User { + origin: UserMessageOrigin::User, + .. + } + ) { + remaining_turns = remaining_turns.saturating_sub(1); + start_index = index; + if remaining_turns == 0 { + break; + } + } + } + + messages[start_index..].to_vec() +} diff --git a/crates/application/src/governance_surface/mod.rs b/crates/application/src/governance_surface/mod.rs new file mode 100644 index 00000000..e9c612a2 --- /dev/null +++ b/crates/application/src/governance_surface/mod.rs @@ -0,0 +1,198 @@ +mod assembler; +mod inherited; +mod policy; +mod prompt; +#[cfg(test)] +mod tests; + +pub use assembler::GovernanceSurfaceAssembler; +use astrcode_core::{ + AgentCollaborationPolicyContext, CapabilityCall, LlmMessage, ModeId, PolicyContext, + ResolvedExecutionLimitsSnapshot, ResolvedRuntimeConfig, ResolvedSubagentContextOverrides, + SpawnCapabilityGrant, +}; +use astrcode_kernel::CapabilityRouter; +use astrcode_session_runtime::AgentPromptSubmission; +pub(crate) use inherited::resolve_inherited_parent_messages; +#[cfg(test)] +pub(crate) use inherited::{build_inherited_messages, select_inherited_recent_tail}; +pub use policy::{ + GOVERNANCE_APPROVAL_MODE_INHERIT, GOVERNANCE_POLICY_REVISION, + ToolCollaborationGovernanceContext, ToolCollaborationGovernanceContextInput, + collaboration_policy_context, effective_allowed_tools_for_limits, +}; +pub use prompt::{ + build_delegation_metadata, build_fresh_child_contract, build_resumed_child_contract, +}; + +use crate::{ApplicationError, CompiledModeEnvelope, ExecutionControl}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum GovernanceBusyPolicy { + BranchOnBusy, + RejectOnBusy, +} + +#[derive(Clone, PartialEq, Default)] +pub struct GovernanceApprovalPipeline { + pub pending: Option>, +} + +#[derive(Clone)] +pub struct ResolvedGovernanceSurface { + pub mode_id: ModeId, + pub runtime: ResolvedRuntimeConfig, + pub capability_router: Option, + pub prompt_declarations: Vec, + pub resolved_limits: ResolvedExecutionLimitsSnapshot, + pub resolved_overrides: Option, + pub injected_messages: Vec, + pub policy_context: PolicyContext, + pub collaboration_policy: AgentCollaborationPolicyContext, + pub approval: GovernanceApprovalPipeline, + pub governance_revision: String, + pub busy_policy: GovernanceBusyPolicy, + pub diagnostics: Vec, +} + +impl ResolvedGovernanceSurface { + pub fn validate(&self) -> Result<(), ApplicationError> { + if self.governance_revision.trim().is_empty() { + return Err(ApplicationError::Internal( + "governance revision must not be empty".to_string(), + )); + } + if self.collaboration_policy.policy_revision != self.governance_revision { + return Err(ApplicationError::Internal( + "collaboration policy revision must match governance surface revision".to_string(), + )); + } + Ok(()) + } + + pub fn allowed_capability_names(&self) -> Vec { + self.resolved_limits.allowed_tools.clone() + } + + pub fn prompt_facts_context(&self) -> astrcode_core::PromptGovernanceContext { + astrcode_core::PromptGovernanceContext { + allowed_capability_names: self.allowed_capability_names(), + mode_id: Some(self.mode_id.clone()), + approval_mode: if self.approval.pending.is_some() { + "required".to_string() + } else { + GOVERNANCE_APPROVAL_MODE_INHERIT.to_string() + }, + policy_revision: self.governance_revision.clone(), + max_subrun_depth: Some(self.collaboration_policy.max_subrun_depth), + max_spawn_per_turn: Some(self.collaboration_policy.max_spawn_per_turn), + } + } + + pub fn into_submission( + self, + agent: astrcode_core::AgentEventContext, + source_tool_call_id: Option, + ) -> AgentPromptSubmission { + let prompt_governance = self.prompt_facts_context(); + AgentPromptSubmission { + agent, + capability_router: self.capability_router, + prompt_declarations: self.prompt_declarations, + resolved_limits: Some(self.resolved_limits), + resolved_overrides: self.resolved_overrides, + injected_messages: self.injected_messages, + source_tool_call_id, + policy_context: Some(self.policy_context), + governance_revision: Some(self.governance_revision), + approval: self.approval.pending.map(Box::new), + prompt_governance: Some(prompt_governance), + } + } + + pub async fn check_model_request( + &self, + engine: &dyn astrcode_core::PolicyEngine, + request: astrcode_core::ModelRequest, + ) -> astrcode_core::Result { + engine + .check_model_request(request, &self.policy_context) + .await + } + + pub async fn check_capability_call( + &self, + engine: &dyn astrcode_core::PolicyEngine, + call: CapabilityCall, + ) -> astrcode_core::Result> { + engine + .check_capability_call(call, &self.policy_context) + .await + } +} + +struct BuildSurfaceInput { + session_id: String, + turn_id: String, + working_dir: String, + profile: String, + compiled: CompiledModeEnvelope, + runtime: ResolvedRuntimeConfig, + requested_busy_policy: GovernanceBusyPolicy, + resolved_overrides: Option, + injected_messages: Vec, + leading_prompt_declaration: Option, +} + +#[derive(Debug, Clone)] +pub struct SessionGovernanceInput { + pub session_id: String, + pub turn_id: String, + pub working_dir: String, + pub profile: String, + pub mode_id: ModeId, + pub runtime: ResolvedRuntimeConfig, + pub control: Option, + pub extra_prompt_declarations: Vec, + pub busy_policy: GovernanceBusyPolicy, +} + +#[derive(Debug, Clone)] +pub struct RootGovernanceInput { + pub session_id: String, + pub turn_id: String, + pub working_dir: String, + pub profile: String, + pub mode_id: ModeId, + pub runtime: ResolvedRuntimeConfig, + pub control: Option, +} + +#[derive(Debug, Clone)] +pub struct FreshChildGovernanceInput { + pub session_id: String, + pub turn_id: String, + pub working_dir: String, + pub mode_id: ModeId, + pub runtime: ResolvedRuntimeConfig, + pub parent_allowed_tools: Vec, + pub capability_grant: Option, + pub description: String, + pub task: String, + pub busy_policy: GovernanceBusyPolicy, +} + +#[derive(Debug, Clone)] +pub struct ResumedChildGovernanceInput { + pub session_id: String, + pub turn_id: String, + pub working_dir: String, + pub mode_id: ModeId, + pub runtime: ResolvedRuntimeConfig, + pub allowed_tools: Vec, + pub resolved_limits: ResolvedExecutionLimitsSnapshot, + pub delegation: Option, + pub message: String, + pub context: Option, + pub busy_policy: GovernanceBusyPolicy, +} diff --git a/crates/application/src/governance_surface/policy.rs b/crates/application/src/governance_surface/policy.rs new file mode 100644 index 00000000..3d412cf3 --- /dev/null +++ b/crates/application/src/governance_surface/policy.rs @@ -0,0 +1,189 @@ +use astrcode_core::{ + AgentCollaborationPolicyContext, ApprovalPending, ApprovalRequest, CapabilityCall, ModeId, + PolicyContext, ResolvedExecutionLimitsSnapshot, ResolvedRuntimeConfig, ResolvedTurnEnvelope, +}; +use serde_json::{Value, json}; + +use super::{GovernanceApprovalPipeline, GovernanceBusyPolicy}; + +pub const GOVERNANCE_POLICY_REVISION: &str = "governance-surface-v1"; +pub const GOVERNANCE_APPROVAL_MODE_INHERIT: &str = "inherit"; + +#[derive(Debug, Clone)] +pub struct ToolCollaborationGovernanceContext { + runtime: ResolvedRuntimeConfig, + session_id: String, + turn_id: String, + parent_agent_id: Option, + source_tool_call_id: Option, + policy: AgentCollaborationPolicyContext, + governance_revision: String, + mode_id: ModeId, +} + +#[derive(Debug, Clone)] +pub struct ToolCollaborationGovernanceContextInput { + pub runtime: ResolvedRuntimeConfig, + pub session_id: String, + pub turn_id: String, + pub parent_agent_id: Option, + pub source_tool_call_id: Option, + pub policy: AgentCollaborationPolicyContext, + pub governance_revision: String, + pub mode_id: ModeId, +} + +impl ToolCollaborationGovernanceContext { + pub fn new(input: ToolCollaborationGovernanceContextInput) -> Self { + Self { + runtime: input.runtime, + session_id: input.session_id, + turn_id: input.turn_id, + parent_agent_id: input.parent_agent_id, + source_tool_call_id: input.source_tool_call_id, + policy: input.policy, + governance_revision: input.governance_revision, + mode_id: input.mode_id, + } + } + + pub fn runtime(&self) -> &ResolvedRuntimeConfig { + &self.runtime + } + + pub fn session_id(&self) -> &str { + &self.session_id + } + + pub fn turn_id(&self) -> &str { + &self.turn_id + } + + pub fn parent_agent_id(&self) -> Option { + self.parent_agent_id.clone() + } + + pub fn source_tool_call_id(&self) -> Option { + self.source_tool_call_id.clone() + } + + pub fn policy(&self) -> &AgentCollaborationPolicyContext { + &self.policy + } + + pub fn governance_revision(&self) -> &str { + &self.governance_revision + } + + pub fn mode_id(&self) -> &ModeId { + &self.mode_id + } +} + +pub fn collaboration_policy_context( + runtime: &ResolvedRuntimeConfig, +) -> AgentCollaborationPolicyContext { + AgentCollaborationPolicyContext { + policy_revision: GOVERNANCE_POLICY_REVISION.to_string(), + max_subrun_depth: runtime.agent.max_subrun_depth, + max_spawn_per_turn: runtime.agent.max_spawn_per_turn, + } +} + +pub fn effective_allowed_tools_for_limits( + gateway: &astrcode_kernel::KernelGateway, + resolved_limits: &ResolvedExecutionLimitsSnapshot, +) -> Vec { + if resolved_limits.allowed_tools.is_empty() { + gateway.capabilities().tool_names() + } else { + resolved_limits.allowed_tools.clone() + } +} + +pub(super) fn build_policy_context( + session_id: &str, + turn_id: &str, + working_dir: &str, + profile: &str, + envelope: &ResolvedTurnEnvelope, +) -> PolicyContext { + PolicyContext { + session_id: session_id.to_string(), + turn_id: turn_id.to_string(), + step_index: 0, + working_dir: working_dir.to_string(), + profile: profile.to_string(), + metadata: json!({ + "governanceRevision": GOVERNANCE_POLICY_REVISION, + "modeId": envelope.mode_id, + "allowedCapabilityNames": envelope.allowed_tools, + "modeDiagnostics": envelope.diagnostics, + }), + } +} + +pub(super) fn default_approval_pipeline( + session_id: &str, + turn_id: &str, + envelope: &ResolvedTurnEnvelope, +) -> GovernanceApprovalPipeline { + if !envelope.action_policies.requires_approval() { + return GovernanceApprovalPipeline { pending: None }; + } + GovernanceApprovalPipeline { + pending: Some(ApprovalPending { + request: ApprovalRequest { + request_id: format!("approval-skeleton:{session_id}:{turn_id}"), + session_id: session_id.to_string(), + turn_id: turn_id.to_string(), + capability: astrcode_core::CapabilitySpec::builder( + "governance.approval.placeholder", + astrcode_core::CapabilityKind::Tool, + ) + .description("placeholder approval skeleton") + .schema(json!({"type": "object"}), json!({"type": "object"})) + .build() + .expect("placeholder capability should build"), + payload: json!({ + "modeId": envelope.mode_id, + "allowedCapabilityNames": envelope.allowed_tools, + }), + prompt: "Governance approval skeleton is installed but disabled by default." + .to_string(), + default: astrcode_core::ApprovalDefault::Allow, + metadata: json!({ + "disabled": true, + "governanceRevision": GOVERNANCE_POLICY_REVISION, + "modeId": envelope.mode_id, + }), + }, + action: CapabilityCall { + request_id: format!("approval-call:{session_id}:{turn_id}"), + capability: astrcode_core::CapabilitySpec::builder( + "governance.approval.placeholder", + astrcode_core::CapabilityKind::Tool, + ) + .description("placeholder approval skeleton") + .schema(json!({"type": "object"}), json!({"type": "object"})) + .build() + .expect("placeholder capability should build"), + payload: Value::Null, + metadata: json!({ + "disabled": true, + "modeId": envelope.mode_id, + }), + }, + }), + } +} + +pub(super) fn resolve_busy_policy( + submit_busy_policy: astrcode_core::SubmitBusyPolicy, + requested_busy_policy: GovernanceBusyPolicy, +) -> GovernanceBusyPolicy { + match submit_busy_policy { + astrcode_core::SubmitBusyPolicy::BranchOnBusy => requested_busy_policy, + astrcode_core::SubmitBusyPolicy::RejectOnBusy => GovernanceBusyPolicy::RejectOnBusy, + } +} diff --git a/crates/application/src/governance_surface/prompt.rs b/crates/application/src/governance_surface/prompt.rs new file mode 100644 index 00000000..a4621d2a --- /dev/null +++ b/crates/application/src/governance_surface/prompt.rs @@ -0,0 +1,216 @@ +use astrcode_core::{ + PromptDeclaration, PromptDeclarationKind, PromptDeclarationRenderTarget, + PromptDeclarationSource, ResolvedExecutionLimitsSnapshot, SystemPromptLayer, +}; + +const AGENT_COLLABORATION_TOOLS: &[&str] = &["spawn", "send", "observe", "close"]; + +pub fn build_delegation_metadata( + description: &str, + prompt: &str, + resolved_limits: &ResolvedExecutionLimitsSnapshot, + restricted: bool, +) -> astrcode_core::DelegationMetadata { + let responsibility_summary = compact_delegation_summary(description, prompt); + let reuse_scope_summary = if restricted { + "只有当下一步仍属于同一责任分支,且所需操作仍落在当前收缩后的 capability surface \ + 内时,才应继续复用这个 child。" + .to_string() + } else { + "只有当下一步仍属于同一责任分支时,才应继续复用这个 child;若责任边界已经改变,应 close \ + 当前分支并重新选择更合适的执行主体。" + .to_string() + }; + + astrcode_core::DelegationMetadata { + responsibility_summary, + reuse_scope_summary, + restricted, + capability_limit_summary: restricted + .then(|| capability_limit_summary(&resolved_limits.allowed_tools)) + .flatten(), + } +} + +pub fn build_fresh_child_contract( + metadata: &astrcode_core::DelegationMetadata, +) -> PromptDeclaration { + let mut content = format!( + "You are a delegated child responsible for one isolated branch.\n\nResponsibility \ + branch:\n- {}\n\nFresh-child rule:\n- Treat this as a new responsibility branch with its \ + own ownership boundary.\n- Do not expand into unrelated exploration or \ + implementation.\n\nUnified send contract:\n- Use downstream `send(agentId + message)` \ + only when you need a direct child to continue a more specific sub-branch.\n- When this \ + branch reaches progress, completion, failure, or a close request, use upstream \ + `send(kind + payload)` to report to your direct parent.\n- Do not wait for an extra \ + confirmation loop before reporting terminal state.\n\nReuse boundary:\n- {}", + metadata.responsibility_summary, metadata.reuse_scope_summary + ); + if let Some(limit_summary) = &metadata.capability_limit_summary { + content.push_str(&format!( + "\n\nCapability limit:\n- {limit_summary}\n- Do not take work that needs tools \ + outside this surface." + )); + } + + PromptDeclaration { + block_id: "child.execution.contract".to_string(), + title: "Child Execution Contract".to_string(), + content, + render_target: PromptDeclarationRenderTarget::System, + layer: SystemPromptLayer::Inherited, + kind: PromptDeclarationKind::ExtensionInstruction, + priority_hint: Some(585), + always_include: true, + source: PromptDeclarationSource::Builtin, + capability_name: None, + origin: Some("child-contract:fresh".to_string()), + } +} + +pub fn build_resumed_child_contract( + metadata: &astrcode_core::DelegationMetadata, + message: &str, + context: Option<&str>, +) -> PromptDeclaration { + let mut content = format!( + "You are continuing an existing delegated child branch.\n\nResponsibility continuity:\n- \ + Keep ownership of the same branch: {}\n\nResumed-child rule:\n- Prioritize the latest \ + delta instruction from the parent.\n- Do not restate or reinterpret the whole original \ + brief unless the new delta requires it.\n\nDelta instruction:\n- {}", + metadata.responsibility_summary, + message.trim() + ); + if let Some(context) = context.filter(|value| !value.trim().is_empty()) { + content.push_str(&format!("\n- Supplementary context: {}", context.trim())); + } + content.push_str(&format!( + "\n\nUnified send contract:\n- Keep using downstream `send(agentId + message)` only for \ + direct child delegation inside the same branch.\n- Use upstream `send(kind + payload)` \ + to report concrete progress, completion, failure, or a close request back to your direct \ + parent.\n- Do not restate the whole branch transcript when reporting upward.\n\nReuse \ + boundary:\n- {}", + metadata.reuse_scope_summary + )); + if let Some(limit_summary) = &metadata.capability_limit_summary { + content.push_str(&format!( + "\n\nCapability limit:\n- {limit_summary}\n- If the delta now needs broader tools, \ + stop stretching this child and let the parent choose a different branch." + )); + } + + PromptDeclaration { + block_id: "child.execution.contract".to_string(), + title: "Child Execution Contract".to_string(), + content, + render_target: PromptDeclarationRenderTarget::System, + layer: SystemPromptLayer::Inherited, + kind: PromptDeclarationKind::ExtensionInstruction, + priority_hint: Some(585), + always_include: true, + source: PromptDeclarationSource::Builtin, + capability_name: None, + origin: Some("child-contract:resumed".to_string()), + } +} + +pub(super) fn collaboration_prompt_declarations( + allowed_tools: &[String], + max_depth: usize, + max_spawn_per_turn: usize, +) -> Vec { + if !allowed_tools.iter().any(|tool_name| { + AGENT_COLLABORATION_TOOLS + .iter() + .any(|candidate| tool_name == candidate) + }) { + return Vec::new(); + } + + vec![PromptDeclaration { + block_id: "governance.collaboration.guide".to_string(), + title: "Child Agent Collaboration Guide".to_string(), + content: format!( + "Use the child-agent tools as one decision protocol.\n\nKeep `agentId` exact. Copy it \ + byte-for-byte in later `send`, `observe`, and `close` calls. Never renumber it, \ + never zero-pad it, and never invent `agent-01` when the tool result says \ + `agent-1`.\n\nDefault protocol:\n1. `spawn` only for a new isolated responsibility \ + with real parallel or context-isolation value.\n2. `send` when the same child should \ + take one concrete next step on the same responsibility branch.\n3. `observe` only \ + when the next decision depends on current child state.\n4. `close` when the branch \ + is done or no longer useful.\n\nDelegation modes:\n- Fresh child: use `spawn` for a \ + new responsibility branch. Give the child a full briefing: task scope, boundaries, \ + expected deliverable, and any important focus or exclusion. Do not treat a short \ + nudge like 'take a look' as a sufficient fresh-child brief.\n- Resumed child: use \ + `send` when the same child should continue the same responsibility branch. Send one \ + concrete delta instruction or clarification, not a full re-briefing of the original \ + task.\n- Restricted child: when you narrow a child with `capabilityGrant`, assign \ + only work that fits that reduced capability surface. If the next step needs tools \ + the restricted child does not have, choose a different child or do the work locally \ + instead of forcing a mismatch.\n\n`Idle` is normal and reusable. Do not respawn just \ + because a child finished one turn. Reuse an idle child with `send(agentId, message)` \ + when the responsibility stays the same. If you are unsure whether the child is still \ + running, idle, or terminated, call `observe(agentId)` once and act on the \ + result.\n\nSpawn sparingly. The runtime enforces a maximum child depth of \ + {max_depth} and at most {max_spawn_per_turn} new children per turn. Start with one \ + child unless there are clearly separate workstreams. Do not blanket-spawn agents \ + just to explore a repo broadly.\n\nAvoid waste:\n- Do not loop on `observe` with no \ + decision attached.\n- If a child is still running and you are simply waiting, prefer \ + a brief shell sleep over spending another tool call on `observe`.\n- Pick one wait \ + mode per pause: either `observe` now because you need a snapshot for the next \ + decision, or sleep briefly because you are only waiting. Do not alternate `shell` \ + and `observe` in a polling loop.\n- After a wait, call `observe` only when the next \ + decision depends on the child's current state.\n- Do not immediately re-`observe` \ + the same child after a fresh delivery unless the state is genuinely ambiguous.\n- Do \ + not stack speculative `send` calls.\n- Do not spawn a new child when an existing \ + idle child already owns the responsibility.\n\nIf a delivery satisfies the request, \ + `close` the branch. If the same child should continue, `send` one precise follow-up. \ + If you see the same `deliveryId` again after recovery, treat it as the same \ + delivery, not a new task.\n\nWhen you are the child on a delegated task, use \ + upstream `send(kind + payload)` to deliver a formal message to your direct parent. \ + Report `progress`, `completed`, `failed`, or `close_request` explicitly. Do not wait \ + for the parent to infer state from raw intermediate steps, and do not end with an \ + open loop like '继续观察中' unless you are also sending a non-terminal `progress` \ + delivery that keeps the branch alive.\n\nWhen you are the parent and receive a child \ + delivery, treat it as a decision point. Do not leave it hanging and do not \ + immediately re-observe the same child unless the state is unclear. Decide \ + immediately whether the result is complete enough to `close` the branch, or whether \ + the same child should continue with one concrete `send` follow-up that names the \ + exact next step." + ), + render_target: PromptDeclarationRenderTarget::System, + layer: SystemPromptLayer::Dynamic, + kind: PromptDeclarationKind::ExtensionInstruction, + priority_hint: Some(600), + always_include: true, + source: PromptDeclarationSource::Builtin, + capability_name: None, + origin: Some("governance:collaboration-guide".to_string()), + }] +} + +fn compact_delegation_summary(description: &str, prompt: &str) -> String { + let candidate = if !description.trim().is_empty() { + description.trim() + } else { + prompt.trim() + }; + let normalized = candidate.split_whitespace().collect::>().join(" "); + let mut chars = normalized.chars(); + let truncated = chars.by_ref().take(160).collect::(); + if chars.next().is_some() { + format!("{truncated}…") + } else { + truncated + } +} + +fn capability_limit_summary(allowed_tools: &[String]) -> Option { + if allowed_tools.is_empty() { + return None; + } + Some(format!( + "本分支当前只允许使用这些工具:{}。", + allowed_tools.join(", ") + )) +} diff --git a/crates/application/src/governance_surface/tests.rs b/crates/application/src/governance_surface/tests.rs new file mode 100644 index 00000000..b9892f92 --- /dev/null +++ b/crates/application/src/governance_surface/tests.rs @@ -0,0 +1,417 @@ +use std::sync::Arc; + +use astrcode_core::{ + AllowAllPolicyEngine, ApprovalDefault, AstrError, CapabilityKind, CapabilitySpec, LlmMessage, + LlmOutput, LlmProvider, LlmRequest, ModeId, ModelLimits, ModelRequest, PromptBuildOutput, + PromptBuildRequest, PromptProvider, ResolvedExecutionLimitsSnapshot, ResolvedRuntimeConfig, + ResourceProvider, ResourceReadResult, ResourceRequestContext, SideEffect, Stability, + UserMessageOrigin, +}; +use async_trait::async_trait; +use serde_json::{Value, json}; + +use super::{ + FreshChildGovernanceInput, GOVERNANCE_POLICY_REVISION, GovernanceApprovalPipeline, + GovernanceBusyPolicy, GovernanceSurfaceAssembler, ResolvedGovernanceSurface, + ResumedChildGovernanceInput, RootGovernanceInput, SessionGovernanceInput, + build_inherited_messages, collaboration_policy_context, select_inherited_recent_tail, +}; +use crate::{ExecutionControl, test_support::StubSessionPort}; + +#[derive(Debug)] +struct TestTool { + name: &'static str, +} + +#[async_trait] +impl astrcode_core::Tool for TestTool { + fn definition(&self) -> astrcode_core::ToolDefinition { + astrcode_core::ToolDefinition { + name: self.name.to_string(), + description: self.name.to_string(), + parameters: json!({"type":"object"}), + } + } + + fn capability_spec( + &self, + ) -> std::result::Result { + CapabilitySpec::builder(self.name, CapabilityKind::Tool) + .description(self.name) + .schema(json!({"type":"object"}), json!({"type":"object"})) + .side_effect(SideEffect::Workspace) + .stability(Stability::Stable) + .build() + } + + async fn execute( + &self, + tool_call_id: String, + _input: Value, + _ctx: &astrcode_core::ToolContext, + ) -> astrcode_core::Result { + Ok(astrcode_core::ToolExecutionResult { + tool_call_id, + tool_name: self.name.to_string(), + ok: true, + output: String::new(), + child_ref: None, + error: None, + metadata: None, + duration_ms: 0, + truncated: false, + }) + } +} + +fn router() -> astrcode_kernel::CapabilityRouter { + astrcode_kernel::CapabilityRouter::builder() + .register_invoker(Arc::new( + astrcode_kernel::ToolCapabilityInvoker::new(Arc::new(TestTool { name: "spawn" })) + .expect("tool should build"), + )) + .register_invoker(Arc::new( + astrcode_kernel::ToolCapabilityInvoker::new(Arc::new(TestTool { name: "readFile" })) + .expect("tool should build"), + )) + .build() + .expect("router should build") +} + +#[derive(Debug)] +struct NoopLlmProvider; + +#[async_trait] +impl LlmProvider for NoopLlmProvider { + async fn generate( + &self, + _request: LlmRequest, + _sink: Option, + ) -> astrcode_core::Result { + Err(AstrError::Validation("noop".to_string())) + } + + fn model_limits(&self) -> ModelLimits { + ModelLimits { + context_window: 32_000, + max_output_tokens: 4_096, + } + } +} + +#[derive(Debug)] +struct NoopPromptProvider; + +#[async_trait] +impl PromptProvider for NoopPromptProvider { + async fn build_prompt( + &self, + _request: PromptBuildRequest, + ) -> astrcode_core::Result { + Ok(PromptBuildOutput { + system_prompt: "noop".to_string(), + system_prompt_blocks: Vec::new(), + cache_metrics: Default::default(), + metadata: json!({}), + }) + } +} + +#[derive(Debug)] +struct NoopResourceProvider; + +#[async_trait] +impl ResourceProvider for NoopResourceProvider { + async fn read_resource( + &self, + uri: &str, + _context: &ResourceRequestContext, + ) -> astrcode_core::Result { + Ok(ResourceReadResult { + uri: uri.to_string(), + content: json!({}), + metadata: json!({}), + }) + } +} + +#[test] +fn session_surface_builds_collaboration_prompt_and_policy_context() { + let kernel = astrcode_kernel::Kernel::builder() + .with_capabilities(router()) + .with_llm_provider(Arc::new(NoopLlmProvider)) + .with_prompt_provider(Arc::new(NoopPromptProvider)) + .with_resource_provider(Arc::new(NoopResourceProvider)) + .build() + .expect("kernel should build"); + let assembler = GovernanceSurfaceAssembler::default(); + let surface = assembler + .session_surface( + &kernel, + SessionGovernanceInput { + session_id: "session-1".to_string(), + turn_id: "turn-1".to_string(), + working_dir: ".".to_string(), + profile: "coding".to_string(), + mode_id: ModeId::code(), + runtime: ResolvedRuntimeConfig::default(), + control: None, + extra_prompt_declarations: Vec::new(), + busy_policy: GovernanceBusyPolicy::BranchOnBusy, + }, + ) + .expect("surface should build"); + + assert_eq!(surface.governance_revision, GOVERNANCE_POLICY_REVISION); + assert!( + surface + .prompt_declarations + .iter() + .any(|declaration| declaration.origin.as_deref() + == Some("governance:collaboration-guide")) + ); + assert_eq!(surface.prompt_facts_context().approval_mode, "inherit"); +} + +#[tokio::test] +async fn surface_policy_pipeline_defaults_to_allow_all() { + let surface = ResolvedGovernanceSurface { + mode_id: ModeId::code(), + runtime: ResolvedRuntimeConfig::default(), + capability_router: None, + prompt_declarations: Vec::new(), + resolved_limits: ResolvedExecutionLimitsSnapshot { + allowed_tools: vec!["readFile".to_string()], + max_steps: Some(4), + }, + resolved_overrides: None, + injected_messages: Vec::new(), + policy_context: astrcode_core::PolicyContext { + session_id: "session-1".to_string(), + turn_id: "turn-1".to_string(), + step_index: 0, + working_dir: ".".to_string(), + profile: "coding".to_string(), + metadata: json!({}), + }, + collaboration_policy: collaboration_policy_context(&ResolvedRuntimeConfig::default()), + approval: GovernanceApprovalPipeline { + pending: Some(astrcode_core::ApprovalPending { + request: astrcode_core::ApprovalRequest { + request_id: "approval".to_string(), + session_id: "session-1".to_string(), + turn_id: "turn-1".to_string(), + capability: CapabilitySpec::builder("placeholder", CapabilityKind::Tool) + .description("placeholder") + .schema(json!({"type":"object"}), json!({"type":"object"})) + .build() + .expect("placeholder should build"), + payload: Value::Null, + prompt: "disabled".to_string(), + default: ApprovalDefault::Allow, + metadata: json!({}), + }, + action: astrcode_core::CapabilityCall { + request_id: "approval-call".to_string(), + capability: CapabilitySpec::builder("placeholder", CapabilityKind::Tool) + .description("placeholder") + .schema(json!({"type":"object"}), json!({"type":"object"})) + .build() + .expect("placeholder should build"), + payload: Value::Null, + metadata: json!({}), + }, + }), + }, + governance_revision: GOVERNANCE_POLICY_REVISION.to_string(), + busy_policy: GovernanceBusyPolicy::BranchOnBusy, + diagnostics: Vec::new(), + }; + let request = ModelRequest { + messages: vec![LlmMessage::User { + content: "hello".to_string(), + origin: UserMessageOrigin::User, + }], + tools: Vec::new(), + system_prompt: Some("system".to_string()), + system_prompt_blocks: Vec::new(), + }; + let checked = surface + .check_model_request(&AllowAllPolicyEngine, request) + .await + .expect("request should pass"); + assert_eq!(checked.system_prompt.as_deref(), Some("system")); + assert!(surface.approval.pending.is_some()); +} + +#[test] +fn inherited_messages_follow_compact_and_tail_policy() { + let inherited = build_inherited_messages( + &[ + LlmMessage::User { + content: "summary".to_string(), + origin: UserMessageOrigin::CompactSummary, + }, + LlmMessage::User { + content: "turn-1".to_string(), + origin: UserMessageOrigin::User, + }, + LlmMessage::Assistant { + content: "answer-1".to_string(), + tool_calls: Vec::new(), + reasoning: None, + }, + LlmMessage::User { + content: "turn-2".to_string(), + origin: UserMessageOrigin::User, + }, + ], + &astrcode_core::ResolvedSubagentContextOverrides { + include_compact_summary: true, + include_recent_tail: true, + fork_mode: Some(astrcode_core::ForkMode::LastNTurns(1)), + ..astrcode_core::ResolvedSubagentContextOverrides::default() + }, + ); + assert_eq!(inherited.len(), 2); +} + +#[test] +fn root_surface_applies_execution_control_without_special_case_logic() { + let kernel = astrcode_kernel::Kernel::builder() + .with_capabilities(router()) + .with_llm_provider(Arc::new(NoopLlmProvider)) + .with_prompt_provider(Arc::new(NoopPromptProvider)) + .with_resource_provider(Arc::new(NoopResourceProvider)) + .build() + .expect("kernel should build"); + let assembler = GovernanceSurfaceAssembler::default(); + let surface = assembler + .root_surface( + &kernel, + RootGovernanceInput { + session_id: "session-1".to_string(), + turn_id: "turn-1".to_string(), + working_dir: ".".to_string(), + profile: "coding".to_string(), + mode_id: ModeId::code(), + runtime: ResolvedRuntimeConfig::default(), + control: Some(ExecutionControl { + max_steps: Some(7), + manual_compact: None, + }), + }, + ) + .expect("surface should build"); + + assert!(surface.capability_router.is_none()); + assert_eq!(surface.resolved_limits.max_steps, Some(7)); + assert_eq!(surface.busy_policy, GovernanceBusyPolicy::BranchOnBusy); +} + +#[tokio::test] +async fn fresh_child_surface_restricts_tools_and_inherits_governance_defaults() { + let kernel = astrcode_kernel::Kernel::builder() + .with_capabilities(router()) + .with_llm_provider(Arc::new(NoopLlmProvider)) + .with_prompt_provider(Arc::new(NoopPromptProvider)) + .with_resource_provider(Arc::new(NoopResourceProvider)) + .build() + .expect("kernel should build"); + let assembler = GovernanceSurfaceAssembler::default(); + let session_runtime = StubSessionPort::default(); + let surface = assembler + .fresh_child_surface( + &kernel, + &session_runtime, + FreshChildGovernanceInput { + session_id: "session-1".to_string(), + turn_id: "turn-1".to_string(), + working_dir: ".".to_string(), + mode_id: ModeId::code(), + runtime: ResolvedRuntimeConfig::default(), + parent_allowed_tools: vec!["spawn".to_string(), "readFile".to_string()], + capability_grant: Some(astrcode_core::SpawnCapabilityGrant { + allowed_tools: vec!["readFile".to_string()], + }), + description: "只做读取".to_string(), + task: "inspect file".to_string(), + busy_policy: GovernanceBusyPolicy::BranchOnBusy, + }, + ) + .await + .expect("surface should build"); + + assert_eq!( + surface.resolved_limits.allowed_tools, + vec!["readFile".to_string()] + ); + assert!(surface.capability_router.is_some()); + assert!( + surface + .prompt_declarations + .iter() + .any(|declaration| declaration.origin.as_deref() == Some("child-contract:fresh")) + ); +} + +#[test] +fn resumed_child_surface_reuses_existing_limits_and_contract_source() { + let kernel = astrcode_kernel::Kernel::builder() + .with_capabilities(router()) + .with_llm_provider(Arc::new(NoopLlmProvider)) + .with_prompt_provider(Arc::new(NoopPromptProvider)) + .with_resource_provider(Arc::new(NoopResourceProvider)) + .build() + .expect("kernel should build"); + let assembler = GovernanceSurfaceAssembler::default(); + let limits = ResolvedExecutionLimitsSnapshot { + allowed_tools: vec!["readFile".to_string()], + max_steps: Some(5), + }; + let surface = assembler + .resumed_child_surface( + &kernel, + ResumedChildGovernanceInput { + session_id: "session-1".to_string(), + turn_id: "turn-1".to_string(), + working_dir: ".".to_string(), + mode_id: ModeId::code(), + runtime: ResolvedRuntimeConfig::default(), + allowed_tools: Vec::new(), + resolved_limits: limits.clone(), + delegation: None, + message: "continue with the same branch".to_string(), + context: Some("keep scope tight".to_string()), + busy_policy: GovernanceBusyPolicy::RejectOnBusy, + }, + ) + .expect("surface should build"); + + assert_eq!(surface.resolved_limits.allowed_tools, limits.allowed_tools); + assert_eq!(surface.resolved_limits.max_steps, limits.max_steps); + assert_eq!(surface.busy_policy, GovernanceBusyPolicy::RejectOnBusy); + assert!( + surface + .prompt_declarations + .iter() + .any(|declaration| declaration.origin.as_deref() == Some("child-contract:resumed")) + ); +} + +#[test] +fn select_inherited_recent_tail_keeps_full_history_without_fork_mode() { + let messages = vec![ + LlmMessage::User { + content: "hello".to_string(), + origin: UserMessageOrigin::User, + }, + LlmMessage::Assistant { + content: "world".to_string(), + tool_calls: Vec::new(), + reasoning: None, + }, + ]; + + assert_eq!(select_inherited_recent_tail(&messages, None), messages); +} diff --git a/crates/application/src/lib.rs b/crates/application/src/lib.rs index 80becb41..2ac60279 100644 --- a/crates/application/src/lib.rs +++ b/crates/application/src/lib.rs @@ -3,10 +3,15 @@ use std::{path::Path, sync::Arc}; use astrcode_core::AgentProfile; use tokio::sync::broadcast; +use crate::config::ConfigService; + mod agent_use_cases; +mod governance_surface; mod ports; mod session_use_cases; -mod terminal_use_cases; +mod terminal_queries; +#[cfg(test)] +mod test_support; pub mod agent; pub mod composer; @@ -15,6 +20,7 @@ pub mod errors; pub mod execution; pub mod lifecycle; pub mod mcp; +pub mod mode; pub mod observability; pub mod terminal; pub mod watch; @@ -37,84 +43,15 @@ pub use astrcode_session_runtime::{ SessionTranscriptSnapshot, SubRunEventScope, TurnCollaborationSummary, TurnSummary, }; pub use composer::{ComposerOptionsRequest, ComposerSkillSummary}; -pub use config::{ - // 常量与解析函数 - ALL_ASTRCODE_ENV_VARS, - ANTHROPIC_API_KEY_ENV, - ANTHROPIC_MESSAGES_API_URL, - ANTHROPIC_MODELS_API_URL, - ANTHROPIC_VERSION, - ASTRCODE_HOME_DIR_ENV, - ASTRCODE_MAX_TOOL_CONCURRENCY_ENV, - ASTRCODE_PLUGIN_DIRS_ENV, - ASTRCODE_TEST_HOME_ENV, - ASTRCODE_TOOL_INLINE_LIMIT_PREFIX, - ASTRCODE_TOOL_RESULT_INLINE_LIMIT_ENV, - BUILD_ENV_VARS, - CURRENT_CONFIG_VERSION, - ConfigService, - DEEPSEEK_API_KEY_ENV, - DEFAULT_API_SESSION_TTL_HOURS, - DEFAULT_AUTO_COMPACT_ENABLED, - DEFAULT_COMPACT_KEEP_RECENT_TURNS, - DEFAULT_COMPACT_THRESHOLD_PERCENT, - DEFAULT_FINALIZED_AGENT_RETAIN_LIMIT, - DEFAULT_INBOX_CAPACITY, - DEFAULT_LLM_CONNECT_TIMEOUT_SECS, - DEFAULT_LLM_MAX_RETRIES, - DEFAULT_LLM_READ_TIMEOUT_SECS, - DEFAULT_LLM_RETRY_BASE_DELAY_MS, - DEFAULT_MAX_CONCURRENT_AGENTS, - DEFAULT_MAX_CONCURRENT_BRANCH_DEPTH, - DEFAULT_MAX_CONSECUTIVE_FAILURES, - DEFAULT_MAX_GREP_LINES, - DEFAULT_MAX_IMAGE_SIZE, - DEFAULT_MAX_OUTPUT_CONTINUATION_ATTEMPTS, - DEFAULT_MAX_REACTIVE_COMPACT_ATTEMPTS, - DEFAULT_MAX_RECOVERED_FILES, - DEFAULT_MAX_STEPS, - DEFAULT_MAX_SUBRUN_DEPTH, - DEFAULT_MAX_TOOL_CONCURRENCY, - DEFAULT_MAX_TRACKED_FILES, - DEFAULT_OPENAI_CONTEXT_LIMIT, - DEFAULT_PARENT_DELIVERY_CAPACITY, - DEFAULT_RECOVERY_TOKEN_BUDGET, - DEFAULT_RECOVERY_TRUNCATE_BYTES, - DEFAULT_RESERVED_CONTEXT_SIZE, - DEFAULT_SESSION_BROADCAST_CAPACITY, - DEFAULT_SESSION_RECENT_RECORD_LIMIT, - DEFAULT_SUMMARY_RESERVE_TOKENS, - DEFAULT_TOOL_RESULT_INLINE_LIMIT, - DEFAULT_TOOL_RESULT_MAX_BYTES, - DEFAULT_TOOL_RESULT_PREVIEW_LIMIT, - ENV_REFERENCE_PREFIX, - HOME_ENV_VARS, - LITERAL_VALUE_PREFIX, - McpConfigFileScope, - PLUGIN_ENV_VARS, - PROVIDER_API_KEY_ENV_VARS, - PROVIDER_KIND_ANTHROPIC, - PROVIDER_KIND_OPENAI, - RUNTIME_ENV_VARS, - ResolvedAgentConfig, - ResolvedConfigSummary, - ResolvedRuntimeConfig, - TAURI_ENV_TARGET_TRIPLE_ENV, - api_key_preview, - is_env_var_name, - list_model_options, - max_tool_concurrency, - resolve_active_selection, - resolve_agent_config, - resolve_anthropic_messages_api_url, - resolve_anthropic_models_api_url, - resolve_config_summary, - resolve_current_model, - resolve_openai_chat_completions_api_url, - resolve_runtime_config, -}; pub use errors::ApplicationError; pub use execution::{ExecutionControl, ProfileResolutionService, RootExecutionRequest}; +pub use governance_surface::{ + FreshChildGovernanceInput, GOVERNANCE_APPROVAL_MODE_INHERIT, GOVERNANCE_POLICY_REVISION, + GovernanceBusyPolicy, GovernanceSurfaceAssembler, ResolvedGovernanceSurface, + ResumedChildGovernanceInput, RootGovernanceInput, SessionGovernanceInput, + ToolCollaborationGovernanceContext, build_delegation_metadata, build_fresh_child_contract, + build_resumed_child_contract, collaboration_policy_context, effective_allowed_tools_for_limits, +}; pub use lifecycle::governance::{ AppGovernance, ObservabilitySnapshotProvider, RuntimeGovernancePort, RuntimeGovernanceSnapshot, RuntimeReloader, SessionInfoProvider, @@ -123,6 +60,11 @@ pub use mcp::{ McpActionSummary, McpConfigScope, McpPort, McpServerStatusSummary, McpServerStatusView, McpService, RegisterMcpServerInput, }; +pub use mode::{ + BuiltinModeCatalog, CompiledModeEnvelope, ModeCatalog, ModeSummary, builtin_mode_catalog, + compile_capability_selector, compile_mode_envelope, compile_mode_envelope_for_child, + validate_mode_transition, +}; pub use observability::{ AgentCollaborationScorecardSnapshot, ExecutionDiagnosticsSnapshot, GovernanceSnapshot, OperationMetricsSnapshot, ReloadResult, ReplayMetricsSnapshot, ReplayPath, @@ -135,19 +77,6 @@ pub use ports::{ ComposerSkillPort, }; pub use session_use_cases::summarize_session_meta; -pub use terminal::{ - ConversationAuthoritativeSummary, ConversationChildSummaryFacts, - ConversationChildSummarySummary, ConversationControlFacts, ConversationControlSummary, - ConversationFacts, ConversationFocus, ConversationRehydrateFacts, ConversationRehydrateReason, - ConversationResumeCandidateFacts, ConversationSlashAction, ConversationSlashActionSummary, - ConversationSlashCandidateFacts, ConversationSlashCandidateSummary, ConversationStreamFacts, - ConversationStreamReplayFacts, TerminalChildSummaryFacts, TerminalControlFacts, TerminalFacts, - TerminalRehydrateFacts, TerminalRehydrateReason, TerminalResumeCandidateFacts, - TerminalSlashAction, TerminalSlashCandidateFacts, TerminalStreamFacts, - TerminalStreamReplayFacts, summarize_conversation_authoritative, - summarize_conversation_child_ref, summarize_conversation_child_summary, - summarize_conversation_control, summarize_conversation_slash_candidate, -}; pub use watch::{WatchEvent, WatchPort, WatchService, WatchSource}; /// 唯一业务用例入口。 @@ -158,6 +87,8 @@ pub struct App { config_service: Arc, composer_service: Arc, composer_skills: Arc, + governance_surface: Arc, + mode_catalog: Arc, mcp_service: Arc, agent_service: Arc, } @@ -247,12 +178,15 @@ pub struct SubRunStatusSummary { } impl App { + #[allow(clippy::too_many_arguments)] pub fn new( kernel: Arc, session_runtime: Arc, profiles: Arc, config_service: Arc, composer_skills: Arc, + governance_surface: Arc, + mode_catalog: Arc, mcp_service: Arc, agent_service: Arc, ) -> Self { @@ -263,6 +197,8 @@ impl App { config_service, composer_service: Arc::new(composer::ComposerService::new()), composer_skills, + governance_surface, + mode_catalog, mcp_service, agent_service, } @@ -296,6 +232,14 @@ impl App { &self.composer_skills } + pub fn governance_surface(&self) -> &Arc { + &self.governance_surface + } + + pub fn mode_catalog(&self) -> &Arc { + &self.mode_catalog + } + pub fn agent(&self) -> &Arc { &self.agent_service } @@ -315,6 +259,7 @@ impl App { self.kernel.as_ref(), self.session_runtime.as_ref(), &self.profiles, + self.governance_surface.as_ref(), request, runtime, ) diff --git a/crates/application/src/mode/catalog.rs b/crates/application/src/mode/catalog.rs new file mode 100644 index 00000000..88841e84 --- /dev/null +++ b/crates/application/src/mode/catalog.rs @@ -0,0 +1,279 @@ +use std::{ + collections::BTreeMap, + sync::{Arc, RwLock}, +}; + +use astrcode_core::{ + ActionPolicies, ActionPolicyEffect, ActionPolicyRule, CapabilitySelector, ChildPolicySpec, + GovernanceModeSpec, ModeExecutionPolicySpec, ModeId, PromptProgramEntry, Result, + SubmitBusyPolicy, TransitionPolicySpec, +}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ModeSummary { + pub id: ModeId, + pub name: String, + pub description: String, +} + +#[derive(Debug, Clone)] +pub struct ModeCatalogEntry { + pub spec: GovernanceModeSpec, + pub builtin: bool, +} + +#[derive(Debug, Clone, Default)] +pub struct ModeCatalogSnapshot { + pub entries: BTreeMap, +} + +impl ModeCatalogSnapshot { + pub fn get(&self, mode_id: &ModeId) -> Option<&ModeCatalogEntry> { + self.entries.get(mode_id.as_str()) + } + + pub fn list(&self) -> Vec { + self.entries + .values() + .map(|entry| ModeSummary { + id: entry.spec.id.clone(), + name: entry.spec.name.clone(), + description: entry.spec.description.clone(), + }) + .collect() + } +} + +#[derive(Debug, Clone)] +pub struct ModeCatalog { + snapshot: Arc>, +} + +impl ModeCatalog { + pub fn new( + builtin_modes: impl IntoIterator, + plugin_modes: impl IntoIterator, + ) -> Result { + let snapshot = build_snapshot(builtin_modes, plugin_modes)?; + Ok(Self { + snapshot: Arc::new(RwLock::new(snapshot)), + }) + } + + pub fn snapshot(&self) -> ModeCatalogSnapshot { + self.snapshot + .read() + .expect("mode catalog lock poisoned") + .clone() + } + + pub fn list(&self) -> Vec { + self.snapshot().list() + } + + pub fn get(&self, mode_id: &ModeId) -> Option { + self.snapshot().get(mode_id).map(|entry| entry.spec.clone()) + } + + pub fn replace_plugin_modes( + &self, + plugin_modes: impl IntoIterator, + ) -> Result<()> { + let current = self.snapshot(); + let builtin_modes = current + .entries + .values() + .filter(|entry| entry.builtin) + .map(|entry| entry.spec.clone()) + .collect::>(); + let snapshot = build_snapshot(builtin_modes, plugin_modes)?; + *self.snapshot.write().expect("mode catalog lock poisoned") = snapshot; + Ok(()) + } +} + +pub type BuiltinModeCatalog = ModeCatalog; + +pub fn builtin_mode_catalog() -> Result { + ModeCatalog::new(builtin_mode_specs(), Vec::new()) +} + +fn build_snapshot( + builtin_modes: impl IntoIterator, + plugin_modes: impl IntoIterator, +) -> Result { + let mut entries = BTreeMap::new(); + for (builtin, spec) in builtin_modes + .into_iter() + .map(|spec| (true, spec)) + .chain(plugin_modes.into_iter().map(|spec| (false, spec))) + { + spec.validate()?; + entries.insert( + spec.id.as_str().to_string(), + ModeCatalogEntry { spec, builtin }, + ); + } + Ok(ModeCatalogSnapshot { entries }) +} + +fn builtin_mode_specs() -> Vec { + let transitions = TransitionPolicySpec { + allowed_targets: vec![ModeId::code(), ModeId::plan(), ModeId::review()], + }; + + vec![ + GovernanceModeSpec { + id: ModeId::code(), + name: "Code".to_string(), + description: "默认执行模式,保留完整能力面与委派能力。".to_string(), + capability_selector: CapabilitySelector::AllTools, + action_policies: ActionPolicies::default(), + child_policy: ChildPolicySpec { + allow_delegation: true, + allow_recursive_delegation: true, + default_mode_id: Some(ModeId::code()), + ..ChildPolicySpec::default() + }, + execution_policy: ModeExecutionPolicySpec { + submit_busy_policy: Some(SubmitBusyPolicy::BranchOnBusy), + ..ModeExecutionPolicySpec::default() + }, + prompt_program: vec![PromptProgramEntry { + block_id: "mode.code".to_string(), + title: "Execution Mode".to_string(), + content: "You are in execution mode. Prefer direct progress, make concrete code \ + changes when needed, and use delegation only when isolation or \ + parallelism materially helps." + .to_string(), + priority_hint: Some(600), + }], + transition_policy: transitions.clone(), + }, + GovernanceModeSpec { + id: ModeId::plan(), + name: "Plan".to_string(), + description: "规划模式,只暴露只读工具并禁止继续委派。".to_string(), + capability_selector: CapabilitySelector::Difference { + base: Box::new(CapabilitySelector::AllTools), + subtract: Box::new(CapabilitySelector::Union(vec![ + CapabilitySelector::SideEffect(astrcode_core::SideEffect::Local), + CapabilitySelector::SideEffect(astrcode_core::SideEffect::Workspace), + CapabilitySelector::SideEffect(astrcode_core::SideEffect::External), + CapabilitySelector::Tag("agent".to_string()), + ])), + }, + action_policies: ActionPolicies { + default_effect: ActionPolicyEffect::Allow, + rules: vec![ActionPolicyRule { + selector: CapabilitySelector::Tag("agent".to_string()), + effect: ActionPolicyEffect::Deny, + }], + context_strategy: Some(astrcode_core::ContextStrategy::Summarize), + }, + child_policy: ChildPolicySpec { + allow_delegation: false, + allow_recursive_delegation: false, + default_mode_id: Some(ModeId::code()), + restricted: true, + reuse_scope_summary: Some( + "当前 mode 不允许继续派生 child branch;先完成规划,再由后续执行 turn \ + 决定是否委派。" + .to_string(), + ), + ..ChildPolicySpec::default() + }, + execution_policy: ModeExecutionPolicySpec { + submit_busy_policy: Some(SubmitBusyPolicy::RejectOnBusy), + ..ModeExecutionPolicySpec::default() + }, + prompt_program: vec![PromptProgramEntry { + block_id: "mode.plan".to_string(), + title: "Planning Mode".to_string(), + content: "You are in planning mode. Focus on analysis, proposals, sequencing, and \ + constraints. Do not write files or perform side-effecting actions in \ + this turn." + .to_string(), + priority_hint: Some(600), + }], + transition_policy: transitions.clone(), + }, + GovernanceModeSpec { + id: ModeId::review(), + name: "Review".to_string(), + description: "审查模式,只保留严格只读工具并收紧步数。".to_string(), + capability_selector: CapabilitySelector::Intersection(vec![ + CapabilitySelector::AllTools, + CapabilitySelector::SideEffect(astrcode_core::SideEffect::None), + CapabilitySelector::Difference { + base: Box::new(CapabilitySelector::AllTools), + subtract: Box::new(CapabilitySelector::Tag("agent".to_string())), + }, + ]), + action_policies: ActionPolicies { + default_effect: ActionPolicyEffect::Allow, + rules: vec![ActionPolicyRule { + selector: CapabilitySelector::Tag("agent".to_string()), + effect: ActionPolicyEffect::Deny, + }], + context_strategy: Some(astrcode_core::ContextStrategy::Summarize), + }, + child_policy: ChildPolicySpec { + allow_delegation: false, + allow_recursive_delegation: false, + default_mode_id: Some(ModeId::review()), + restricted: true, + reuse_scope_summary: Some( + "当前 mode 仅允许本地审查,不允许在同一 turn 内扩张成新的执行分支。" + .to_string(), + ), + ..ChildPolicySpec::default() + }, + execution_policy: ModeExecutionPolicySpec { + submit_busy_policy: Some(SubmitBusyPolicy::RejectOnBusy), + ..ModeExecutionPolicySpec::default() + }, + prompt_program: vec![PromptProgramEntry { + block_id: "mode.review".to_string(), + title: "Review Mode".to_string(), + content: "You are in review mode. Prioritize findings, risks, regressions, and \ + verification gaps. Stay read-only and avoid speculative edits." + .to_string(), + priority_hint: Some(600), + }], + transition_policy: transitions, + }, + ] +} + +#[cfg(test)] +mod tests { + use astrcode_core::{CapabilitySelector, ModeId, Result}; + + use super::builtin_mode_catalog; + + #[test] + fn builtin_catalog_contains_three_builtin_modes() -> Result<()> { + let catalog = builtin_mode_catalog()?; + let ids = catalog + .list() + .into_iter() + .map(|summary| summary.id) + .collect::>(); + assert_eq!(ids, vec![ModeId::code(), ModeId::plan(), ModeId::review()]); + Ok(()) + } + + #[test] + fn builtin_review_mode_uses_read_only_selector_shape() -> Result<()> { + let catalog = builtin_mode_catalog()?; + let review = catalog + .get(&ModeId::review()) + .expect("review mode should exist"); + assert!(matches!( + review.capability_selector, + CapabilitySelector::Intersection(_) + )); + Ok(()) + } +} diff --git a/crates/application/src/mode/compiler.rs b/crates/application/src/mode/compiler.rs new file mode 100644 index 00000000..ea3dd7ee --- /dev/null +++ b/crates/application/src/mode/compiler.rs @@ -0,0 +1,410 @@ +use std::collections::BTreeSet; + +use astrcode_core::{ + AstrError, CapabilitySelector, CapabilitySpec, GovernanceModeSpec, PromptDeclaration, + PromptDeclarationKind, PromptDeclarationRenderTarget, PromptDeclarationSource, + ResolvedTurnEnvelope, Result, SpawnCapabilityGrant, SystemPromptLayer, +}; +use astrcode_kernel::CapabilityRouter; + +#[derive(Clone)] +pub struct CompiledModeEnvelope { + pub spec: GovernanceModeSpec, + pub envelope: ResolvedTurnEnvelope, + pub capability_router: Option, +} + +pub fn compile_capability_selector( + capability_specs: &[CapabilitySpec], + selector: &CapabilitySelector, +) -> Result> { + let selected = evaluate_selector(capability_specs, selector)?; + Ok(selected.into_iter().collect()) +} + +pub fn compile_mode_envelope( + base_router: &CapabilityRouter, + spec: &GovernanceModeSpec, + extra_prompt_declarations: Vec, +) -> Result { + let allowed_tools = + compile_capability_selector(&base_router.capability_specs(), &spec.capability_selector)?; + let child_allowed_tools = + child_allowed_tools(&base_router.capability_specs(), spec, &allowed_tools, None)?; + let prompt_declarations = mode_prompt_declarations(spec, extra_prompt_declarations); + let envelope = ResolvedTurnEnvelope { + mode_id: spec.id.clone(), + allowed_tools: allowed_tools.clone(), + prompt_declarations: prompt_declarations.clone(), + action_policies: spec.action_policies.clone(), + child_policy: astrcode_core::ResolvedChildPolicy { + mode_id: spec + .child_policy + .default_mode_id + .clone() + .unwrap_or_default(), + allow_delegation: spec.child_policy.allow_delegation, + allow_recursive_delegation: spec.child_policy.allow_recursive_delegation, + allowed_profile_ids: spec.child_policy.allowed_profile_ids.clone(), + allowed_tools: child_allowed_tools, + restricted: spec.child_policy.restricted, + fork_mode: spec + .child_policy + .fork_mode + .clone() + .or(spec.execution_policy.fork_mode.clone()), + reuse_scope_summary: spec.child_policy.reuse_scope_summary.clone(), + }, + submit_busy_policy: spec + .execution_policy + .submit_busy_policy + .unwrap_or(astrcode_core::SubmitBusyPolicy::BranchOnBusy), + fork_mode: spec.execution_policy.fork_mode.clone(), + diagnostics: if allowed_tools.is_empty() { + vec![format!( + "mode '{}' compiled to an empty tool surface", + spec.id + )] + } else { + Vec::new() + }, + }; + let capability_router = subset_router(base_router, &allowed_tools)?; + Ok(CompiledModeEnvelope { + spec: spec.clone(), + envelope, + capability_router, + }) +} + +pub fn compile_mode_envelope_for_child( + base_router: &CapabilityRouter, + spec: &GovernanceModeSpec, + parent_allowed_tools: &[String], + capability_grant: Option<&SpawnCapabilityGrant>, +) -> Result { + let mode_allowed_tools = + compile_capability_selector(&base_router.capability_specs(), &spec.capability_selector)?; + let effective_parent_allowed_tools = if parent_allowed_tools.is_empty() { + mode_allowed_tools + } else { + parent_allowed_tools + .iter() + .filter(|tool| { + mode_allowed_tools + .iter() + .any(|candidate| candidate == *tool) + }) + .cloned() + .collect::>() + }; + let child_tools = child_allowed_tools( + &base_router.capability_specs(), + spec, + &effective_parent_allowed_tools, + capability_grant, + )?; + let prompt_declarations = mode_prompt_declarations(spec, Vec::new()); + let envelope = ResolvedTurnEnvelope { + mode_id: spec.id.clone(), + allowed_tools: child_tools.clone(), + prompt_declarations: prompt_declarations.clone(), + action_policies: spec.action_policies.clone(), + child_policy: astrcode_core::ResolvedChildPolicy { + mode_id: spec + .child_policy + .default_mode_id + .clone() + .unwrap_or_default(), + allow_delegation: spec.child_policy.allow_delegation, + allow_recursive_delegation: spec.child_policy.allow_recursive_delegation, + allowed_profile_ids: spec.child_policy.allowed_profile_ids.clone(), + allowed_tools: child_tools.clone(), + restricted: spec.child_policy.restricted || capability_grant.is_some(), + fork_mode: spec + .child_policy + .fork_mode + .clone() + .or(spec.execution_policy.fork_mode.clone()), + reuse_scope_summary: spec.child_policy.reuse_scope_summary.clone(), + }, + submit_busy_policy: spec + .execution_policy + .submit_busy_policy + .unwrap_or(astrcode_core::SubmitBusyPolicy::BranchOnBusy), + fork_mode: spec + .execution_policy + .fork_mode + .clone() + .or(spec.child_policy.fork_mode.clone()), + diagnostics: if child_tools.is_empty() { + vec![format!( + "child mode '{}' compiled to an empty inheritable tool surface", + spec.id + )] + } else { + Vec::new() + }, + }; + let capability_router = subset_router(base_router, &child_tools)?; + Ok(CompiledModeEnvelope { + spec: spec.clone(), + envelope, + capability_router, + }) +} + +fn evaluate_selector( + capability_specs: &[CapabilitySpec], + selector: &CapabilitySelector, +) -> Result> { + let tools = capability_specs + .iter() + .filter(|spec| spec.kind.is_tool()) + .collect::>(); + Ok(match selector { + CapabilitySelector::AllTools => tools + .into_iter() + .map(|spec| spec.name.to_string()) + .collect(), + CapabilitySelector::Name(name) => tools + .into_iter() + .filter(|spec| spec.name.as_str() == name.as_str()) + .map(|spec| spec.name.to_string()) + .collect(), + CapabilitySelector::Kind(kind) => tools + .into_iter() + .filter(|spec| spec.kind == *kind) + .map(|spec| spec.name.to_string()) + .collect(), + CapabilitySelector::SideEffect(side_effect) => tools + .into_iter() + .filter(|spec| spec.side_effect == *side_effect) + .map(|spec| spec.name.to_string()) + .collect(), + CapabilitySelector::Tag(tag) => tools + .into_iter() + .filter(|spec| spec.tags.iter().any(|candidate| candidate == tag)) + .map(|spec| spec.name.to_string()) + .collect(), + CapabilitySelector::Union(selectors) => { + let mut combined = BTreeSet::new(); + for selector in selectors { + combined.extend(evaluate_selector(capability_specs, selector)?); + } + combined + }, + CapabilitySelector::Intersection(selectors) => { + let mut iter = selectors.iter(); + let Some(first) = iter.next() else { + return Ok(BTreeSet::new()); + }; + let mut combined = evaluate_selector(capability_specs, first)?; + for selector in iter { + let next = evaluate_selector(capability_specs, selector)?; + combined = combined.intersection(&next).cloned().collect(); + } + combined + }, + CapabilitySelector::Difference { base, subtract } => { + let base = evaluate_selector(capability_specs, base)?; + let subtract = evaluate_selector(capability_specs, subtract)?; + base.difference(&subtract).cloned().collect() + }, + }) +} + +fn child_allowed_tools( + capability_specs: &[CapabilitySpec], + spec: &GovernanceModeSpec, + parent_allowed_tools: &[String], + capability_grant: Option<&SpawnCapabilityGrant>, +) -> Result> { + if !spec.child_policy.allow_delegation { + return Ok(Vec::new()); + } + let mut allowed = if let Some(selector) = &spec.child_policy.capability_selector { + let selected = evaluate_selector(capability_specs, selector)?; + parent_allowed_tools + .iter() + .filter(|tool| selected.contains(tool.as_str())) + .cloned() + .collect::>() + } else { + parent_allowed_tools.to_vec() + }; + if let Some(grant) = capability_grant { + grant.validate()?; + let requested = grant.normalized_allowed_tools()?; + allowed.retain(|tool| requested.iter().any(|candidate| candidate == tool)); + } + Ok(allowed) +} + +fn mode_prompt_declarations( + spec: &GovernanceModeSpec, + extra_prompt_declarations: Vec, +) -> Vec { + let mut declarations = spec + .prompt_program + .iter() + .map(|entry| PromptDeclaration { + block_id: entry.block_id.clone(), + title: entry.title.clone(), + content: entry.content.clone(), + render_target: PromptDeclarationRenderTarget::System, + layer: SystemPromptLayer::Dynamic, + kind: PromptDeclarationKind::ExtensionInstruction, + priority_hint: entry.priority_hint, + always_include: true, + source: PromptDeclarationSource::Builtin, + capability_name: None, + origin: Some(format!("mode:{}", spec.id)), + }) + .collect::>(); + declarations.extend(extra_prompt_declarations); + declarations +} + +fn subset_router( + base_router: &CapabilityRouter, + allowed_tools: &[String], +) -> Result> { + let all_tools = base_router.tool_names(); + let allowed_set = allowed_tools + .iter() + .map(String::as_str) + .collect::>(); + let all_set = all_tools + .iter() + .map(String::as_str) + .collect::>(); + if allowed_set == all_set { + return Ok(None); + } + if allowed_tools.is_empty() { + return Ok(Some(CapabilityRouter::empty())); + } + Ok(Some( + base_router + .subset_for_tools_checked(allowed_tools) + .map_err(|error| AstrError::Validation(error.to_string()))?, + )) +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use astrcode_core::{ + CapabilityContext, CapabilityExecutionResult, CapabilityInvoker, CapabilityKind, + CapabilitySpec, SideEffect, + }; + use astrcode_kernel::CapabilityRouter; + use async_trait::async_trait; + use serde_json::Value; + + use super::compile_capability_selector; + use crate::mode::builtin_mode_catalog; + + #[derive(Debug)] + struct FakeCapabilityInvoker { + spec: CapabilitySpec, + } + + impl FakeCapabilityInvoker { + fn new(spec: CapabilitySpec) -> Self { + Self { spec } + } + } + + #[async_trait] + impl CapabilityInvoker for FakeCapabilityInvoker { + fn capability_spec(&self) -> CapabilitySpec { + self.spec.clone() + } + + async fn invoke( + &self, + _payload: Value, + _ctx: &CapabilityContext, + ) -> astrcode_core::Result { + Ok(CapabilityExecutionResult::ok( + self.spec.name.to_string(), + Value::Null, + )) + } + } + + fn router() -> CapabilityRouter { + CapabilityRouter::builder() + .register_invoker(Arc::new(FakeCapabilityInvoker::new( + CapabilitySpec::builder("readFile", CapabilityKind::Tool) + .description("read") + .schema( + serde_json::json!({"type":"object"}), + serde_json::json!({"type":"object"}), + ) + .tags(["filesystem", "read"]) + .side_effect(SideEffect::None) + .build() + .expect("readFile should build"), + ))) + .register_invoker(Arc::new(FakeCapabilityInvoker::new( + CapabilitySpec::builder("writeFile", CapabilityKind::Tool) + .description("write") + .schema( + serde_json::json!({"type":"object"}), + serde_json::json!({"type":"object"}), + ) + .tags(["filesystem", "write"]) + .side_effect(SideEffect::Workspace) + .build() + .expect("writeFile should build"), + ))) + .register_invoker(Arc::new(FakeCapabilityInvoker::new( + CapabilitySpec::builder("spawn", CapabilityKind::Tool) + .description("spawn") + .schema( + serde_json::json!({"type":"object"}), + serde_json::json!({"type":"object"}), + ) + .tags(["agent"]) + .side_effect(SideEffect::None) + .build() + .expect("spawn should build"), + ))) + .build() + .expect("router should build") + } + + #[test] + fn builtin_modes_compile_expected_tool_equivalence() { + let router = router(); + let catalog = builtin_mode_catalog().expect("builtin catalog should build"); + + let code = catalog.get(&astrcode_core::ModeId::code()).unwrap(); + let plan = catalog.get(&astrcode_core::ModeId::plan()).unwrap(); + let review = catalog.get(&astrcode_core::ModeId::review()).unwrap(); + + assert_eq!( + compile_capability_selector(&router.capability_specs(), &code.capability_selector) + .expect("code selector should compile"), + vec![ + "readFile".to_string(), + "spawn".to_string(), + "writeFile".to_string() + ] + ); + assert_eq!( + compile_capability_selector(&router.capability_specs(), &plan.capability_selector) + .expect("plan selector should compile"), + vec!["readFile".to_string()] + ); + assert_eq!( + compile_capability_selector(&router.capability_specs(), &review.capability_selector) + .expect("review selector should compile"), + vec!["readFile".to_string()] + ); + } +} diff --git a/crates/application/src/mode/mod.rs b/crates/application/src/mode/mod.rs new file mode 100644 index 00000000..34800b1f --- /dev/null +++ b/crates/application/src/mode/mod.rs @@ -0,0 +1,13 @@ +mod catalog; +mod compiler; +mod validator; + +pub use catalog::{ + BuiltinModeCatalog, ModeCatalog, ModeCatalogEntry, ModeCatalogSnapshot, ModeSummary, + builtin_mode_catalog, +}; +pub use compiler::{ + CompiledModeEnvelope, compile_capability_selector, compile_mode_envelope, + compile_mode_envelope_for_child, +}; +pub use validator::{ModeTransitionDecision, validate_mode_transition}; diff --git a/crates/application/src/mode/validator.rs b/crates/application/src/mode/validator.rs new file mode 100644 index 00000000..76ce5174 --- /dev/null +++ b/crates/application/src/mode/validator.rs @@ -0,0 +1,69 @@ +use astrcode_core::{AstrError, GovernanceModeSpec, ModeId, Result}; + +use super::ModeCatalog; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ModeTransitionDecision { + pub from_mode_id: ModeId, + pub to_mode_id: ModeId, + pub target: GovernanceModeSpec, +} + +pub fn validate_mode_transition( + catalog: &ModeCatalog, + from_mode_id: &ModeId, + to_mode_id: &ModeId, +) -> Result { + let current = catalog + .get(from_mode_id) + .ok_or_else(|| AstrError::Validation(format!("unknown current mode '{}'", from_mode_id)))?; + let target = catalog + .get(to_mode_id) + .ok_or_else(|| AstrError::Validation(format!("unknown target mode '{}'", to_mode_id)))?; + if !current + .transition_policy + .allowed_targets + .iter() + .any(|candidate| candidate == to_mode_id) + { + return Err(AstrError::Validation(format!( + "mode transition '{}' -> '{}' is not allowed", + from_mode_id, to_mode_id + ))); + } + Ok(ModeTransitionDecision { + from_mode_id: from_mode_id.clone(), + to_mode_id: to_mode_id.clone(), + target, + }) +} + +#[cfg(test)] +mod tests { + use super::validate_mode_transition; + use crate::mode::builtin_mode_catalog; + + #[test] + fn builtin_transition_accepts_known_target() { + let catalog = builtin_mode_catalog().expect("builtin catalog should build"); + let decision = validate_mode_transition( + &catalog, + &astrcode_core::ModeId::code(), + &astrcode_core::ModeId::plan(), + ) + .expect("transition should succeed"); + assert_eq!(decision.to_mode_id, astrcode_core::ModeId::plan()); + } + + #[test] + fn unknown_target_is_rejected() { + let catalog = builtin_mode_catalog().expect("builtin catalog should build"); + let error = validate_mode_transition( + &catalog, + &astrcode_core::ModeId::code(), + &astrcode_core::ModeId::from("missing"), + ) + .expect_err("unknown target should fail"); + assert!(error.to_string().contains("unknown target mode")); + } +} diff --git a/crates/application/src/observability/collector.rs b/crates/application/src/observability/collector.rs index b283caf0..61dd9d48 100644 --- a/crates/application/src/observability/collector.rs +++ b/crates/application/src/observability/collector.rs @@ -660,7 +660,7 @@ mod tests { fn collector_snapshot_derives_collaboration_scorecard() { let collector = RuntimeObservabilityCollector::new(); let policy = AgentCollaborationPolicyContext { - policy_revision: "agent-collaboration-v1".to_string(), + policy_revision: "governance-surface-v1".to_string(), max_subrun_depth: 3, max_spawn_per_turn: 3, }; @@ -682,6 +682,8 @@ mod tests { summary: Some("spawned".to_string()), latency_ms: None, source_tool_call_id: Some("call-1".to_string().into()), + governance_revision: Some("governance-surface-v1".to_string()), + mode_id: Some(astrcode_core::ModeId::code()), policy: policy.clone(), }); collector.record_agent_collaboration_fact(&AgentCollaborationFact { @@ -701,6 +703,8 @@ mod tests { summary: Some("observe".to_string()), latency_ms: None, source_tool_call_id: Some("call-2".to_string().into()), + governance_revision: Some("governance-surface-v1".to_string()), + mode_id: Some(astrcode_core::ModeId::code()), policy: policy.clone(), }); collector.record_agent_collaboration_fact(&AgentCollaborationFact { @@ -720,6 +724,8 @@ mod tests { summary: Some("reused".to_string()), latency_ms: None, source_tool_call_id: Some("call-3".to_string().into()), + governance_revision: Some("governance-surface-v1".to_string()), + mode_id: Some(astrcode_core::ModeId::code()), policy: policy.clone(), }); collector.record_agent_collaboration_fact(&AgentCollaborationFact { @@ -739,6 +745,8 @@ mod tests { summary: Some("consumed".to_string()), latency_ms: Some(250), source_tool_call_id: None, + governance_revision: Some("governance-surface-v1".to_string()), + mode_id: Some(astrcode_core::ModeId::code()), policy, }); diff --git a/crates/application/src/ports/app_session.rs b/crates/application/src/ports/app_session.rs index bfb12296..15c35a86 100644 --- a/crates/application/src/ports/app_session.rs +++ b/crates/application/src/ports/app_session.rs @@ -4,8 +4,8 @@ use astrcode_core::{ }; use astrcode_session_runtime::{ AgentPromptSubmission, ConversationSnapshotFacts, ConversationStreamReplayFacts, ForkPoint, - ForkResult, SessionCatalogEvent, SessionControlStateSnapshot, SessionReplay, SessionRuntime, - SessionTranscriptSnapshot, + ForkResult, SessionCatalogEvent, SessionControlStateSnapshot, SessionModeSnapshot, + SessionReplay, SessionRuntime, SessionTranscriptSnapshot, }; use async_trait::async_trait; use tokio::sync::broadcast; @@ -54,6 +54,16 @@ pub trait AppSessionPort: Send + Sync { &self, session_id: &str, ) -> astrcode_core::Result; + async fn session_mode_state( + &self, + session_id: &str, + ) -> astrcode_core::Result; + async fn switch_mode( + &self, + session_id: &str, + from: astrcode_core::ModeId, + to: astrcode_core::ModeId, + ) -> astrcode_core::Result; async fn session_child_nodes( &self, session_id: &str, @@ -157,6 +167,22 @@ impl AppSessionPort for SessionRuntime { self.session_control_state(session_id).await } + async fn session_mode_state( + &self, + session_id: &str, + ) -> astrcode_core::Result { + self.session_mode_state(session_id).await + } + + async fn switch_mode( + &self, + session_id: &str, + from: astrcode_core::ModeId, + to: astrcode_core::ModeId, + ) -> astrcode_core::Result { + self.switch_mode(session_id, from, to).await + } + async fn session_child_nodes( &self, session_id: &str, diff --git a/crates/application/src/session_use_cases.rs b/crates/application/src/session_use_cases.rs index 3152fa68..e42e1526 100644 --- a/crates/application/src/session_use_cases.rs +++ b/crates/application/src/session_use_cases.rs @@ -2,19 +2,19 @@ use std::path::Path; use astrcode_core::{ - AgentEventContext, ChildSessionNode, DeleteProjectResult, ExecutionAccepted, PromptDeclaration, - PromptDeclarationKind, PromptDeclarationRenderTarget, PromptDeclarationSource, SessionMeta, - StoredEvent, SystemPromptLayer, + AgentEventContext, ChildSessionNode, DeleteProjectResult, ExecutionAccepted, ModeId, + PromptDeclaration, SessionMeta, StoredEvent, }; use crate::{ App, ApplicationError, CompactSessionAccepted, CompactSessionSummary, ExecutionControl, - PromptAcceptedSummary, PromptSkillInvocation, SessionControlStateSnapshot, SessionListSummary, - SessionReplay, SessionTranscriptSnapshot, + ModeSummary, PromptAcceptedSummary, PromptSkillInvocation, SessionControlStateSnapshot, + SessionListSummary, SessionReplay, SessionTranscriptSnapshot, agent::{ IMPLICIT_ROOT_PROFILE_ID, implicit_session_root_agent_id, root_execution_event_context, }, format_local_rfc3339, + governance_surface::{GovernanceBusyPolicy, SessionGovernanceInput}, }; impl App { @@ -119,19 +119,9 @@ impl App { .session_runtime .get_session_working_dir(session_id) .await?; - let mut runtime = self + let runtime = self .config_service .load_resolved_runtime_config(Some(Path::new(&working_dir)))?; - if let Some(control) = control { - if control.manual_compact.is_some() { - return Err(ApplicationError::InvalidArgument( - "manualCompact is not valid for prompt submission".to_string(), - )); - } - if let Some(max_steps) = control.max_steps { - runtime.max_steps = max_steps as usize; - } - } let root_agent = self.ensure_session_root_agent_context(session_id).await?; let prompt_declarations = match skill_invocation { @@ -141,16 +131,34 @@ impl App { )?], None => Vec::new(), }; + let surface = self.governance_surface.session_surface( + self.kernel.as_ref(), + SessionGovernanceInput { + session_id: session_id.to_string(), + turn_id: astrcode_core::generate_turn_id(), + working_dir: working_dir.clone(), + profile: root_agent + .agent_profile + .clone() + .unwrap_or_else(|| IMPLICIT_ROOT_PROFILE_ID.to_string()), + mode_id: self + .session_runtime + .session_mode_state(session_id) + .await + .map_err(ApplicationError::from)? + .current_mode_id, + runtime, + control, + extra_prompt_declarations: prompt_declarations, + busy_policy: GovernanceBusyPolicy::BranchOnBusy, + }, + )?; self.session_runtime .submit_prompt_for_agent( session_id, text, - runtime, - astrcode_session_runtime::AgentPromptSubmission { - agent: root_agent, - prompt_declarations, - ..Default::default() - }, + surface.runtime.clone(), + surface.into_submission(root_agent, None), ) .await .map_err(ApplicationError::from) @@ -282,6 +290,49 @@ impl App { .map_err(ApplicationError::from) } + pub async fn list_modes(&self) -> Result, ApplicationError> { + Ok(self.mode_catalog.list()) + } + + pub async fn session_mode_state( + &self, + session_id: &str, + ) -> Result { + self.session_runtime + .session_mode_state(session_id) + .await + .map_err(ApplicationError::from) + } + + pub async fn switch_mode( + &self, + session_id: &str, + target_mode_id: ModeId, + ) -> Result { + let current = self + .session_runtime + .session_mode_state(session_id) + .await + .map_err(ApplicationError::from)?; + if current.current_mode_id == target_mode_id { + return Ok(current); + } + crate::validate_mode_transition( + self.mode_catalog.as_ref(), + ¤t.current_mode_id, + &target_mode_id, + ) + .map_err(ApplicationError::from)?; + self.session_runtime + .switch_mode(session_id, current.current_mode_id, target_mode_id) + .await + .map_err(ApplicationError::from)?; + self.session_runtime + .session_mode_state(session_id) + .await + .map_err(ApplicationError::from) + } + /// 返回指定 session 的 durable 存储事件。 /// /// Debug Workbench 需要基于服务端真相构造 trace, @@ -374,41 +425,9 @@ impl App { skill_invocation.skill_id )) })?; - let mut content = format!( - "The user explicitly selected the `{}` skill for this turn.\n\nSelected skill:\n- id: \ - {}\n- description: {}\n\nTurn contract:\n- Call the `Skill` tool for `{}` before \ - continuing.\n- Treat the user's message as the task-specific instruction for this \ - skill.\n- If the user message is empty, follow the skill's default workflow and ask \ - only if blocked.\n- Do not silently substitute a different skill unless `{}` is \ - unavailable.", - skill.id, - skill.id, - skill.description.trim(), - skill.id, - skill.id - ); - if let Some(user_prompt) = skill_invocation - .user_prompt - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - { - content.push_str(&format!("\n- User prompt focus: {}", user_prompt)); - } - - Ok(PromptDeclaration { - block_id: format!("submission.skill.{}", skill.id), - title: format!("Selected Skill: {}", skill.id), - content, - render_target: PromptDeclarationRenderTarget::System, - layer: SystemPromptLayer::Dynamic, - kind: PromptDeclarationKind::ExtensionInstruction, - priority_hint: Some(590), - always_include: true, - source: PromptDeclarationSource::Builtin, - capability_name: None, - origin: Some(format!("skill-slash:{}", skill.id)), - }) + Ok(self + .governance_surface + .build_submission_skill_declaration(&skill, skill_invocation.user_prompt.as_deref())) } } diff --git a/crates/application/src/terminal_queries/cursor.rs b/crates/application/src/terminal_queries/cursor.rs new file mode 100644 index 00000000..2f01f2a1 --- /dev/null +++ b/crates/application/src/terminal_queries/cursor.rs @@ -0,0 +1,38 @@ +use crate::ApplicationError; + +pub(super) fn validate_cursor_format(cursor: &str) -> Result<(), ApplicationError> { + let Some((storage_seq, subindex)) = cursor.split_once('.') else { + return Err(ApplicationError::InvalidArgument(format!( + "invalid cursor '{cursor}'" + ))); + }; + if storage_seq.parse::().is_err() || subindex.parse::().is_err() { + return Err(ApplicationError::InvalidArgument(format!( + "invalid cursor '{cursor}'" + ))); + } + Ok(()) +} + +pub(super) fn cursor_is_after_head( + requested_cursor: &str, + latest_cursor: Option<&str>, +) -> Result { + let Some(latest_cursor) = latest_cursor else { + return Ok(false); + }; + Ok(parse_cursor(requested_cursor)? > parse_cursor(latest_cursor)?) +} + +fn parse_cursor(cursor: &str) -> Result<(u64, u32), ApplicationError> { + let (storage_seq, subindex) = cursor + .split_once('.') + .ok_or_else(|| ApplicationError::InvalidArgument(format!("invalid cursor '{cursor}'")))?; + let storage_seq = storage_seq + .parse::() + .map_err(|_| ApplicationError::InvalidArgument(format!("invalid cursor '{cursor}'")))?; + let subindex = subindex + .parse::() + .map_err(|_| ApplicationError::InvalidArgument(format!("invalid cursor '{cursor}'")))?; + Ok((storage_seq, subindex)) +} diff --git a/crates/application/src/terminal_queries/mod.rs b/crates/application/src/terminal_queries/mod.rs new file mode 100644 index 00000000..0e654f8f --- /dev/null +++ b/crates/application/src/terminal_queries/mod.rs @@ -0,0 +1,25 @@ +mod cursor; +mod resume; +mod snapshot; +mod summary; +#[cfg(test)] +mod tests; + +use astrcode_session_runtime::SessionControlStateSnapshot; + +use crate::terminal::{TerminalControlFacts, TerminalLastCompactMetaFacts}; + +fn map_control_facts(control: SessionControlStateSnapshot) -> TerminalControlFacts { + TerminalControlFacts { + phase: control.phase, + active_turn_id: control.active_turn_id, + manual_compact_pending: control.manual_compact_pending, + compacting: control.compacting, + last_compact_meta: control + .last_compact_meta + .map(|meta| TerminalLastCompactMetaFacts { + trigger: meta.trigger, + meta: meta.meta, + }), + } +} diff --git a/crates/application/src/terminal_queries/resume.rs b/crates/application/src/terminal_queries/resume.rs new file mode 100644 index 00000000..379fae8d --- /dev/null +++ b/crates/application/src/terminal_queries/resume.rs @@ -0,0 +1,274 @@ +use std::{cmp::Reverse, collections::HashSet, path::Path}; + +use crate::{ + App, ApplicationError, ComposerOptionKind, ComposerOptionsRequest, SessionMeta, + terminal::{ + ConversationAuthoritativeSummary, ConversationFocus, TerminalChildSummaryFacts, + TerminalControlFacts, TerminalResumeCandidateFacts, TerminalSlashAction, + TerminalSlashCandidateFacts, summarize_conversation_authoritative, + }, +}; + +impl App { + pub async fn terminal_resume_candidates( + &self, + query: Option<&str>, + limit: usize, + ) -> Result, ApplicationError> { + let metas = self.session_runtime.list_session_metas().await?; + let query = normalize_query(query); + let limit = normalize_limit(limit); + let mut items = metas + .into_iter() + .filter(|meta| resume_candidate_matches(meta, query.as_deref())) + .map(|meta| TerminalResumeCandidateFacts { + session_id: meta.session_id, + title: meta.title, + display_name: meta.display_name, + working_dir: meta.working_dir, + updated_at: meta.updated_at, + created_at: meta.created_at, + phase: meta.phase, + parent_session_id: meta.parent_session_id, + }) + .collect::>(); + + items.sort_by_key(|item| Reverse(item.updated_at)); + items.truncate(limit); + Ok(items) + } + + pub async fn terminal_child_summaries( + &self, + session_id: &str, + ) -> Result, ApplicationError> { + self.conversation_child_summaries(session_id, &ConversationFocus::Root) + .await + } + + pub async fn conversation_child_summaries( + &self, + session_id: &str, + focus: &ConversationFocus, + ) -> Result, ApplicationError> { + self.validate_non_empty("sessionId", session_id)?; + let focus_session_id = self + .resolve_conversation_focus_session_id(session_id, focus) + .await?; + let children = self + .session_runtime + .session_child_nodes(&focus_session_id) + .await?; + let session_metas = self.session_runtime.list_session_metas().await?; + + let summaries = children + .into_iter() + .filter(|node| node.parent_sub_run_id().is_none()) + .map(|node| async { + self.require_permission( + node.parent_session_id.as_str() == focus_session_id, + format!( + "child '{}' is not visible from session '{}'", + node.sub_run_id(), + focus_session_id + ), + )?; + let child_meta = session_metas + .iter() + .find(|meta| meta.session_id == node.child_session_id.as_str()); + let child_transcript = self + .session_runtime + .conversation_snapshot(node.child_session_id.as_str()) + .await?; + Ok::<_, ApplicationError>(TerminalChildSummaryFacts { + node, + phase: child_transcript.phase, + title: child_meta.map(|meta| meta.title.clone()), + display_name: child_meta.map(|meta| meta.display_name.clone()), + recent_output: super::summary::latest_terminal_summary(&child_transcript), + }) + }) + .collect::>(); + + let mut resolved = Vec::with_capacity(summaries.len()); + for summary in summaries { + resolved.push(summary.await?); + } + resolved.sort_by(|left, right| left.node.sub_run_id().cmp(right.node.sub_run_id())); + Ok(resolved) + } + + pub async fn terminal_slash_candidates( + &self, + session_id: &str, + query: Option<&str>, + ) -> Result, ApplicationError> { + self.validate_non_empty("sessionId", session_id)?; + let working_dir = self + .session_runtime + .get_session_working_dir(session_id) + .await?; + let query = normalize_query(query); + let control = self.terminal_control_facts(session_id).await?; + let mut candidates = terminal_builtin_candidates(&control); + candidates.extend( + self.list_composer_options( + session_id, + ComposerOptionsRequest { + query: query.clone(), + kinds: vec![ComposerOptionKind::Skill], + limit: 50, + }, + ) + .await? + .into_iter() + .map(|option| TerminalSlashCandidateFacts { + kind: option.kind, + id: option.id.clone(), + title: option.title, + description: option.description, + keywords: option.keywords, + badges: option.badges, + action: TerminalSlashAction::InsertText { + text: format!("/{}", option.id), + }, + }), + ); + + if let Some(query) = query.as_deref() { + candidates.retain(|candidate| slash_candidate_matches(candidate, query)); + } + + let _ = Path::new(&working_dir); + Ok(candidates) + } + + pub async fn terminal_control_facts( + &self, + session_id: &str, + ) -> Result { + let control = self + .session_runtime + .session_control_state(session_id) + .await?; + Ok(super::map_control_facts(control)) + } + + pub async fn conversation_authoritative_summary( + &self, + session_id: &str, + focus: &ConversationFocus, + ) -> Result { + Ok(summarize_conversation_authoritative( + &self.terminal_control_facts(session_id).await?, + &self.conversation_child_summaries(session_id, focus).await?, + &self.terminal_slash_candidates(session_id, None).await?, + )) + } + + pub(super) async fn resolve_conversation_focus_session_id( + &self, + root_session_id: &str, + focus: &ConversationFocus, + ) -> Result { + match focus { + ConversationFocus::Root => Ok(root_session_id.to_string()), + ConversationFocus::SubRun { sub_run_id } => { + let mut pending = vec![root_session_id.to_string()]; + let mut visited = HashSet::new(); + + while let Some(session_id) = pending.pop() { + if !visited.insert(session_id.clone()) { + continue; + } + for node in self + .session_runtime + .session_child_nodes(&session_id) + .await? + { + if node.sub_run_id().as_str() == *sub_run_id { + return Ok(node.child_session_id.to_string()); + } + pending.push(node.child_session_id.to_string()); + } + } + + Err(ApplicationError::NotFound(format!( + "sub-run '{}' not found under session '{}'", + sub_run_id, root_session_id + ))) + }, + } + } +} + +fn normalize_query(query: Option<&str>) -> Option { + query + .map(str::trim) + .filter(|query| !query.is_empty()) + .map(|query| query.to_lowercase()) +} + +fn normalize_limit(limit: usize) -> usize { + if limit == 0 { 20 } else { limit } +} + +fn resume_candidate_matches(meta: &SessionMeta, query: Option<&str>) -> bool { + let Some(query) = query else { + return true; + }; + [ + meta.session_id.as_str(), + meta.title.as_str(), + meta.display_name.as_str(), + meta.working_dir.as_str(), + ] + .iter() + .any(|field| field.to_lowercase().contains(query)) +} + +fn terminal_builtin_candidates(control: &TerminalControlFacts) -> Vec { + let mut candidates = vec![ + TerminalSlashCandidateFacts { + kind: ComposerOptionKind::Command, + id: "new".to_string(), + title: "新建会话".to_string(), + description: "创建新 session 并切换焦点".to_string(), + keywords: vec!["new".to_string(), "session".to_string()], + badges: vec!["built-in".to_string()], + action: TerminalSlashAction::CreateSession, + }, + TerminalSlashCandidateFacts { + kind: ComposerOptionKind::Command, + id: "resume".to_string(), + title: "恢复会话".to_string(), + description: "搜索并切换到已有 session".to_string(), + keywords: vec!["resume".to_string(), "switch".to_string()], + badges: vec!["built-in".to_string()], + action: TerminalSlashAction::OpenResume, + }, + ]; + + if !control.manual_compact_pending && !control.compacting { + candidates.push(TerminalSlashCandidateFacts { + kind: ComposerOptionKind::Command, + id: "compact".to_string(), + title: "压缩上下文".to_string(), + description: "向服务端提交显式 compact 控制请求".to_string(), + keywords: vec!["compact".to_string(), "compress".to_string()], + badges: vec!["built-in".to_string()], + action: TerminalSlashAction::RequestCompact, + }); + } + candidates +} + +fn slash_candidate_matches(candidate: &TerminalSlashCandidateFacts, query: &str) -> bool { + candidate.id.to_lowercase().contains(query) + || candidate.title.to_lowercase().contains(query) + || candidate.description.to_lowercase().contains(query) + || candidate + .keywords + .iter() + .any(|keyword| keyword.to_lowercase().contains(query)) +} diff --git a/crates/application/src/terminal_queries/snapshot.rs b/crates/application/src/terminal_queries/snapshot.rs new file mode 100644 index 00000000..a52a7948 --- /dev/null +++ b/crates/application/src/terminal_queries/snapshot.rs @@ -0,0 +1,116 @@ +use crate::{ + App, ApplicationError, + terminal::{ + ConversationFocus, TerminalFacts, TerminalRehydrateFacts, TerminalRehydrateReason, + TerminalStreamFacts, TerminalStreamReplayFacts, + }, +}; + +impl App { + pub async fn conversation_snapshot_facts( + &self, + session_id: &str, + focus: ConversationFocus, + ) -> Result { + self.validate_non_empty("sessionId", session_id)?; + let focus_session_id = self + .resolve_conversation_focus_session_id(session_id, &focus) + .await?; + let transcript = self + .session_runtime + .conversation_snapshot(&focus_session_id) + .await?; + let session_title = self + .session_runtime + .list_session_metas() + .await? + .into_iter() + .find(|meta| meta.session_id == session_id) + .map(|meta| meta.title) + .ok_or_else(|| { + ApplicationError::NotFound(format!("session '{session_id}' not found")) + })?; + let control = self.terminal_control_facts(session_id).await?; + let child_summaries = self + .conversation_child_summaries(session_id, &focus) + .await?; + let slash_candidates = self.terminal_slash_candidates(session_id, None).await?; + + Ok(TerminalFacts { + active_session_id: session_id.to_string(), + session_title, + transcript, + control, + child_summaries, + slash_candidates, + }) + } + + pub async fn terminal_snapshot_facts( + &self, + session_id: &str, + ) -> Result { + self.conversation_snapshot_facts(session_id, ConversationFocus::Root) + .await + } + + pub async fn conversation_stream_facts( + &self, + session_id: &str, + last_event_id: Option<&str>, + focus: ConversationFocus, + ) -> Result { + self.validate_non_empty("sessionId", session_id)?; + let focus_session_id = self + .resolve_conversation_focus_session_id(session_id, &focus) + .await?; + + if let Some(requested_cursor) = last_event_id { + super::cursor::validate_cursor_format(requested_cursor)?; + let transcript = self + .session_runtime + .conversation_snapshot(&focus_session_id) + .await?; + let latest_cursor = crate::terminal::latest_transcript_cursor(&transcript); + if super::cursor::cursor_is_after_head(requested_cursor, latest_cursor.as_deref())? { + return Ok(TerminalStreamFacts::RehydrateRequired( + TerminalRehydrateFacts { + session_id: session_id.to_string(), + requested_cursor: requested_cursor.to_string(), + latest_cursor, + reason: TerminalRehydrateReason::CursorExpired, + }, + )); + } + } + + let replay = self + .session_runtime + .conversation_stream_replay(&focus_session_id, last_event_id) + .await?; + let control = self.terminal_control_facts(session_id).await?; + let child_summaries = self + .conversation_child_summaries(session_id, &focus) + .await?; + let slash_candidates = self.terminal_slash_candidates(session_id, None).await?; + + Ok(TerminalStreamFacts::Replay(Box::new( + TerminalStreamReplayFacts { + active_session_id: session_id.to_string(), + replay, + control, + child_summaries, + slash_candidates, + }, + ))) + } + + pub async fn terminal_stream_facts( + &self, + session_id: &str, + last_event_id: Option<&str>, + ) -> Result { + self.conversation_stream_facts(session_id, last_event_id, ConversationFocus::Root) + .await + } +} diff --git a/crates/application/src/terminal_queries/summary.rs b/crates/application/src/terminal_queries/summary.rs new file mode 100644 index 00000000..bdecd55a --- /dev/null +++ b/crates/application/src/terminal_queries/summary.rs @@ -0,0 +1,63 @@ +use astrcode_session_runtime::{ + ConversationBlockFacts, ConversationChildHandoffBlockFacts, ConversationErrorBlockFacts, + ConversationSnapshotFacts, ConversationSystemNoteBlockFacts, ToolCallBlockFacts, +}; + +use crate::terminal::{latest_transcript_cursor, truncate_terminal_summary}; + +pub(super) fn latest_terminal_summary(snapshot: &ConversationSnapshotFacts) -> Option { + snapshot + .blocks + .iter() + .rev() + .find_map(summary_from_block) + .or_else(|| latest_transcript_cursor(snapshot).map(|cursor| format!("cursor:{cursor}"))) +} + +fn summary_from_block(block: &ConversationBlockFacts) -> Option { + match block { + ConversationBlockFacts::Assistant(block) => summary_from_markdown(&block.markdown), + ConversationBlockFacts::ToolCall(block) => summary_from_tool_call(block), + ConversationBlockFacts::ChildHandoff(block) => summary_from_child_handoff(block), + ConversationBlockFacts::Error(block) => summary_from_error_block(block), + ConversationBlockFacts::SystemNote(block) => summary_from_system_note(block), + ConversationBlockFacts::User(_) | ConversationBlockFacts::Thinking(_) => None, + } +} + +fn summary_from_markdown(markdown: &str) -> Option { + (!markdown.trim().is_empty()).then(|| truncate_terminal_summary(markdown)) +} + +fn summary_from_tool_call(block: &ToolCallBlockFacts) -> Option { + block + .summary + .as_deref() + .filter(|summary| !summary.trim().is_empty()) + .map(truncate_terminal_summary) + .or_else(|| { + block + .error + .as_deref() + .filter(|error| !error.trim().is_empty()) + .map(truncate_terminal_summary) + }) + .or_else(|| summary_from_markdown(&block.streams.stderr)) + .or_else(|| summary_from_markdown(&block.streams.stdout)) +} + +fn summary_from_child_handoff(block: &ConversationChildHandoffBlockFacts) -> Option { + block + .message + .as_deref() + .filter(|message| !message.trim().is_empty()) + .map(truncate_terminal_summary) +} + +fn summary_from_error_block(block: &ConversationErrorBlockFacts) -> Option { + summary_from_markdown(&block.message) +} + +fn summary_from_system_note(block: &ConversationSystemNoteBlockFacts) -> Option { + summary_from_markdown(&block.markdown) +} diff --git a/crates/application/src/terminal_queries/tests.rs b/crates/application/src/terminal_queries/tests.rs new file mode 100644 index 00000000..11aaa87b --- /dev/null +++ b/crates/application/src/terminal_queries/tests.rs @@ -0,0 +1,608 @@ +use std::{path::Path, sync::Arc, time::Duration}; + +use astrcode_core::AgentEvent; +use astrcode_session_runtime::{ConversationBlockFacts, SessionRuntime}; +use async_trait::async_trait; +use tokio::time::timeout; + +use crate::{ + App, AppKernelPort, AppSessionPort, ApplicationError, ComposerResolvedSkill, ComposerSkillPort, + ConfigService, McpConfigScope, McpPort, McpServerStatusView, McpService, + ProfileResolutionService, + agent::{ + AgentOrchestrationService, + test_support::{TestLlmBehavior, build_agent_test_harness}, + }, + composer::ComposerSkillSummary, + mcp::RegisterMcpServerInput, + terminal::{ConversationFocus, TerminalRehydrateReason, TerminalStreamFacts}, +}; + +struct StaticComposerSkillPort { + summaries: Vec, +} + +impl ComposerSkillPort for StaticComposerSkillPort { + fn list_skill_summaries(&self, _working_dir: &Path) -> Vec { + self.summaries.clone() + } + + fn resolve_skill(&self, _working_dir: &Path, skill_id: &str) -> Option { + self.summaries + .iter() + .find(|summary| summary.id == skill_id) + .map(|summary| ComposerResolvedSkill { + id: summary.id.clone(), + description: summary.description.clone(), + guide: format!("guide for {}", summary.id), + }) + } +} + +struct NoopMcpPort; + +#[async_trait] +impl McpPort for NoopMcpPort { + async fn list_server_status(&self) -> Vec { + Vec::new() + } + + async fn approve_server(&self, _server_signature: &str) -> Result<(), ApplicationError> { + Ok(()) + } + + async fn reject_server(&self, _server_signature: &str) -> Result<(), ApplicationError> { + Ok(()) + } + + async fn reconnect_server(&self, _name: &str) -> Result<(), ApplicationError> { + Ok(()) + } + + async fn reset_project_choices(&self) -> Result<(), ApplicationError> { + Ok(()) + } + + async fn upsert_server(&self, _input: &RegisterMcpServerInput) -> Result<(), ApplicationError> { + Ok(()) + } + + async fn remove_server( + &self, + _scope: McpConfigScope, + _name: &str, + ) -> Result<(), ApplicationError> { + Ok(()) + } + + async fn set_server_enabled( + &self, + _scope: McpConfigScope, + _name: &str, + _enabled: bool, + ) -> Result<(), ApplicationError> { + Ok(()) + } +} + +struct TerminalAppHarness { + app: App, + session_runtime: Arc, +} + +fn build_terminal_app_harness(skill_ids: &[&str]) -> TerminalAppHarness { + build_terminal_app_harness_with_behavior( + skill_ids, + TestLlmBehavior::Succeed { + content: "子代理已完成。".to_string(), + }, + ) +} + +fn build_terminal_app_harness_with_behavior( + skill_ids: &[&str], + llm_behavior: TestLlmBehavior, +) -> TerminalAppHarness { + let harness = build_agent_test_harness(llm_behavior).expect("agent test harness should build"); + let kernel: Arc = harness.kernel.clone(); + let session_runtime = harness.session_runtime.clone(); + let session_port: Arc = session_runtime.clone(); + let config: Arc = harness.config_service.clone(); + let profiles: Arc = harness.profiles.clone(); + let composer_skills: Arc = Arc::new(StaticComposerSkillPort { + summaries: skill_ids + .iter() + .map(|id| ComposerSkillSummary::new(*id, format!("{id} description"))) + .collect(), + }); + let mcp_service = Arc::new(McpService::new(Arc::new(NoopMcpPort))); + let agent_service: Arc = Arc::new(harness.service.clone()); + let app = App::new( + kernel, + session_port, + profiles, + config, + composer_skills, + Arc::new(crate::governance_surface::GovernanceSurfaceAssembler::default()), + Arc::new(crate::mode::builtin_mode_catalog().expect("builtin mode catalog should build")), + mcp_service, + agent_service, + ); + TerminalAppHarness { + app, + session_runtime, + } +} + +#[tokio::test] +async fn terminal_stream_facts_expose_live_llm_deltas_before_durable_completion() { + let harness = build_terminal_app_harness_with_behavior( + &[], + TestLlmBehavior::Stream { + reasoning_chunks: vec!["先".to_string(), "整理".to_string()], + text_chunks: vec!["流".to_string(), "式".to_string()], + final_content: "流式完成".to_string(), + final_reasoning: Some("先整理".to_string()), + }, + ); + let project = tempfile::tempdir().expect("tempdir should be created"); + let session = harness + .app + .create_session(project.path().display().to_string()) + .await + .expect("session should be created"); + + let TerminalStreamFacts::Replay(replay) = harness + .app + .terminal_stream_facts(&session.session_id, None) + .await + .expect("stream facts should build") + else { + panic!("fresh stream should start from replay facts"); + }; + let mut live_receiver = replay.replay.replay.live_receiver; + + let accepted = harness + .app + .submit_prompt(&session.session_id, "请流式回答".to_string()) + .await + .expect("prompt should submit"); + + let mut live_events = Vec::new(); + for _ in 0..4 { + live_events.push( + timeout(Duration::from_secs(1), live_receiver.recv()) + .await + .expect("live delta should arrive in time") + .expect("live receiver should stay open"), + ); + } + + assert!(matches!( + &live_events[0], + AgentEvent::ThinkingDelta { delta, .. } if delta == "先" + )); + assert!(matches!( + &live_events[1], + AgentEvent::ThinkingDelta { delta, .. } if delta == "整理" + )); + assert!(matches!( + &live_events[2], + AgentEvent::ModelDelta { delta, .. } if delta == "流" + )); + assert!(matches!( + &live_events[3], + AgentEvent::ModelDelta { delta, .. } if delta == "式" + )); + + harness + .session_runtime + .wait_for_turn_terminal_snapshot(&session.session_id, accepted.turn_id.as_str()) + .await + .expect("turn should settle"); + + let snapshot = harness + .app + .terminal_snapshot_facts(&session.session_id) + .await + .expect("terminal snapshot should build"); + assert!(snapshot.transcript.blocks.iter().any(|block| matches!( + block, + ConversationBlockFacts::Assistant(block) if block.markdown == "流式完成" + ))); + assert!(snapshot.transcript.blocks.iter().any(|block| matches!( + block, + ConversationBlockFacts::Thinking(block) if block.markdown == "先整理" + ))); +} + +#[tokio::test] +async fn terminal_snapshot_facts_hydrate_history_control_and_slash_candidates() { + let harness = build_terminal_app_harness(&["openspec-apply-change"]); + let project = tempfile::tempdir().expect("tempdir should be created"); + let session = harness + .app + .create_session(project.path().display().to_string()) + .await + .expect("session should be created"); + harness + .app + .submit_prompt(&session.session_id, "请总结当前仓库".to_string()) + .await + .expect("prompt should submit"); + + let facts = harness + .app + .terminal_snapshot_facts(&session.session_id) + .await + .expect("terminal snapshot should build"); + + assert_eq!(facts.active_session_id, session.session_id); + assert!(!facts.transcript.blocks.is_empty()); + assert!(facts.transcript.cursor.is_some()); + assert!( + facts + .slash_candidates + .iter() + .any(|candidate| candidate.id == "new") + ); + assert!( + facts + .slash_candidates + .iter() + .any(|candidate| candidate.id == "resume") + ); + assert!( + facts + .slash_candidates + .iter() + .any(|candidate| candidate.id == "compact") + ); + assert!( + facts + .slash_candidates + .iter() + .any(|candidate| candidate.id == "openspec-apply-change") + ); + assert!( + facts + .slash_candidates + .iter() + .all(|candidate| candidate.id != "skill") + ); +} + +#[tokio::test] +async fn terminal_stream_facts_returns_replay_for_valid_cursor() { + let harness = build_terminal_app_harness(&[]); + let project = tempfile::tempdir().expect("tempdir should be created"); + let session = harness + .app + .create_session(project.path().display().to_string()) + .await + .expect("session should be created"); + harness + .app + .submit_prompt(&session.session_id, "hello".to_string()) + .await + .expect("prompt should submit"); + let snapshot = harness + .app + .terminal_snapshot_facts(&session.session_id) + .await + .expect("snapshot should build"); + let cursor = snapshot.transcript.cursor.clone(); + + let facts = harness + .app + .terminal_stream_facts(&session.session_id, cursor.as_deref()) + .await + .expect("stream facts should build"); + + match facts { + TerminalStreamFacts::Replay(replay) => { + assert_eq!(replay.active_session_id, session.session_id); + assert!(replay.replay.replay.history.is_empty()); + assert!(replay.replay.replay_frames.is_empty()); + assert_eq!( + replay + .replay + .seed_records + .last() + .map(|record| record.event_id.as_str()), + snapshot.transcript.cursor.as_deref() + ); + }, + TerminalStreamFacts::RehydrateRequired(_) => { + panic!("valid cursor should not require rehydrate"); + }, + } +} + +#[tokio::test] +async fn terminal_stream_facts_falls_back_to_rehydrate_for_future_cursor() { + let harness = build_terminal_app_harness(&[]); + let project = tempfile::tempdir().expect("tempdir should be created"); + let session = harness + .app + .create_session(project.path().display().to_string()) + .await + .expect("session should be created"); + harness + .app + .submit_prompt(&session.session_id, "hello".to_string()) + .await + .expect("prompt should submit"); + + let facts = harness + .app + .terminal_stream_facts(&session.session_id, Some("999999.9")) + .await + .expect("stream facts should build"); + + match facts { + TerminalStreamFacts::Replay(_) => { + panic!("future cursor should require rehydrate"); + }, + TerminalStreamFacts::RehydrateRequired(rehydrate) => { + assert_eq!(rehydrate.reason, TerminalRehydrateReason::CursorExpired); + assert_eq!(rehydrate.requested_cursor, "999999.9"); + assert!(rehydrate.latest_cursor.is_some()); + }, + } +} + +#[tokio::test] +async fn terminal_resume_candidates_use_server_fact_and_recent_sorting() { + let harness = build_terminal_app_harness(&[]); + let project = tempfile::tempdir().expect("tempdir should be created"); + let older_dir = project.path().join("older"); + let newer_dir = project.path().join("newer"); + std::fs::create_dir_all(&older_dir).expect("older dir should exist"); + std::fs::create_dir_all(&newer_dir).expect("newer dir should exist"); + let older = harness + .app + .create_session(older_dir.display().to_string()) + .await + .expect("older session should be created"); + tokio::time::sleep(std::time::Duration::from_millis(5)).await; + let newer = harness + .app + .create_session(newer_dir.display().to_string()) + .await + .expect("newer session should be created"); + + let candidates = harness + .app + .terminal_resume_candidates(Some("newer"), 20) + .await + .expect("resume candidates should build"); + + assert_eq!(candidates.len(), 1); + assert_eq!(candidates[0].session_id, newer.session_id); + let all_candidates = harness + .app + .terminal_resume_candidates(None, 20) + .await + .expect("resume candidates should build"); + assert_eq!(all_candidates[0].session_id, newer.session_id); + assert_eq!(all_candidates[1].session_id, older.session_id); +} + +#[tokio::test] +async fn terminal_child_summaries_only_return_direct_visible_children() { + let harness = build_terminal_app_harness(&[]); + let project = tempfile::tempdir().expect("tempdir should be created"); + let parent_dir = project.path().join("parent"); + let child_dir = project.path().join("child"); + let unrelated_dir = project.path().join("unrelated"); + std::fs::create_dir_all(&parent_dir).expect("parent dir should exist"); + std::fs::create_dir_all(&child_dir).expect("child dir should exist"); + std::fs::create_dir_all(&unrelated_dir).expect("unrelated dir should exist"); + let parent = harness + .session_runtime + .create_session(parent_dir.display().to_string()) + .await + .expect("parent session should be created"); + let child = harness + .session_runtime + .create_session(child_dir.display().to_string()) + .await + .expect("child session should be created"); + let unrelated = harness + .session_runtime + .create_session(unrelated_dir.display().to_string()) + .await + .expect("unrelated session should be created"); + + let root = harness + .app + .ensure_session_root_agent_context(&parent.session_id) + .await + .expect("root context should exist"); + + harness + .session_runtime + .append_child_session_notification( + &parent.session_id, + "turn-parent", + root.clone(), + astrcode_core::ChildSessionNotification { + notification_id: "child-1".to_string().into(), + child_ref: astrcode_core::ChildAgentRef { + identity: astrcode_core::ChildExecutionIdentity { + agent_id: "agent-child".to_string().into(), + session_id: parent.session_id.clone().into(), + sub_run_id: "subrun-child".to_string().into(), + }, + parent: astrcode_core::ParentExecutionRef { + parent_agent_id: root.agent_id.clone(), + parent_sub_run_id: None, + }, + lineage_kind: astrcode_core::ChildSessionLineageKind::Spawn, + status: astrcode_core::AgentLifecycleStatus::Running, + open_session_id: child.session_id.clone().into(), + }, + kind: astrcode_core::ChildSessionNotificationKind::Started, + source_tool_call_id: Some("tool-call-1".to_string().into()), + delivery: Some(astrcode_core::ParentDelivery { + idempotency_key: "child-1".to_string(), + origin: astrcode_core::ParentDeliveryOrigin::Explicit, + terminal_semantics: astrcode_core::ParentDeliveryTerminalSemantics::NonTerminal, + source_turn_id: Some("turn-child".to_string()), + payload: astrcode_core::ParentDeliveryPayload::Progress( + astrcode_core::ProgressParentDeliveryPayload { + message: "child progress".to_string(), + }, + ), + }), + }, + ) + .await + .expect("child notification should append"); + + let accepted = harness + .app + .submit_prompt(&child.session_id, "child output".to_string()) + .await + .expect("child prompt should submit"); + harness + .session_runtime + .wait_for_turn_terminal_snapshot(&child.session_id, accepted.turn_id.as_str()) + .await + .expect("child turn should settle"); + harness + .app + .submit_prompt(&unrelated.session_id, "ignore me".to_string()) + .await + .expect("unrelated prompt should submit"); + + let children = harness + .app + .terminal_child_summaries(&parent.session_id) + .await + .expect("child summaries should build"); + + assert_eq!(children.len(), 1); + assert_eq!(children[0].node.child_session_id, child.session_id.into()); + assert!( + children[0] + .recent_output + .as_deref() + .is_some_and(|summary| summary.contains("子代理已完成")) + ); +} + +#[tokio::test] +async fn conversation_focus_snapshot_reads_child_session_transcript() { + let harness = build_terminal_app_harness(&[]); + let project = tempfile::tempdir().expect("tempdir should be created"); + let parent_dir = project.path().join("parent"); + let child_dir = project.path().join("child"); + std::fs::create_dir_all(&parent_dir).expect("parent dir should exist"); + std::fs::create_dir_all(&child_dir).expect("child dir should exist"); + let parent = harness + .session_runtime + .create_session(parent_dir.display().to_string()) + .await + .expect("parent session should be created"); + let child = harness + .session_runtime + .create_session(child_dir.display().to_string()) + .await + .expect("child session should be created"); + let root = harness + .app + .ensure_session_root_agent_context(&parent.session_id) + .await + .expect("root context should exist"); + + harness + .session_runtime + .append_child_session_notification( + &parent.session_id, + "turn-parent", + root.clone(), + astrcode_core::ChildSessionNotification { + notification_id: "child-1".to_string().into(), + child_ref: astrcode_core::ChildAgentRef { + identity: astrcode_core::ChildExecutionIdentity { + agent_id: "agent-child".to_string().into(), + session_id: parent.session_id.clone().into(), + sub_run_id: "subrun-child".to_string().into(), + }, + parent: astrcode_core::ParentExecutionRef { + parent_agent_id: root.agent_id.clone(), + parent_sub_run_id: None, + }, + lineage_kind: astrcode_core::ChildSessionLineageKind::Spawn, + status: astrcode_core::AgentLifecycleStatus::Running, + open_session_id: child.session_id.clone().into(), + }, + kind: astrcode_core::ChildSessionNotificationKind::Started, + source_tool_call_id: Some("tool-call-1".to_string().into()), + delivery: Some(astrcode_core::ParentDelivery { + idempotency_key: "child-1".to_string(), + origin: astrcode_core::ParentDeliveryOrigin::Explicit, + terminal_semantics: astrcode_core::ParentDeliveryTerminalSemantics::NonTerminal, + source_turn_id: Some("turn-child".to_string()), + payload: astrcode_core::ParentDeliveryPayload::Progress( + astrcode_core::ProgressParentDeliveryPayload { + message: "child progress".to_string(), + }, + ), + }), + }, + ) + .await + .expect("child notification should append"); + + harness + .app + .submit_prompt(&parent.session_id, "parent prompt".to_string()) + .await + .expect("parent prompt should submit"); + harness + .app + .submit_prompt(&child.session_id, "child prompt".to_string()) + .await + .expect("child prompt should submit"); + + let facts = harness + .app + .conversation_snapshot_facts( + &parent.session_id, + ConversationFocus::SubRun { + sub_run_id: "subrun-child".to_string(), + }, + ) + .await + .expect("conversation focus snapshot should build"); + + assert_eq!(facts.active_session_id, parent.session_id); + assert!(facts.transcript.blocks.iter().any(|block| matches!( + block, + ConversationBlockFacts::User(block) if block.markdown == "child prompt" + ))); + assert!(facts.transcript.blocks.iter().all(|block| !matches!( + block, + ConversationBlockFacts::User(block) if block.markdown == "parent prompt" + ))); + assert!(facts.child_summaries.is_empty()); +} + +#[test] +fn cursor_is_after_head_treats_equal_cursor_as_caught_up() { + assert!( + !super::cursor::cursor_is_after_head("12.3", Some("12.3")) + .expect("equal cursor should parse") + ); + assert!( + super::cursor::cursor_is_after_head("12.4", Some("12.3")) + .expect("newer cursor should parse") + ); + assert!( + !super::cursor::cursor_is_after_head("12.2", Some("12.3")) + .expect("older cursor should parse") + ); +} diff --git a/crates/application/src/terminal_use_cases.rs b/crates/application/src/terminal_use_cases.rs deleted file mode 100644 index d912435f..00000000 --- a/crates/application/src/terminal_use_cases.rs +++ /dev/null @@ -1,1109 +0,0 @@ -use std::{cmp::Reverse, collections::HashSet, path::Path}; - -use astrcode_session_runtime::{ - ConversationBlockFacts, ConversationChildHandoffBlockFacts, ConversationErrorBlockFacts, - ConversationSnapshotFacts, ConversationSystemNoteBlockFacts, SessionControlStateSnapshot, - ToolCallBlockFacts, -}; - -use crate::{ - App, ApplicationError, ComposerOptionKind, ComposerOptionsRequest, SessionMeta, - terminal::{ - ConversationAuthoritativeSummary, ConversationFocus, TerminalChildSummaryFacts, - TerminalControlFacts, TerminalFacts, TerminalLastCompactMetaFacts, TerminalRehydrateFacts, - TerminalRehydrateReason, TerminalResumeCandidateFacts, TerminalSlashAction, - TerminalSlashCandidateFacts, TerminalStreamFacts, TerminalStreamReplayFacts, - latest_transcript_cursor, summarize_conversation_authoritative, truncate_terminal_summary, - }, -}; - -impl App { - pub async fn conversation_snapshot_facts( - &self, - session_id: &str, - focus: ConversationFocus, - ) -> Result { - self.validate_non_empty("sessionId", session_id)?; - let focus_session_id = self - .resolve_conversation_focus_session_id(session_id, &focus) - .await?; - let transcript = self - .session_runtime - .conversation_snapshot(&focus_session_id) - .await?; - let session_title = self - .session_runtime - .list_session_metas() - .await? - .into_iter() - .find(|meta| meta.session_id == session_id) - .map(|meta| meta.title) - .ok_or_else(|| { - ApplicationError::NotFound(format!("session '{session_id}' not found")) - })?; - let control = self.terminal_control_facts(session_id).await?; - let child_summaries = self - .conversation_child_summaries(session_id, &focus) - .await?; - let slash_candidates = self.terminal_slash_candidates(session_id, None).await?; - - Ok(TerminalFacts { - active_session_id: session_id.to_string(), - session_title, - transcript, - control, - child_summaries, - slash_candidates, - }) - } - - pub async fn terminal_snapshot_facts( - &self, - session_id: &str, - ) -> Result { - self.conversation_snapshot_facts(session_id, ConversationFocus::Root) - .await - } - - pub async fn conversation_stream_facts( - &self, - session_id: &str, - last_event_id: Option<&str>, - focus: ConversationFocus, - ) -> Result { - self.validate_non_empty("sessionId", session_id)?; - let focus_session_id = self - .resolve_conversation_focus_session_id(session_id, &focus) - .await?; - - if let Some(requested_cursor) = last_event_id { - validate_cursor_format(requested_cursor)?; - let transcript = self - .session_runtime - .conversation_snapshot(&focus_session_id) - .await?; - let latest_cursor = latest_transcript_cursor(&transcript); - if cursor_is_after_head(requested_cursor, latest_cursor.as_deref())? { - return Ok(TerminalStreamFacts::RehydrateRequired( - TerminalRehydrateFacts { - session_id: session_id.to_string(), - requested_cursor: requested_cursor.to_string(), - latest_cursor, - reason: TerminalRehydrateReason::CursorExpired, - }, - )); - } - } - - let replay = self - .session_runtime - .conversation_stream_replay(&focus_session_id, last_event_id) - .await?; - let control = self.terminal_control_facts(session_id).await?; - let child_summaries = self - .conversation_child_summaries(session_id, &focus) - .await?; - let slash_candidates = self.terminal_slash_candidates(session_id, None).await?; - - Ok(TerminalStreamFacts::Replay(Box::new( - TerminalStreamReplayFacts { - active_session_id: session_id.to_string(), - replay, - control, - child_summaries, - slash_candidates, - }, - ))) - } - - pub async fn terminal_stream_facts( - &self, - session_id: &str, - last_event_id: Option<&str>, - ) -> Result { - self.conversation_stream_facts(session_id, last_event_id, ConversationFocus::Root) - .await - } - - pub async fn terminal_resume_candidates( - &self, - query: Option<&str>, - limit: usize, - ) -> Result, ApplicationError> { - let metas = self.session_runtime.list_session_metas().await?; - let query = normalize_query(query); - let limit = normalize_limit(limit); - let mut items = metas - .into_iter() - .filter(|meta| resume_candidate_matches(meta, query.as_deref())) - .map(|meta| TerminalResumeCandidateFacts { - session_id: meta.session_id, - title: meta.title, - display_name: meta.display_name, - working_dir: meta.working_dir, - updated_at: meta.updated_at, - created_at: meta.created_at, - phase: meta.phase, - parent_session_id: meta.parent_session_id, - }) - .collect::>(); - - items.sort_by_key(|item| Reverse(item.updated_at)); - items.truncate(limit); - Ok(items) - } - - pub async fn terminal_child_summaries( - &self, - session_id: &str, - ) -> Result, ApplicationError> { - self.conversation_child_summaries(session_id, &ConversationFocus::Root) - .await - } - - pub async fn conversation_child_summaries( - &self, - session_id: &str, - focus: &ConversationFocus, - ) -> Result, ApplicationError> { - self.validate_non_empty("sessionId", session_id)?; - let focus_session_id = self - .resolve_conversation_focus_session_id(session_id, focus) - .await?; - let children = self - .session_runtime - .session_child_nodes(&focus_session_id) - .await?; - let session_metas = self.session_runtime.list_session_metas().await?; - - let summaries = children - .into_iter() - .filter(|node| node.parent_sub_run_id().is_none()) - .map(|node| async { - self.require_permission( - node.parent_session_id.as_str() == focus_session_id, - format!( - "child '{}' is not visible from session '{}'", - node.sub_run_id(), - focus_session_id - ), - )?; - let child_meta = session_metas - .iter() - .find(|meta| meta.session_id == node.child_session_id.as_str()); - let child_transcript = self - .session_runtime - .conversation_snapshot(node.child_session_id.as_str()) - .await?; - Ok::<_, ApplicationError>(TerminalChildSummaryFacts { - node, - phase: child_transcript.phase, - title: child_meta.map(|meta| meta.title.clone()), - display_name: child_meta.map(|meta| meta.display_name.clone()), - recent_output: latest_terminal_summary(&child_transcript), - }) - }) - .collect::>(); - - let mut resolved = Vec::with_capacity(summaries.len()); - for summary in summaries { - resolved.push(summary.await?); - } - resolved.sort_by(|left, right| left.node.sub_run_id().cmp(right.node.sub_run_id())); - Ok(resolved) - } - - async fn resolve_conversation_focus_session_id( - &self, - root_session_id: &str, - focus: &ConversationFocus, - ) -> Result { - match focus { - ConversationFocus::Root => Ok(root_session_id.to_string()), - ConversationFocus::SubRun { sub_run_id } => { - let mut pending = vec![root_session_id.to_string()]; - let mut visited = HashSet::new(); - - while let Some(session_id) = pending.pop() { - if !visited.insert(session_id.clone()) { - continue; - } - for node in self - .session_runtime - .session_child_nodes(&session_id) - .await? - { - if node.sub_run_id().as_str() == *sub_run_id { - return Ok(node.child_session_id.to_string()); - } - pending.push(node.child_session_id.to_string()); - } - } - - Err(ApplicationError::NotFound(format!( - "sub-run '{}' not found under session '{}'", - sub_run_id, root_session_id - ))) - }, - } - } - - pub async fn terminal_slash_candidates( - &self, - session_id: &str, - query: Option<&str>, - ) -> Result, ApplicationError> { - self.validate_non_empty("sessionId", session_id)?; - let working_dir = self - .session_runtime - .get_session_working_dir(session_id) - .await?; - let query = normalize_query(query); - let control = self.terminal_control_facts(session_id).await?; - let mut candidates = terminal_builtin_candidates(&control); - candidates.extend( - self.list_composer_options( - session_id, - ComposerOptionsRequest { - query: query.clone(), - kinds: vec![ComposerOptionKind::Skill], - limit: 50, - }, - ) - .await? - .into_iter() - .map(|option| TerminalSlashCandidateFacts { - kind: option.kind, - id: option.id.clone(), - title: option.title, - description: option.description, - keywords: option.keywords, - badges: option.badges, - action: TerminalSlashAction::InsertText { - text: format!("/{}", option.id), - }, - }), - ); - - if let Some(query) = query.as_deref() { - candidates.retain(|candidate| slash_candidate_matches(candidate, query)); - } - - // Why: 顶层 palette 只暴露固定命令与可见 skill,不把 capability 噪声直接塞给终端。 - let _ = Path::new(&working_dir); - Ok(candidates) - } - - pub async fn terminal_control_facts( - &self, - session_id: &str, - ) -> Result { - let control = self - .session_runtime - .session_control_state(session_id) - .await?; - Ok(map_control_facts(control)) - } - - pub async fn conversation_authoritative_summary( - &self, - session_id: &str, - focus: &ConversationFocus, - ) -> Result { - Ok(summarize_conversation_authoritative( - &self.terminal_control_facts(session_id).await?, - &self.conversation_child_summaries(session_id, focus).await?, - &self.terminal_slash_candidates(session_id, None).await?, - )) - } -} - -fn map_control_facts(control: SessionControlStateSnapshot) -> TerminalControlFacts { - TerminalControlFacts { - phase: control.phase, - active_turn_id: control.active_turn_id, - manual_compact_pending: control.manual_compact_pending, - compacting: control.compacting, - last_compact_meta: control - .last_compact_meta - .map(|meta| TerminalLastCompactMetaFacts { - trigger: meta.trigger, - meta: meta.meta, - }), - } -} - -fn validate_cursor_format(cursor: &str) -> Result<(), ApplicationError> { - let Some((storage_seq, subindex)) = cursor.split_once('.') else { - return Err(ApplicationError::InvalidArgument(format!( - "invalid cursor '{cursor}'" - ))); - }; - if storage_seq.parse::().is_err() || subindex.parse::().is_err() { - return Err(ApplicationError::InvalidArgument(format!( - "invalid cursor '{cursor}'" - ))); - } - Ok(()) -} - -fn cursor_is_after_head( - requested_cursor: &str, - latest_cursor: Option<&str>, -) -> Result { - let Some(latest_cursor) = latest_cursor else { - return Ok(false); - }; - Ok(parse_cursor(requested_cursor)? > parse_cursor(latest_cursor)?) -} - -fn parse_cursor(cursor: &str) -> Result<(u64, u32), ApplicationError> { - let (storage_seq, subindex) = cursor - .split_once('.') - .ok_or_else(|| ApplicationError::InvalidArgument(format!("invalid cursor '{cursor}'")))?; - let storage_seq = storage_seq - .parse::() - .map_err(|_| ApplicationError::InvalidArgument(format!("invalid cursor '{cursor}'")))?; - let subindex = subindex - .parse::() - .map_err(|_| ApplicationError::InvalidArgument(format!("invalid cursor '{cursor}'")))?; - Ok((storage_seq, subindex)) -} - -fn normalize_query(query: Option<&str>) -> Option { - query - .map(str::trim) - .filter(|query| !query.is_empty()) - .map(|query| query.to_lowercase()) -} - -fn normalize_limit(limit: usize) -> usize { - if limit == 0 { 20 } else { limit } -} - -fn resume_candidate_matches(meta: &SessionMeta, query: Option<&str>) -> bool { - let Some(query) = query else { - return true; - }; - [ - meta.session_id.as_str(), - meta.title.as_str(), - meta.display_name.as_str(), - meta.working_dir.as_str(), - ] - .iter() - .any(|field| field.to_lowercase().contains(query)) -} - -fn terminal_builtin_candidates(control: &TerminalControlFacts) -> Vec { - let mut candidates = vec![ - TerminalSlashCandidateFacts { - kind: ComposerOptionKind::Command, - id: "new".to_string(), - title: "新建会话".to_string(), - description: "创建新 session 并切换焦点".to_string(), - keywords: vec!["new".to_string(), "session".to_string()], - badges: vec!["built-in".to_string()], - action: TerminalSlashAction::CreateSession, - }, - TerminalSlashCandidateFacts { - kind: ComposerOptionKind::Command, - id: "resume".to_string(), - title: "恢复会话".to_string(), - description: "搜索并切换到已有 session".to_string(), - keywords: vec!["resume".to_string(), "switch".to_string()], - badges: vec!["built-in".to_string()], - action: TerminalSlashAction::OpenResume, - }, - ]; - - if !control.manual_compact_pending && !control.compacting { - candidates.push(TerminalSlashCandidateFacts { - kind: ComposerOptionKind::Command, - id: "compact".to_string(), - title: "压缩上下文".to_string(), - description: "向服务端提交显式 compact 控制请求".to_string(), - keywords: vec!["compact".to_string(), "compress".to_string()], - badges: vec!["built-in".to_string()], - action: TerminalSlashAction::RequestCompact, - }); - } - candidates -} - -fn slash_candidate_matches(candidate: &TerminalSlashCandidateFacts, query: &str) -> bool { - candidate.id.to_lowercase().contains(query) - || candidate.title.to_lowercase().contains(query) - || candidate.description.to_lowercase().contains(query) - || candidate - .keywords - .iter() - .any(|keyword| keyword.to_lowercase().contains(query)) -} - -fn latest_terminal_summary(snapshot: &ConversationSnapshotFacts) -> Option { - snapshot - .blocks - .iter() - .rev() - .find_map(summary_from_block) - .or_else(|| latest_transcript_cursor(snapshot).map(|cursor| format!("cursor:{cursor}"))) -} - -fn summary_from_block(block: &ConversationBlockFacts) -> Option { - match block { - ConversationBlockFacts::Assistant(block) => summary_from_markdown(&block.markdown), - ConversationBlockFacts::ToolCall(block) => summary_from_tool_call(block), - ConversationBlockFacts::ChildHandoff(block) => summary_from_child_handoff(block), - ConversationBlockFacts::Error(block) => summary_from_error_block(block), - ConversationBlockFacts::SystemNote(block) => summary_from_system_note(block), - ConversationBlockFacts::User(_) | ConversationBlockFacts::Thinking(_) => None, - } -} - -fn summary_from_markdown(markdown: &str) -> Option { - (!markdown.trim().is_empty()).then(|| truncate_terminal_summary(markdown)) -} - -fn summary_from_tool_call(block: &ToolCallBlockFacts) -> Option { - block - .summary - .as_deref() - .filter(|summary| !summary.trim().is_empty()) - .map(truncate_terminal_summary) - .or_else(|| { - block - .error - .as_deref() - .filter(|error| !error.trim().is_empty()) - .map(truncate_terminal_summary) - }) - .or_else(|| summary_from_markdown(&block.streams.stderr)) - .or_else(|| summary_from_markdown(&block.streams.stdout)) -} - -fn summary_from_child_handoff(block: &ConversationChildHandoffBlockFacts) -> Option { - block - .message - .as_deref() - .filter(|message| !message.trim().is_empty()) - .map(truncate_terminal_summary) -} - -fn summary_from_error_block(block: &ConversationErrorBlockFacts) -> Option { - summary_from_markdown(&block.message) -} - -fn summary_from_system_note(block: &ConversationSystemNoteBlockFacts) -> Option { - summary_from_markdown(&block.markdown) -} - -#[cfg(test)] -mod tests { - use std::{path::Path, sync::Arc, time::Duration}; - - use astrcode_core::AgentEvent; - use astrcode_session_runtime::SessionRuntime; - use async_trait::async_trait; - use tokio::time::timeout; - - use super::*; - use crate::{ - AppKernelPort, AppSessionPort, ComposerResolvedSkill, ComposerSkillPort, ConfigService, - McpConfigScope, McpPort, McpServerStatusView, McpService, ProfileResolutionService, - agent::{ - AgentOrchestrationService, - test_support::{TestLlmBehavior, build_agent_test_harness}, - }, - composer::ComposerSkillSummary, - mcp::RegisterMcpServerInput, - }; - - struct StaticComposerSkillPort { - summaries: Vec, - } - - impl ComposerSkillPort for StaticComposerSkillPort { - fn list_skill_summaries(&self, _working_dir: &Path) -> Vec { - self.summaries.clone() - } - - fn resolve_skill( - &self, - _working_dir: &Path, - skill_id: &str, - ) -> Option { - self.summaries - .iter() - .find(|summary| summary.id == skill_id) - .map(|summary| ComposerResolvedSkill { - id: summary.id.clone(), - description: summary.description.clone(), - guide: format!("guide for {}", summary.id), - }) - } - } - - struct NoopMcpPort; - - #[async_trait] - impl McpPort for NoopMcpPort { - async fn list_server_status(&self) -> Vec { - Vec::new() - } - - async fn approve_server(&self, _server_signature: &str) -> Result<(), ApplicationError> { - Ok(()) - } - - async fn reject_server(&self, _server_signature: &str) -> Result<(), ApplicationError> { - Ok(()) - } - - async fn reconnect_server(&self, _name: &str) -> Result<(), ApplicationError> { - Ok(()) - } - - async fn reset_project_choices(&self) -> Result<(), ApplicationError> { - Ok(()) - } - - async fn upsert_server( - &self, - _input: &RegisterMcpServerInput, - ) -> Result<(), ApplicationError> { - Ok(()) - } - - async fn remove_server( - &self, - _scope: McpConfigScope, - _name: &str, - ) -> Result<(), ApplicationError> { - Ok(()) - } - - async fn set_server_enabled( - &self, - _scope: McpConfigScope, - _name: &str, - _enabled: bool, - ) -> Result<(), ApplicationError> { - Ok(()) - } - } - - struct TerminalAppHarness { - app: App, - session_runtime: Arc, - } - - fn build_terminal_app_harness(skill_ids: &[&str]) -> TerminalAppHarness { - build_terminal_app_harness_with_behavior( - skill_ids, - TestLlmBehavior::Succeed { - content: "子代理已完成。".to_string(), - }, - ) - } - - fn build_terminal_app_harness_with_behavior( - skill_ids: &[&str], - llm_behavior: TestLlmBehavior, - ) -> TerminalAppHarness { - let harness = - build_agent_test_harness(llm_behavior).expect("agent test harness should build"); - let kernel: Arc = harness.kernel.clone(); - let session_runtime = harness.session_runtime.clone(); - let session_port: Arc = session_runtime.clone(); - let config: Arc = harness.config_service.clone(); - let profiles: Arc = harness.profiles.clone(); - let composer_skills: Arc = Arc::new(StaticComposerSkillPort { - summaries: skill_ids - .iter() - .map(|id| ComposerSkillSummary::new(*id, format!("{id} description"))) - .collect(), - }); - let mcp_service = Arc::new(McpService::new(Arc::new(NoopMcpPort))); - let agent_service: Arc = Arc::new(harness.service.clone()); - let app = App::new( - kernel, - session_port, - profiles, - config, - composer_skills, - mcp_service, - agent_service, - ); - TerminalAppHarness { - app, - session_runtime, - } - } - - #[tokio::test] - async fn terminal_stream_facts_expose_live_llm_deltas_before_durable_completion() { - let harness = build_terminal_app_harness_with_behavior( - &[], - TestLlmBehavior::Stream { - reasoning_chunks: vec!["先".to_string(), "整理".to_string()], - text_chunks: vec!["流".to_string(), "式".to_string()], - final_content: "流式完成".to_string(), - final_reasoning: Some("先整理".to_string()), - }, - ); - let project = tempfile::tempdir().expect("tempdir should be created"); - let session = harness - .app - .create_session(project.path().display().to_string()) - .await - .expect("session should be created"); - - let TerminalStreamFacts::Replay(replay) = harness - .app - .terminal_stream_facts(&session.session_id, None) - .await - .expect("stream facts should build") - else { - panic!("fresh stream should start from replay facts"); - }; - let mut live_receiver = replay.replay.replay.live_receiver; - - let accepted = harness - .app - .submit_prompt(&session.session_id, "请流式回答".to_string()) - .await - .expect("prompt should submit"); - - let mut live_events = Vec::new(); - for _ in 0..4 { - live_events.push( - timeout(Duration::from_secs(1), live_receiver.recv()) - .await - .expect("live delta should arrive in time") - .expect("live receiver should stay open"), - ); - } - - assert!(matches!( - &live_events[0], - AgentEvent::ThinkingDelta { delta, .. } if delta == "先" - )); - assert!(matches!( - &live_events[1], - AgentEvent::ThinkingDelta { delta, .. } if delta == "整理" - )); - assert!(matches!( - &live_events[2], - AgentEvent::ModelDelta { delta, .. } if delta == "流" - )); - assert!(matches!( - &live_events[3], - AgentEvent::ModelDelta { delta, .. } if delta == "式" - )); - - harness - .session_runtime - .wait_for_turn_terminal_snapshot(&session.session_id, accepted.turn_id.as_str()) - .await - .expect("turn should settle"); - - let snapshot = harness - .app - .terminal_snapshot_facts(&session.session_id) - .await - .expect("terminal snapshot should build"); - assert!(snapshot.transcript.blocks.iter().any(|block| matches!( - block, - ConversationBlockFacts::Assistant(block) if block.markdown == "流式完成" - ))); - assert!(snapshot.transcript.blocks.iter().any(|block| matches!( - block, - ConversationBlockFacts::Thinking(block) if block.markdown == "先整理" - ))); - } - - #[tokio::test] - async fn terminal_snapshot_facts_hydrate_history_control_and_slash_candidates() { - let harness = build_terminal_app_harness(&["openspec-apply-change"]); - let project = tempfile::tempdir().expect("tempdir should be created"); - let session = harness - .app - .create_session(project.path().display().to_string()) - .await - .expect("session should be created"); - harness - .app - .submit_prompt(&session.session_id, "请总结当前仓库".to_string()) - .await - .expect("prompt should submit"); - - let facts = harness - .app - .terminal_snapshot_facts(&session.session_id) - .await - .expect("terminal snapshot should build"); - - assert_eq!(facts.active_session_id, session.session_id); - assert!(!facts.transcript.blocks.is_empty()); - assert!(facts.transcript.cursor.is_some()); - assert!( - facts - .slash_candidates - .iter() - .any(|candidate| candidate.id == "new") - ); - assert!( - facts - .slash_candidates - .iter() - .any(|candidate| candidate.id == "resume") - ); - assert!( - facts - .slash_candidates - .iter() - .any(|candidate| candidate.id == "compact") - ); - assert!( - facts - .slash_candidates - .iter() - .any(|candidate| candidate.id == "openspec-apply-change") - ); - assert!( - facts - .slash_candidates - .iter() - .all(|candidate| candidate.id != "skill") - ); - } - - #[tokio::test] - async fn terminal_stream_facts_returns_replay_for_valid_cursor() { - let harness = build_terminal_app_harness(&[]); - let project = tempfile::tempdir().expect("tempdir should be created"); - let session = harness - .app - .create_session(project.path().display().to_string()) - .await - .expect("session should be created"); - harness - .app - .submit_prompt(&session.session_id, "hello".to_string()) - .await - .expect("prompt should submit"); - let snapshot = harness - .app - .terminal_snapshot_facts(&session.session_id) - .await - .expect("snapshot should build"); - let cursor = snapshot.transcript.cursor.clone(); - - let facts = harness - .app - .terminal_stream_facts(&session.session_id, cursor.as_deref()) - .await - .expect("stream facts should build"); - - match facts { - TerminalStreamFacts::Replay(replay) => { - assert_eq!(replay.active_session_id, session.session_id); - assert!(replay.replay.replay.history.is_empty()); - assert!(replay.replay.replay_frames.is_empty()); - assert_eq!( - replay - .replay - .seed_records - .last() - .map(|record| record.event_id.as_str()), - snapshot.transcript.cursor.as_deref() - ); - }, - TerminalStreamFacts::RehydrateRequired(_) => { - panic!("valid cursor should not require rehydrate"); - }, - } - } - - #[tokio::test] - async fn terminal_stream_facts_falls_back_to_rehydrate_for_future_cursor() { - let harness = build_terminal_app_harness(&[]); - let project = tempfile::tempdir().expect("tempdir should be created"); - let session = harness - .app - .create_session(project.path().display().to_string()) - .await - .expect("session should be created"); - harness - .app - .submit_prompt(&session.session_id, "hello".to_string()) - .await - .expect("prompt should submit"); - - let facts = harness - .app - .terminal_stream_facts(&session.session_id, Some("999999.9")) - .await - .expect("stream facts should build"); - - match facts { - TerminalStreamFacts::Replay(_) => { - panic!("future cursor should require rehydrate"); - }, - TerminalStreamFacts::RehydrateRequired(rehydrate) => { - assert_eq!(rehydrate.reason, TerminalRehydrateReason::CursorExpired); - assert_eq!(rehydrate.requested_cursor, "999999.9"); - assert!(rehydrate.latest_cursor.is_some()); - }, - } - } - - #[tokio::test] - async fn terminal_resume_candidates_use_server_fact_and_recent_sorting() { - let harness = build_terminal_app_harness(&[]); - let project = tempfile::tempdir().expect("tempdir should be created"); - let older_dir = project.path().join("older"); - let newer_dir = project.path().join("newer"); - std::fs::create_dir_all(&older_dir).expect("older dir should exist"); - std::fs::create_dir_all(&newer_dir).expect("newer dir should exist"); - let older = harness - .app - .create_session(older_dir.display().to_string()) - .await - .expect("older session should be created"); - tokio::time::sleep(std::time::Duration::from_millis(5)).await; - let newer = harness - .app - .create_session(newer_dir.display().to_string()) - .await - .expect("newer session should be created"); - - let candidates = harness - .app - .terminal_resume_candidates(Some("newer"), 20) - .await - .expect("resume candidates should build"); - - assert_eq!(candidates.len(), 1); - assert_eq!(candidates[0].session_id, newer.session_id); - let all_candidates = harness - .app - .terminal_resume_candidates(None, 20) - .await - .expect("resume candidates should build"); - assert_eq!(all_candidates[0].session_id, newer.session_id); - assert_eq!(all_candidates[1].session_id, older.session_id); - } - - #[tokio::test] - async fn terminal_child_summaries_only_return_direct_visible_children() { - let harness = build_terminal_app_harness(&[]); - let project = tempfile::tempdir().expect("tempdir should be created"); - let parent_dir = project.path().join("parent"); - let child_dir = project.path().join("child"); - let unrelated_dir = project.path().join("unrelated"); - std::fs::create_dir_all(&parent_dir).expect("parent dir should exist"); - std::fs::create_dir_all(&child_dir).expect("child dir should exist"); - std::fs::create_dir_all(&unrelated_dir).expect("unrelated dir should exist"); - let parent = harness - .session_runtime - .create_session(parent_dir.display().to_string()) - .await - .expect("parent session should be created"); - let child = harness - .session_runtime - .create_session(child_dir.display().to_string()) - .await - .expect("child session should be created"); - let unrelated = harness - .session_runtime - .create_session(unrelated_dir.display().to_string()) - .await - .expect("unrelated session should be created"); - - let root = harness - .app - .ensure_session_root_agent_context(&parent.session_id) - .await - .expect("root context should exist"); - - harness - .session_runtime - .append_child_session_notification( - &parent.session_id, - "turn-parent", - root.clone(), - astrcode_core::ChildSessionNotification { - notification_id: "child-1".to_string().into(), - child_ref: astrcode_core::ChildAgentRef { - identity: astrcode_core::ChildExecutionIdentity { - agent_id: "agent-child".to_string().into(), - session_id: parent.session_id.clone().into(), - sub_run_id: "subrun-child".to_string().into(), - }, - parent: astrcode_core::ParentExecutionRef { - parent_agent_id: root.agent_id.clone(), - parent_sub_run_id: None, - }, - lineage_kind: astrcode_core::ChildSessionLineageKind::Spawn, - status: astrcode_core::AgentLifecycleStatus::Running, - open_session_id: child.session_id.clone().into(), - }, - kind: astrcode_core::ChildSessionNotificationKind::Started, - source_tool_call_id: Some("tool-call-1".to_string().into()), - delivery: Some(astrcode_core::ParentDelivery { - idempotency_key: "child-1".to_string(), - origin: astrcode_core::ParentDeliveryOrigin::Explicit, - terminal_semantics: - astrcode_core::ParentDeliveryTerminalSemantics::NonTerminal, - source_turn_id: Some("turn-child".to_string()), - payload: astrcode_core::ParentDeliveryPayload::Progress( - astrcode_core::ProgressParentDeliveryPayload { - message: "child progress".to_string(), - }, - ), - }), - }, - ) - .await - .expect("child notification should append"); - - let accepted = harness - .app - .submit_prompt(&child.session_id, "child output".to_string()) - .await - .expect("child prompt should submit"); - harness - .session_runtime - .wait_for_turn_terminal_snapshot(&child.session_id, accepted.turn_id.as_str()) - .await - .expect("child turn should settle"); - harness - .app - .submit_prompt(&unrelated.session_id, "ignore me".to_string()) - .await - .expect("unrelated prompt should submit"); - - let children = harness - .app - .terminal_child_summaries(&parent.session_id) - .await - .expect("child summaries should build"); - - assert_eq!(children.len(), 1); - assert_eq!(children[0].node.child_session_id, child.session_id.into()); - assert!( - children[0] - .recent_output - .as_deref() - .is_some_and(|summary| summary.contains("子代理已完成")) - ); - } - - #[tokio::test] - async fn conversation_focus_snapshot_reads_child_session_transcript() { - let harness = build_terminal_app_harness(&[]); - let project = tempfile::tempdir().expect("tempdir should be created"); - let parent_dir = project.path().join("parent"); - let child_dir = project.path().join("child"); - std::fs::create_dir_all(&parent_dir).expect("parent dir should exist"); - std::fs::create_dir_all(&child_dir).expect("child dir should exist"); - let parent = harness - .session_runtime - .create_session(parent_dir.display().to_string()) - .await - .expect("parent session should be created"); - let child = harness - .session_runtime - .create_session(child_dir.display().to_string()) - .await - .expect("child session should be created"); - let root = harness - .app - .ensure_session_root_agent_context(&parent.session_id) - .await - .expect("root context should exist"); - - harness - .session_runtime - .append_child_session_notification( - &parent.session_id, - "turn-parent", - root.clone(), - astrcode_core::ChildSessionNotification { - notification_id: "child-1".to_string().into(), - child_ref: astrcode_core::ChildAgentRef { - identity: astrcode_core::ChildExecutionIdentity { - agent_id: "agent-child".to_string().into(), - session_id: parent.session_id.clone().into(), - sub_run_id: "subrun-child".to_string().into(), - }, - parent: astrcode_core::ParentExecutionRef { - parent_agent_id: root.agent_id.clone(), - parent_sub_run_id: None, - }, - lineage_kind: astrcode_core::ChildSessionLineageKind::Spawn, - status: astrcode_core::AgentLifecycleStatus::Running, - open_session_id: child.session_id.clone().into(), - }, - kind: astrcode_core::ChildSessionNotificationKind::Started, - source_tool_call_id: Some("tool-call-1".to_string().into()), - delivery: Some(astrcode_core::ParentDelivery { - idempotency_key: "child-1".to_string(), - origin: astrcode_core::ParentDeliveryOrigin::Explicit, - terminal_semantics: - astrcode_core::ParentDeliveryTerminalSemantics::NonTerminal, - source_turn_id: Some("turn-child".to_string()), - payload: astrcode_core::ParentDeliveryPayload::Progress( - astrcode_core::ProgressParentDeliveryPayload { - message: "child progress".to_string(), - }, - ), - }), - }, - ) - .await - .expect("child notification should append"); - - harness - .app - .submit_prompt(&parent.session_id, "parent prompt".to_string()) - .await - .expect("parent prompt should submit"); - harness - .app - .submit_prompt(&child.session_id, "child prompt".to_string()) - .await - .expect("child prompt should submit"); - - let facts = harness - .app - .conversation_snapshot_facts( - &parent.session_id, - ConversationFocus::SubRun { - sub_run_id: "subrun-child".to_string(), - }, - ) - .await - .expect("conversation focus snapshot should build"); - - assert_eq!(facts.active_session_id, parent.session_id); - assert!(facts.transcript.blocks.iter().any(|block| matches!( - block, - ConversationBlockFacts::User(block) if block.markdown == "child prompt" - ))); - assert!(facts.transcript.blocks.iter().all(|block| !matches!( - block, - ConversationBlockFacts::User(block) if block.markdown == "parent prompt" - ))); - assert!(facts.child_summaries.is_empty()); - } - - #[test] - fn cursor_is_after_head_treats_equal_cursor_as_caught_up() { - assert!(!cursor_is_after_head("12.3", Some("12.3")).expect("equal cursor should parse")); - assert!(cursor_is_after_head("12.4", Some("12.3")).expect("newer cursor should parse")); - assert!(!cursor_is_after_head("12.2", Some("12.3")).expect("older cursor should parse")); - } -} diff --git a/crates/application/src/test_support.rs b/crates/application/src/test_support.rs new file mode 100644 index 00000000..ef5762ab --- /dev/null +++ b/crates/application/src/test_support.rs @@ -0,0 +1,300 @@ +use astrcode_core::{ + AgentCollaborationFact, AgentEventContext, AgentLifecycleStatus, DeleteProjectResult, + ExecutionAccepted, InputBatchAckedPayload, InputBatchStartedPayload, InputDiscardedPayload, + InputQueuedPayload, ModeId, ResolvedRuntimeConfig, SessionId, SessionMeta, StoredEvent, TurnId, +}; +use astrcode_kernel::PendingParentDelivery; +use astrcode_session_runtime::{ + AgentObserveSnapshot, AgentPromptSubmission, ConversationSnapshotFacts, + ConversationStreamReplayFacts, ForkPoint, ForkResult, ProjectedTurnOutcome, + SessionCatalogEvent, SessionControlStateSnapshot, SessionModeSnapshot, SessionReplay, + SessionTranscriptSnapshot, TurnTerminalSnapshot, +}; +use async_trait::async_trait; +use tokio::sync::broadcast; + +use crate::{AgentSessionPort, AppSessionPort}; + +fn unimplemented_for_test(area: &str) -> ! { + panic!("not used in {area}") +} + +#[derive(Debug, Default)] +pub(crate) struct StubSessionPort { + stored_events: Vec, +} + +#[async_trait] +impl AppSessionPort for StubSessionPort { + fn subscribe_catalog_events(&self) -> broadcast::Receiver { + let (_tx, rx) = broadcast::channel(1); + rx + } + + async fn list_session_metas(&self) -> astrcode_core::Result> { + unimplemented_for_test("application test stub") + } + + async fn create_session(&self, _working_dir: String) -> astrcode_core::Result { + unimplemented_for_test("application test stub") + } + + async fn fork_session( + &self, + _session_id: &SessionId, + _fork_point: ForkPoint, + ) -> astrcode_core::Result { + unimplemented_for_test("application test stub") + } + + async fn delete_session(&self, _session_id: &str) -> astrcode_core::Result<()> { + unimplemented_for_test("application test stub") + } + + async fn delete_project( + &self, + _working_dir: &str, + ) -> astrcode_core::Result { + unimplemented_for_test("application test stub") + } + + async fn get_session_working_dir(&self, _session_id: &str) -> astrcode_core::Result { + unimplemented_for_test("application test stub") + } + + async fn submit_prompt_for_agent( + &self, + _session_id: &str, + _text: String, + _runtime: ResolvedRuntimeConfig, + _submission: AgentPromptSubmission, + ) -> astrcode_core::Result { + unimplemented_for_test("application test stub") + } + + async fn interrupt_session(&self, _session_id: &str) -> astrcode_core::Result<()> { + unimplemented_for_test("application test stub") + } + + async fn compact_session( + &self, + _session_id: &str, + _runtime: ResolvedRuntimeConfig, + _instructions: Option, + ) -> astrcode_core::Result { + unimplemented_for_test("application test stub") + } + + async fn session_transcript_snapshot( + &self, + _session_id: &str, + ) -> astrcode_core::Result { + unimplemented_for_test("application test stub") + } + + async fn conversation_snapshot( + &self, + _session_id: &str, + ) -> astrcode_core::Result { + unimplemented_for_test("application test stub") + } + + async fn session_control_state( + &self, + _session_id: &str, + ) -> astrcode_core::Result { + unimplemented_for_test("application test stub") + } + + async fn session_mode_state( + &self, + _session_id: &str, + ) -> astrcode_core::Result { + Ok(SessionModeSnapshot { + current_mode_id: ModeId::code(), + last_mode_changed_at: None, + }) + } + + async fn switch_mode( + &self, + _session_id: &str, + _from: ModeId, + _to: ModeId, + ) -> astrcode_core::Result { + unimplemented_for_test("application test stub") + } + + async fn session_child_nodes( + &self, + _session_id: &str, + ) -> astrcode_core::Result> { + unimplemented_for_test("application test stub") + } + + async fn session_stored_events( + &self, + _session_id: &SessionId, + ) -> astrcode_core::Result> { + Ok(self.stored_events.clone()) + } + + async fn session_replay( + &self, + _session_id: &str, + _last_event_id: Option<&str>, + ) -> astrcode_core::Result { + unimplemented_for_test("application test stub") + } + + async fn conversation_stream_replay( + &self, + _session_id: &str, + _last_event_id: Option<&str>, + ) -> astrcode_core::Result { + unimplemented_for_test("application test stub") + } +} + +#[async_trait] +impl AgentSessionPort for StubSessionPort { + async fn create_child_session( + &self, + _working_dir: &str, + _parent_session_id: &str, + ) -> astrcode_core::Result { + unimplemented_for_test("application test stub") + } + + async fn submit_prompt_for_agent_with_submission( + &self, + _session_id: &str, + _text: String, + _runtime: ResolvedRuntimeConfig, + _submission: AgentPromptSubmission, + ) -> astrcode_core::Result { + unimplemented_for_test("application test stub") + } + + async fn try_submit_prompt_for_agent_with_turn_id( + &self, + _session_id: &str, + _turn_id: TurnId, + _text: String, + _runtime: ResolvedRuntimeConfig, + _submission: AgentPromptSubmission, + ) -> astrcode_core::Result> { + unimplemented_for_test("application test stub") + } + + async fn submit_queued_inputs_for_agent_with_turn_id( + &self, + _session_id: &str, + _turn_id: TurnId, + _queued_inputs: Vec, + _runtime: ResolvedRuntimeConfig, + _submission: AgentPromptSubmission, + ) -> astrcode_core::Result> { + unimplemented_for_test("application test stub") + } + + async fn append_agent_input_queued( + &self, + _session_id: &str, + _turn_id: &str, + _agent: AgentEventContext, + _payload: InputQueuedPayload, + ) -> astrcode_core::Result { + unimplemented_for_test("application test stub") + } + + async fn append_agent_input_discarded( + &self, + _session_id: &str, + _turn_id: &str, + _agent: AgentEventContext, + _payload: InputDiscardedPayload, + ) -> astrcode_core::Result { + unimplemented_for_test("application test stub") + } + + async fn append_agent_input_batch_started( + &self, + _session_id: &str, + _turn_id: &str, + _agent: AgentEventContext, + _payload: InputBatchStartedPayload, + ) -> astrcode_core::Result { + unimplemented_for_test("application test stub") + } + + async fn append_agent_input_batch_acked( + &self, + _session_id: &str, + _turn_id: &str, + _agent: AgentEventContext, + _payload: InputBatchAckedPayload, + ) -> astrcode_core::Result { + unimplemented_for_test("application test stub") + } + + async fn append_child_session_notification( + &self, + _session_id: &str, + _turn_id: &str, + _agent: AgentEventContext, + _notification: astrcode_core::ChildSessionNotification, + ) -> astrcode_core::Result { + unimplemented_for_test("application test stub") + } + + async fn append_agent_collaboration_fact( + &self, + _session_id: &str, + _turn_id: &str, + _agent: AgentEventContext, + _fact: AgentCollaborationFact, + ) -> astrcode_core::Result { + unimplemented_for_test("application test stub") + } + + async fn pending_delivery_ids_for_agent( + &self, + _session_id: &str, + _agent_id: &str, + ) -> astrcode_core::Result> { + unimplemented_for_test("application test stub") + } + + async fn recoverable_parent_deliveries( + &self, + _parent_session_id: &str, + ) -> astrcode_core::Result> { + unimplemented_for_test("application test stub") + } + + async fn observe_agent_session( + &self, + _open_session_id: &str, + _target_agent_id: &str, + _lifecycle_status: AgentLifecycleStatus, + ) -> astrcode_core::Result { + unimplemented_for_test("application test stub") + } + + async fn project_turn_outcome( + &self, + _session_id: &str, + _turn_id: &str, + ) -> astrcode_core::Result { + unimplemented_for_test("application test stub") + } + + async fn wait_for_turn_terminal_snapshot( + &self, + _session_id: &str, + _turn_id: &str, + ) -> astrcode_core::Result { + unimplemented_for_test("application test stub") + } +} diff --git a/crates/cli/src/app/coordinator.rs b/crates/cli/src/app/coordinator.rs index eba2d79d..e975a479 100644 --- a/crates/cli/src/app/coordinator.rs +++ b/crates/cli/src/app/coordinator.rs @@ -5,7 +5,7 @@ use astrcode_client::{ AstrcodeClientTransport, AstrcodeCompactSessionRequest, AstrcodeConversationBannerErrorCodeDto, AstrcodeConversationErrorEnvelopeDto, AstrcodeCreateSessionRequest, AstrcodeExecutionControlDto, AstrcodePromptRequest, AstrcodePromptSkillInvocation, - AstrcodeSaveActiveSelectionRequest, ConversationStreamItem, + AstrcodeSaveActiveSelectionRequest, AstrcodeSwitchModeRequest, ConversationStreamItem, }; use super::{ @@ -114,6 +114,60 @@ where self.state.set_model_query(query.clone(), items); self.refresh_model_options(query).await; }, + Command::Mode { query } => { + let query = query.unwrap_or_default(); + if query.is_empty() { + let Some(session_id) = self.state.conversation.active_session_id.clone() else { + let available = self + .state + .shell + .available_modes + .iter() + .map(|mode| mode.id.as_str()) + .collect::>() + .join(", "); + if available.is_empty() { + self.state.set_error_status("no active session"); + } else { + self.state.set_error_status(format!( + "no active session · available modes: {available}" + )); + } + return; + }; + let client = self.client.clone(); + self.state.set_status("loading mode state"); + self.dispatch_async(async move { + let result = client.get_session_mode(&session_id).await; + Some(Action::SessionModeLoaded { session_id, result }) + }); + return; + } + + let Some(session_id) = self.state.conversation.active_session_id.clone() else { + self.state.set_error_status("no active session"); + return; + }; + let requested_mode_id = query; + let client = self.client.clone(); + self.state + .set_status(format!("switching mode to {requested_mode_id}")); + self.dispatch_async(async move { + let result = client + .switch_mode( + &session_id, + AstrcodeSwitchModeRequest { + mode_id: requested_mode_id.clone(), + }, + ) + .await; + Some(Action::ModeSwitched { + session_id, + requested_mode_id, + result, + }) + }); + }, Command::Compact => { let Some(session_id) = self.state.conversation.active_session_id.clone() else { self.state.set_error_status("no active session"); @@ -244,6 +298,14 @@ where }); } + pub(super) async fn refresh_modes(&self) { + let client = self.client.clone(); + self.dispatch_async(async move { + let result = client.list_modes().await; + Some(Action::ModesLoaded(result)) + }); + } + pub(super) async fn refresh_model_options(&self, query: String) { let client = self.client.clone(); self.dispatch_async(async move { @@ -265,6 +327,7 @@ where } let candidates = slash_candidates_with_local_commands( &self.state.conversation.slash_candidates, + &self.state.shell.available_modes, query.as_str(), ); let items = if query.trim().is_empty() { @@ -323,6 +386,7 @@ where let query = self.slash_query_for_current_input(); let candidates = slash_candidates_with_local_commands( &self.state.conversation.slash_candidates, + &self.state.shell.available_modes, query.as_str(), ); self.state diff --git a/crates/cli/src/app/mod.rs b/crates/cli/src/app/mod.rs index dc7e345d..1c9a8ee9 100644 --- a/crates/cli/src/app/mod.rs +++ b/crates/cli/src/app/mod.rs @@ -16,8 +16,9 @@ use anyhow::{Context, Result}; use astrcode_client::{ AstrcodeClient, AstrcodeClientError, AstrcodeClientTransport, AstrcodeConversationSlashCandidatesResponseDto, AstrcodeConversationSnapshotResponseDto, - AstrcodeCurrentModelInfoDto, AstrcodeModelOptionDto, AstrcodePromptAcceptedResponse, - AstrcodeReqwestTransport, AstrcodeSessionListItem, ClientConfig, ConversationStreamItem, + AstrcodeCurrentModelInfoDto, AstrcodeModeSummaryDto, AstrcodeModelOptionDto, + AstrcodePromptAcceptedResponse, AstrcodeReqwestTransport, AstrcodeSessionListItem, + AstrcodeSessionModeStateDto, ClientConfig, ConversationStreamItem, }; use clap::Parser; use crossterm::{ @@ -89,6 +90,7 @@ enum Action { result: Result, }, CurrentModelLoaded(Result), + ModesLoaded(Result, AstrcodeClientError>), ModelOptionsLoaded { query: String, result: Result, AstrcodeClientError>, @@ -106,6 +108,15 @@ enum Action { session_id: String, result: Result, }, + SessionModeLoaded { + session_id: String, + result: Result, + }, + ModeSwitched { + session_id: String, + requested_mode_id: String, + result: Result, + }, } pub async fn run_from_env() -> Result<()> { @@ -150,6 +161,7 @@ async fn run_app(launcher_session: LauncherSession) -> Resu ); controller.refresh_current_model().await; + controller.refresh_modes().await; controller.refresh_model_options(String::new()).await; controller.bootstrap().await?; @@ -476,8 +488,11 @@ where match result { Ok(candidates) => { - let items = - slash_candidates_with_local_commands(&candidates.items, query.as_str()); + let items = slash_candidates_with_local_commands( + &candidates.items, + &self.state.shell.available_modes, + query.as_str(), + ); self.state.set_slash_query(query, items); }, Err(error) => self.apply_status_error(error), @@ -487,6 +502,10 @@ where Ok(current_model) => self.state.update_current_model(current_model), Err(error) => self.apply_status_error(error), }, + Action::ModesLoaded(result) => match result { + Ok(modes) => self.state.update_modes(modes), + Err(error) => self.apply_status_error(error), + }, Action::ModelOptionsLoaded { query, result } => match result { Ok(model_options) => { self.state.update_model_options(model_options.clone()); @@ -546,6 +565,52 @@ where Err(error) => self.apply_status_error(error), } }, + Action::SessionModeLoaded { session_id, result } => { + if !self.active_session_matches(session_id.as_str()) { + return Ok(()); + } + match result { + Ok(mode) => { + let available = self + .state + .shell + .available_modes + .iter() + .map(|summary| summary.id.as_str()) + .collect::>() + .join(", "); + if available.is_empty() { + self.state + .set_status(format!("mode {}", mode.current_mode_id)); + } else { + self.state.set_status(format!( + "mode {} · available: {}", + mode.current_mode_id, available + )); + } + }, + Err(error) => self.apply_status_error(error), + } + }, + Action::ModeSwitched { + session_id, + requested_mode_id, + result, + } => { + if !self.active_session_matches(session_id.as_str()) { + return Ok(()); + } + match result { + Ok(mode) => { + self.state.set_status(format!( + "mode {} · next turn will use {}", + mode.current_mode_id, requested_mode_id + )); + self.refresh_modes().await; + }, + Err(error) => self.apply_status_error(error), + } + }, } Ok(()) } @@ -843,6 +908,7 @@ fn filter_resume_sessions( fn slash_candidates_with_local_commands( candidates: &[astrcode_client::AstrcodeConversationSlashCandidateDto], + modes: &[astrcode_client::AstrcodeModeSummaryDto], query: &str, ) -> Vec { let mut merged = candidates.to_vec(); @@ -870,6 +936,63 @@ fn slash_candidates_with_local_commands( merged.push(model_candidate); } + let mode_candidate = astrcode_client::AstrcodeConversationSlashCandidateDto { + id: "mode".to_string(), + title: "/mode".to_string(), + description: "查看或切换当前 session 的治理 mode".to_string(), + keywords: vec![ + "mode".to_string(), + "governance".to_string(), + "plan".to_string(), + "review".to_string(), + "code".to_string(), + ], + action_kind: astrcode_client::AstrcodeConversationSlashActionKindDto::ExecuteCommand, + action_value: "/mode".to_string(), + }; + if !merged + .iter() + .any(|candidate| candidate.id == mode_candidate.id) + && fuzzy_contains( + query, + [ + mode_candidate.id.clone(), + mode_candidate.title.clone(), + mode_candidate.description.clone(), + ], + ) + { + merged.push(mode_candidate); + } + + for mode in modes { + let candidate = astrcode_client::AstrcodeConversationSlashCandidateDto { + id: format!("mode:{}", mode.id), + title: format!("/mode {}", mode.id), + description: format!("切换到 {} · {}", mode.name, mode.description), + keywords: vec![ + "mode".to_string(), + "governance".to_string(), + mode.id.clone(), + mode.name.clone(), + ], + action_kind: astrcode_client::AstrcodeConversationSlashActionKindDto::ExecuteCommand, + action_value: format!("/mode {}", mode.id), + }; + if !merged.iter().any(|existing| existing.id == candidate.id) + && fuzzy_contains( + query, + [ + candidate.id.clone(), + candidate.title.clone(), + candidate.description.clone(), + ], + ) + { + merged.push(candidate); + } + } + merged } @@ -981,7 +1104,7 @@ mod tests { #[test] fn slash_candidates_with_local_commands_includes_model_entry() { - let items = super::slash_candidates_with_local_commands(&[], "model"); + let items = super::slash_candidates_with_local_commands(&[], &[], "model"); assert!(items.iter().any(|item| item.id == "model")); } diff --git a/crates/cli/src/command/mod.rs b/crates/cli/src/command/mod.rs index c86df892..c3aa2f0a 100644 --- a/crates/cli/src/command/mod.rs +++ b/crates/cli/src/command/mod.rs @@ -20,6 +20,9 @@ pub enum Command { Model { query: Option, }, + Mode { + query: Option, + }, Compact, SkillInvoke { skill_id: String, @@ -101,6 +104,7 @@ pub fn parse_command( "/new" => Command::New, "/resume" => Command::Resume { query: tail }, "/model" => Command::Model { query: tail }, + "/mode" => Command::Mode { query: tail }, "/compact" => Command::Compact, _ if head.starts_with('/') => { let skill_id = head.trim_start_matches('/'); @@ -162,6 +166,12 @@ mod tests { query: Some("claude".to_string()) } ); + assert_eq!( + parse_command("/mode review", &[]), + Command::Mode { + query: Some("review".to_string()) + } + ); assert_eq!( parse_command( "/review 修复失败测试", diff --git a/crates/cli/src/state/mod.rs b/crates/cli/src/state/mod.rs index c6364d5e..d085c162 100644 --- a/crates/cli/src/state/mod.rs +++ b/crates/cli/src/state/mod.rs @@ -11,7 +11,8 @@ use std::{path::PathBuf, time::Duration}; use astrcode_client::{ AstrcodeConversationErrorEnvelopeDto, AstrcodeConversationSlashCandidateDto, AstrcodeConversationSnapshotResponseDto, AstrcodeConversationStreamEnvelopeDto, - AstrcodeCurrentModelInfoDto, AstrcodeModelOptionDto, AstrcodePhaseDto, AstrcodeSessionListItem, + AstrcodeCurrentModelInfoDto, AstrcodeModeSummaryDto, AstrcodeModelOptionDto, AstrcodePhaseDto, + AstrcodeSessionListItem, }; pub use conversation::ConversationState; pub use debug::DebugChannelState; @@ -239,6 +240,13 @@ impl CliState { } } + pub fn update_modes(&mut self, modes: Vec) { + if self.shell.available_modes != modes { + self.shell.available_modes = modes; + self.render.mark_dirty(); + } + } + pub fn set_resume_query( &mut self, query: impl Into, diff --git a/crates/cli/src/state/shell.rs b/crates/cli/src/state/shell.rs index eafc6310..045c0e95 100644 --- a/crates/cli/src/state/shell.rs +++ b/crates/cli/src/state/shell.rs @@ -1,6 +1,8 @@ use std::path::PathBuf; -use astrcode_client::{AstrcodeCurrentModelInfoDto, AstrcodeModelOptionDto}; +use astrcode_client::{ + AstrcodeCurrentModelInfoDto, AstrcodeModeSummaryDto, AstrcodeModelOptionDto, +}; use crate::capability::TerminalCapabilities; @@ -11,6 +13,7 @@ pub struct ShellState { pub capabilities: TerminalCapabilities, pub current_model: Option, pub model_options: Vec, + pub available_modes: Vec, } impl Default for ShellState { @@ -27,6 +30,7 @@ impl Default for ShellState { }, current_model: None, model_options: Vec::new(), + available_modes: Vec::new(), } } } @@ -43,6 +47,7 @@ impl ShellState { capabilities, current_model: None, model_options: Vec::new(), + available_modes: Vec::new(), } } } diff --git a/crates/client/src/lib.rs b/crates/client/src/lib.rs index 7d7484ca..f69fdb54 100644 --- a/crates/client/src/lib.rs +++ b/crates/client/src/lib.rs @@ -5,8 +5,9 @@ use std::sync::Arc; use astrcode_protocol::http::{ AuthExchangeRequest, AuthExchangeResponse, CompactSessionRequest, CompactSessionResponse, - CreateSessionRequest, CurrentModelInfoDto, ExecutionControlDto, ModelOptionDto, + CreateSessionRequest, CurrentModelInfoDto, ExecutionControlDto, ModeSummaryDto, ModelOptionDto, PromptAcceptedResponse, PromptRequest, SaveActiveSelectionRequest, SessionListItem, + SessionModeStateDto, SwitchModeRequest, conversation::v1::{ ConversationCursorDto, ConversationDeltaDto, ConversationErrorEnvelopeDto, ConversationSlashCandidatesResponseDto, ConversationSnapshotResponseDto, @@ -36,11 +37,13 @@ pub use astrcode_protocol::http::{ CompactSessionResponse as AstrcodeCompactSessionResponse, CreateSessionRequest as AstrcodeCreateSessionRequest, CurrentModelInfoDto as AstrcodeCurrentModelInfoDto, - ExecutionControlDto as AstrcodeExecutionControlDto, ModelOptionDto as AstrcodeModelOptionDto, - PhaseDto as AstrcodePhaseDto, PromptAcceptedResponse as AstrcodePromptAcceptedResponse, + ExecutionControlDto as AstrcodeExecutionControlDto, ModeSummaryDto as AstrcodeModeSummaryDto, + ModelOptionDto as AstrcodeModelOptionDto, PhaseDto as AstrcodePhaseDto, + PromptAcceptedResponse as AstrcodePromptAcceptedResponse, PromptRequest as AstrcodePromptRequest, PromptSkillInvocation as AstrcodePromptSkillInvocation, SaveActiveSelectionRequest as AstrcodeSaveActiveSelectionRequest, - SessionListItem as AstrcodeSessionListItem, + SessionListItem as AstrcodeSessionListItem, SessionModeStateDto as AstrcodeSessionModeStateDto, + SwitchModeRequest as AstrcodeSwitchModeRequest, conversation::v1::{ ConversationAssistantBlockDto as AstrcodeConversationAssistantBlockDto, ConversationBannerDto as AstrcodeConversationBannerDto, @@ -237,6 +240,46 @@ where .await } + pub async fn list_modes(&self) -> Result, ClientError> { + self.send_json::, Value>( + TransportMethod::Get, + "/api/modes", + Vec::new(), + None, + true, + ) + .await + } + + pub async fn get_session_mode( + &self, + session_id: &str, + ) -> Result { + self.send_json::( + TransportMethod::Get, + &format!("/api/sessions/{session_id}/mode"), + Vec::new(), + None, + true, + ) + .await + } + + pub async fn switch_mode( + &self, + session_id: &str, + request: SwitchModeRequest, + ) -> Result { + self.send_json( + TransportMethod::Post, + &format!("/api/sessions/{session_id}/mode"), + Vec::new(), + Some(request), + true, + ) + .await + } + pub async fn submit_prompt( &self, session_id: &str, diff --git a/crates/core/src/agent/mod.rs b/crates/core/src/agent/mod.rs index 78634afc..78f8bb6f 100644 --- a/crates/core/src/agent/mod.rs +++ b/crates/core/src/agent/mod.rs @@ -1151,6 +1151,10 @@ pub struct AgentCollaborationFact { pub latency_ms: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub source_tool_call_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub mode_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub governance_revision: Option, pub policy: AgentCollaborationPolicyContext, } diff --git a/crates/core/src/event/phase.rs b/crates/core/src/event/phase.rs index 20248bc5..76046088 100644 --- a/crates/core/src/event/phase.rs +++ b/crates/core/src/event/phase.rs @@ -33,6 +33,7 @@ pub fn target_phase(event: &StorageEvent) -> Phase { | StorageEventPayload::SubRunFinished { .. } | StorageEventPayload::ChildSessionNotification { .. } | StorageEventPayload::AgentCollaborationFact { .. } + | StorageEventPayload::ModeChanged { .. } | StorageEventPayload::AgentInputQueued { .. } | StorageEventPayload::AgentInputBatchStarted { .. } | StorageEventPayload::AgentInputBatchAcked { .. } @@ -108,6 +109,7 @@ impl PhaseTracker { | StorageEventPayload::SubRunFinished { .. } | StorageEventPayload::ChildSessionNotification { .. } | StorageEventPayload::AgentCollaborationFact { .. } + | StorageEventPayload::ModeChanged { .. } | StorageEventPayload::AgentInputQueued { .. } | StorageEventPayload::AgentInputBatchStarted { .. } | StorageEventPayload::AgentInputBatchAcked { .. } diff --git a/crates/core/src/event/translate.rs b/crates/core/src/event/translate.rs index d7c75fed..18c0d5be 100644 --- a/crates/core/src/event/translate.rs +++ b/crates/core/src/event/translate.rs @@ -250,6 +250,7 @@ impl EventTranslator { }); }, StorageEventPayload::AgentCollaborationFact { .. } => {}, + StorageEventPayload::ModeChanged { .. } => {}, StorageEventPayload::AssistantDelta { token, .. } => { if let Some(turn_id) = turn_id_ref { push(AgentEvent::ModelDelta { diff --git a/crates/core/src/event/types.rs b/crates/core/src/event/types.rs index b817e3b9..9d728403 100644 --- a/crates/core/src/event/types.rs +++ b/crates/core/src/event/types.rs @@ -15,8 +15,8 @@ use serde_json::Value; use crate::{ AgentCollaborationFact, AgentEventContext, AstrError, ChildAgentRef, ChildSessionNotification, InputBatchAckedPayload, InputBatchStartedPayload, InputDiscardedPayload, InputQueuedPayload, - PersistedToolOutput, ResolvedExecutionLimitsSnapshot, ResolvedSubagentContextOverrides, Result, - SubRunResult, ToolOutputStream, UserMessageOrigin, + ModeId, PersistedToolOutput, ResolvedExecutionLimitsSnapshot, ResolvedSubagentContextOverrides, + Result, SubRunResult, ToolOutputStream, UserMessageOrigin, }; /// Prompt/缓存指标共享载荷。 @@ -245,6 +245,13 @@ pub enum StorageEventPayload { )] timestamp: Option>, }, + /// 会话治理模式变更。 + ModeChanged { + from: ModeId, + to: ModeId, + #[serde(with = "crate::local_rfc3339")] + timestamp: DateTime, + }, /// Turn 完成(一轮 Agent 循环结束)。 TurnDone { #[serde(with = "crate::local_rfc3339")] diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index 12bd2076..452000ff 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -30,6 +30,7 @@ pub mod home; pub mod hook; pub mod ids; pub mod local_server; +pub mod mode; pub mod observability; pub mod plugin; pub mod policy; @@ -111,6 +112,12 @@ pub use hook::{ }; pub use ids::{AgentId, CapabilityName, SessionId, SubRunId, TurnId}; pub use local_server::{LOCAL_SERVER_READY_PREFIX, LocalServerInfo}; +pub use mode::{ + ActionPolicies, ActionPolicyEffect, ActionPolicyRule, BUILTIN_MODE_CODE_ID, + BUILTIN_MODE_PLAN_ID, BUILTIN_MODE_REVIEW_ID, CapabilitySelector, ChildPolicySpec, + GovernanceModeSpec, ModeExecutionPolicySpec, ModeId, PromptProgramEntry, ResolvedChildPolicy, + ResolvedTurnEnvelope, SubmitBusyPolicy, TransitionPolicySpec, +}; pub use observability::{ AgentCollaborationScorecardSnapshot, ExecutionDiagnosticsSnapshot, OperationMetricsSnapshot, ReplayMetricsSnapshot, ReplayPath, RuntimeMetricsRecorder, RuntimeObservabilitySnapshot, @@ -127,8 +134,8 @@ pub use ports::{ LlmUsage, ModelLimits, PromptAgentProfileSummary, PromptBuildCacheMetrics, PromptBuildOutput, PromptBuildRequest, PromptDeclaration, PromptDeclarationKind, PromptDeclarationRenderTarget, PromptDeclarationSource, PromptEntrySummary, PromptFacts, PromptFactsProvider, - PromptFactsRequest, PromptProvider, PromptSkillSummary, ResourceProvider, ResourceReadResult, - ResourceRequestContext, + PromptFactsRequest, PromptGovernanceContext, PromptProvider, PromptSkillSummary, + ResourceProvider, ResourceReadResult, ResourceRequestContext, }; pub use projection::{AgentState, AgentStateProjector, project}; pub use registry::{CapabilityContext, CapabilityExecutionResult, CapabilityInvoker}; diff --git a/crates/core/src/mode/mod.rs b/crates/core/src/mode/mod.rs new file mode 100644 index 00000000..9e84012c --- /dev/null +++ b/crates/core/src/mode/mod.rs @@ -0,0 +1,442 @@ +use serde::{Deserialize, Serialize}; + +use crate::{ + AstrError, CapabilityKind, ContextStrategy, ForkMode, PromptDeclaration, Result, SideEffect, + normalize_non_empty_unique_string_list, +}; + +pub const BUILTIN_MODE_CODE_ID: &str = "code"; +pub const BUILTIN_MODE_PLAN_ID: &str = "plan"; +pub const BUILTIN_MODE_REVIEW_ID: &str = "review"; + +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(transparent)] +pub struct ModeId(String); + +impl ModeId { + pub fn new(value: impl Into) -> Result { + let value = value.into(); + let trimmed = value.trim(); + if trimmed.is_empty() { + return Err(AstrError::Validation("modeId 不能为空".to_string())); + } + Ok(Self(trimmed.to_string())) + } + + pub fn code() -> Self { + Self(BUILTIN_MODE_CODE_ID.to_string()) + } + + pub fn plan() -> Self { + Self(BUILTIN_MODE_PLAN_ID.to_string()) + } + + pub fn review() -> Self { + Self(BUILTIN_MODE_REVIEW_ID.to_string()) + } + + pub fn as_str(&self) -> &str { + self.0.as_str() + } +} + +impl Default for ModeId { + fn default() -> Self { + Self::code() + } +} + +impl From<&str> for ModeId { + fn from(value: &str) -> Self { + Self(value.trim().to_string()) + } +} + +impl From for ModeId { + fn from(value: String) -> Self { + Self(value.trim().to_string()) + } +} + +impl std::fmt::Display for ModeId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.as_str()) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "snake_case")] +pub enum SubmitBusyPolicy { + #[default] + BranchOnBusy, + RejectOnBusy, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum CapabilitySelector { + AllTools, + Name(String), + Kind(CapabilityKind), + SideEffect(SideEffect), + Tag(String), + Union(Vec), + Intersection(Vec), + Difference { + base: Box, + subtract: Box, + }, +} + +impl CapabilitySelector { + pub fn validate(&self) -> Result<()> { + match self { + Self::AllTools => Ok(()), + Self::Name(name) => validate_non_empty_trimmed("capabilitySelector.name", name), + Self::Kind(_) | Self::SideEffect(_) => Ok(()), + Self::Tag(tag) => validate_non_empty_trimmed("capabilitySelector.tag", tag), + Self::Union(selectors) | Self::Intersection(selectors) => { + if selectors.is_empty() { + return Err(AstrError::Validation( + "capabilitySelector 组合操作不能为空".to_string(), + )); + } + for selector in selectors { + selector.validate()?; + } + Ok(()) + }, + Self::Difference { base, subtract } => { + base.validate()?; + subtract.validate()?; + Ok(()) + }, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "snake_case")] +pub enum ActionPolicyEffect { + #[default] + Allow, + Deny, + Ask, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ActionPolicyRule { + pub selector: CapabilitySelector, + pub effect: ActionPolicyEffect, +} + +impl ActionPolicyRule { + pub fn validate(&self) -> Result<()> { + self.selector.validate() + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct ActionPolicies { + #[serde(default = "default_action_policy_effect")] + pub default_effect: ActionPolicyEffect, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub rules: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub context_strategy: Option, +} + +impl ActionPolicies { + pub fn validate(&self) -> Result<()> { + for rule in &self.rules { + rule.validate()?; + } + Ok(()) + } + + pub fn requires_approval(&self) -> bool { + self.rules + .iter() + .any(|rule| matches!(rule.effect, ActionPolicyEffect::Ask)) + || matches!(self.default_effect, ActionPolicyEffect::Ask) + } +} + +fn default_action_policy_effect() -> ActionPolicyEffect { + ActionPolicyEffect::Allow +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct ChildPolicySpec { + #[serde(default = "default_true")] + pub allow_delegation: bool, + #[serde(default = "default_true")] + pub allow_recursive_delegation: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub default_mode_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub capability_selector: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub allowed_profile_ids: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub fork_mode: Option, + #[serde(default)] + pub restricted: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reuse_scope_summary: Option, +} + +impl ChildPolicySpec { + pub fn validate(&self) -> Result<()> { + if let Some(selector) = &self.capability_selector { + selector.validate()?; + } + normalize_non_empty_unique_string_list( + &self.allowed_profile_ids, + "childPolicy.allowedProfileIds", + )?; + if let Some(summary) = &self.reuse_scope_summary { + validate_non_empty_trimmed("childPolicy.reuseScopeSummary", summary)?; + } + Ok(()) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct ModeExecutionPolicySpec { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub submit_busy_policy: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub fork_mode: Option, +} + +impl ModeExecutionPolicySpec { + pub fn validate(&self) -> Result<()> { + Ok(()) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PromptProgramEntry { + pub block_id: String, + pub title: String, + pub content: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub priority_hint: Option, +} + +impl PromptProgramEntry { + pub fn validate(&self) -> Result<()> { + validate_non_empty_trimmed("promptProgram.blockId", &self.block_id)?; + validate_non_empty_trimmed("promptProgram.title", &self.title)?; + validate_non_empty_trimmed("promptProgram.content", &self.content)?; + Ok(()) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct TransitionPolicySpec { + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub allowed_targets: Vec, +} + +impl TransitionPolicySpec { + pub fn validate(&self) -> Result<()> { + let values = self + .allowed_targets + .iter() + .map(|mode_id| mode_id.as_str().to_string()) + .collect::>(); + normalize_non_empty_unique_string_list(&values, "transitionPolicy.allowedTargets")?; + Ok(()) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GovernanceModeSpec { + pub id: ModeId, + pub name: String, + pub description: String, + pub capability_selector: CapabilitySelector, + #[serde(default)] + pub action_policies: ActionPolicies, + #[serde(default)] + pub child_policy: ChildPolicySpec, + #[serde(default)] + pub execution_policy: ModeExecutionPolicySpec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub prompt_program: Vec, + #[serde(default)] + pub transition_policy: TransitionPolicySpec, +} + +impl GovernanceModeSpec { + pub fn validate(&self) -> Result<()> { + validate_non_empty_trimmed("mode.id", self.id.as_str())?; + validate_non_empty_trimmed("mode.name", &self.name)?; + validate_non_empty_trimmed("mode.description", &self.description)?; + self.capability_selector.validate()?; + self.action_policies.validate()?; + self.child_policy.validate()?; + self.execution_policy.validate()?; + self.transition_policy.validate()?; + for entry in &self.prompt_program { + entry.validate()?; + } + Ok(()) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct ResolvedChildPolicy { + pub mode_id: ModeId, + #[serde(default = "default_true")] + pub allow_delegation: bool, + #[serde(default = "default_true")] + pub allow_recursive_delegation: bool, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub allowed_profile_ids: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub allowed_tools: Vec, + #[serde(default)] + pub restricted: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub fork_mode: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reuse_scope_summary: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ResolvedTurnEnvelope { + pub mode_id: ModeId, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub allowed_tools: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub prompt_declarations: Vec, + #[serde(default)] + pub action_policies: ActionPolicies, + #[serde(default)] + pub child_policy: ResolvedChildPolicy, + #[serde(default)] + pub submit_busy_policy: SubmitBusyPolicy, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub fork_mode: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub diagnostics: Vec, +} + +impl ResolvedTurnEnvelope { + pub fn approval_mode(&self) -> String { + if self.action_policies.requires_approval() { + "required".to_string() + } else { + "inherit".to_string() + } + } +} + +fn validate_non_empty_trimmed(field: &str, value: impl AsRef) -> Result<()> { + if value.as_ref().trim().is_empty() { + return Err(AstrError::Validation(format!("{field} 不能为空"))); + } + Ok(()) +} + +const fn default_true() -> bool { + true +} + +#[cfg(test)] +mod tests { + use super::{ + ActionPolicies, BUILTIN_MODE_CODE_ID, CapabilitySelector, GovernanceModeSpec, ModeId, + PromptProgramEntry, ResolvedTurnEnvelope, SubmitBusyPolicy, + }; + use crate::{CapabilityKind, PromptDeclaration, SideEffect, SystemPromptLayer}; + + #[test] + fn mode_id_defaults_to_builtin_code() { + assert_eq!(ModeId::default().as_str(), BUILTIN_MODE_CODE_ID); + } + + #[test] + fn capability_selector_validation_rejects_empty_union() { + let error = CapabilitySelector::Union(Vec::new()) + .validate() + .expect_err("empty union should be rejected"); + assert!(error.to_string().contains("不能为空")); + } + + #[test] + fn governance_mode_spec_round_trips_and_validates() { + let spec = GovernanceModeSpec { + id: ModeId::plan(), + name: "Plan".to_string(), + description: "只读规划".to_string(), + capability_selector: CapabilitySelector::Intersection(vec![ + CapabilitySelector::Kind(CapabilityKind::Tool), + CapabilitySelector::Difference { + base: Box::new(CapabilitySelector::AllTools), + subtract: Box::new(CapabilitySelector::SideEffect(SideEffect::Workspace)), + }, + ]), + action_policies: ActionPolicies::default(), + child_policy: Default::default(), + execution_policy: Default::default(), + prompt_program: vec![PromptProgramEntry { + block_id: "mode.plan".to_string(), + title: "Plan".to_string(), + content: "plan first".to_string(), + priority_hint: Some(600), + }], + transition_policy: Default::default(), + }; + + spec.validate().expect("spec should be valid"); + let encoded = serde_json::to_string(&spec).expect("spec should serialize"); + let decoded: GovernanceModeSpec = + serde_json::from_str(&encoded).expect("spec should deserialize"); + assert_eq!(decoded.id, ModeId::plan()); + } + + #[test] + fn resolved_turn_envelope_reports_required_approval_mode_when_rule_asks() { + let envelope = ResolvedTurnEnvelope { + mode_id: ModeId::review(), + allowed_tools: vec!["readFile".to_string()], + prompt_declarations: vec![PromptDeclaration { + block_id: "mode.review".to_string(), + title: "Review".to_string(), + content: "review only".to_string(), + render_target: crate::PromptDeclarationRenderTarget::System, + layer: SystemPromptLayer::Dynamic, + kind: crate::PromptDeclarationKind::ExtensionInstruction, + priority_hint: Some(600), + always_include: true, + source: crate::PromptDeclarationSource::Builtin, + capability_name: None, + origin: None, + }], + action_policies: ActionPolicies { + default_effect: crate::ActionPolicyEffect::Ask, + rules: Vec::new(), + context_strategy: None, + }, + child_policy: Default::default(), + submit_busy_policy: SubmitBusyPolicy::RejectOnBusy, + fork_mode: None, + diagnostics: Vec::new(), + }; + + assert_eq!(envelope.approval_mode(), "required"); + } +} diff --git a/crates/core/src/ports.rs b/crates/core/src/ports.rs index f8dd94ea..e1f3fe3a 100644 --- a/crates/core/src/ports.rs +++ b/crates/core/src/ports.rs @@ -227,6 +227,23 @@ pub struct PromptDeclaration { } /// Prompt 事实查询请求。 +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct PromptGovernanceContext { + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub allowed_capability_names: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub mode_id: Option, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub approval_mode: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub policy_revision: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub max_subrun_depth: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub max_spawn_per_turn: Option, +} + #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] #[serde(rename_all = "camelCase")] pub struct PromptFactsRequest { @@ -237,6 +254,8 @@ pub struct PromptFactsRequest { pub working_dir: PathBuf, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub allowed_capability_names: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub governance: Option, } /// Prompt 组装前的已解析事实。 diff --git a/crates/core/src/projection/agent_state.rs b/crates/core/src/projection/agent_state.rs index ef3cf7a8..00cf4a77 100644 --- a/crates/core/src/projection/agent_state.rs +++ b/crates/core/src/projection/agent_state.rs @@ -22,7 +22,8 @@ use std::path::PathBuf; use chrono::{DateTime, Utc}; use crate::{ - InvocationKind, LlmMessage, Phase, ReasoningContent, ToolCallRequest, UserMessageOrigin, + InvocationKind, LlmMessage, ModeId, Phase, ReasoningContent, ToolCallRequest, + UserMessageOrigin, event::{StorageEvent, StorageEventPayload}, format_compact_summary, split_assistant_content, }; @@ -41,6 +42,8 @@ pub struct AgentState { pub messages: Vec, /// 当前执行阶段 pub phase: Phase, + /// 当前治理模式 ID。 + pub mode_id: ModeId, /// 已完成的 turn 数量 pub turn_count: usize, /// 最后一条 assistant 消息的时间戳。 @@ -56,6 +59,7 @@ impl Default for AgentState { working_dir: PathBuf::new(), messages: Vec::new(), phase: Phase::Idle, + mode_id: ModeId::default(), turn_count: 0, last_assistant_at: None, } @@ -221,6 +225,10 @@ impl AgentStateProjector { ); }, + StorageEventPayload::ModeChanged { to, .. } => { + self.state.mode_id = to.clone(); + }, + StorageEventPayload::TurnDone { .. } => { self.flush_pending_assistant(); self.state.phase = Phase::Idle; diff --git a/crates/plugin/src/invoker.rs b/crates/plugin/src/invoker.rs index 143c26c9..de14b44d 100644 --- a/crates/plugin/src/invoker.rs +++ b/crates/plugin/src/invoker.rs @@ -183,6 +183,14 @@ impl Supervisor { pub fn declared_skills(&self) -> Vec { self.remote_initialize().skills.clone() } + + /// 获取此插件声明的治理 mode 列表。 + /// + /// 返回插件在握手阶段通过 `InitializeResultData.modes` 声明的 mode。 + /// 调用方负责决定如何校验并注册这些 mode。 + pub fn declared_modes(&self) -> Vec { + self.remote_initialize().modes.clone() + } } /// 完成流式调用并收集结果。 diff --git a/crates/plugin/src/peer.rs b/crates/plugin/src/peer.rs index ff333713..4178b912 100644 --- a/crates/plugin/src/peer.rs +++ b/crates/plugin/src/peer.rs @@ -520,6 +520,7 @@ impl PeerInner { handlers, profiles, skills: vec![], + modes: vec![], metadata, }; *self.remote_initialize.lock().await = Some(negotiated.clone()); @@ -789,6 +790,7 @@ impl PeerInner { handlers: self.local_initialize.handlers.clone(), profiles: self.local_initialize.profiles.clone(), skills: vec![], + modes: vec![], metadata: self.local_initialize.metadata.clone(), } } diff --git a/crates/protocol/src/http/mod.rs b/crates/protocol/src/http/mod.rs index 9300b54e..aae66fd9 100644 --- a/crates/protocol/src/http/mod.rs +++ b/crates/protocol/src/http/mod.rs @@ -71,8 +71,8 @@ pub use runtime::{ }; pub use session::{ CompactSessionRequest, CompactSessionResponse, CreateSessionRequest, DeleteProjectResultDto, - ForkSessionRequest, PromptAcceptedResponse, PromptRequest, PromptSkillInvocation, - SessionListItem, + ForkSessionRequest, ModeSummaryDto, PromptAcceptedResponse, PromptRequest, + PromptSkillInvocation, SessionListItem, SessionModeStateDto, SwitchModeRequest, }; pub use session_event::{SessionCatalogEventEnvelope, SessionCatalogEventPayload}; pub use terminal::v1::{ diff --git a/crates/protocol/src/http/session.rs b/crates/protocol/src/http/session.rs index f797de12..9d24498f 100644 --- a/crates/protocol/src/http/session.rs +++ b/crates/protocol/src/http/session.rs @@ -57,6 +57,31 @@ pub struct SessionListItem { pub phase: PhaseDto, } +/// 可供 session 使用的 mode 摘要。 +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct ModeSummaryDto { + pub id: String, + pub name: String, + pub description: String, +} + +/// session 当前治理 mode 状态。 +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct SessionModeStateDto { + pub current_mode_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_mode_changed_at: Option, +} + +/// `POST /api/sessions/:id/mode` 请求体。 +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct SwitchModeRequest { + pub mode_id: String, +} + /// `POST /api/sessions/:id/prompt` 请求体——向会话提交用户提示词。 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] diff --git a/crates/protocol/src/plugin/handshake.rs b/crates/protocol/src/plugin/handshake.rs index 0f5fcf78..934a4009 100644 --- a/crates/protocol/src/plugin/handshake.rs +++ b/crates/protocol/src/plugin/handshake.rs @@ -10,6 +10,7 @@ //! //! 握手完成后,双方进入正常的调用/事件流阶段。 +use astrcode_core::GovernanceModeSpec; use serde::{Deserialize, Serialize}; use serde_json::Value; @@ -63,6 +64,11 @@ pub struct InitializeMessage { /// 插件可以通过 `skills` 字段声明自己提供的 skill。Host 将这些声明解析为 /// `SkillSpec`(来源标记为 `Plugin`),并统一纳入 `SkillCatalog` 管理。 /// Skill 资产文件会在初始化时被物化到 runtime 缓存目录。 +/// +/// ## Mode 声明 +/// +/// 插件也可以通过 `modes` 字段声明治理 mode。Host 会在 bootstrap / reload 时 +/// 把这些 mode 注册到 `ModeCatalog`,并在后续 turn 编译阶段纳入统一治理装配。 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct InitializeResultData { @@ -85,6 +91,12 @@ pub struct InitializeResultData { /// Skill 资产文件会被物化到 runtime 缓存目录供运行时访问。 #[serde(default, skip_serializing_if = "Vec::is_empty")] pub skills: Vec, + /// 插件声明的治理 mode 列表。 + /// + /// 这些 mode 会被 host 校验后注册到 `ModeCatalog`,供 session 切换与 turn + /// governance 编译复用。 + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub modes: Vec, /// 扩展元数据 #[serde(default)] pub metadata: Value, diff --git a/crates/protocol/src/plugin/tests.rs b/crates/protocol/src/plugin/tests.rs index 7f5f8fb3..38aac0c7 100644 --- a/crates/protocol/src/plugin/tests.rs +++ b/crates/protocol/src/plugin/tests.rs @@ -3,6 +3,10 @@ //! 验证各类消息(初始化、调用、事件、结果等)的序列化/反序列化 //! 是否正确,确保 JSON 格式与协议版本兼容。 +use astrcode_core::{ + ActionPolicies, CapabilitySelector, ChildPolicySpec, GovernanceModeSpec, + ModeExecutionPolicySpec, ModeId, TransitionPolicySpec, +}; use serde_json::json; use super::{ @@ -117,6 +121,7 @@ fn plugin_messages_roundtrip_as_v5_json() { handlers: vec![], profiles: vec![], skills: vec![], + modes: vec![], metadata: json!({}), }) .expect("serialize initialize result"), @@ -158,6 +163,7 @@ fn initialize_result_uses_result_kind_payload() { handlers: vec![], profiles: vec![], skills: vec![], + modes: vec![], metadata: json!({ "mode": "stdio" }), }) .expect("serialize initialize result"), @@ -171,6 +177,40 @@ fn initialize_result_uses_result_kind_payload() { assert_eq!(decoded.capabilities[0].name.as_str(), "tool.echo"); } +#[test] +fn initialize_result_serializes_declared_modes() { + let mode = GovernanceModeSpec { + id: ModeId::from("plugin.plan-lite"), + name: "Plan Lite".to_string(), + description: "Plugin-provided planning mode.".to_string(), + capability_selector: CapabilitySelector::Tag("read-only".to_string()), + action_policies: ActionPolicies::default(), + child_policy: ChildPolicySpec::default(), + execution_policy: ModeExecutionPolicySpec::default(), + prompt_program: vec![], + transition_policy: TransitionPolicySpec { + allowed_targets: vec![ModeId::code()], + }, + }; + let result = InitializeResultData { + protocol_version: PROTOCOL_VERSION.to_string(), + peer: sample_peer(), + capabilities: vec![], + handlers: vec![], + profiles: vec![], + skills: vec![], + modes: vec![mode.clone()], + metadata: json!({}), + }; + + let encoded = serde_json::to_value(&result).expect("initialize result should serialize"); + assert_eq!(encoded["modes"][0]["id"], "plugin.plan-lite"); + + let decoded: InitializeResultData = + serde_json::from_value(encoded).expect("initialize result should deserialize"); + assert_eq!(decoded.modes, vec![mode]); +} + #[test] fn invocation_context_supports_coding_profile_shape() { let context = InvocationContext { diff --git a/crates/protocol/tests/conformance.rs b/crates/protocol/tests/conformance.rs index 58a80166..70881754 100644 --- a/crates/protocol/tests/conformance.rs +++ b/crates/protocol/tests/conformance.rs @@ -193,6 +193,7 @@ fn result_initialize_fixture_freezes_handshake_response_shape() { handlers: vec![], profiles: vec![sample_profile()], skills: vec![], + modes: vec![], metadata: json!({ "transport": "stdio" }), }) .expect("initialize result should serialize"), diff --git a/crates/server/src/bootstrap/governance.rs b/crates/server/src/bootstrap/governance.rs index 414c0c94..e9295935 100644 --- a/crates/server/src/bootstrap/governance.rs +++ b/crates/server/src/bootstrap/governance.rs @@ -8,8 +8,8 @@ use std::{path::PathBuf, sync::Arc}; use astrcode_adapter_mcp::manager::McpConnectionManager; use astrcode_adapter_skills::{SkillCatalog, load_builtin_skills}; use astrcode_application::{ - AppGovernance, ApplicationError, ConfigService, RuntimeGovernancePort, - RuntimeGovernanceSnapshot, RuntimeObservabilityCollector, RuntimeReloader, SessionInfoProvider, + AppGovernance, ApplicationError, ModeCatalog, RuntimeGovernancePort, RuntimeGovernanceSnapshot, + RuntimeObservabilityCollector, RuntimeReloader, SessionInfoProvider, config::ConfigService, lifecycle::TaskRegistry, }; use astrcode_plugin::Supervisor; @@ -38,6 +38,7 @@ pub(crate) struct GovernanceBuildInput { pub plugin_skill_root: PathBuf, pub plugin_supervisors: Vec>, pub working_dir: PathBuf, + pub mode_catalog: Option>, } pub(crate) fn build_app_governance(input: GovernanceBuildInput) -> Arc { @@ -56,6 +57,7 @@ pub(crate) fn build_app_governance(input: GovernanceBuildInput) -> Arc> = input .plugin_supervisors @@ -164,6 +166,7 @@ struct ServerRuntimeReloader { plugin_search_paths: Vec, plugin_skill_root: PathBuf, working_dir: PathBuf, + mode_catalog: Option>, } impl std::fmt::Debug for ServerRuntimeReloader { @@ -194,6 +197,11 @@ impl RuntimeReloader for ServerRuntimeReloader { let previous_base_skills = self.skill_catalog.base_skills(); let mut next_base_skills = load_builtin_skills(); next_base_skills.extend(plugin_bootstrap.skills.clone()); + if let Some(mode_catalog) = &self.mode_catalog { + mode_catalog + .replace_plugin_modes(plugin_bootstrap.modes.clone()) + .map_err(ApplicationError::from)?; + } let previous_capabilities = self.capability_sync.current_capabilities(); let previous_plugins = self.coordinator.plugin_registry().snapshot(); diff --git a/crates/server/src/bootstrap/mcp.rs b/crates/server/src/bootstrap/mcp.rs index 642c4cf5..a659d973 100644 --- a/crates/server/src/bootstrap/mcp.rs +++ b/crates/server/src/bootstrap/mcp.rs @@ -16,8 +16,8 @@ use astrcode_adapter_mcp::{ }; use astrcode_adapter_storage::mcp_settings_store::FileMcpSettingsStore; use astrcode_application::{ - ApplicationError, ConfigService, McpConfigFileScope, McpPort, McpServerStatusView, - RegisterMcpServerInput, + ApplicationError, McpPort, McpServerStatusView, RegisterMcpServerInput, + config::{ConfigService, McpConfigFileScope}, }; use async_trait::async_trait; diff --git a/crates/server/src/bootstrap/plugins.rs b/crates/server/src/bootstrap/plugins.rs index 1f22840c..3cd7c4e7 100644 --- a/crates/server/src/bootstrap/plugins.rs +++ b/crates/server/src/bootstrap/plugins.rs @@ -16,6 +16,7 @@ use std::{ }; use astrcode_adapter_skills::{SkillSource, SkillSpec, collect_asset_files, is_valid_skill_name}; +use astrcode_core::GovernanceModeSpec; use astrcode_plugin::{PluginLoader, Supervisor, default_initialize_message, default_profiles}; use astrcode_protocol::plugin::{PeerDescriptor, SkillDescriptor}; use log::warn; @@ -30,6 +31,8 @@ pub(crate) struct PluginBootstrapResult { pub invokers: Vec>, /// 物化后的插件 skill。 pub skills: Vec, + /// 插件声明的治理 mode。 + pub modes: Vec, /// 插件注册表引用(治理视图使用)。 pub registry: Arc, /// 活跃的插件 supervisor 列表(shutdown 时需要关闭)。 @@ -69,6 +72,7 @@ pub(crate) async fn bootstrap_plugins_with_skill_root( return PluginBootstrapResult { invokers: Vec::new(), skills: Vec::new(), + modes: Vec::new(), registry, supervisors: Vec::new(), search_paths, @@ -91,6 +95,7 @@ pub(crate) async fn bootstrap_plugins_with_skill_root( let mut all_invokers: Vec> = Vec::new(); let mut all_skills = Vec::new(); + let mut all_modes = Vec::new(); let mut supervisors = Vec::new(); for manifest in manifests { @@ -116,16 +121,19 @@ pub(crate) async fn bootstrap_plugins_with_skill_root( &name, supervisor.declared_skills(), ); + let modes = supervisor.declared_modes(); log::info!( - "plugin '{name}' initialized with {} capabilities and {} skills", + "plugin '{name}' initialized with {} capabilities, {} skills and {} modes", capabilities.len(), - skills.len() + skills.len(), + modes.len() ); registry.record_initialized(manifest, capabilities, warnings); all_invokers.extend(invokers); all_skills.extend(skills); + all_modes.extend(modes); supervisors.push(supervisor); }, Err(error) => { @@ -143,6 +151,7 @@ pub(crate) async fn bootstrap_plugins_with_skill_root( PluginBootstrapResult { invokers: all_invokers, skills: all_skills, + modes: all_modes, registry, supervisors, search_paths, @@ -356,6 +365,7 @@ mod tests { let result = bootstrap_plugins(vec![]).await; assert!(result.invokers.is_empty()); assert!(result.skills.is_empty()); + assert!(result.modes.is_empty()); assert!(result.supervisors.is_empty()); assert!(result.registry.snapshot().is_empty()); } @@ -365,6 +375,7 @@ mod tests { let result = bootstrap_plugins(vec![PathBuf::from("/nonexistent/path")]).await; assert!(result.invokers.is_empty()); assert!(result.skills.is_empty()); + assert!(result.modes.is_empty()); assert!(result.supervisors.is_empty()); } diff --git a/crates/server/src/bootstrap/prompt_facts.rs b/crates/server/src/bootstrap/prompt_facts.rs index 9cbf2989..32d39d50 100644 --- a/crates/server/src/bootstrap/prompt_facts.rs +++ b/crates/server/src/bootstrap/prompt_facts.rs @@ -11,7 +11,7 @@ use std::{ use astrcode_adapter_agents::AgentProfileLoader; use astrcode_adapter_mcp::manager::McpConnectionManager; use astrcode_adapter_skills::SkillCatalog; -use astrcode_application::{ConfigService, resolve_current_model}; +use astrcode_application::config::{ConfigService, resolve_current_model}; use async_trait::async_trait; use super::deps::core::{ @@ -57,6 +57,7 @@ impl PromptFactsProvider for RuntimePromptFactsProvider { .load_overlayed_config(Some(working_dir.as_path())) .map_err(|error| AstrError::Internal(error.to_string()))?; let runtime = resolve_runtime_config(&config.runtime); + let governance = request.governance.clone().unwrap_or_default(); let selection = resolve_current_model(&config) .map_err(|error| AstrError::Internal(error.to_string()))?; let skill_summaries = self @@ -79,7 +80,12 @@ impl PromptFactsProvider for RuntimePromptFactsProvider { .await .prompt_declarations .into_iter() - .filter(|declaration| prompt_declaration_is_visible(request, declaration)) + .filter(|declaration| { + prompt_declaration_is_visible( + governance.allowed_capability_names.as_slice(), + declaration, + ) + }) .map(convert_prompt_declaration) .collect(); @@ -89,13 +95,16 @@ impl PromptFactsProvider for RuntimePromptFactsProvider { working_dir.as_path(), request.session_id.as_ref().map(ToString::to_string), request.turn_id.as_ref().map(ToString::to_string), + governance.approval_mode.as_str(), ), metadata: serde_json::json!({ "configVersion": config.version, "activeProfile": config.active_profile, "activeModel": config.active_model, - "agentMaxSubrunDepth": runtime.agent.max_subrun_depth, - "agentMaxSpawnPerTurn": runtime.agent.max_spawn_per_turn, + "modeId": governance.mode_id, + "agentMaxSubrunDepth": governance.max_subrun_depth.unwrap_or(runtime.agent.max_subrun_depth), + "agentMaxSpawnPerTurn": governance.max_spawn_per_turn.unwrap_or(runtime.agent.max_spawn_per_turn), + "governancePolicyRevision": governance.policy_revision, }), skills: skill_summaries, agent_profiles, @@ -108,6 +117,7 @@ fn build_profile_context( working_dir: &Path, session_id: Option, turn_id: Option, + approval_mode: &str, ) -> serde_json::Value { let working_dir = normalize_context_path(working_dir); let mut context = serde_json::Map::new(); @@ -121,7 +131,11 @@ fn build_profile_context( ); context.insert( "approvalMode".to_string(), - serde_json::Value::String("inherit".to_string()), + serde_json::Value::String(if approval_mode.trim().is_empty() { + "inherit".to_string() + } else { + approval_mode.to_string() + }), ); if let Some(session_id) = session_id { context.insert( @@ -198,15 +212,14 @@ fn convert_prompt_declaration( } fn prompt_declaration_is_visible( - request: &PromptFactsRequest, + allowed_capability_names: &[String], declaration: &astrcode_adapter_prompt::PromptDeclaration, ) -> bool { declaration .capability_name .as_ref() .is_none_or(|capability_name| { - request - .allowed_capability_names + allowed_capability_names .iter() .any(|allowed| allowed == capability_name) }) @@ -249,13 +262,24 @@ mod tests { .iter() .map(|name| (*name).to_string()) .collect(), + governance: Some(astrcode_core::PromptGovernanceContext { + allowed_capability_names: allowed_capability_names + .iter() + .map(|name| (*name).to_string()) + .collect(), + mode_id: Some(astrcode_core::ModeId::code()), + approval_mode: "inherit".to_string(), + policy_revision: "governance-surface-v1".to_string(), + max_subrun_depth: Some(3), + max_spawn_per_turn: Some(2), + }), } } #[test] fn prompt_declaration_visibility_keeps_capabilityless_declarations() { assert!(prompt_declaration_is_visible( - &request(&[]), + &request(&[]).governance.unwrap().allowed_capability_names, &declaration(None) )); } @@ -263,7 +287,10 @@ mod tests { #[test] fn prompt_declaration_visibility_filters_out_ungranted_capabilities() { assert!(!prompt_declaration_is_visible( - &request(&["readFile"]), + &request(&["readFile"]) + .governance + .unwrap() + .allowed_capability_names, &declaration(Some("spawn")) )); } @@ -271,7 +298,10 @@ mod tests { #[test] fn prompt_declaration_visibility_keeps_granted_capabilities() { assert!(prompt_declaration_is_visible( - &request(&["spawn"]), + &request(&["spawn"]) + .governance + .unwrap() + .allowed_capability_names, &declaration(Some("spawn")) )); } diff --git a/crates/server/src/bootstrap/providers.rs b/crates/server/src/bootstrap/providers.rs index 9815c98f..d540c178 100644 --- a/crates/server/src/bootstrap/providers.rs +++ b/crates/server/src/bootstrap/providers.rs @@ -20,9 +20,10 @@ use astrcode_adapter_prompt::{ }; use astrcode_adapter_storage::config_store::FileConfigStore; use astrcode_application::{ - ApplicationError, ConfigService, ProfileResolutionService, + ApplicationError, ProfileResolutionService, config::{ - api_key, resolve_anthropic_messages_api_url, resolve_openai_chat_completions_api_url, + ConfigService, api_key, resolve_anthropic_messages_api_url, resolve_current_model, + resolve_openai_chat_completions_api_url, }, execution::ProfileProvider, }; @@ -114,7 +115,7 @@ impl ConfigBackedLlmProvider { let config = self .config_service .load_overlayed_config(Some(self.working_dir.as_path()))?; - let selection = astrcode_application::resolve_current_model(&config)?; + let selection = resolve_current_model(&config)?; let profile = config .profiles .iter() diff --git a/crates/server/src/bootstrap/runtime.rs b/crates/server/src/bootstrap/runtime.rs index e4278ff6..2a460350 100644 --- a/crates/server/src/bootstrap/runtime.rs +++ b/crates/server/src/bootstrap/runtime.rs @@ -11,8 +11,8 @@ use std::{ use astrcode_adapter_storage::core_port::FsEventStore; use astrcode_adapter_tools::builtin_tools::tool_search::ToolSearchIndex; use astrcode_application::{ - AgentOrchestrationService, App, AppGovernance, RuntimeObservabilityCollector, WatchService, - lifecycle::TaskRegistry, + AgentOrchestrationService, App, AppGovernance, GovernanceSurfaceAssembler, + RuntimeObservabilityCollector, WatchService, builtin_mode_catalog, lifecycle::TaskRegistry, }; use super::{ @@ -157,6 +157,7 @@ pub async fn bootstrap_server_runtime_with_options( let PluginBootstrapResult { invokers: plugin_invokers, skills: plugin_skills, + modes: plugin_modes, registry: plugin_registry, supervisors: plugin_supervisors, search_paths: plugin_search_paths, @@ -193,6 +194,11 @@ pub async fn bootstrap_server_runtime_with_options( )?); let observability = Arc::new(RuntimeObservabilityCollector::new()); let task_registry = Arc::new(TaskRegistry::new()); + let mode_catalog = Arc::new(builtin_mode_catalog()?); + mode_catalog.replace_plugin_modes(plugin_modes.clone())?; + let governance_surface = Arc::new(GovernanceSurfaceAssembler::new( + mode_catalog.as_ref().clone(), + )); let event_store: Arc = Arc::new(FsEventStore::new_with_projects_root( paths.projects_root.clone(), @@ -220,6 +226,7 @@ pub async fn bootstrap_server_runtime_with_options( session_runtime.clone(), config_service.clone(), profiles.clone(), + Arc::clone(&governance_surface), task_registry.clone(), observability.clone(), )); @@ -253,6 +260,8 @@ pub async fn bootstrap_server_runtime_with_options( profiles, config_service.clone(), Arc::new(RuntimeComposerSkillPort::new(skill_catalog.clone())), + Arc::clone(&governance_surface), + Arc::clone(&mode_catalog), mcp_service, agent_service, )); @@ -270,6 +279,7 @@ pub async fn bootstrap_server_runtime_with_options( plugin_skill_root: paths.plugin_skill_root.clone(), plugin_supervisors, working_dir: working_dir.clone(), + mode_catalog: Some(mode_catalog), }); let profile_watch_runtime = if options.enable_profile_watch { Some( diff --git a/crates/server/src/http/mapper.rs b/crates/server/src/http/mapper.rs index d2c24fff..b4727a28 100644 --- a/crates/server/src/http/mapper.rs +++ b/crates/server/src/http/mapper.rs @@ -20,11 +20,13 @@ //! - **SSE 工具**:事件 ID 解析/格式化(`{storage_seq}.{subindex}` 格式) use astrcode_application::{ - AgentExecuteSummary, ApplicationError, ComposerOption, Config, ResolvedConfigSummary, - ResolvedRuntimeStatusSummary, SessionCatalogEvent, SessionListSummary, - SubRunStatusSourceSummary, SubRunStatusSummary, SubagentContextOverrides, - list_model_options as resolve_model_options, - resolve_current_model as resolve_runtime_current_model, + AgentExecuteSummary, ApplicationError, ComposerOption, Config, ResolvedRuntimeStatusSummary, + SessionCatalogEvent, SessionListSummary, SubRunStatusSourceSummary, SubRunStatusSummary, + SubagentContextOverrides, + config::{ + ResolvedConfigSummary, list_model_options as resolve_model_options, + resolve_current_model as resolve_runtime_current_model, + }, }; use astrcode_protocol::http::{ AgentExecuteResponseDto, ComposerOptionsResponseDto, ConfigView, CurrentModelInfoDto, diff --git a/crates/server/src/http/routes/config.rs b/crates/server/src/http/routes/config.rs index e2021927..b289611d 100644 --- a/crates/server/src/http/routes/config.rs +++ b/crates/server/src/http/routes/config.rs @@ -5,7 +5,7 @@ //! - `POST /api/config/active-selection` — 保存活跃的 profile/model 选择 use astrcode_application::{ - format_local_rfc3339, resolve_config_summary, resolve_runtime_status_summary, + config::resolve_config_summary, format_local_rfc3339, resolve_runtime_status_summary, }; use astrcode_protocol::http::{ConfigReloadResponse, ConfigView, SaveActiveSelectionRequest}; use axum::{ diff --git a/crates/server/src/http/routes/conversation.rs b/crates/server/src/http/routes/conversation.rs index 8af68219..d92ec215 100644 --- a/crates/server/src/http/routes/conversation.rs +++ b/crates/server/src/http/routes/conversation.rs @@ -1,9 +1,12 @@ use std::{convert::Infallible, pin::Pin, time::Duration}; use astrcode_application::{ - ApplicationError, ConversationAuthoritativeSummary, ConversationChildSummarySummary, - ConversationControlSummary, ConversationFocus, ConversationSlashCandidateSummary, - TerminalStreamFacts, TerminalStreamReplayFacts, + ApplicationError, + terminal::{ + ConversationAuthoritativeSummary, ConversationChildSummarySummary, + ConversationControlSummary, ConversationFocus, ConversationSlashCandidateSummary, + TerminalStreamFacts, TerminalStreamReplayFacts, summarize_conversation_authoritative, + }, }; use astrcode_core::AgentEvent; use astrcode_protocol::http::conversation::v1::{ @@ -348,7 +351,7 @@ struct ConversationAuthoritativeFacts { impl ConversationAuthoritativeFacts { fn from_replay(facts: &TerminalStreamReplayFacts) -> Self { - Self::from_summary(astrcode_application::summarize_conversation_authoritative( + Self::from_summary(summarize_conversation_authoritative( &facts.control, &facts.child_summaries, &facts.slash_candidates, @@ -581,7 +584,7 @@ type ConversationSse = Sse Router { "/api/sessions", post(sessions::create_session).get(sessions::list_sessions), ) + .route("/api/modes", get(sessions::list_modes)) .route("/api/session-events", get(sessions::session_catalog_events)) .route( "/api/sessions/{id}/composer/options", @@ -103,6 +107,10 @@ pub(crate) fn build_api_router() -> Router { "/api/sessions/{id}/interrupt", post(sessions::interrupt_session), ) + .route( + "/api/sessions/{id}/mode", + get(sessions::get_session_mode).post(sessions::switch_mode), + ) .route("/api/sessions/{id}", delete(sessions::delete_session)) .route("/api/projects", delete(sessions::delete_project)) .route("/api/config", get(config::get_config)) diff --git a/crates/server/src/http/routes/sessions/mod.rs b/crates/server/src/http/routes/sessions/mod.rs index e61fc051..b60d275f 100644 --- a/crates/server/src/http/routes/sessions/mod.rs +++ b/crates/server/src/http/routes/sessions/mod.rs @@ -10,9 +10,9 @@ mod stream; use axum::http::StatusCode; pub(crate) use mutation::{ compact_session, create_session, delete_project, delete_session, fork_session, - interrupt_session, submit_prompt, + interrupt_session, submit_prompt, switch_mode, }; -pub(crate) use query::list_sessions; +pub(crate) use query::{get_session_mode, list_modes, list_sessions}; pub(crate) use stream::session_catalog_events; use crate::ApiError; diff --git a/crates/server/src/http/routes/sessions/mutation.rs b/crates/server/src/http/routes/sessions/mutation.rs index 4e12aad5..7d957c75 100644 --- a/crates/server/src/http/routes/sessions/mutation.rs +++ b/crates/server/src/http/routes/sessions/mutation.rs @@ -1,6 +1,7 @@ use astrcode_protocol::http::{ CompactSessionRequest, CompactSessionResponse, CreateSessionRequest, DeleteProjectResultDto, ForkSessionRequest, PromptAcceptedResponse, PromptRequest, SessionListItem, + SessionModeStateDto, SwitchModeRequest, }; use axum::{ Json, @@ -148,6 +149,30 @@ pub(crate) async fn fork_session( ))) } +pub(crate) async fn switch_mode( + State(state): State, + headers: HeaderMap, + Path(session_id): Path, + Json(request): Json, +) -> Result<(StatusCode, Json), ApiError> { + require_auth(&state, &headers, None)?; + let session_id = validate_session_path_id(&session_id)?; + let mode = state + .app + .switch_mode(&session_id, request.mode_id.into()) + .await + .map_err(ApiError::from)?; + Ok(( + StatusCode::ACCEPTED, + Json(SessionModeStateDto { + current_mode_id: mode.current_mode_id.to_string(), + last_mode_changed_at: mode + .last_mode_changed_at + .map(astrcode_application::format_local_rfc3339), + }), + )) +} + pub(crate) async fn delete_session( State(state): State, headers: HeaderMap, diff --git a/crates/server/src/http/routes/sessions/query.rs b/crates/server/src/http/routes/sessions/query.rs index 4080c090..bbc3b1eb 100644 --- a/crates/server/src/http/routes/sessions/query.rs +++ b/crates/server/src/http/routes/sessions/query.rs @@ -1,7 +1,14 @@ -use astrcode_protocol::http::SessionListItem; -use axum::{Json, extract::State, http::HeaderMap}; +use astrcode_protocol::http::{ModeSummaryDto, SessionListItem, SessionModeStateDto}; +use axum::{ + Json, + extract::{Path, State}, + http::HeaderMap, +}; -use crate::{ApiError, AppState, auth::require_auth, mapper::to_session_list_item}; +use crate::{ + ApiError, AppState, auth::require_auth, mapper::to_session_list_item, + routes::sessions::validate_session_path_id, +}; pub(crate) async fn list_sessions( State(state): State, @@ -19,3 +26,43 @@ pub(crate) async fn list_sessions( .collect(); Ok(Json(sessions)) } + +pub(crate) async fn list_modes( + State(state): State, + headers: HeaderMap, +) -> Result>, ApiError> { + require_auth(&state, &headers, None)?; + let modes = state + .app + .list_modes() + .await + .map_err(ApiError::from)? + .into_iter() + .map(|summary| ModeSummaryDto { + id: summary.id.to_string(), + name: summary.name, + description: summary.description, + }) + .collect(); + Ok(Json(modes)) +} + +pub(crate) async fn get_session_mode( + State(state): State, + headers: HeaderMap, + Path(session_id): Path, +) -> Result, ApiError> { + require_auth(&state, &headers, None)?; + let session_id = validate_session_path_id(&session_id)?; + let mode = state + .app + .session_mode_state(&session_id) + .await + .map_err(ApiError::from)?; + Ok(Json(SessionModeStateDto { + current_mode_id: mode.current_mode_id.to_string(), + last_mode_changed_at: mode + .last_mode_changed_at + .map(astrcode_application::format_local_rfc3339), + })) +} diff --git a/crates/server/src/http/terminal_projection.rs b/crates/server/src/http/terminal_projection.rs index 33af6973..46a8738c 100644 --- a/crates/server/src/http/terminal_projection.rs +++ b/crates/server/src/http/terminal_projection.rs @@ -1,10 +1,11 @@ use std::collections::HashMap; -use astrcode_application::{ +use astrcode_application::terminal::{ ConversationChildSummarySummary, ConversationControlSummary, ConversationSlashActionSummary, ConversationSlashCandidateSummary, TerminalChildSummaryFacts, TerminalFacts, - TerminalRehydrateFacts, summarize_conversation_child_ref, summarize_conversation_child_summary, - summarize_conversation_control, summarize_conversation_slash_candidate, + TerminalRehydrateFacts, TerminalSlashCandidateFacts, summarize_conversation_child_ref, + summarize_conversation_child_summary, summarize_conversation_control, + summarize_conversation_slash_candidate, }; use astrcode_core::ChildAgentRef; use astrcode_protocol::http::{ @@ -115,7 +116,7 @@ pub(crate) fn project_conversation_rehydrate_envelope( } pub(crate) fn project_conversation_slash_candidates( - candidates: &[astrcode_application::TerminalSlashCandidateFacts], + candidates: &[TerminalSlashCandidateFacts], ) -> ConversationSlashCandidatesResponseDto { ConversationSlashCandidatesResponseDto { items: candidates diff --git a/crates/session-runtime/src/command/mod.rs b/crates/session-runtime/src/command/mod.rs index 103c3463..ed39915e 100644 --- a/crates/session-runtime/src/command/mod.rs +++ b/crates/session-runtime/src/command/mod.rs @@ -3,7 +3,7 @@ use std::path::Path; use astrcode_core::{ AgentCollaborationFact, AgentEventContext, ChildSessionNotification, EventTranslator, InputBatchAckedPayload, InputBatchStartedPayload, InputDiscardedPayload, InputQueuedPayload, - Result, StorageEvent, StorageEventPayload, StoredEvent, + ModeId, Result, StorageEvent, StorageEventPayload, StoredEvent, }; use chrono::Utc; @@ -179,6 +179,31 @@ impl<'a> SessionCommands<'a> { Ok(false) } + pub async fn switch_mode( + &self, + session_id: &str, + from: ModeId, + to: ModeId, + ) -> Result { + let session_id = astrcode_core::SessionId::from(crate::normalize_session_id(session_id)); + let session_state = self.runtime.query().session_state(&session_id).await?; + let mut translator = EventTranslator::new(session_state.current_phase()?); + append_and_broadcast( + &session_state, + &StorageEvent { + turn_id: None, + agent: AgentEventContext::default(), + payload: StorageEventPayload::ModeChanged { + from, + to, + timestamp: Utc::now(), + }, + }, + &mut translator, + ) + .await + } + async fn append_agent_input_event( &self, session_id: &str, diff --git a/crates/session-runtime/src/lib.rs b/crates/session-runtime/src/lib.rs index dc04d3a8..a5663b97 100644 --- a/crates/session-runtime/src/lib.rs +++ b/crates/session-runtime/src/lib.rs @@ -40,8 +40,8 @@ pub use query::{ ConversationStreamProjector, ConversationStreamReplayFacts, ConversationSystemNoteBlockFacts, ConversationSystemNoteKind, ConversationThinkingBlockFacts, ConversationTranscriptErrorKind, ConversationUserBlockFacts, LastCompactMetaSnapshot, ProjectedTurnOutcome, - SessionControlStateSnapshot, SessionReplay, SessionTranscriptSnapshot, ToolCallBlockFacts, - ToolCallStreamsFacts, TurnTerminalSnapshot, recoverable_parent_deliveries, + SessionControlStateSnapshot, SessionModeSnapshot, SessionReplay, SessionTranscriptSnapshot, + ToolCallBlockFacts, ToolCallStreamsFacts, TurnTerminalSnapshot, recoverable_parent_deliveries, }; pub(crate) use state::{InputQueueEventAppend, SessionStateEventSink, append_input_queue_event}; pub use state::{ @@ -292,6 +292,10 @@ impl SessionRuntime { self.query().session_child_nodes(session_id).await } + pub async fn session_mode_state(&self, session_id: &str) -> Result { + self.query().session_mode_state(session_id).await + } + /// 读取指定 session 的工作目录。 pub async fn get_session_working_dir(&self, session_id: &str) -> Result { self.query().session_working_dir(session_id).await @@ -482,6 +486,15 @@ impl SessionRuntime { .await } + pub async fn switch_mode( + &self, + session_id: &str, + from: astrcode_core::ModeId, + to: astrcode_core::ModeId, + ) -> Result { + self.command().switch_mode(session_id, from, to).await + } + async fn session_phase(&self, session_id: &SessionId) -> Result { if let Some(entry) = self.sessions.get(session_id) { return entry.actor.state().current_phase(); diff --git a/crates/session-runtime/src/query/agent.rs b/crates/session-runtime/src/query/agent.rs index 7828a7f1..04c0841d 100644 --- a/crates/session-runtime/src/query/agent.rs +++ b/crates/session-runtime/src/query/agent.rs @@ -56,11 +56,10 @@ fn active_task_summary( .iter() .rev() .find_map(|message| match message { - LlmMessage::User { content, origin } - if matches!(origin, astrcode_core::UserMessageOrigin::User) => - { - summarize_inline_text(content, 120) - }, + LlmMessage::User { + content, + origin: astrcode_core::UserMessageOrigin::User, + } => summarize_inline_text(content, 120), _ => None, }); } @@ -88,7 +87,7 @@ fn extract_last_turn_tail(messages: &[LlmMessage]) -> Vec { mod tests { use std::path::PathBuf; - use astrcode_core::{AgentState, LlmMessage, Phase, UserMessageOrigin}; + use astrcode_core::{AgentState, LlmMessage, ModeId, Phase, UserMessageOrigin}; use super::{build_agent_observe_snapshot, extract_last_turn_tail}; @@ -98,6 +97,7 @@ mod tests { working_dir: PathBuf::from("/tmp"), phase, turn_count: 2, + mode_id: ModeId::code(), messages, last_assistant_at: None, } @@ -159,8 +159,10 @@ mod tests { #[test] fn build_agent_observe_snapshot_uses_turn_tail_when_active_delivery_exists() { - let mut input_queue_projection = astrcode_core::InputQueueProjection::default(); - input_queue_projection.active_delivery_ids = vec!["delivery-1".into()]; + let input_queue_projection = astrcode_core::InputQueueProjection { + active_delivery_ids: vec!["delivery-1".into()], + ..Default::default() + }; let snapshot = build_agent_observe_snapshot( astrcode_core::AgentLifecycleStatus::Idle, diff --git a/crates/session-runtime/src/query/mod.rs b/crates/session-runtime/src/query/mod.rs index d2548995..13a3f7ac 100644 --- a/crates/session-runtime/src/query/mod.rs +++ b/crates/session-runtime/src/query/mod.rs @@ -24,7 +24,7 @@ pub use conversation::{ }; pub use input_queue::recoverable_parent_deliveries; pub(crate) use service::SessionQueries; -pub use terminal::{LastCompactMetaSnapshot, SessionControlStateSnapshot}; +pub use terminal::{LastCompactMetaSnapshot, SessionControlStateSnapshot, SessionModeSnapshot}; pub(crate) use transcript::current_turn_messages; pub use transcript::{SessionReplay, SessionTranscriptSnapshot}; pub use turn::{ProjectedTurnOutcome, TurnTerminalSnapshot}; diff --git a/crates/session-runtime/src/query/service.rs b/crates/session-runtime/src/query/service.rs index 75b070e3..8d01b698 100644 --- a/crates/session-runtime/src/query/service.rs +++ b/crates/session-runtime/src/query/service.rs @@ -8,8 +8,8 @@ use tokio::sync::broadcast::error::RecvError; use crate::{ AgentObserveSnapshot, ConversationSnapshotFacts, ConversationStreamReplayFacts, - LastCompactMetaSnapshot, ProjectedTurnOutcome, SessionControlStateSnapshot, SessionReplay, - SessionRuntime, SessionState, TurnTerminalSnapshot, + LastCompactMetaSnapshot, ProjectedTurnOutcome, SessionControlStateSnapshot, + SessionModeSnapshot, SessionReplay, SessionRuntime, SessionState, TurnTerminalSnapshot, query::{ agent::build_agent_observe_snapshot, conversation::{build_conversation_replay_frames, project_conversation_snapshot}, @@ -65,6 +65,8 @@ impl<'a> SessionQueries<'a> { manual_compact_pending: actor.state().manual_compact_pending()?, compacting: actor.state().compacting(), last_compact_meta, + current_mode_id: actor.state().current_mode_id()?, + last_mode_changed_at: actor.state().last_mode_changed_at()?, }) } @@ -74,6 +76,15 @@ impl<'a> SessionQueries<'a> { actor.state().list_child_session_nodes() } + pub async fn session_mode_state(&self, session_id: &str) -> Result { + let session_id = SessionId::from(crate::normalize_session_id(session_id)); + let actor = self.runtime.ensure_loaded_session(&session_id).await?; + Ok(SessionModeSnapshot { + current_mode_id: actor.state().current_mode_id()?, + last_mode_changed_at: actor.state().last_mode_changed_at()?, + }) + } + pub async fn session_working_dir(&self, session_id: &str) -> Result { let session_id = SessionId::from(crate::normalize_session_id(session_id)); let actor = self.runtime.ensure_loaded_session(&session_id).await?; diff --git a/crates/session-runtime/src/query/terminal.rs b/crates/session-runtime/src/query/terminal.rs index 48b4c068..88322f91 100644 --- a/crates/session-runtime/src/query/terminal.rs +++ b/crates/session-runtime/src/query/terminal.rs @@ -1,4 +1,5 @@ use astrcode_core::{CompactAppliedMeta, CompactTrigger, Phase}; +use chrono::{DateTime, Utc}; #[derive(Debug, Clone, PartialEq, Eq)] pub struct LastCompactMetaSnapshot { @@ -17,4 +18,12 @@ pub struct SessionControlStateSnapshot { pub manual_compact_pending: bool, pub compacting: bool, pub last_compact_meta: Option, + pub current_mode_id: astrcode_core::ModeId, + pub last_mode_changed_at: Option>, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SessionModeSnapshot { + pub current_mode_id: astrcode_core::ModeId, + pub last_mode_changed_at: Option>, } diff --git a/crates/session-runtime/src/state/mod.rs b/crates/session-runtime/src/state/mod.rs index 4be772b8..b7ac39cc 100644 --- a/crates/session-runtime/src/state/mod.rs +++ b/crates/session-runtime/src/state/mod.rs @@ -21,12 +21,13 @@ use std::{ use astrcode_core::{ AgentEvent, AgentState, AgentStateProjector, CancelToken, ChildSessionNode, EventTranslator, - InputQueueProjection, Phase, ResolvedRuntimeConfig, Result, SessionEventRecord, - SessionTurnLease, StoredEvent, + InputQueueProjection, ModeId, Phase, ResolvedRuntimeConfig, Result, SessionEventRecord, + SessionTurnLease, StorageEventPayload, StoredEvent, support::{self}, }; use cache::{RecentSessionEvents, RecentStoredEvents}; use child_sessions::{child_node_from_stored_event, rebuild_child_nodes}; +use chrono::{DateTime, Utc}; pub(crate) use execution::SessionStateEventSink; pub use execution::{append_and_broadcast, complete_session_execution, prepare_session_execution}; pub(crate) use input_queue::{InputQueueEventAppend, append_input_queue_event}; @@ -55,6 +56,8 @@ pub struct SessionState { pub pending_manual_compact: StdMutex, pub pending_manual_compact_request: StdMutex>, pub compact_failure_count: StdMutex, + pub current_mode: StdMutex, + pub last_mode_changed_at: StdMutex>>, pub broadcaster: broadcast::Sender, live_broadcaster: broadcast::Sender, pub writer: Arc, @@ -115,6 +118,13 @@ impl SessionState { pending_manual_compact: StdMutex::new(false), pending_manual_compact_request: StdMutex::new(None), compact_failure_count: StdMutex::new(0), + current_mode: StdMutex::new(projector.snapshot().mode_id.clone()), + last_mode_changed_at: StdMutex::new(recent_stored.iter().rev().find_map(|stored| { + match &stored.event.payload { + StorageEventPayload::ModeChanged { timestamp, .. } => Some(*timestamp), + _ => None, + } + })), broadcaster, live_broadcaster, writer, @@ -155,6 +165,17 @@ impl SessionState { )?) } + pub fn current_mode_id(&self) -> Result { + Ok(support::lock_anyhow(&self.current_mode, "session current mode")?.clone()) + } + + pub fn last_mode_changed_at(&self) -> Result>> { + Ok(*support::lock_anyhow( + &self.last_mode_changed_at, + "session last mode changed at", + )?) + } + pub fn complete_execution_state(&self, phase: Phase) { // Why: 先清除 running 标志再设置 phase,避免外部观察者看到 phase=Idle // 但 running 仍为 true 的竞态窗口(如 compact 在 turn 完成后立即被调用)。 @@ -225,6 +246,12 @@ impl SessionState { { let mut projector = support::lock_anyhow(&self.projector, "session projector")?; projector.apply(&stored.event); + *support::lock_anyhow(&self.current_mode, "session current mode")? = + projector.snapshot().mode_id.clone(); + } + if let StorageEventPayload::ModeChanged { timestamp, .. } = &stored.event.payload { + *support::lock_anyhow(&self.last_mode_changed_at, "session last mode changed at")? = + Some(*timestamp); } let records = translator.translate(stored); support::lock_anyhow(&self.recent_records, "session recent records")?.push_batch(&records); diff --git a/crates/session-runtime/src/turn/compaction_cycle.rs b/crates/session-runtime/src/turn/compaction_cycle.rs index 6c586d73..bc70dee4 100644 --- a/crates/session-runtime/src/turn/compaction_cycle.rs +++ b/crates/session-runtime/src/turn/compaction_cycle.rs @@ -101,6 +101,7 @@ pub async fn try_reactive_compact( session_state: None, current_agent_id: ctx.agent.agent_id.as_ref().map(|id| id.as_str()), submission_prompt_declarations: &[], + prompt_governance: None, }) .await?; diff --git a/crates/session-runtime/src/turn/manual_compact.rs b/crates/session-runtime/src/turn/manual_compact.rs index 35366b6a..d22dd156 100644 --- a/crates/session-runtime/src/turn/manual_compact.rs +++ b/crates/session-runtime/src/turn/manual_compact.rs @@ -52,6 +52,7 @@ pub(crate) async fn build_manual_compact_events( session_state: Some(request.session_state), current_agent_id: None, submission_prompt_declarations: &[], + prompt_governance: None, }) .await?; diff --git a/crates/session-runtime/src/turn/request.rs b/crates/session-runtime/src/turn/request.rs index dca8e4e1..fdd25995 100644 --- a/crates/session-runtime/src/turn/request.rs +++ b/crates/session-runtime/src/turn/request.rs @@ -9,7 +9,7 @@ use std::{collections::HashSet, path::Path, sync::Arc, time::Instant}; use astrcode_core::{ AgentEventContext, CompactTrigger, LlmMessage, LlmRequest, PromptBuildOutput, PromptBuildRequest, PromptDeclaration, PromptFacts, PromptFactsProvider, PromptFactsRequest, - Result, StorageEvent, UserMessageOrigin, + PromptGovernanceContext, Result, StorageEvent, UserMessageOrigin, }; use astrcode_kernel::KernelGateway; @@ -49,6 +49,7 @@ pub struct AssemblePromptRequest<'a> { pub session_state: &'a crate::SessionState, pub tool_result_replacement_state: &'a mut ToolResultReplacementState, pub prompt_declarations: &'a [PromptDeclaration], + pub prompt_governance: Option<&'a PromptGovernanceContext>, } pub struct AssemblePromptResult { @@ -70,6 +71,7 @@ pub(crate) struct PromptOutputRequest<'a> { pub session_state: Option<&'a crate::SessionState>, pub current_agent_id: Option<&'a str>, pub submission_prompt_declarations: &'a [PromptDeclaration], + pub prompt_governance: Option<&'a PromptGovernanceContext>, } /// Why: request assembly 要回答“最终如何形成一次 LLM 请求”, @@ -124,6 +126,7 @@ pub async fn assemble_prompt_request( session_state: Some(request.session_state), current_agent_id: request.agent.agent_id.as_ref().map(|id| id.as_str()), submission_prompt_declarations: request.prompt_declarations, + prompt_governance: request.prompt_governance, }) .await?; let mut snapshot = build_prompt_snapshot( @@ -190,6 +193,7 @@ pub async fn assemble_prompt_request( session_state: Some(request.session_state), current_agent_id: request.agent.agent_id.as_ref().map(|id| id.as_str()), submission_prompt_declarations: request.prompt_declarations, + prompt_governance: request.prompt_governance, }) .await?; snapshot = build_prompt_snapshot( @@ -251,6 +255,7 @@ pub(crate) async fn build_prompt_output( session_state, current_agent_id, submission_prompt_declarations, + prompt_governance, } = request; let facts = prompt_facts_provider .resolve_prompt_facts(&PromptFactsRequest { @@ -263,6 +268,7 @@ pub(crate) async fn build_prompt_output( .into_iter() .map(|spec| spec.name.to_string()) .collect(), + governance: prompt_governance.cloned(), }) .await?; let turn_index = count_user_turns(messages); @@ -479,6 +485,7 @@ mod tests { session_state: &session_state, tool_result_replacement_state: &mut replacement_state, prompt_declarations: &[], + prompt_governance: None, }) .await .expect("assembly should succeed"); @@ -535,6 +542,7 @@ mod tests { session_state: &session_state, tool_result_replacement_state: &mut replacement_state, prompt_declarations: &[], + prompt_governance: None, }) .await .expect("assembly should succeed"); @@ -678,6 +686,7 @@ mod tests { session_state: None, current_agent_id: None, submission_prompt_declarations: &submission_declarations, + prompt_governance: None, }) .await .expect("prompt output should build"); diff --git a/crates/session-runtime/src/turn/runner.rs b/crates/session-runtime/src/turn/runner.rs index 4ff26588..0ee041b1 100644 --- a/crates/session-runtime/src/turn/runner.rs +++ b/crates/session-runtime/src/turn/runner.rs @@ -28,7 +28,8 @@ use std::{collections::HashSet, path::Path, sync::Arc, time::Instant}; use astrcode_core::{ AgentEventContext, CancelToken, LlmMessage, PromptDeclaration, PromptFactsProvider, - ResolvedRuntimeConfig, Result, StorageEvent, StorageEventPayload, ToolDefinition, + PromptGovernanceContext, ResolvedRuntimeConfig, Result, StorageEvent, StorageEventPayload, + ToolDefinition, }; use astrcode_kernel::{CapabilityRouter, Kernel, KernelGateway}; use chrono::{DateTime, Utc}; @@ -67,6 +68,7 @@ pub(crate) struct TurnRunRequest { pub prompt_facts_provider: Arc, pub capability_router: Option, pub prompt_declarations: Vec, + pub prompt_governance: Option, } /// Turn 执行结果。 @@ -91,6 +93,7 @@ struct TurnExecutionResources<'a> { cancel: &'a CancelToken, agent: &'a AgentEventContext, prompt_declarations: &'a [PromptDeclaration], + prompt_governance: Option<&'a PromptGovernanceContext>, tools: Arc<[ToolDefinition]>, settings: ContextWindowSettings, clearable_tools: HashSet, @@ -107,6 +110,7 @@ struct TurnExecutionRequestView<'a> { cancel: &'a CancelToken, agent: &'a AgentEventContext, prompt_declarations: &'a [PromptDeclaration], + prompt_governance: Option<&'a PromptGovernanceContext>, } struct TurnExecutionContext { @@ -154,6 +158,7 @@ impl<'a> TurnExecutionResources<'a> { cancel: request.cancel, agent: request.agent, prompt_declarations: request.prompt_declarations, + prompt_governance: request.prompt_governance, tools: Arc::from(gateway.capabilities().tool_definitions()), settings, clearable_tools: CLEARABLE_TOOLS @@ -285,6 +290,7 @@ pub async fn run_turn(kernel: Arc, request: TurnRunRequest) -> Result, request: TurnRunRequest) -> Result( cancel, agent, prompt_declarations: &[], + prompt_governance: None, }, ) } diff --git a/crates/session-runtime/src/turn/submit.rs b/crates/session-runtime/src/turn/submit.rs index e6a6d35f..8b96cf56 100644 --- a/crates/session-runtime/src/turn/submit.rs +++ b/crates/session-runtime/src/turn/submit.rs @@ -1,11 +1,12 @@ use std::{sync::Arc, time::Instant}; use astrcode_core::{ - AgentEventContext, CancelToken, CompletedParentDeliveryPayload, EventTranslator, - ExecutionAccepted, LlmMessage, ParentDelivery, ParentDeliveryOrigin, ParentDeliveryPayload, - ParentDeliveryTerminalSemantics, Phase, PromptDeclaration, ResolvedExecutionLimitsSnapshot, - ResolvedRuntimeConfig, ResolvedSubagentContextOverrides, Result, RuntimeMetricsRecorder, - SessionId, StorageEvent, StorageEventPayload, TurnId, UserMessageOrigin, + AgentEventContext, ApprovalPending, CancelToken, CapabilityCall, + CompletedParentDeliveryPayload, EventTranslator, ExecutionAccepted, LlmMessage, ParentDelivery, + ParentDeliveryOrigin, ParentDeliveryPayload, ParentDeliveryTerminalSemantics, Phase, + PolicyContext, PromptDeclaration, ResolvedExecutionLimitsSnapshot, ResolvedRuntimeConfig, + ResolvedSubagentContextOverrides, Result, RuntimeMetricsRecorder, SessionId, StorageEvent, + StorageEventPayload, TurnId, UserMessageOrigin, }; use astrcode_kernel::CapabilityRouter; use chrono::Utc; @@ -26,6 +27,16 @@ enum SubmitBusyPolicy { RejectOnBusy, } +struct SubmitPromptRequest { + session_id: String, + turn_id: Option, + live_user_input: Option, + queued_inputs: Vec, + runtime: ResolvedRuntimeConfig, + busy_policy: SubmitBusyPolicy, + submission: AgentPromptSubmission, +} + struct TurnExecutionTask { kernel: Arc, request: crate::turn::RunnerRequest, @@ -41,6 +52,10 @@ pub struct AgentPromptSubmission { pub resolved_overrides: Option, pub injected_messages: Vec, pub source_tool_call_id: Option, + pub policy_context: Option, + pub governance_revision: Option, + pub approval: Option>>, + pub prompt_governance: Option, } #[derive(Debug, Clone)] @@ -285,15 +300,15 @@ impl SessionRuntime { runtime: ResolvedRuntimeConfig, submission: AgentPromptSubmission, ) -> Result { - self.submit_prompt_inner( - session_id, - None, - Some(text), - Vec::new(), + self.submit_prompt_inner(SubmitPromptRequest { + session_id: session_id.to_string(), + turn_id: None, + live_user_input: Some(text), + queued_inputs: Vec::new(), runtime, - SubmitBusyPolicy::BranchOnBusy, + busy_policy: SubmitBusyPolicy::BranchOnBusy, submission, - ) + }) .await .and_then(|accepted| { accepted.ok_or_else(|| { @@ -312,15 +327,15 @@ impl SessionRuntime { runtime: ResolvedRuntimeConfig, submission: AgentPromptSubmission, ) -> Result> { - self.submit_prompt_inner( - session_id, - None, - Some(text), - Vec::new(), + self.submit_prompt_inner(SubmitPromptRequest { + session_id: session_id.to_string(), + turn_id: None, + live_user_input: Some(text), + queued_inputs: Vec::new(), runtime, - SubmitBusyPolicy::RejectOnBusy, + busy_policy: SubmitBusyPolicy::RejectOnBusy, submission, - ) + }) .await } @@ -332,15 +347,15 @@ impl SessionRuntime { runtime: ResolvedRuntimeConfig, submission: AgentPromptSubmission, ) -> Result> { - self.submit_prompt_inner( - session_id, - Some(turn_id), - Some(text), - Vec::new(), + self.submit_prompt_inner(SubmitPromptRequest { + session_id: session_id.to_string(), + turn_id: Some(turn_id), + live_user_input: Some(text), + queued_inputs: Vec::new(), runtime, - SubmitBusyPolicy::RejectOnBusy, + busy_policy: SubmitBusyPolicy::RejectOnBusy, submission, - ) + }) .await } @@ -351,15 +366,15 @@ impl SessionRuntime { runtime: ResolvedRuntimeConfig, submission: AgentPromptSubmission, ) -> Result { - self.submit_prompt_inner( - session_id, - None, - Some(text), - Vec::new(), + self.submit_prompt_inner(SubmitPromptRequest { + session_id: session_id.to_string(), + turn_id: None, + live_user_input: Some(text), + queued_inputs: Vec::new(), runtime, - SubmitBusyPolicy::BranchOnBusy, + busy_policy: SubmitBusyPolicy::BranchOnBusy, submission, - ) + }) .await? .ok_or_else(|| { astrcode_core::AstrError::Validation( @@ -376,28 +391,31 @@ impl SessionRuntime { runtime: ResolvedRuntimeConfig, submission: AgentPromptSubmission, ) -> Result> { - self.submit_prompt_inner( - session_id, - Some(turn_id), - None, + self.submit_prompt_inner(SubmitPromptRequest { + session_id: session_id.to_string(), + turn_id: Some(turn_id), + live_user_input: None, queued_inputs, runtime, - SubmitBusyPolicy::RejectOnBusy, + busy_policy: SubmitBusyPolicy::RejectOnBusy, submission, - ) + }) .await } async fn submit_prompt_inner( &self, - session_id: &str, - turn_id: Option, - live_user_input: Option, - queued_inputs: Vec, - runtime: ResolvedRuntimeConfig, - busy_policy: SubmitBusyPolicy, - submission: AgentPromptSubmission, + request: SubmitPromptRequest, ) -> Result> { + let SubmitPromptRequest { + session_id, + turn_id, + live_user_input, + queued_inputs, + runtime, + busy_policy, + submission, + } = request; let live_user_input = live_user_input .map(|text| text.trim().to_string()) .filter(|text| !text.is_empty()); @@ -412,7 +430,7 @@ impl SessionRuntime { )); } - let requested_session_id = SessionId::from(crate::state::normalize_session_id(session_id)); + let requested_session_id = SessionId::from(crate::state::normalize_session_id(&session_id)); let turn_id = turn_id.unwrap_or_else(|| TurnId::from(astrcode_core::generate_turn_id())); let cancel = CancelToken::new(); let submit_target = match busy_policy { @@ -444,6 +462,10 @@ impl SessionRuntime { resolved_overrides, injected_messages, source_tool_call_id, + policy_context: _, + governance_revision: _, + approval: _, + prompt_governance, } = submission; prepare_session_execution( @@ -522,6 +544,7 @@ impl SessionRuntime { prompt_facts_provider: Arc::clone(&self.prompt_facts_provider), capability_router, prompt_declarations, + prompt_governance, }, finalize: TurnFinalizeContext { kernel: Arc::clone(&self.kernel), @@ -969,15 +992,15 @@ mod tests { event_store.push_busy("turn-busy"); let result = runtime - .submit_prompt_inner( - &session.session_id, - None, - Some("hello".to_string()), - Vec::new(), - ResolvedRuntimeConfig::default(), - SubmitBusyPolicy::RejectOnBusy, - AgentPromptSubmission::default(), - ) + .submit_prompt_inner(SubmitPromptRequest { + session_id: session.session_id.clone(), + turn_id: None, + live_user_input: Some("hello".to_string()), + queued_inputs: Vec::new(), + runtime: ResolvedRuntimeConfig::default(), + busy_policy: SubmitBusyPolicy::RejectOnBusy, + submission: AgentPromptSubmission::default(), + }) .await .expect("submit should not error"); @@ -999,18 +1022,18 @@ mod tests { event_store.push_busy("turn-busy"); let accepted = runtime - .submit_prompt_inner( - &session.session_id, - None, - Some("hello".to_string()), - Vec::new(), - ResolvedRuntimeConfig { + .submit_prompt_inner(SubmitPromptRequest { + session_id: session.session_id.clone(), + turn_id: None, + live_user_input: Some("hello".to_string()), + queued_inputs: Vec::new(), + runtime: ResolvedRuntimeConfig { max_concurrent_branch_depth: 2, ..ResolvedRuntimeConfig::default() }, - SubmitBusyPolicy::BranchOnBusy, - AgentPromptSubmission::default(), - ) + busy_policy: SubmitBusyPolicy::BranchOnBusy, + submission: AgentPromptSubmission::default(), + }) .await .expect("submit should not error") .expect("branch-on-busy should always accept"); @@ -1080,18 +1103,18 @@ mod tests { .expect("test session should be created"); let accepted = runtime - .submit_prompt_inner( - &session.session_id, - None, - Some("live user input".to_string()), - vec![ + .submit_prompt_inner(SubmitPromptRequest { + session_id: session.session_id.clone(), + turn_id: None, + live_user_input: Some("live user input".to_string()), + queued_inputs: vec![ "queued child result".to_string(), "queued reactivation context".to_string(), ], - ResolvedRuntimeConfig::default(), - SubmitBusyPolicy::RejectOnBusy, - AgentPromptSubmission::default(), - ) + runtime: ResolvedRuntimeConfig::default(), + busy_policy: SubmitBusyPolicy::RejectOnBusy, + submission: AgentPromptSubmission::default(), + }) .await .expect("submit should not error") .expect("submit should be accepted"); @@ -1154,14 +1177,14 @@ mod tests { .expect("test session should be created"); let accepted = runtime - .submit_prompt_inner( - &session.session_id, - None, - Some("child task".to_string()), - Vec::new(), - ResolvedRuntimeConfig::default(), - SubmitBusyPolicy::RejectOnBusy, - AgentPromptSubmission { + .submit_prompt_inner(SubmitPromptRequest { + session_id: session.session_id.clone(), + turn_id: None, + live_user_input: Some("child task".to_string()), + queued_inputs: Vec::new(), + runtime: ResolvedRuntimeConfig::default(), + busy_policy: SubmitBusyPolicy::RejectOnBusy, + submission: AgentPromptSubmission { injected_messages: vec![ LlmMessage::User { content: "parent turn".to_string(), @@ -1175,7 +1198,7 @@ mod tests { ], ..AgentPromptSubmission::default() }, - ) + }) .await .expect("submit should not error") .expect("submit should be accepted"); diff --git a/crates/session-runtime/src/turn/summary.rs b/crates/session-runtime/src/turn/summary.rs index acd95e6d..6c311b81 100644 --- a/crates/session-runtime/src/turn/summary.rs +++ b/crates/session-runtime/src/turn/summary.rs @@ -200,8 +200,10 @@ mod tests { summary: None, latency_ms, source_tool_call_id: None, + governance_revision: Some("governance-surface-v1".to_string()), + mode_id: Some(astrcode_core::ModeId::code()), policy: AgentCollaborationPolicyContext { - policy_revision: "agent-collaboration-v1".to_string(), + policy_revision: "governance-surface-v1".to_string(), max_subrun_depth: 3, max_spawn_per_turn: 3, }, diff --git a/openspec/changes/collaboration-mode-system/tasks.md b/openspec/changes/collaboration-mode-system/tasks.md index afeba304..96d8699b 100644 --- a/openspec/changes/collaboration-mode-system/tasks.md +++ b/openspec/changes/collaboration-mode-system/tasks.md @@ -1,88 +1,88 @@ ## 1. Core 治理模型 -- [ ] 1.1 在 `crates/core/src/mode/mod.rs` 定义开放式 `ModeId`、`GovernanceModeSpec`、`CapabilitySelector`(含 Name/Kind/SideEffect/Tag/AllTools 及组合操作)、`ActionPolicies`、`ChildPolicySpec` 与 `ResolvedTurnEnvelope` -- [ ] 1.2 在 `crates/core/src/lib.rs` 导出 mode 模块,并为序列化、校验与默认 builtin mode ID 补充单元测试 -- [ ] 1.3 在 `crates/core/src/event/types.rs` 增加 `ModeChanged { from: ModeId, to: ModeId }` 事件载荷,并补充旧会话默认回退到 `execute` 的测试 +- [x] 1.1 在 `crates/core/src/mode/mod.rs` 定义开放式 `ModeId`、`GovernanceModeSpec`、`CapabilitySelector`(含 Name/Kind/SideEffect/Tag/AllTools 及组合操作)、`ActionPolicies`、`ChildPolicySpec` 与 `ResolvedTurnEnvelope` +- [x] 1.2 在 `crates/core/src/lib.rs` 导出 mode 模块,并为序列化、校验与默认 builtin mode ID 补充单元测试 +- [x] 1.3 在 `crates/core/src/event/types.rs` 增加 `ModeChanged { from: ModeId, to: ModeId }` 事件载荷,并补充旧会话默认回退到 `execute` 的测试 ## 2. Capability Selector 编译 -- [ ] 2.1 在 `crates/application/src/mode/compiler.rs` 实现 `CapabilitySelector -> CapabilityRouter` 编译逻辑,从当前 `CapabilitySpec` / capability router 投影能力面 -- [ ] 2.2 实现组合选择器(交集、并集、差集)的编译逻辑 -- [ ] 2.3 确保 child capability router 从 parent mode child policy + SpawnCapabilityGrant 交集推导 -- [ ] 2.4 为 execute/plan/review 三个 builtin mode 的 capability 选择编写等价测试 +- [x] 2.1 在 `crates/application/src/mode/compiler.rs` 实现 `CapabilitySelector -> CapabilityRouter` 编译逻辑,从当前 `CapabilitySpec` / capability router 投影能力面 +- [x] 2.2 实现组合选择器(交集、并集、差集)的编译逻辑 +- [x] 2.3 确保 child capability router 从 parent mode child policy + SpawnCapabilityGrant 交集推导 +- [x] 2.4 为 execute/plan/review 三个 builtin mode 的 capability 选择编写等价测试 ## 3. Application Catalog 与 Compiler -- [ ] 3.1 在 `crates/application/src/mode/catalog.rs` 实现 builtin mode catalog,定义 `execute`(code)、`plan`、`review` 三个首批 mode spec(含 CapabilitySelector、ActionPolicies、ChildPolicySpec、prompt program) -- [ ] 3.2 在 `crates/application/src/mode/compiler.rs` 实现 `GovernanceModeSpec -> ResolvedTurnEnvelope` 完整编译逻辑(capability router + prompt declarations + execution limits + action policies + child policy) -- [ ] 3.3 在 `crates/application/src/mode/validator.rs` 实现统一 transition / entry policy 校验入口,并补充非法切换、next-turn 生效等测试 +- [x] 3.1 在 `crates/application/src/mode/catalog.rs` 实现 builtin mode catalog,定义 `execute`(code)、`plan`、`review` 三个首批 mode spec(含 CapabilitySelector、ActionPolicies、ChildPolicySpec、prompt program) +- [x] 3.2 在 `crates/application/src/mode/compiler.rs` 实现 `GovernanceModeSpec -> ResolvedTurnEnvelope` 完整编译逻辑(capability router + prompt declarations + execution limits + action policies + child policy) +- [x] 3.3 在 `crates/application/src/mode/validator.rs` 实现统一 transition / entry policy 校验入口,并补充非法切换、next-turn 生效等测试 ## 4. Mode 执行限制 -- [ ] 4.1 在 envelope 编译中实现 mode-specific max_steps 解析,与用户 ExecutionControl 取交集(更严格者生效) -- [ ] 4.2 在 envelope 编译中实现 mode-specific ForkMode 约束(如 restricted mode 强制 LastNTurns) -- [ ] 4.3 评估 SubmitBusyPolicy 是否需要由 mode 指定,如果是则在 envelope 编译中实现 -- [ ] 4.4 在 envelope 编译中实现 AgentConfig 治理参数覆盖(max_subrun_depth、max_spawn_per_turn) +- [x] 4.1 在 envelope 编译中实现 mode-specific max_steps 解析,与用户 ExecutionControl 取交集(更严格者生效) +- [x] 4.2 在 envelope 编译中实现 mode-specific ForkMode 约束(如 restricted mode 强制 LastNTurns) +- [x] 4.3 评估 SubmitBusyPolicy 是否需要由 mode 指定,如果是则在 envelope 编译中实现 +- [x] 4.4 在 envelope 编译中实现 AgentConfig 治理参数覆盖(max_subrun_depth、max_spawn_per_turn) ## 5. Mode Policy Engine 集成 -- [ ] 5.1 将 `PolicyContext` 构建改为从治理包络派生,消除与治理包络字段的重复组装 -- [ ] 5.2 实现 mode action policies 到 `PolicyEngine` 行为的映射(Allow/Deny 裁决) -- [ ] 5.3 确保 `decide_context_strategy` 能参考 mode 的上下文治理偏好 -- [ ] 5.4 确保 builtin mode 使用 AllowAllPolicyEngine 等价行为,补充默认放行测试 +- [x] 5.1 将 `PolicyContext` 构建改为从治理包络派生,消除与治理包络字段的重复组装 +- [x] 5.2 实现 mode action policies 到 `PolicyEngine` 行为的映射(Allow/Deny 裁决) +- [x] 5.3 确保 `decide_context_strategy` 能参考 mode 的上下文治理偏好 +- [x] 5.4 确保 builtin mode 使用 AllowAllPolicyEngine 等价行为,补充默认放行测试 ## 6. Mode Prompt Program -- [ ] 6.1 为 execute/plan/review 三个 builtin mode 定义 prompt program(生成 PromptDeclarations) -- [ ] 6.2 确保 mode declarations 通过标准注入路径(`TurnRunRequest.prompt_declarations` -> `PromptDeclarationContributor`)进入 prompt 组装 -- [ ] 6.3 重构 `WorkflowExamplesContributor`,让治理专属内容改为由 mode prompt program 提供 -- [ ] 6.4 确保 `PromptFactsProvider` 的 metadata 和 declaration 过滤与 mode envelope 保持一致 -- [ ] 6.5 验证 `CapabilityPromptContributor` 和 `AgentProfileSummaryContributor` 通过 PromptContext 自动响应 mode 能力面变化 +- [x] 6.1 为 execute/plan/review 三个 builtin mode 定义 prompt program(生成 PromptDeclarations) +- [x] 6.2 确保 mode declarations 通过标准注入路径(`TurnRunRequest.prompt_declarations` -> `PromptDeclarationContributor`)进入 prompt 组装 +- [x] 6.3 重构 `WorkflowExamplesContributor`,让治理专属内容改为由 mode prompt program 提供 +- [x] 6.4 确保 `PromptFactsProvider` 的 metadata 和 declaration 过滤与 mode envelope 保持一致 +- [x] 6.5 验证 `CapabilityPromptContributor` 和 `AgentProfileSummaryContributor` 通过 PromptContext 自动响应 mode 能力面变化 ## 7. Session Runtime 集成 -- [ ] 7.1 在 `crates/core/src/projection/agent_state.rs` 的 `AgentState` 增加 `mode_id` 字段,在 `AgentStateProjector::apply()` 增加 `ModeChanged` 事件处理 -- [ ] 7.2 在 `crates/session-runtime/src/state/mod.rs` 的 `SessionState` 增加 `current_mode` per-field mutex -- [ ] 7.3 在 `crates/session-runtime/src/turn/submit.rs` 的 submit 边界解析当前 mode,并把 `ResolvedTurnEnvelope` 收口到 `AgentPromptSubmission` / `RunnerRequest` -- [ ] 7.4 修改 `crates/session-runtime/src/turn/runner.rs`,确保 turn 工具面从 envelope 的 capability router 读取 -- [ ] 7.5 确保旧 session replay 时 ModeChanged 事件缺失回退到 `execute` +- [x] 7.1 在 `crates/core/src/projection/agent_state.rs` 的 `AgentState` 增加 `mode_id` 字段,在 `AgentStateProjector::apply()` 增加 `ModeChanged` 事件处理 +- [x] 7.2 在 `crates/session-runtime/src/state/mod.rs` 的 `SessionState` 增加 `current_mode` per-field mutex +- [x] 7.3 在 `crates/session-runtime/src/turn/submit.rs` 的 submit 边界解析当前 mode,并把 `ResolvedTurnEnvelope` 收口到 `AgentPromptSubmission` / `RunnerRequest` +- [x] 7.4 修改 `crates/session-runtime/src/turn/runner.rs`,确保 turn 工具面从 envelope 的 capability router 读取 +- [x] 7.5 确保旧 session replay 时 ModeChanged 事件缺失回退到 `execute` ## 8. Delegation 与 Child Policy -- [ ] 8.1 实现 mode child policy 到 `DelegationMetadata` 的推导逻辑 -- [ ] 8.2 实现 mode child policy 到 `SpawnCapabilityGrant` 的推导逻辑 -- [ ] 8.3 修改 `crates/application/src/execution/subagent.rs` 和 `agent/mod.rs`,让 child 初始 mode 和 execution contract 来自 resolved child policy -- [ ] 8.4 确保 `AgentProfileSummaryContributor` 在 mode 禁止 delegation 时不渲染(通过现有 spawn 守卫条件自动生效) +- [x] 8.1 实现 mode child policy 到 `DelegationMetadata` 的推导逻辑 +- [x] 8.2 实现 mode child policy 到 `SpawnCapabilityGrant` 的推导逻辑 +- [x] 8.3 修改 `crates/application/src/execution/subagent.rs` 和 `agent/mod.rs`,让 child 初始 mode 和 execution contract 来自 resolved child policy +- [x] 8.4 确保 `AgentProfileSummaryContributor` 在 mode 禁止 delegation 时不渲染(通过现有 spawn 守卫条件自动生效) ## 9. /mode 命令 -- [ ] 9.1 在 `crates/cli/src/command/mod.rs` 的 `Command` enum 增加 `Mode { query: Option }` 变体 -- [ ] 9.2 在 `parse_command` 中增加 `"/mode"` arm -- [ ] 9.3 实现 tab 补全,从 mode catalog 获取候选(集成到 slash_candidates 机制) -- [ ] 9.4 在 `coordinator.rs` 中实现 `/mode` 命令的执行调度 -- [ ] 9.5 实现 mode 状态显示(当前 mode、可用 mode 列表、transition 拒绝反馈) +- [x] 9.1 在 `crates/cli/src/command/mod.rs` 的 `Command` enum 增加 `Mode { query: Option }` 变体 +- [x] 9.2 在 `parse_command` 中增加 `"/mode"` arm +- [x] 9.3 实现 tab 补全,从 mode catalog 获取候选(集成到 slash_candidates 机制) +- [x] 9.4 在 `coordinator.rs` 中实现 `/mode` 命令的执行调度 +- [x] 9.5 实现 mode 状态显示(当前 mode、可用 mode 列表、transition 拒绝反馈) ## 10. Bootstrap、Reload 与验证 -- [ ] 10.1 在 `crates/server/src/bootstrap/governance.rs` 的 `GovernanceBuildInput` 中增加 mode catalog 参数 -- [ ] 10.2 在 `ServerRuntimeReloader` 的 reload 编排中增加 mode catalog 替换步骤(与能力面替换原子性) -- [ ] 10.3 在 `crates/server/src/bootstrap/runtime.rs` 中装配 builtin mode catalog -- [ ] 10.4 确保插件 mode 在 bootstrap 握手阶段可注册到同一 catalog +- [x] 10.1 在 `crates/server/src/bootstrap/governance.rs` 的 `GovernanceBuildInput` 中增加 mode catalog 参数 +- [x] 10.2 在 `ServerRuntimeReloader` 的 reload 编排中增加 mode catalog 替换步骤(与能力面替换原子性) +- [x] 10.3 在 `crates/server/src/bootstrap/runtime.rs` 中装配 builtin mode catalog +- [x] 10.4 确保插件 mode 在 bootstrap 握手阶段可注册到同一 catalog ## 11. 协作审计与可观测性 -- [ ] 11.1 在 `AgentCollaborationFact` 增加可选的 `mode_id` 字段 -- [ ] 11.2 确保审计事实记录当前 turn 开始时的 mode(不受 turn 内 mode 变更影响) -- [ ] 11.3 在 `ObservabilitySnapshotProvider` 快照中增加当前 mode 和变更时间戳 -- [ ] 11.4 实现 envelope 编译的诊断信息记录(如空能力面警告) +- [x] 11.1 在 `AgentCollaborationFact` 增加可选的 `mode_id` 字段 +- [x] 11.2 确保审计事实记录当前 turn 开始时的 mode(不受 turn 内 mode 变更影响) +- [x] 11.3 在 `ObservabilitySnapshotProvider` 快照中增加当前 mode 和变更时间戳 +- [x] 11.4 实现 envelope 编译的诊断信息记录(如空能力面警告) ## 12. 集成测试与验证 -- [ ] 12.1 为 mode-aware collaboration guidance、delegation catalog 和 restricted child contract 增加回归测试 -- [ ] 12.2 为 mode 切换的 next-turn 生效语义增加测试 -- [ ] 12.3 为 CapabilitySelector 编译的等价性增加测试 -- [ ] 12.4 为 PolicyEngine 与 mode action policies 的集成增加测试 -- [ ] 12.5 运行 `cargo fmt --all` -- [ ] 12.6 运行 `cargo test --workspace --exclude astrcode` -- [ ] 12.7 运行 `node scripts/check-crate-boundaries.mjs` -- [ ] 12.8 手动验证:切换 builtin mode、确认下一 turn 生效、确认 child delegation surface 与 prompt guidance 随 mode 收敛 +- [x] 12.1 为 mode-aware collaboration guidance、delegation catalog 和 restricted child contract 增加回归测试 +- [x] 12.2 为 mode 切换的 next-turn 生效语义增加测试 +- [x] 12.3 为 CapabilitySelector 编译的等价性增加测试 +- [x] 12.4 为 PolicyEngine 与 mode action policies 的集成增加测试 +- [x] 12.5 运行 `cargo fmt --all` +- [x] 12.6 运行 `cargo test --workspace --exclude astrcode` +- [x] 12.7 运行 `node scripts/check-crate-boundaries.mjs` +- [x] 12.8 手动验证:切换 builtin mode、确认下一 turn 生效、确认 child delegation surface 与 prompt guidance 随 mode 收敛 diff --git a/openspec/changes/governance-surface-cleanup/tasks.md b/openspec/changes/governance-surface-cleanup/tasks.md index 93a4294d..07432c56 100644 --- a/openspec/changes/governance-surface-cleanup/tasks.md +++ b/openspec/changes/governance-surface-cleanup/tasks.md @@ -1,77 +1,77 @@ ## 1. 统一治理包络模型 -- [ ] 1.1 在 `crates/core` 或 `crates/application` 中引入统一治理包络类型(如 `ResolvedGovernanceSurface`),覆盖 scoped router、prompt declarations、resolved limits、overrides、injected messages、policy context 与 collaboration audit context -- [ ] 1.2 梳理 `crates/session-runtime/src/turn/submit.rs` 中 `AgentPromptSubmission` 的职责,决定是替换还是瘦身为治理包络的 transport 形状 -- [ ] 1.3 为统一治理包络补充字段校验与基础单元测试,确保不同入口可复用同一输出形状 +- [x] 1.1 在 `crates/core` 或 `crates/application` 中引入统一治理包络类型(如 `ResolvedGovernanceSurface`),覆盖 scoped router、prompt declarations、resolved limits、overrides、injected messages、policy context 与 collaboration audit context +- [x] 1.2 梳理 `crates/session-runtime/src/turn/submit.rs` 中 `AgentPromptSubmission` 的职责,决定是替换还是瘦身为治理包络的 transport 形状 +- [x] 1.3 为统一治理包络补充字段校验与基础单元测试,确保不同入口可复用同一输出形状 ## 2. 收口入口装配路径 -- [ ] 2.1 在 `crates/application` 新增治理装配服务,让 `execution/root.rs`、`execution/subagent.rs` 与 `session_use_cases.rs` 统一调用 -- [ ] 2.2 清理 root / session / subagent 三条路径里手工拼接 scoped router、prompt declarations、limits 的重复逻辑 -- [ ] 2.3 为 root、普通 session submit、fresh child、resumed child 四类入口补充一致性测试 +- [x] 2.1 在 `crates/application` 新增治理装配服务,让 `execution/root.rs`、`execution/subagent.rs` 与 `session_use_cases.rs` 统一调用 +- [x] 2.2 清理 root / session / subagent 三条路径里手工拼接 scoped router、prompt declarations、limits 的重复逻辑 +- [x] 2.3 为 root、普通 session submit、fresh child、resumed child 四类入口补充一致性测试 ## 3. Capability Router 统一装配 -- [ ] 3.1 将 `execution/root.rs:71-85` 的 root capability router 构建迁入治理装配器 -- [ ] 3.2 将 `execution/subagent.rs:141-172` 的 child capability router 构建(parent_allowed_tools ∩ SpawnCapabilityGrant 交集)迁入治理装配器 -- [ ] 3.3 将 `agent/routing.rs:571-722` 的 resumed child scoped router 构建迁入治理装配器 -- [ ] 3.4 确保三条路径统一后各自保留必要的入口类型差异化参数,补充回归测试 +- [x] 3.1 将 `execution/root.rs:71-85` 的 root capability router 构建迁入治理装配器 +- [x] 3.2 将 `execution/subagent.rs:141-172` 的 child capability router 构建(parent_allowed_tools ∩ SpawnCapabilityGrant 交集)迁入治理装配器 +- [x] 3.3 将 `agent/routing.rs:571-722` 的 resumed child scoped router 构建迁入治理装配器 +- [x] 3.4 确保三条路径统一后各自保留必要的入口类型差异化参数,补充回归测试 ## 4. 执行限制与控制收口 -- [ ] 4.1 将 `ResolvedExecutionLimitsSnapshot` 的构建逻辑从各入口迁入治理装配器 -- [ ] 4.2 将 `ExecutionControl`(max_steps、manual_compact)作为治理装配器的输入参数,不再在 session_use_cases.rs 中直接覆写 runtime config -- [ ] 4.3 将 `ForkMode` 和上下文继承策略(`select_inherited_recent_tail`)作为治理包络的一部分 -- [ ] 4.4 评估 `SubmitBusyPolicy` 是否需要成为治理包络的可配置字段,还是保持固定策略 -- [ ] 4.5 将 `AgentConfig` 中 max_subrun_depth、max_spawn_per_turn 等治理参数改为治理装配器的输入源,不再被消费方直接读取 +- [x] 4.1 将 `ResolvedExecutionLimitsSnapshot` 的构建逻辑从各入口迁入治理装配器 +- [x] 4.2 将 `ExecutionControl`(max_steps、manual_compact)作为治理装配器的输入参数,不再在 session_use_cases.rs 中直接覆写 runtime config +- [x] 4.3 将 `ForkMode` 和上下文继承策略(`select_inherited_recent_tail`)作为治理包络的一部分 +- [x] 4.4 评估 `SubmitBusyPolicy` 是否需要成为治理包络的可配置字段,还是保持固定策略 +- [x] 4.5 将 `AgentConfig` 中 max_subrun_depth、max_spawn_per_turn 等治理参数改为治理装配器的输入源,不再被消费方直接读取 ## 5. 策略引擎接入管线 -- [ ] 5.1 将 `PolicyContext` 的构建改为从治理包络派生,消除与治理包络字段的重复组装 -- [ ] 5.2 确保 `PolicyEngine` 的三个检查点能读取治理包络中的 capability surface 和 execution limits -- [ ] 5.3 建立 `ApprovalRequest` / `ApprovalResolution` / `ApprovalPending` 的管线骨架,但默认不触发 -- [ ] 5.4 保持 `AllowAllPolicyEngine` 作为默认实现,补充管线存在但默认放行的测试 +- [x] 5.1 将 `PolicyContext` 的构建改为从治理包络派生,消除与治理包络字段的重复组装 +- [x] 5.2 确保 `PolicyEngine` 的三个检查点能读取治理包络中的 capability surface 和 execution limits +- [x] 5.3 建立 `ApprovalRequest` / `ApprovalResolution` / `ApprovalPending` 的管线骨架,但默认不触发 +- [x] 5.4 保持 `AllowAllPolicyEngine` 作为默认实现,补充管线存在但默认放行的测试 ## 6. 委派策略元数据收口 -- [ ] 6.1 将 `build_delegation_metadata`(agent/mod.rs:287-312)迁入治理装配器 -- [ ] 6.2 将 `SpawnCapabilityGrant` 的解析从 spawn 参数迁入治理包络 -- [ ] 6.3 将 `AgentCollaborationPolicyContext` 的构建改为从治理包络获取参数 -- [ ] 6.4 将 `enforce_spawn_budget_for_turn` 改为使用治理包络中的限制参数 -- [ ] 6.5 确保 `persist_delegation_for_handle` 持久化的数据与治理包络一致 +- [x] 6.1 将 `build_delegation_metadata`(agent/mod.rs:287-312)迁入治理装配器 +- [x] 6.2 将 `SpawnCapabilityGrant` 的解析从 spawn 参数迁入治理包络 +- [x] 6.3 将 `AgentCollaborationPolicyContext` 的构建改为从治理包络获取参数 +- [x] 6.4 将 `enforce_spawn_budget_for_turn` 改为使用治理包络中的限制参数 +- [x] 6.5 确保 `persist_delegation_for_handle` 持久化的数据与治理包络一致 ## 7. Prompt 与 Delegation 真相收口 -- [ ] 7.1 将 `crates/application/src/agent/mod.rs` 中 fresh/resumed child contract 生成逻辑迁入统一治理装配链路 -- [ ] 7.2 收口 `crates/adapter-prompt/src/contributors/workflow_examples.rs` 中 authoritative 协作 guidance,使 adapter 仅渲染上游声明 -- [ ] 7.3 确保 delegation catalog、child contract 与协作 guidance 使用同一治理事实源,并补充回归测试 +- [x] 7.1 将 `crates/application/src/agent/mod.rs` 中 fresh/resumed child contract 生成逻辑迁入统一治理装配链路 +- [x] 7.2 收口 `crates/adapter-prompt/src/contributors/workflow_examples.rs` 中 authoritative 协作 guidance,使 adapter 仅渲染上游声明 +- [x] 7.3 确保 delegation catalog、child contract 与协作 guidance 使用同一治理事实源,并补充回归测试 ## 8. Prompt 事实治理联动显式化 -- [ ] 8.1 将 `prompt_declaration_is_visible`(prompt_facts.rs:200-213)的过滤逻辑上移到治理装配层 -- [ ] 8.2 将 `PromptFacts.metadata` 中 `agentMaxSubrunDepth` / `agentMaxSpawnPerTurn` 改为从治理包络获取,消除 vars dict 传递 -- [ ] 8.3 确保 `build_profile_context` 中的 approvalMode 与治理包络中的策略配置一致 -- [ ] 8.4 重构 `PromptFactsProvider` 为治理包络的消费者,不再独立实现治理过滤 +- [x] 8.1 将 `prompt_declaration_is_visible`(prompt_facts.rs:200-213)的过滤逻辑上移到治理装配层 +- [x] 8.2 将 `PromptFacts.metadata` 中 `agentMaxSubrunDepth` / `agentMaxSpawnPerTurn` 改为从治理包络获取,消除 vars dict 传递 +- [x] 8.3 确保 `build_profile_context` 中的 approvalMode 与治理包络中的策略配置一致 +- [x] 8.4 重构 `PromptFactsProvider` 为治理包络的消费者,不再独立实现治理过滤 ## 9. Bootstrap/Runtime 治理生命周期 -- [ ] 9.1 在 `GovernanceBuildInput`(server/bootstrap/governance.rs)中预留 mode catalog 参数(Option 类型) -- [ ] 9.2 在 `AppGovernance.reload()` 编排中预留 mode catalog 替换步骤(本轮为空操作) -- [ ] 9.3 确保 `RuntimeCoordinator.replace_runtime_surface` 后续 turn 使用更新后的治理包络 -- [ ] 9.4 确保 `CapabilitySurfaceSync` 能力面变更后通知治理装配器刷新缓存 +- [x] 9.1 在 `GovernanceBuildInput`(server/bootstrap/governance.rs)中预留 mode catalog 参数(Option 类型) +- [x] 9.2 在 `AppGovernance.reload()` 编排中预留 mode catalog 替换步骤(本轮为空操作) +- [x] 9.3 确保 `RuntimeCoordinator.replace_runtime_surface` 后续 turn 使用更新后的治理包络 +- [x] 9.4 确保 `CapabilitySurfaceSync` 能力面变更后通知治理装配器刷新缓存 ## 10. 协作审计事实关联 -- [ ] 10.1 为 `AgentCollaborationFact` 增加可选的治理包络标识字段(governance_revision 或 envelope_hash) -- [ ] 10.2 将 `CollaborationFactRecord` 的构建参数改为从治理包络获取 -- [ ] 10.3 确保 `AGENT_COLLABORATION_POLICY_REVISION` 与治理包络中的策略版本一致 +- [x] 10.1 为 `AgentCollaborationFact` 增加可选的治理包络标识字段(governance_revision 或 envelope_hash) +- [x] 10.2 将 `CollaborationFactRecord` 的构建参数改为从治理包络获取 +- [x] 10.3 确保 `AGENT_COLLABORATION_POLICY_REVISION` 与治理包络中的策略版本一致 ## 11. 清理与验证 -- [ ] 11.1 清理旧 helper、重复命名与临时桥接代码,保持模块职责与文件结构清晰一致 -- [ ] 11.2 运行 `cargo fmt --all` -- [ ] 11.3 运行 `cargo test --workspace --exclude astrcode` -- [ ] 11.4 运行 `node scripts/check-crate-boundaries.mjs` -- [ ] 11.5 手动验证 root/session/subagent 提交路径的默认行为等价,且治理声明来源已统一 -- [ ] 11.6 验证 PolicyEngine 管线存在但默认行为与当前等价 -- [ ] 11.7 验证 PromptFactsProvider 退化后 prompt 输出与当前等价 +- [x] 11.1 清理旧 helper、重复命名与临时桥接代码,保持模块职责与文件结构清晰一致 +- [x] 11.2 运行 `cargo fmt --all` +- [x] 11.3 运行 `cargo test --workspace --exclude astrcode` +- [x] 11.4 运行 `node scripts/check-crate-boundaries.mjs` +- [x] 11.5 手动验证 root/session/subagent 提交路径的默认行为等价,且治理声明来源已统一 +- [x] 11.6 验证 PolicyEngine 管线存在但默认行为与当前等价 +- [x] 11.7 验证 PromptFactsProvider 退化后 prompt 输出与当前等价 From 6d5bddfadfe0fba1c9597b4f46ba64e7de5a400e Mon Sep 17 00:00:00 2001 From: whatevertogo Date: Sun, 19 Apr 2026 12:31:50 +0800 Subject: [PATCH 37/53] =?UTF-8?q?=E2=9C=A8=20feat(notes):=20=E6=B7=BB?= =?UTF-8?q?=E5=8A=A0=E5=B7=A5=E4=BD=9C=E6=B5=81=E7=A8=8B=E8=8D=89=E7=A8=BF?= =?UTF-8?q?=E5=88=B0=E8=AE=BE=E8=AE=A1=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/ideas/notes.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/ideas/notes.md b/docs/ideas/notes.md index 11eb1297..fd6f04f8 100644 --- a/docs/ideas/notes.md +++ b/docs/ideas/notes.md @@ -12,4 +12,5 @@ 6. fork agent 7. pending messages(完成部分) 8. 更好的compact功能 -9. 多agent共享任务列表 \ No newline at end of file +9. 多agent共享任务列表 +10. draft → test → review → improve → repeat \ No newline at end of file From 8d2835a84526f4e9bd510aad49cca21ca44d68ab Mon Sep 17 00:00:00 2001 From: whatevertogo Date: Sun, 19 Apr 2026 13:03:39 +0800 Subject: [PATCH 38/53] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor(application?= =?UTF-8?q?,core):=20=E7=A7=BB=E9=99=A4=E6=9C=AA=E8=90=BD=E5=9C=B0?= =?UTF-8?q?=E7=9A=84=20ContextStrategy=20=E7=AD=96=E7=95=A5=E6=9E=9A?= =?UTF-8?q?=E4=B8=BE=EF=BC=8C=E8=A1=A5=E5=85=85=E5=AD=90=E5=9F=9F=E6=A8=A1?= =?UTF-8?q?=E5=9D=97=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit crates/core/src/policy/engine.rs - 移除从未消费的 ContextStrategy/ContextDecisionInput 类型和 decide_context_strategy trait 方法 crates/core/src/mode/mod.rs - 从 ActionPolicies 中移除 context_strategy 字段 crates/application/src/mode/catalog.rs - 移除 Plan/Review mode 对已删 context_strategy 的引用 crates/application/src/**/*.rs - 为 governance_surface、mode、agent、terminal_queries、ports、terminal、execution 等子域补充模块级文档 - 补充关键 inline 注释(limits 覆盖语义、工具白名单优先级、router 复用条件) crates/session-runtime/src/context_window/* - 在 auto_compact 和 settings 中记录设计决策:未来扩展应在显式参数上做,不恢复粗粒度策略枚举 --- crates/application/src/agent/context.rs | 6 ++ crates/application/src/agent/terminal.rs | 5 ++ crates/application/src/agent/test_support.rs | 5 ++ crates/application/src/agent_use_cases.rs | 5 +- crates/application/src/config/test_support.rs | 4 ++ crates/application/src/errors.rs | 5 ++ crates/application/src/execution/control.rs | 5 ++ .../src/governance_surface/assembler.rs | 14 ++++ .../src/governance_surface/inherited.rs | 9 +++ .../application/src/governance_surface/mod.rs | 13 ++++ .../src/governance_surface/policy.rs | 9 +++ .../src/governance_surface/prompt.rs | 8 +++ crates/application/src/lib.rs | 9 +++ crates/application/src/mode/catalog.rs | 14 +++- crates/application/src/mode/compiler.rs | 9 +++ crates/application/src/mode/mod.rs | 10 +++ crates/application/src/mode/validator.rs | 5 ++ crates/application/src/ports/mod.rs | 9 +++ crates/application/src/session_use_cases.rs | 6 +- crates/application/src/terminal/mod.rs | 5 ++ .../src/terminal_queries/cursor.rs | 5 ++ .../application/src/terminal_queries/mod.rs | 8 +++ .../src/terminal_queries/resume.rs | 5 ++ .../src/terminal_queries/snapshot.rs | 5 ++ .../src/terminal_queries/summary.rs | 6 ++ crates/application/src/test_support.rs | 5 ++ crates/core/src/lib.rs | 4 +- crates/core/src/mode/mod.rs | 5 +- crates/core/src/policy/engine.rs | 72 +------------------ crates/core/src/policy/mod.rs | 4 +- .../src/context_window/compaction.rs | 3 + .../src/context_window/settings.rs | 2 + 32 files changed, 197 insertions(+), 82 deletions(-) diff --git a/crates/application/src/agent/context.rs b/crates/application/src/agent/context.rs index f81f84a5..56102af7 100644 --- a/crates/application/src/agent/context.rs +++ b/crates/application/src/agent/context.rs @@ -1,3 +1,9 @@ +//! Agent 协作事实记录与上下文构建。 +//! +//! 从 agent/mod.rs 提取出的两个关注点: +//! - `CollaborationFactRecord`:记录一次协作动作(spawn/send/observe/close)的结构化事实 +//! - `AgentOrchestrationService` 的上下文构建方法(root/child 的 event context) + use std::path::Path; use astrcode_core::{ diff --git a/crates/application/src/agent/terminal.rs b/crates/application/src/agent/terminal.rs index d439f602..48d3635a 100644 --- a/crates/application/src/agent/terminal.rs +++ b/crates/application/src/agent/terminal.rs @@ -1,3 +1,8 @@ +//! Child turn 终态投递与父侧通知投影。 +//! +//! 当子代理 turn 结束时,将终态结果(completed/failed/close_request)投影为 +//! `ChildSessionNotification`,通过 wake 机制投递到父侧 input queue 驱动父级决策。 + use std::time::Instant; use astrcode_core::{ diff --git a/crates/application/src/agent/test_support.rs b/crates/application/src/agent/test_support.rs index da7e4391..759629f3 100644 --- a/crates/application/src/agent/test_support.rs +++ b/crates/application/src/agent/test_support.rs @@ -1,3 +1,8 @@ +//! Agent 编排子域的测试基础设施。 +//! +//! 提供 `AgentTestHarness` 和 `AgentTestEnvGuard`,用于在隔离环境中测试 +//! `AgentOrchestrationService` 的协作编排逻辑,无需启动真实 session-runtime。 + use std::{ collections::HashMap, path::Path, diff --git a/crates/application/src/agent_use_cases.rs b/crates/application/src/agent_use_cases.rs index 449114f6..5247cdd9 100644 --- a/crates/application/src/agent_use_cases.rs +++ b/crates/application/src/agent_use_cases.rs @@ -1,3 +1,7 @@ +//! Agent 控制用例(`App` 的 agent 相关方法)。 +//! +//! 通过 kernel 的稳定控制合同实现 agent 状态查询、子运行生命周期管理等用例。 + use astrcode_core::{ AgentEventContext, AgentLifecycleStatus, AgentTurnOutcome, InvocationKind, ResolvedExecutionLimitsSnapshot, ResolvedSubagentContextOverrides, StorageEventPayload, @@ -5,7 +9,6 @@ use astrcode_core::{ }; use astrcode_kernel::SubRunStatusView; -/// ! 这是 App 的用例实现,不是 ports use crate::{ AgentExecuteSummary, App, ApplicationError, RootExecutionRequest, SubRunStatusSourceSummary, SubRunStatusSummary, summarize_session_meta, diff --git a/crates/application/src/config/test_support.rs b/crates/application/src/config/test_support.rs index 2b75fb4a..d569f28b 100644 --- a/crates/application/src/config/test_support.rs +++ b/crates/application/src/config/test_support.rs @@ -1,3 +1,7 @@ +//! 配置服务测试桩。 +//! +//! 提供内存实现的 `ConfigStore`,用于配置相关单元测试。 + use std::{ path::{Path, PathBuf}, sync::Mutex, diff --git a/crates/application/src/errors.rs b/crates/application/src/errors.rs index 0d4b14f5..d67da74f 100644 --- a/crates/application/src/errors.rs +++ b/crates/application/src/errors.rs @@ -1,3 +1,8 @@ +//! 应用层错误类型。 +//! +//! `ApplicationError` 是 application 层唯一的错误枚举, +//! 通过 `From` 转换桥接 core / session-runtime 的底层错误。 + use thiserror::Error; #[derive(Debug, Error)] diff --git a/crates/application/src/execution/control.rs b/crates/application/src/execution/control.rs index ee915797..07e18b04 100644 --- a/crates/application/src/execution/control.rs +++ b/crates/application/src/execution/control.rs @@ -1 +1,6 @@ +//! 执行控制参数 re-export。 +//! +//! 将 `astrcode_core::ExecutionControl` 直接 re-export, +//! 供 application 各模块统一从 `execution::ExecutionControl` 引入。 + pub use astrcode_core::ExecutionControl; diff --git a/crates/application/src/governance_surface/assembler.rs b/crates/application/src/governance_surface/assembler.rs index 2677e3d0..07836f07 100644 --- a/crates/application/src/governance_surface/assembler.rs +++ b/crates/application/src/governance_surface/assembler.rs @@ -1,3 +1,14 @@ +//! 治理面装配器。 +//! +//! `GovernanceSurfaceAssembler` 是治理面子域的核心:将 mode spec、runtime 配置、 +//! 执行控制等输入编译成 `ResolvedGovernanceSurface`,供 turn 提交时一次性消费。 +//! +//! 装配过程: +//! 1. 从 `ModeCatalog` 查找 mode spec → 编译 `CapabilitySelector` 得到工具白名单 +//! 2. 构建 `PolicyContext` 和 `AgentCollaborationPolicyContext` +//! 3. 注入 prompt declarations(mode prompt + 协作指导 + skill 声明) +//! 4. 解析 busy policy(是否在 session busy 时分支或拒绝) + use astrcode_core::{ ResolvedExecutionLimitsSnapshot, ResolvedRuntimeConfig, ResolvedSubagentContextOverrides, ResolvedTurnEnvelope, @@ -269,11 +280,13 @@ impl GovernanceSurfaceAssembler { kernel: &dyn AppKernelPort, input: ResumedChildGovernanceInput, ) -> Result { + // resumed child 复用首次 spawn 时解析的 limits,因此用 resolved_limits 覆盖 runtime 默认值 let mut runtime = input.runtime; if let Some(max_steps) = input.resolved_limits.max_steps { runtime.max_steps = max_steps as usize; } let compiled = self.compile_mode_surface(kernel, &input.mode_id, Vec::new())?; + // 工具白名单优先级:input.allowed_tools > resolved_limits > mode 编译结果 let allowed_tools = if input.allowed_tools.is_empty() { if input.resolved_limits.allowed_tools.is_empty() { compiled.envelope.allowed_tools.clone() @@ -291,6 +304,7 @@ impl GovernanceSurfaceAssembler { false, ) }); + // 当 allowed_tools 与 mode 编译结果一致时复用 router,否则重建子集 router let compiled = CompiledModeEnvelope { capability_router: if allowed_tools == compiled.envelope.allowed_tools { compiled.capability_router diff --git a/crates/application/src/governance_surface/inherited.rs b/crates/application/src/governance_surface/inherited.rs index 3b728aa7..3fb528c8 100644 --- a/crates/application/src/governance_surface/inherited.rs +++ b/crates/application/src/governance_surface/inherited.rs @@ -1,3 +1,9 @@ +//! 父级上下文继承:子代理启动时从父 session 继承消息。 +//! +//! 支持两种继承策略: +//! - **Compact summary**:从父消息中提取压缩摘要,给子代理一个精简的上下文概览 +//! - **Recent tail**:按 fork mode 截取父消息尾部(LastNTurns 或 FullHistory) + use astrcode_core::{ ForkMode, LlmMessage, ResolvedSubagentContextOverrides, UserMessageOrigin, project, }; @@ -54,6 +60,8 @@ pub(crate) fn build_inherited_messages( inherited } +/// 从父消息中选择要继承的最近尾部。 +/// 先排除 CompactSummary 消息(已单独处理),再按 fork_mode 截取。 pub(crate) fn select_inherited_recent_tail( parent_messages: &[LlmMessage], fork_mode: Option<&ForkMode>, @@ -80,6 +88,7 @@ pub(crate) fn select_inherited_recent_tail( } } +/// 从尾部倒数 `turns` 个 User 消息作为 turn 边界,截取最近的 N 个 turn。 fn tail_messages_for_last_n_turns(messages: &[LlmMessage], turns: usize) -> Vec { if turns == 0 || messages.is_empty() { return Vec::new(); diff --git a/crates/application/src/governance_surface/mod.rs b/crates/application/src/governance_surface/mod.rs index e9c612a2..b8af6a82 100644 --- a/crates/application/src/governance_surface/mod.rs +++ b/crates/application/src/governance_surface/mod.rs @@ -1,3 +1,16 @@ +//! # 治理面子域(Governance Surface) +//! +//! 统一管理每次 turn 的治理决策:工具白名单、审批策略、子代理委派策略、协作指导 prompt。 +//! +//! 核心流程:`*GovernanceInput` → `GovernanceSurfaceAssembler` → `ResolvedGovernanceSurface` → +//! `AgentPromptSubmission` +//! +//! 入口场景: +//! - **Session turn**:`session_surface()` — 用户直接发起的 turn +//! - **Root execution**:`root_surface()` — 根代理首次执行(委托到 session_surface) +//! - **Fresh child**:`fresh_child_surface()` — spawn 新子代理,需要继承父级上下文 +//! - **Resumed child**:`resumed_child_surface()` — 向已有子代理 send 消息,复用已有策略 + mod assembler; mod inherited; mod policy; diff --git a/crates/application/src/governance_surface/policy.rs b/crates/application/src/governance_surface/policy.rs index 3d412cf3..7c555438 100644 --- a/crates/application/src/governance_surface/policy.rs +++ b/crates/application/src/governance_surface/policy.rs @@ -1,3 +1,10 @@ +//! 治理策略上下文与审批管线构建。 +//! +//! 提供三个核心功能: +//! - 构建协作策略上下文(`collaboration_policy_context`),包含 depth/spawn 限制 +//! - 构建审批管线(`default_approval_pipeline`),当 mode 要求审批时安装占位骨架 +//! - 计算有效工具列表(`effective_allowed_tools_for_limits`),空列表回退到全量 + use astrcode_core::{ AgentCollaborationPolicyContext, ApprovalPending, ApprovalRequest, CapabilityCall, ModeId, PolicyContext, ResolvedExecutionLimitsSnapshot, ResolvedRuntimeConfig, ResolvedTurnEnvelope, @@ -131,6 +138,7 @@ pub(super) fn default_approval_pipeline( if !envelope.action_policies.requires_approval() { return GovernanceApprovalPipeline { pending: None }; } + // 安装占位审批骨架:当前 disabled,后续会接入真实审批引擎 GovernanceApprovalPipeline { pending: Some(ApprovalPending { request: ApprovalRequest { @@ -178,6 +186,7 @@ pub(super) fn default_approval_pipeline( } } +/// 解析 busy policy:mode 级别 RejectOnBusy 强制覆盖,否则使用请求方指定的策略。 pub(super) fn resolve_busy_policy( submit_busy_policy: astrcode_core::SubmitBusyPolicy, requested_busy_policy: GovernanceBusyPolicy, diff --git a/crates/application/src/governance_surface/prompt.rs b/crates/application/src/governance_surface/prompt.rs index a4621d2a..4e6f3569 100644 --- a/crates/application/src/governance_surface/prompt.rs +++ b/crates/application/src/governance_surface/prompt.rs @@ -1,3 +1,11 @@ +//! 治理 prompt 声明构建。 +//! +//! 生成子代理委派相关的 prompt declarations: +//! - `build_delegation_metadata`:构建委派元数据(责任摘要、复用边界、能力限制) +//! - `build_fresh_child_contract`:新子代理的系统 prompt 契约 +//! - `build_resumed_child_contract`:继续委派的增量指令 prompt +//! - `collaboration_prompt_declarations`:四工具协作指导 prompt + use astrcode_core::{ PromptDeclaration, PromptDeclarationKind, PromptDeclarationRenderTarget, PromptDeclarationSource, ResolvedExecutionLimitsSnapshot, SystemPromptLayer, diff --git a/crates/application/src/lib.rs b/crates/application/src/lib.rs index 2ac60279..357b62b4 100644 --- a/crates/application/src/lib.rs +++ b/crates/application/src/lib.rs @@ -1,3 +1,12 @@ +//! # Astrcode 应用层 +//! +//! 纯业务编排层,不依赖任何 adapter-* crate,只依赖 core / kernel / session-runtime。 +//! +//! 核心职责: +//! - 通过 `App` 结构体暴露所有业务用例入口 +//! - 持有并编排 governance surface(治理面)、mode catalog(模式目录)等基础设施 +//! - 通过 port trait 与 adapter 层解耦(AppKernelPort / AppSessionPort / ComposerSkillPort) + use std::{path::Path, sync::Arc}; use astrcode_core::AgentProfile; diff --git a/crates/application/src/mode/catalog.rs b/crates/application/src/mode/catalog.rs index 88841e84..ccffcadd 100644 --- a/crates/application/src/mode/catalog.rs +++ b/crates/application/src/mode/catalog.rs @@ -1,3 +1,15 @@ +//! 治理模式注册目录。 +//! +//! `ModeCatalog` 管理所有可用的治理模式(内置 + 插件扩展),提供: +//! - 按 ModeId 查找 mode spec +//! - 列出所有可用 mode 的摘要(供 API 返回) +//! - 热替换插件 mode(bootstrap / reload 时调用 `replace_plugin_modes`) +//! +//! 内置三种 mode: +//! - **Code**:默认执行模式,保留完整能力面与委派能力 +//! - **Plan**:规划模式,只暴露只读工具,禁止委派 +//! - **Review**:审查模式,严格只读,禁止委派,收紧步数 + use std::{ collections::BTreeMap, sync::{Arc, RwLock}, @@ -169,7 +181,6 @@ fn builtin_mode_specs() -> Vec { selector: CapabilitySelector::Tag("agent".to_string()), effect: ActionPolicyEffect::Deny, }], - context_strategy: Some(astrcode_core::ContextStrategy::Summarize), }, child_policy: ChildPolicySpec { allow_delegation: false, @@ -216,7 +227,6 @@ fn builtin_mode_specs() -> Vec { selector: CapabilitySelector::Tag("agent".to_string()), effect: ActionPolicyEffect::Deny, }], - context_strategy: Some(astrcode_core::ContextStrategy::Summarize), }, child_policy: ChildPolicySpec { allow_delegation: false, diff --git a/crates/application/src/mode/compiler.rs b/crates/application/src/mode/compiler.rs index ea3dd7ee..3f6da3d6 100644 --- a/crates/application/src/mode/compiler.rs +++ b/crates/application/src/mode/compiler.rs @@ -1,3 +1,11 @@ +//! 治理模式编译器。 +//! +//! 将声明式的 `GovernanceModeSpec` 编译为运行时可消费的 `CompiledModeEnvelope`: +//! - 通过 `CapabilitySelector` 从全量 capability 中筛选出允许的工具名列表 +//! - 递归处理组合选择器(Union / Intersection / Difference) +//! - 为子代理额外计算继承后的工具白名单(parent ∩ mode ∩ grant) +//! - 生成 mode prompt declarations 和子代理策略 + use std::collections::BTreeSet; use astrcode_core::{ @@ -85,6 +93,7 @@ pub fn compile_mode_envelope_for_child( ) -> Result { let mode_allowed_tools = compile_capability_selector(&base_router.capability_specs(), &spec.capability_selector)?; + // 子代理工具 = parent ∩ mode;parent 为空时直接取 mode 全量 let effective_parent_allowed_tools = if parent_allowed_tools.is_empty() { mode_allowed_tools } else { diff --git a/crates/application/src/mode/mod.rs b/crates/application/src/mode/mod.rs index 34800b1f..1bb755bd 100644 --- a/crates/application/src/mode/mod.rs +++ b/crates/application/src/mode/mod.rs @@ -1,3 +1,13 @@ +//! # 治理模式子域(Governance Mode) +//! +//! 管理 session 可用的治理模式(code / plan / review 及插件扩展 mode)。 +//! +//! 三个子模块各司其职: +//! - `catalog`:模式注册目录,支持内置 + 插件扩展,可热替换插件 mode +//! - `compiler`:将 `GovernanceModeSpec` 编译为 `ResolvedTurnEnvelope`(工具白名单 + 策略 + +//! prompt) +//! - `validator`:校验 mode 之间的合法转换 + mod catalog; mod compiler; mod validator; diff --git a/crates/application/src/mode/validator.rs b/crates/application/src/mode/validator.rs index 76ce5174..53b8c802 100644 --- a/crates/application/src/mode/validator.rs +++ b/crates/application/src/mode/validator.rs @@ -1,3 +1,8 @@ +//! 治理模式转换校验。 +//! +//! 校验 session 从一个 mode 切换到另一个 mode 是否合法: +//! 检查当前 mode 的 `transition_policy.allowed_targets` 是否包含目标 mode。 + use astrcode_core::{AstrError, GovernanceModeSpec, ModeId, Result}; use super::ModeCatalog; diff --git a/crates/application/src/ports/mod.rs b/crates/application/src/ports/mod.rs index 0213dc30..7d85e955 100644 --- a/crates/application/src/ports/mod.rs +++ b/crates/application/src/ports/mod.rs @@ -1,3 +1,12 @@ +//! # 应用层端口(Port) +//! +//! 定义 application 层与外部系统交互的 trait 契约,实现依赖反转: +//! - `AppKernelPort`:`App` 依赖的 kernel 控制面 +//! - `AgentKernelPort`:Agent 编排子域扩展的 kernel 端口 +//! - `AppSessionPort`:`App` 依赖的 session-runtime 稳定端口 +//! - `AgentSessionPort`:Agent 编排子域扩展的 session 端口 +//! - `ComposerSkillPort`:composer 输入补全的 skill 查询端口 + mod agent_kernel; mod agent_session; mod app_kernel; diff --git a/crates/application/src/session_use_cases.rs b/crates/application/src/session_use_cases.rs index e42e1526..ca09ee3d 100644 --- a/crates/application/src/session_use_cases.rs +++ b/crates/application/src/session_use_cases.rs @@ -1,4 +1,8 @@ -/// ! 这是 App 的用例实现,不是 ports +//! Session 用例(`App` 的 session 相关方法)。 +//! +//! 用户直接发起的 session 操作:prompt 提交、compact、mode 切换、 +//! session 列表查询、快照查询等。这些方法组装治理面并委托到 session-runtime。 + use std::path::Path; use astrcode_core::{ diff --git a/crates/application/src/terminal/mod.rs b/crates/application/src/terminal/mod.rs index 8d673e1f..e33cb29a 100644 --- a/crates/application/src/terminal/mod.rs +++ b/crates/application/src/terminal/mod.rs @@ -1,3 +1,8 @@ +//! 终端层数据模型与投影辅助。 +//! +//! 定义面向前端的事件流数据模型(`TerminalFacts`、`ConversationSlashCandidateFacts` 等) +//! 以及从 session-runtime 快照到终端视图的投影辅助函数。 + use astrcode_core::{ChildAgentRef, ChildSessionNode, CompactAppliedMeta, CompactTrigger, Phase}; use astrcode_session_runtime::{ ConversationSnapshotFacts as RuntimeConversationSnapshotFacts, diff --git a/crates/application/src/terminal_queries/cursor.rs b/crates/application/src/terminal_queries/cursor.rs index 2f01f2a1..d11727da 100644 --- a/crates/application/src/terminal_queries/cursor.rs +++ b/crates/application/src/terminal_queries/cursor.rs @@ -1,3 +1,8 @@ +//! 游标格式校验与比较工具。 +//! +//! 游标格式为 `{storage_seq}.{subindex}`,用于分页查询时标记位置。 +//! `cursor_is_after_head` 判断请求的游标是否已超过最新位置(即客户端是否有未读数据)。 + use crate::ApplicationError; pub(super) fn validate_cursor_format(cursor: &str) -> Result<(), ApplicationError> { diff --git a/crates/application/src/terminal_queries/mod.rs b/crates/application/src/terminal_queries/mod.rs index 0e654f8f..c2716776 100644 --- a/crates/application/src/terminal_queries/mod.rs +++ b/crates/application/src/terminal_queries/mod.rs @@ -1,3 +1,11 @@ +//! # 终端查询子域 +//! +//! 从旧 `terminal_use_cases.rs` 拆分而来,按职责分为四个查询模块: +//! - `cursor`:游标格式校验与比较 +//! - `resume`:会话恢复候选列表 +//! - `snapshot`:会话快照查询(conversation + transcript) +//! - `summary`:会话摘要提取 + mod cursor; mod resume; mod snapshot; diff --git a/crates/application/src/terminal_queries/resume.rs b/crates/application/src/terminal_queries/resume.rs index 379fae8d..78c5b800 100644 --- a/crates/application/src/terminal_queries/resume.rs +++ b/crates/application/src/terminal_queries/resume.rs @@ -1,3 +1,8 @@ +//! 会话恢复候选列表查询。 +//! +//! 根据搜索关键词和限制数量,从 session 列表中筛选出可恢复的会话候选项, +//! 按更新时间倒序排列。支持按标题、工作目录、会话 ID 模糊匹配。 + use std::{cmp::Reverse, collections::HashSet, path::Path}; use crate::{ diff --git a/crates/application/src/terminal_queries/snapshot.rs b/crates/application/src/terminal_queries/snapshot.rs index a52a7948..34a0a64e 100644 --- a/crates/application/src/terminal_queries/snapshot.rs +++ b/crates/application/src/terminal_queries/snapshot.rs @@ -1,3 +1,8 @@ +//! 会话快照查询。 +//! +//! 从 session-runtime 获取 conversation/transcript 快照并映射为 +//! terminal 层的事实模型(`TerminalFacts` / `ConversationStreamReplayFacts`)。 + use crate::{ App, ApplicationError, terminal::{ diff --git a/crates/application/src/terminal_queries/summary.rs b/crates/application/src/terminal_queries/summary.rs index bdecd55a..f56a7433 100644 --- a/crates/application/src/terminal_queries/summary.rs +++ b/crates/application/src/terminal_queries/summary.rs @@ -1,3 +1,9 @@ +//! 终端摘要提取。 +//! +//! 从 conversation snapshot 中提取最新一条有意义的摘要文本, +//! 按 block 类型降级选择:assistant markdown → tool call summary/error → child handoff → error → +//! system note。 所有候选项都为空时回退到游标位置。 + use astrcode_session_runtime::{ ConversationBlockFacts, ConversationChildHandoffBlockFacts, ConversationErrorBlockFacts, ConversationSnapshotFacts, ConversationSystemNoteBlockFacts, ToolCallBlockFacts, diff --git a/crates/application/src/test_support.rs b/crates/application/src/test_support.rs index ef5762ab..fdd29d3e 100644 --- a/crates/application/src/test_support.rs +++ b/crates/application/src/test_support.rs @@ -1,3 +1,8 @@ +//! 应用层测试桩。 +//! +//! 提供 `StubSessionPort`,实现 `AppSessionPort` + `AgentSessionPort` 两个 trait, +//! 用于 `application` 内部单元测试,避免依赖真实 `SessionRuntime`。 + use astrcode_core::{ AgentCollaborationFact, AgentEventContext, AgentLifecycleStatus, DeleteProjectResult, ExecutionAccepted, InputBatchAckedPayload, InputBatchStartedPayload, InputDiscardedPayload, diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index 452000ff..5d70a929 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -126,8 +126,8 @@ pub use observability::{ pub use plugin::{PluginHealth, PluginManifest, PluginRegistry, PluginState, PluginType}; pub use policy::{ AllowAllPolicyEngine, ApprovalDefault, ApprovalPending, ApprovalRequest, ApprovalResolution, - CapabilityCall, ContextDecisionInput, ContextStrategy, ModelRequest, PolicyContext, - PolicyEngine, PolicyVerdict, SystemPromptBlock, SystemPromptLayer, + CapabilityCall, ModelRequest, PolicyContext, PolicyEngine, PolicyVerdict, SystemPromptBlock, + SystemPromptLayer, }; pub use ports::{ EventStore, LlmEvent, LlmEventSink, LlmFinishReason, LlmOutput, LlmProvider, LlmRequest, diff --git a/crates/core/src/mode/mod.rs b/crates/core/src/mode/mod.rs index 9e84012c..3663b129 100644 --- a/crates/core/src/mode/mod.rs +++ b/crates/core/src/mode/mod.rs @@ -1,7 +1,7 @@ use serde::{Deserialize, Serialize}; use crate::{ - AstrError, CapabilityKind, ContextStrategy, ForkMode, PromptDeclaration, Result, SideEffect, + AstrError, CapabilityKind, ForkMode, PromptDeclaration, Result, SideEffect, normalize_non_empty_unique_string_list, }; @@ -144,8 +144,6 @@ pub struct ActionPolicies { pub default_effect: ActionPolicyEffect, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub rules: Vec, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub context_strategy: Option, } impl ActionPolicies { @@ -429,7 +427,6 @@ mod tests { action_policies: ActionPolicies { default_effect: crate::ActionPolicyEffect::Ask, rules: Vec::new(), - context_strategy: None, }, child_policy: Default::default(), submit_busy_policy: SubmitBusyPolicy::RejectOnBusy, diff --git a/crates/core/src/policy/engine.rs b/crates/core/src/policy/engine.rs index 27dc486f..a1c5eed5 100644 --- a/crates/core/src/policy/engine.rs +++ b/crates/core/src/policy/engine.rs @@ -123,39 +123,6 @@ pub struct PolicyContext { pub metadata: Value, } -/// 上下文策略输入。 -/// -/// Loop 在构建完完整请求快照后,将上下文压力和局部建议封装为只读输入, -/// 交给策略层做最终裁决。这样 compact 等局部策略不会绕过全局护栏。 -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ContextDecisionInput { - /// 当前请求预估 token 数。 - pub estimated_tokens: usize, - /// 模型上下文窗口大小。 - pub context_window: usize, - /// 扣除保留区后的有效窗口大小。 - pub effective_window: usize, - /// 当前配置下触发策略的阈值 token 数。 - pub threshold_tokens: usize, - /// 本轮 microcompact 截断的 tool result 数量。 - pub truncated_tool_results: usize, - /// Loop 的局部建议策略。 - pub suggested_strategy: ContextStrategy, -} - -/// 上下文策略裁决。 -/// -/// 这是 loop 在“是否 compact / summarize / truncate / ignore”上的统一决策结果。 -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum ContextStrategy { - Compact, - Summarize, - Truncate, - Ignore, -} - /// 审批默认值 /// /// 用于 UI 展示默认选项。 @@ -311,13 +278,6 @@ pub trait PolicyEngine: Send + Sync { call: CapabilityCall, ctx: &PolicyContext, ) -> Result>; - - /// 根据完整请求快照裁决上下文策略。 - async fn decide_context_strategy( - &self, - input: &ContextDecisionInput, - _ctx: &PolicyContext, - ) -> Result; } /// 允许所有操作的策略引擎。 @@ -343,17 +303,6 @@ impl PolicyEngine for AllowAllPolicyEngine { ) -> Result> { Ok(PolicyVerdict::Allow(call)) } - - async fn decide_context_strategy( - &self, - input: &ContextDecisionInput, - _ctx: &PolicyContext, - ) -> Result { - // AllowAll 必须显式选择循环的建议,以便上下文策略行为 - // 始终在实现站点可见,而不是隐藏在特性级别的兼容性之后 - // default. - Ok(input.suggested_strategy) - } } #[cfg(test)] @@ -361,8 +310,8 @@ mod tests { use serde_json::{Value, json}; use super::{ - AllowAllPolicyEngine, ApprovalDefault, ApprovalRequest, ContextDecisionInput, - ContextStrategy, PolicyContext, PolicyEngine, PolicyVerdict, + AllowAllPolicyEngine, ApprovalDefault, ApprovalRequest, PolicyContext, PolicyEngine, + PolicyVerdict, }; use crate::{ CapabilityKind, CapabilitySpec, InvocationMode, ModelRequest, SideEffect, Stability, @@ -429,23 +378,6 @@ mod tests { .expect("call should pass"), PolicyVerdict::Allow(call) ); - assert_eq!( - policy - .decide_context_strategy( - &ContextDecisionInput { - estimated_tokens: 90_000, - context_window: 128_000, - effective_window: 100_000, - threshold_tokens: 90_000, - truncated_tool_results: 1, - suggested_strategy: ContextStrategy::Compact, - }, - &policy_context(), - ) - .await - .expect("context strategy should pass"), - ContextStrategy::Compact - ); } #[test] diff --git a/crates/core/src/policy/mod.rs b/crates/core/src/policy/mod.rs index 6f68c9d1..90add1c4 100644 --- a/crates/core/src/policy/mod.rs +++ b/crates/core/src/policy/mod.rs @@ -12,6 +12,6 @@ mod engine; pub use engine::{ AllowAllPolicyEngine, ApprovalDefault, ApprovalPending, ApprovalRequest, ApprovalResolution, - CapabilityCall, ContextDecisionInput, ContextStrategy, ModelRequest, PolicyContext, - PolicyEngine, PolicyVerdict, SystemPromptBlock, SystemPromptLayer, + CapabilityCall, ModelRequest, PolicyContext, PolicyEngine, PolicyVerdict, SystemPromptBlock, + SystemPromptLayer, }; diff --git a/crates/session-runtime/src/context_window/compaction.rs b/crates/session-runtime/src/context_window/compaction.rs index e3948869..72e6eac6 100644 --- a/crates/session-runtime/src/context_window/compaction.rs +++ b/crates/session-runtime/src/context_window/compaction.rs @@ -117,6 +117,9 @@ struct PreparedCompactInput { /// /// 通过 `gateway` 调用 LLM 对历史前缀生成摘要,替换为压缩后的消息。 /// 返回 `None` 表示没有可压缩的内容。 +/// +/// 当前系统只有这一套真实 compact 流程。若未来需要按 mode 调整行为,应扩展 +/// `CompactConfig` / `ContextWindowSettings` 这类显式参数,而不是恢复未消费的粗粒度策略枚举。 pub async fn auto_compact( gateway: &KernelGateway, messages: &[LlmMessage], diff --git a/crates/session-runtime/src/context_window/settings.rs b/crates/session-runtime/src/context_window/settings.rs index 10823641..61a653de 100644 --- a/crates/session-runtime/src/context_window/settings.rs +++ b/crates/session-runtime/src/context_window/settings.rs @@ -38,6 +38,8 @@ impl ContextWindowSettings { impl From<&ResolvedRuntimeConfig> for ContextWindowSettings { fn from(config: &ResolvedRuntimeConfig) -> Self { + // TODO: 如果未来需要 mode 感知的上下文压缩,请在 compact 参数模型上做显式覆盖, + // 而不是重新引入 summarize/truncate/ignore 这类未落地的策略枚举。 Self { auto_compact_enabled: config.auto_compact_enabled, compact_threshold_percent: config.compact_threshold_percent, From 65a555631a958364db9b09a1ce60b4632bd0dfeb Mon Sep 17 00:00:00 2001 From: whatevertogo Date: Sun, 19 Apr 2026 14:14:56 +0800 Subject: [PATCH 39/53] comments --- crates/application/src/agent/context.rs | 18 ++++++++ crates/application/src/agent/mod.rs | 18 ++++++++ crates/application/src/agent/observe.rs | 4 ++ crates/application/src/agent/routing.rs | 45 ++++++++++++++++++- crates/application/src/agent/terminal.rs | 18 ++++++++ crates/application/src/agent/wake.rs | 30 +++++++++++++ crates/application/src/agent_use_cases.rs | 12 +++++ crates/application/src/execution/mod.rs | 7 +++ crates/application/src/execution/root.rs | 4 ++ crates/application/src/execution/subagent.rs | 5 +++ .../application/src/governance_surface/mod.rs | 12 +++++ .../src/governance_surface/tests.rs | 8 ++++ .../src/observability/collector.rs | 11 +++++ crates/application/src/ports/agent_kernel.rs | 10 +++++ crates/application/src/ports/agent_session.rs | 10 +++++ crates/application/src/ports/app_kernel.rs | 9 ++++ crates/application/src/ports/app_session.rs | 8 ++++ .../application/src/ports/composer_skill.rs | 6 +++ crates/application/src/session_use_cases.rs | 23 ++++++++++ .../application/src/terminal_queries/tests.rs | 8 ++++ crates/core/src/action.rs | 2 + crates/core/src/agent/mod.rs | 6 +++ crates/core/src/composer.rs | 5 +++ crates/core/src/config.rs | 8 ++++ crates/core/src/event/phase.rs | 23 +++++++--- crates/core/src/event/translate.rs | 14 +++++- crates/core/src/execution_result.rs | 5 +++ crates/core/src/ids.rs | 9 ++-- crates/core/src/local_server.rs | 7 ++- crates/core/src/mode/mod.rs | 15 +++++++ crates/core/src/observability.rs | 16 +++++++ crates/core/src/plugin/registry.rs | 6 ++- crates/core/src/project.rs | 17 ++++--- crates/core/src/projection/agent_state.rs | 23 ++++++++-- crates/core/src/registry/router.rs | 6 +-- crates/core/src/runtime/coordinator.rs | 16 ++++--- crates/core/src/session_catalog.rs | 5 +++ crates/core/src/shell.rs | 18 ++++++++ crates/core/src/store.rs | 17 ++++--- crates/core/src/tool_result_persist.rs | 15 +++++-- 40 files changed, 456 insertions(+), 43 deletions(-) diff --git a/crates/application/src/agent/context.rs b/crates/application/src/agent/context.rs index 56102af7..d718f85b 100644 --- a/crates/application/src/agent/context.rs +++ b/crates/application/src/agent/context.rs @@ -215,6 +215,10 @@ fn default_resolved_limits_for_gateway( } } +/// 为 handle 补填 resolved_limits。 +/// +/// 某些老路径注册的 root agent(如隐式注册)可能没有在注册时就写入 limits, +/// 此函数检测到空 allowed_tools 时从 gateway 全量 capability 补填。 async fn ensure_handle_has_resolved_limits( kernel: &dyn crate::AgentKernelPort, gateway: &astrcode_kernel::KernelGateway, @@ -426,6 +430,15 @@ impl AgentOrchestrationService { Err(astrcode_core::AstrError::Internal(message)) } + /// 确保当前 ToolContext 中存在一个有效的 parent agent handle。 + /// + /// 查找策略(按优先级): + /// 1. ctx 中已有显式 agent_id → 直接查找已有 handle;若为 depth=0 且无 resolved_limits 则补填 + /// 2. ctx 中已有显式 agent_id 但无 handle → 若是 RootExecution 调用则自动注册,否则报 NotFound + /// 3. ctx 中无 agent_id → 按 session 查找隐式 root handle + /// 4. 以上都找不到 → 注册隐式 root agent(`root-agent:{session_id}`) + /// + /// 返回的 handle 保证已携带 resolved_limits(来自 gateway 全量 capability)。 pub(super) async fn ensure_parent_agent_handle( &self, ctx: &ToolContext, @@ -522,6 +535,11 @@ impl AgentOrchestrationService { .map_err(AgentOrchestrationError::Internal) } + /// 校验当前 turn 的 spawn 预算是否耗尽。 + /// + /// 每个 parent agent 在单个 turn 内可 spawn 的子 agent 数量受 + /// `collaboration_policy.max_spawn_per_turn` 限制。 + /// 超限时引导 LLM 复用已有 child(send/observe/close)。 pub(super) async fn enforce_spawn_budget_for_turn( &self, parent_agent_id: &str, diff --git a/crates/application/src/agent/mod.rs b/crates/application/src/agent/mod.rs index a027bd9c..f45f51fb 100644 --- a/crates/application/src/agent/mod.rs +++ b/crates/application/src/agent/mod.rs @@ -143,6 +143,11 @@ pub(crate) async fn persist_delegation_for_handle( Ok(handle) } +/// 将 child terminal notification 包装为 durable input queue 信封。 +/// +/// 提取 notification 中 delivery 的消息文本作为信封内容, +/// 同时携带 sender 的 lifecycle status、turn outcome 和 open session id, +/// 供父级 wake turn 消费时了解子代理的最新状态。 pub(crate) fn child_delivery_input_queue_envelope( notification: &astrcode_core::ChildSessionNotification, target_agent_id: String, @@ -159,6 +164,10 @@ pub(crate) fn child_delivery_input_queue_envelope( } } +/// 从 notification 的 delivery payload 中提取可读消息文本。 +/// +/// 优先使用 delivery.payload.message(),为空时回退到默认提示。 +/// 这是终端通知、durable input queue、wake turn 共享的消息提取逻辑。 pub(crate) fn terminal_notification_message( notification: &astrcode_core::ChildSessionNotification, ) -> String { @@ -171,6 +180,11 @@ pub(crate) fn terminal_notification_message( .unwrap_or_else(|| "子 Agent 未提供可读交付。".to_string()) } +/// 从 child notification 推断 sender 的 last turn outcome。 +/// +/// 仅在 child 处于 Idle 状态时才有有效的 outcome(表示已完成一轮 turn)。 +/// 根据 delivery payload 类型映射:Completed → Completed, Failed → Failed, CloseRequest → +/// Cancelled。 用于 durable input queue 信封中的 sender 状态追踪。 pub(crate) fn terminal_notification_turn_outcome( notification: &astrcode_core::ChildSessionNotification, ) -> Option { @@ -234,6 +248,10 @@ pub(crate) fn child_collaboration_artifacts( artifacts } +/// 构建子代理协作的 artifact 引用列表(subRun + agent + parentSession + session + parentAgent)。 +/// +/// `include_parent_sub_run` 控制 是否包含 parentSubRun artifact, +/// spawn handoff 时不需要(因为已有 subRun),其他场景需要。 pub(crate) fn child_reference_artifacts( child: &SubRunHandle, parent_session_id: &str, diff --git a/crates/application/src/agent/observe.rs b/crates/application/src/agent/observe.rs index 77206668..8f8cca52 100644 --- a/crates/application/src/agent/observe.rs +++ b/crates/application/src/agent/observe.rs @@ -12,6 +12,10 @@ use super::{AgentOrchestrationService, ObserveSnapshotSignature}; impl AgentOrchestrationService { /// 获取目标 child agent 的只读快照。 + /// + /// 返回子代理的 lifecycle、phase、turn count、active task、最近输出等状态信息。 + /// 内置幂等去重:同一 turn 内连续 observe 相同状态会被拒绝(state_unchanged), + /// 防止 LLM 在等待子代理完成时无意义地反复 poll。 pub async fn observe_child( &self, params: ObserveParams, diff --git a/crates/application/src/agent/routing.rs b/crates/application/src/agent/routing.rs index b653f1d6..61dfdf2c 100644 --- a/crates/application/src/agent/routing.rs +++ b/crates/application/src/agent/routing.rs @@ -166,6 +166,16 @@ impl AgentOrchestrationService { .await } + /// 子代理上行消息投递(send to parent)。 + /// + /// 完整校验链: + /// 1. 验证调用者有 child agent context + /// 2. 验证调用者的 handle 存在 + /// 3. 验证存在 direct parent agent(root agent 不能上行) + /// 4. 验证 parent handle 存在且未终止 + /// 5. 验证有 source turn id + /// + /// 投递后触发父级 reactivation,让父级消费这条 delivery。 async fn send_to_parent( &self, params: SendToParentParams, @@ -470,7 +480,14 @@ impl AgentOrchestrationService { } } - /// 关闭子 agent 及其子树(close 协作工具的业务逻辑)。 + /// 关闭子 agent 及其整个子树(close 协作工具的业务逻辑)。 + /// + /// 流程: + /// 1. 验证调用者是目标子 agent 的直接父级 + /// 2. 收集子树所有 handle 用于 durable discard + /// 3. 持久化 InputDiscarded 事件,标记待处理消息为已丢弃 + /// 4. 执行 kernel.terminate_subtree() 级联终止 + /// 5. 记录 Close collaboration fact pub async fn close_child( &self, params: CloseAgentParams, @@ -559,6 +576,7 @@ impl AgentOrchestrationService { } /// resume 失败时恢复之前 drain 出的 inbox 信封。 + /// /// 必须在 resume 前先 drain(否则无法取到 pending 消息来组合 resume prompt), /// 但如果 resume 本身失败,必须把信封放回去,避免消息丢失。 async fn restore_pending_inbox(&self, agent_id: &str, pending: Vec) { @@ -583,6 +601,17 @@ impl AgentOrchestrationService { Err(super::AgentOrchestrationError::Internal(message)) } + /// 如果子 agent 处于 Idle 且不占据并发槽位(如 Resume lineage), + /// 则尝试 resume 它以处理新消息,而非排队等待。 + /// + /// Resume 流程: + /// 1. 排空 inbox 中待处理消息 + /// 2. 将待处理消息与新的 send 输入拼接为 resume prompt + /// 3. 调用 kernel.resume() 重启子 agent turn + /// 4. 构建 resumed 治理面并提交 prompt + /// 5. 注册 turn terminal watcher 等待终态 + /// + /// 如果 resume 失败,会恢复之前排空的 inbox 避免消息丢失。 async fn resume_idle_child_if_needed( &self, child: &SubRunHandle, @@ -773,6 +802,12 @@ impl AgentOrchestrationService { })) } + /// 向正在运行的子 agent 追加消息。 + /// + /// 子 agent 正忙时不能 resume,消息通过 inbox 机制排队: + /// 1. 持久化 InputQueued 事件(durable,crash 可恢复) + /// 2. 通过 kernel.deliver() 投递到内存 inbox + /// 3. 记录 collaboration fact(Queued outcome) async fn queue_message_for_active_child( &self, child: &SubRunHandle, @@ -843,6 +878,9 @@ impl AgentOrchestrationService { } /// 将待处理的 inbox 信封与新的 send 输入拼接为 resume 消息。 +/// +/// 如果只有一条消息(无 pending),直接返回该消息; +/// 多条消息时加上"请按顺序处理以下追加要求"前缀并编号。 fn compose_reusable_child_message( pending: &[astrcode_core::AgentInboxEnvelope], params: &astrcode_core::SendToChildParams, @@ -875,6 +913,10 @@ fn compose_reusable_child_message( format!("请按顺序处理以下追加要求:\n\n{enumerated}") } +/// 根据 delivery payload 类型推断 terminal semantics。 +/// +/// Progress 消息是 NonTerminal(不表示结束), +/// Completed / Failed / CloseRequest 是 Terminal(表示结束)。 fn parent_delivery_terminal_semantics( payload: &ParentDeliveryPayload, ) -> ParentDeliveryTerminalSemantics { @@ -1039,6 +1081,7 @@ impl AgentOrchestrationService { /// /// `Idle` + `None` outcome 的含义是:agent 已空闲但还没有完成过一轮 turn, /// 此时保留调用方传入的 fallback 状态(通常是 handle 上的旧 lifecycle)。 +/// 这避免了把刚 spawn 还没执行过 turn 的 agent 误标为 Idle。 fn project_collaboration_lifecycle( lifecycle: AgentLifecycleStatus, last_turn_outcome: Option, diff --git a/crates/application/src/agent/terminal.rs b/crates/application/src/agent/terminal.rs index 48d3635a..b3ceb4d1 100644 --- a/crates/application/src/agent/terminal.rs +++ b/crates/application/src/agent/terminal.rs @@ -35,6 +35,7 @@ struct ChildTerminalDeliveryProjection { /// 注意:这里显式携带 parent routing truth。 /// `ChildAgentRef` 只用于 stable child reference / projection, /// 禁止再从 `child_ref.session_id` 反推父侧 notification 的落点。 +/// 所有 notification 必须通过显式传入的 `parent_session_id` + `parent_turn_id` 路由。 pub(super) struct ChildTurnTerminalContext { child: astrcode_core::SubRunHandle, execution_session_id: String, @@ -67,6 +68,10 @@ impl ChildTurnTerminalContext { } impl AgentOrchestrationService { + /// 启动后台 watcher 等待 child turn 结束后执行终态收口。 + /// + /// 注册到 task_registry 以便在 shutdown 时统一 abort。 + /// watcher 完成后执行:终态映射 → fallback delivery → 父级 reactivation。 pub(super) fn spawn_child_turn_terminal_watcher( &self, child: astrcode_core::SubRunHandle, @@ -107,6 +112,15 @@ impl AgentOrchestrationService { self.finalize_child_turn_with_outcome(watch, outcome).await } + /// Child turn 终态收口主流程。 + /// + /// 1. 将 turn outcome 映射为 `SubRunResult`(TokenExceeded 视为完成而非失败) + /// 2. 原子更新 live tree 的 lifecycle 和 turn outcome + /// 3. 记录子代理执行指标 + /// 4. 检查是否已有显式 terminal delivery(如 send_to_parent 产生的), 如果有则跳过 fallback + /// delivery + /// 5. 投影出 fallback `ChildSessionNotification` 并追加到父 session + /// 6. 触发父级 reactivation(wake) pub(super) async fn finalize_child_turn_with_outcome( &self, watch: ChildTurnTerminalContext, @@ -321,6 +335,10 @@ fn child_terminal_notification_id(sub_run_id: &str, turn_id: &str, status: SubRu } /// 从 `SubRunResult` 投影出 `ChildTerminalDeliveryProjection`。 +/// +/// 优先使用 result handoff 中携带的显式 delivery(由 send_to_parent 产生), +/// 如果没有则构造 fallback delivery。Fallback 会根据终态类型生成 +/// 对应的 payload(Completed / Failed / CloseRequest)。 fn project_child_terminal_delivery( result: &SubRunResult, fallback_notification_id: &str, diff --git a/crates/application/src/agent/wake.rs b/crates/application/src/agent/wake.rs index aaf0b2cf..06fcd9f6 100644 --- a/crates/application/src/agent/wake.rs +++ b/crates/application/src/agent/wake.rs @@ -19,6 +19,11 @@ use super::{ const MAX_AUTOMATIC_INPUT_FOLLOW_UPS: u8 = 8; impl AgentOrchestrationService { + /// 父级 delivery 唤醒调度入口。 + /// + /// 当子 agent turn 终态或显式上行 send 产生 delivery 时调用, + /// 执行三步:持久化 durable input queue → 排入 kernel delivery buffer → 尝试启动父级 wake + /// turn。 任何一步失败都会记录指标但不会传播错误(best-effort)。 pub async fn reactivate_parent_agent_if_idle( &self, parent_session_id: &str, @@ -79,6 +84,16 @@ impl AgentOrchestrationService { } } + /// 尝试为父级 session 启动一个 delivery 消费 turn。 + /// + /// 流程: + /// 1. reconcile:从 durable 存储恢复可能丢失的 delivery + /// 2. checkout:从 kernel buffer 批量取出待消费 delivery + /// 3. 提交 queued_inputs prompt(而非普通用户 prompt) + /// 4. 如果提交失败或 session 忙,requeue 让后续重试 + /// 5. 成功后启动 wake completion watcher 等待终态 + /// + /// `remaining_follow_ups` 控制自动 follow-up 深度,防止无限递归消费。 pub async fn try_start_parent_delivery_turn( &self, parent_session_id: &str, @@ -201,6 +216,15 @@ impl AgentOrchestrationService { self.task_registry.register_turn_task(handle); } + /// 父级 wake turn 完成后的收口处理。 + /// + /// 判断 wake turn 是否成功(Idle + TurnDone + 无 Error): + /// - 成功:消费 delivery batch、记录 Consumed fact、 如果还有剩余 delivery 则自动触发下一轮 + /// follow-up + /// - 失败:requeue delivery batch 让后续重试 + /// + /// 关键设计:wake turn 不会向更上一级制造新的 terminal delivery, + /// 避免"协作协调 turn"被误当成新的 child work turn 而形成自激膨胀。 async fn finalize_parent_wake_turn( &self, parent_session_id: String, @@ -413,6 +437,12 @@ impl AgentOrchestrationService { Ok(()) } + /// 从 durable 存储恢复可能丢失的 parent delivery。 + /// + /// 场景:进程崩溃后重启,kernel buffer 中的内存状态已丢失, + /// 但 durable input queue 中仍有未消费的 queued 事件。 + /// 此函数通过 `recoverable_parent_deliveries` 从存储事件中 + /// 重建 delivery 并重新排入 kernel buffer。 async fn reconcile_parent_delivery_queue( &self, parent_session_id: &str, diff --git a/crates/application/src/agent_use_cases.rs b/crates/application/src/agent_use_cases.rs index 5247cdd9..b1400aaf 100644 --- a/crates/application/src/agent_use_cases.rs +++ b/crates/application/src/agent_use_cases.rs @@ -62,6 +62,13 @@ impl App { } /// 查询指定 session/sub-run 的共享状态摘要。 + /// + /// 查找策略(按优先级): + /// 1. Live 状态:从 kernel 获取 sub-run 或 root agent 的实时状态 + /// 2. Durable 状态:遍历 child session 的存储事件,投影出持久化的 sub-run 状态 + /// 3. 都找不到:返回默认的 Idle 状态摘要 + /// + /// Durable 路径用于进程重启后 kernel 内存状态已丢失的场景。 pub async fn get_subrun_status_summary( &self, session_id: &str, @@ -97,6 +104,11 @@ impl App { )) } + /// 从 durable 存储事件中投影子运行状态。 + /// + /// 遍历所有 child session 的存储事件,寻找匹配 requested_subrun_id 的 + /// SubRunStarted / SubRunFinished 事件,构建状态摘要。 + /// 用于进程重启后 kernel 内存状态已丢失的场景。 async fn durable_subrun_status_summary( &self, parent_session_id: &str, diff --git a/crates/application/src/execution/mod.rs b/crates/application/src/execution/mod.rs index 5bd54b54..8873b210 100644 --- a/crates/application/src/execution/mod.rs +++ b/crates/application/src/execution/mod.rs @@ -16,6 +16,9 @@ pub use subagent::{SubagentExecutionRequest, launch_subagent}; use crate::ApplicationError; +/// 将 context 信息拼接到 task 前面,形成完整的执行指令。 +/// +/// context 非空时格式为 `{context}\n\n{task}`,context 为空时直接返回 task。 pub(super) fn merge_task_with_context(task: &str, context: Option<&str>) -> String { match context { Some(context) if !context.trim().is_empty() => { @@ -25,6 +28,10 @@ pub(super) fn merge_task_with_context(task: &str, context: Option<&str>) -> Stri } } +/// 校验 profile 的 mode 是否在允许列表内。 +/// +/// 根执行只允许 Primary / All,子代理执行只允许 SubAgent / All。 +/// 不匹配时返回带上下文的错误信息。 pub(super) fn ensure_profile_mode( profile: &AgentProfile, allowed_modes: &[AgentMode], diff --git a/crates/application/src/execution/root.rs b/crates/application/src/execution/root.rs index efe2afba..9f263725 100644 --- a/crates/application/src/execution/root.rs +++ b/crates/application/src/execution/root.rs @@ -140,6 +140,10 @@ fn validate_root_request(request: &RootExecutionRequest) -> Result<(), Applicati Ok(()) } +/// 校验根执行请求不支持 context overrides。 +/// +/// 根执行没有"父上下文"可继承,任何显式 overrides 都不会真正改变执行输入。 +/// 宁可明确拒绝,也不要伪装成"已接受但生效未知"。 fn validate_root_context_overrides_supported( overrides: Option<&SubagentContextOverrides>, ) -> Result<(), ApplicationError> { diff --git a/crates/application/src/execution/subagent.rs b/crates/application/src/execution/subagent.rs index ef9d762d..13b739bb 100644 --- a/crates/application/src/execution/subagent.rs +++ b/crates/application/src/execution/subagent.rs @@ -163,6 +163,11 @@ fn ensure_subagent_profile_mode(profile: &AgentProfile) -> Result<(), Applicatio ensure_profile_mode(profile, &[AgentMode::SubAgent, AgentMode::All], "subagent") } +/// 将 kernel spawn 错误映射为用户友好的应用层错误。 +/// +/// - MaxDepthExceeded → InvalidArgument(提示复用已有 child) +/// - MaxConcurrentExceeded → Conflict(提示等待或关闭已有 child) +/// - ParentAgentNotFound → NotFound fn map_spawn_error(error: AgentControlError) -> ApplicationError { match error { AgentControlError::MaxDepthExceeded { current, max } => { diff --git a/crates/application/src/governance_surface/mod.rs b/crates/application/src/governance_surface/mod.rs index b8af6a82..340fa72b 100644 --- a/crates/application/src/governance_surface/mod.rs +++ b/crates/application/src/governance_surface/mod.rs @@ -40,17 +40,29 @@ pub use prompt::{ use crate::{ApplicationError, CompiledModeEnvelope, ExecutionControl}; +/// Session busy 时的行为策略。 +/// +/// - `BranchOnBusy`:自动创建分支 session 继续处理(默认) +/// - `RejectOnBusy`:拒绝请求并返回错误 #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum GovernanceBusyPolicy { BranchOnBusy, RejectOnBusy, } +/// 审批管线状态。 +/// +/// 如果 mode 要求审批某些能力调用,`pending` 会携带一个 `ApprovalPending` 占位骨架, +/// 在实际执行前需要用户确认。 #[derive(Clone, PartialEq, Default)] pub struct GovernanceApprovalPipeline { pub pending: Option>, } +/// 编译完成的治理面,一次性消费的 turn 级上下文快照。 +/// +/// 包含工具白名单、审批管线、prompt declarations、注入消息、协作策略等全部治理决策。 +/// 通过 `into_submission()` 转换为 `AgentPromptSubmission` 供 session-runtime 消费。 #[derive(Clone)] pub struct ResolvedGovernanceSurface { pub mode_id: ModeId, diff --git a/crates/application/src/governance_surface/tests.rs b/crates/application/src/governance_surface/tests.rs index b9892f92..386c2907 100644 --- a/crates/application/src/governance_surface/tests.rs +++ b/crates/application/src/governance_surface/tests.rs @@ -1,3 +1,11 @@ +//! 治理面子域集成测试。 +//! +//! 验证 `GovernanceSurfaceAssembler` 在不同场景下的端到端行为: +//! - session turn 治理面构建与 prompt declarations 注入 +//! - fresh/resumed child 治理面继承与委派策略 +//! - 工具白名单、审批管线、协作策略上下文的正确性 +//! - 各种 capability selector(all / subset / none / union / difference)的编译结果 + use std::sync::Arc; use astrcode_core::{ diff --git a/crates/application/src/observability/collector.rs b/crates/application/src/observability/collector.rs index 61dd9d48..34c38743 100644 --- a/crates/application/src/observability/collector.rs +++ b/crates/application/src/observability/collector.rs @@ -1,3 +1,14 @@ +//! 运行时可观测性指标收集器。 +//! +//! `RuntimeObservabilityCollector` 实现 `RuntimeMetricsRecorder` 和 +//! `ObservabilitySnapshotProvider` 两个 trait,在内存中聚合运行时指标: +//! - 子代理执行计时与终态统计 +//! - 父级 reactivation 成功/失败计数 +//! - delivery buffer 队列状态 +//! - agent 协作事实追踪 +//! +//! 快照通过 `snapshot()` 返回不可变结构供 API 层消费。 + use std::{ collections::HashMap, sync::{ diff --git a/crates/application/src/ports/agent_kernel.rs b/crates/application/src/ports/agent_kernel.rs index 4fe18ad7..943373c2 100644 --- a/crates/application/src/ports/agent_kernel.rs +++ b/crates/application/src/ports/agent_kernel.rs @@ -1,3 +1,13 @@ +//! Agent 编排子域依赖的 kernel 稳定端口。 +//! +//! `AgentKernelPort` 继承 `AppKernelPort`,扩展了 agent 编排所需的全部 kernel 操作: +//! lifecycle 管理、子 agent spawn/resume/terminate、inbox 投递、parent delivery 队列。 +//! +//! 为什么单独抽 trait:`AgentOrchestrationService` 需要的控制面明显大于 `App`, +//! 避免 `AppKernelPort` 被动膨胀成新的大而全 façade。 +//! +//! 同时提供 `Kernel` 对 `AgentKernelPort` 的 blanket impl。 + use astrcode_core::{ AgentInboxEnvelope, AgentLifecycleStatus, AgentTurnOutcome, ChildSessionNotification, DelegationMetadata, SubRunHandle, diff --git a/crates/application/src/ports/agent_session.rs b/crates/application/src/ports/agent_session.rs index 039d4fa6..af5fd387 100644 --- a/crates/application/src/ports/agent_session.rs +++ b/crates/application/src/ports/agent_session.rs @@ -1,3 +1,13 @@ +//! Agent 编排子域依赖的 session 稳定端口。 +//! +//! `AgentSessionPort` 继承 `AppSessionPort`,扩展了 agent 协作编排所需的全部 session 操作: +//! child session 建立、prompt 提交(带 turn id)、durable input queue 管理、 +//! collaboration fact 追加、observe 快照、turn 终态等待。 +//! +//! 先按职责分组在一个端口中表达完整协作流程,未来根据演化决定是否继续瘦身。 +//! +//! 同时提供 `SessionRuntime` 对 `AgentSessionPort` 的 blanket impl。 + use astrcode_core::{ AgentCollaborationFact, AgentEventContext, AgentLifecycleStatus, ExecutionAccepted, InputBatchAckedPayload, InputBatchStartedPayload, InputDiscardedPayload, InputQueuedPayload, diff --git a/crates/application/src/ports/app_kernel.rs b/crates/application/src/ports/app_kernel.rs index 9d675084..0637c1e7 100644 --- a/crates/application/src/ports/app_kernel.rs +++ b/crates/application/src/ports/app_kernel.rs @@ -1,3 +1,12 @@ +//! `App` 依赖的 kernel 稳定端口。 +//! +//! 定义 `AppKernelPort` trait,将应用层与 kernel 具体实现解耦。 +//! `App` 只需要一组稳定的 agent 控制与 capability 查询契约, +//! 不直接绑定 `Kernel` 的内部结构。 +//! +//! 同时提供 `Kernel` 对 `AppKernelPort` 的 blanket impl, +//! 组合根在 `bootstrap_server_runtime()` 中只需一次 `Arc` 即可满足两个端口的约束。 + use astrcode_core::SubRunHandle; use astrcode_kernel::{ AgentControlError, CloseSubtreeResult, Kernel, KernelGateway, SubRunStatusView, diff --git a/crates/application/src/ports/app_session.rs b/crates/application/src/ports/app_session.rs index 15c35a86..24310a6f 100644 --- a/crates/application/src/ports/app_session.rs +++ b/crates/application/src/ports/app_session.rs @@ -1,3 +1,11 @@ +//! `App` 依赖的 session-runtime 稳定端口。 +//! +//! 定义 `AppSessionPort` trait,将应用层与 `SessionRuntime` 具体实现解耦。 +//! `App` 只编排 session 用例(创建、提交、快照、compact 等), +//! 不直接耦合 `SessionRuntime` 的内部状态管理。 +//! +//! 同时提供 `SessionRuntime` 对 `AppSessionPort` 的 blanket impl。 + use astrcode_core::{ ChildSessionNode, DeleteProjectResult, ExecutionAccepted, ResolvedRuntimeConfig, SessionId, SessionMeta, StoredEvent, diff --git a/crates/application/src/ports/composer_skill.rs b/crates/application/src/ports/composer_skill.rs index 212b3857..82c0b9ab 100644 --- a/crates/application/src/ports/composer_skill.rs +++ b/crates/application/src/ports/composer_skill.rs @@ -1,3 +1,9 @@ +//! Composer 输入补全的 skill 查询端口。 +//! +//! 定义 `ComposerSkillPort` trait 和 `ComposerResolvedSkill` 类型, +//! 将 composer 输入补全与 adapter-skills 的实现细节解耦。 +//! 应用层不应直接依赖 `adapter-skills`,而是通过此端口获取当前会话可见的 skill 信息。 + use std::path::Path; use crate::ComposerSkillSummary; diff --git a/crates/application/src/session_use_cases.rs b/crates/application/src/session_use_cases.rs index ca09ee3d..c59b2a73 100644 --- a/crates/application/src/session_use_cases.rs +++ b/crates/application/src/session_use_cases.rs @@ -108,6 +108,15 @@ impl App { .await } + /// 带 skill 调用选项的 prompt 提交。 + /// + /// 完整流程: + /// 1. 规范化文本(处理 skill invocation 与纯文本的交互) + /// 2. 校验 ExecutionControl 参数 + /// 3. 加载 runtime 配置 + 确保 session root agent context + /// 4. 若有 skill invocation,解析 skill 并构建 prompt declaration + /// 5. 构建治理面(工具白名单、审批策略、协作指导等) + /// 6. 委托 session-runtime 提交 prompt pub async fn submit_prompt_with_options( &self, session_id: &str, @@ -378,6 +387,11 @@ impl App { .map_err(ApplicationError::from) } + /// 确保 session 存在一个 root agent context,如果没有则自动注册隐式 root agent。 + /// + /// 查找逻辑:先通过 kernel 查找已有 handle,找不到则注册隐式 root agent + /// (ID 为 `root-agent:{session_id}`,profile 为 `default`)。 + /// 这是 prompt 提交前的前置步骤,保证 session 总有一个可用的 agent context。 pub(crate) async fn ensure_session_root_agent_context( &self, session_id: &str, @@ -458,6 +472,11 @@ fn normalize_prompt_control( Ok(control) } +/// 规范化 prompt 提交文本,处理 skill invocation 与纯文本的交互。 +/// +/// - 纯文本提交:不允许空文本 +/// - Skill invocation:文本可以为空(由 skill prompt 填充), 但如果同时提供了文本和 skill +/// userPrompt,两者必须一致 fn normalize_submission_text( text: String, skill_invocation: Option<&PromptSkillInvocation>, @@ -491,6 +510,10 @@ fn normalize_submission_text( } } +/// 为手动 compact 请求构建 ExecutionControl。 +/// +/// 强制设置 `manual_compact = true`(如果调用方未指定), +/// 因为 compact 的语义要求这个标志。 fn normalize_compact_control(control: Option) -> Option { let mut control = control.unwrap_or(ExecutionControl { max_steps: None, diff --git a/crates/application/src/terminal_queries/tests.rs b/crates/application/src/terminal_queries/tests.rs index 11aaa87b..24548d1a 100644 --- a/crates/application/src/terminal_queries/tests.rs +++ b/crates/application/src/terminal_queries/tests.rs @@ -1,3 +1,11 @@ +//! 终端查询子域集成测试。 +//! +//! 验证终端查询在完整应用栈上的端到端行为,使用真实的 `App` 组装 +//! (而非 mock),覆盖: +//! - 会话恢复候选列表过滤 +//! - 快照查询与游标比较 +//! - 终端摘要提取 + use std::{path::Path, sync::Arc, time::Duration}; use astrcode_core::AgentEvent; diff --git a/crates/core/src/action.rs b/crates/core/src/action.rs index adb9a7a7..ef208b61 100644 --- a/crates/core/src/action.rs +++ b/crates/core/src/action.rs @@ -119,6 +119,8 @@ impl ToolExecutionResult { /// /// 成功时直接返回输出;失败时拼接错误信息和输出, /// 确保 LLM 能理解工具执行的结果。 + /// 如果关联了子 agent(child_ref),追加精确引用提示, + /// 防止 LLM 自作主张改写 agentId。 pub fn model_content(&self) -> String { let base = if self.ok { self.output.clone() diff --git a/crates/core/src/agent/mod.rs b/crates/core/src/agent/mod.rs index 78f8bb6f..270f264f 100644 --- a/crates/core/src/agent/mod.rs +++ b/crates/core/src/agent/mod.rs @@ -1331,6 +1331,12 @@ impl AgentEventContext { } /// 校验该上下文是否适合作为 durable StorageEvent 的 agent 头部。 + /// + /// 校验规则: + /// - RootExecution:必须有 agent_id + agent_profile,不能有任何 sub-run 字段 + /// - SubRun:必须有 agent_id + parent_turn_id + agent_profile + sub_run_id, 且必须是带 + /// child_session_id 的 IndependentSession + /// - 非空上下文必须声明 invocation_kind pub fn validate_for_storage_event(&self) -> Result<()> { if self.is_empty() { return Ok(()); diff --git a/crates/core/src/composer.rs b/crates/core/src/composer.rs index a2bba407..4b95c362 100644 --- a/crates/core/src/composer.rs +++ b/crates/core/src/composer.rs @@ -1,3 +1,8 @@ +//! # 输入候选项模型 +//! +//! 定义 Composer 输入面板的候选项数据结构。 +//! 候选项可以来自命令、技能或能力声明,用户选择后执行对应的插入或命令动作。 + use serde::{Deserialize, Serialize}; /// 输入候选项的来源类别。 diff --git a/crates/core/src/config.rs b/crates/core/src/config.rs index 5a321c39..066288a2 100644 --- a/crates/core/src/config.rs +++ b/crates/core/src/config.rs @@ -597,6 +597,10 @@ pub fn max_tool_concurrency() -> usize { .max(1) } +/// 从 `Option` 解析出完整的 `ResolvedAgentConfig`。 +/// +/// 逐字段用用户配置覆盖默认值,缺失字段使用默认值。 +/// 所有数值型字段都有 `.max(1)` 保护,防止配置为 0 导致除零或无限循环。 pub fn resolve_agent_config(agent: Option<&AgentConfig>) -> ResolvedAgentConfig { let defaults = ResolvedAgentConfig::default(); ResolvedAgentConfig { @@ -626,6 +630,10 @@ pub fn resolve_agent_config(agent: Option<&AgentConfig>) -> ResolvedAgentConfig } } +/// 从 `RuntimeConfig` 解析出完整的 `ResolvedRuntimeConfig`。 +/// +/// 与 `resolve_agent_config` 类似,逐字段覆盖 + 默认值兜底 + 下界保护。 +/// `compact_threshold_percent` 额外 `.clamp(1, 100)` 防止百分比越界。 pub fn resolve_runtime_config(runtime: &RuntimeConfig) -> ResolvedRuntimeConfig { let defaults = ResolvedRuntimeConfig::default(); ResolvedRuntimeConfig { diff --git a/crates/core/src/event/phase.rs b/crates/core/src/event/phase.rs index 76046088..0dbf30e6 100644 --- a/crates/core/src/event/phase.rs +++ b/crates/core/src/event/phase.rs @@ -67,9 +67,14 @@ pub fn normalize_recovered_phase(phase: Phase) -> Phase { /// Stateful phase tracker. /// -/// Call [`Self::on_event`] whenever a new `StorageEvent` arrives. If the event -/// causes a phase transition you'll get back `Some(AgentEvent::PhaseChanged)` -/// and should push it *before* the primary event record. +/// 维护当前会话阶段状态,在阶段实际变更时才发出 `PhaseChanged` 事件。 +/// 这是 SSE 推送和前端状态指示器的唯一 phase 来源。 +/// +/// 关键设计: +/// - 内部唤醒消息(AutoContinueNudge / QueuedInput / ContinuationPrompt / ReactivationPrompt / +/// CompactSummary)不触发 phase 变更,避免 UI 闪烁 +/// - 辅助事件(PromptMetrics / CompactApplied / SubRun 等)也不触发 phase 变更 +/// - `force_to` 用于 SessionStart → Idle 和 TurnDone → Idle 这类必须变更的场景 pub struct PhaseTracker { current: Phase, } @@ -79,8 +84,10 @@ impl PhaseTracker { Self { current: initial } } - /// Process a storage event and return a `PhaseChanged` event if the phase - /// actually changed. + /// 处理存储事件,若阶段实际变更则返回 `PhaseChanged` 事件。 + /// + /// 返回的事件应在主事件之前推送(before-push), + /// 这样前端先收到 PhaseChanged 再收到实际内容,保证状态指示器及时更新。 pub fn on_event( &mut self, event: &StorageEvent, @@ -137,8 +144,10 @@ impl PhaseTracker { self.current } - /// Force a phase transition. Used by SessionStart and TurnDone where the - /// phase must change regardless of the event type alone. + /// 强制切换到指定阶段,无视事件类型推断。 + /// + /// 用于 SessionStart(必须到 Idle)和 TurnDone(必须回到 Idle)等 + /// 不依赖事件类型就能确定目标阶段的场景。 pub fn force_to( &mut self, phase: Phase, diff --git a/crates/core/src/event/translate.rs b/crates/core/src/event/translate.rs index 18c0d5be..4fe6fab5 100644 --- a/crates/core/src/event/translate.rs +++ b/crates/core/src/event/translate.rs @@ -25,7 +25,10 @@ use crate::{ ToolExecutionResult, UserMessageOrigin, session::SessionEventRecord, split_assistant_content, }; -/// 回放存储事件为会话事件记录 +/// 批量回放存储事件为会话事件记录。 +/// +/// 用于 `/history` 端点和冷启动恢复:将持久化的 `StoredEvent` 序列 +/// 经过 `EventTranslator` 转换为前端可消费的 `AgentEvent` 记录。 /// /// ## 断点续传 /// @@ -472,6 +475,15 @@ impl EventTranslator { } } + /// 从存储事件中提取或推断 turn_id。 + /// + /// 策略: + /// - 事件自身携带 turn_id → 直接使用并缓存为当前 turn + /// - SessionStart → 返回 None(会话启动事件不属于任何 turn) + /// - 其他无 turn_id 的事件 → 复用上一条事件的 turn_id + /// + /// 这样即使部分辅助事件(如 CompactApplied)未携带 turn_id, + /// 也能正确归属到当前 turn 上下文中。 fn turn_id_for(&mut self, event: &StorageEvent) -> Option { if let Some(turn_id) = event.turn_id() { let turn_id = turn_id.to_string(); diff --git a/crates/core/src/execution_result.rs b/crates/core/src/execution_result.rs index 46e5105e..460cf912 100644 --- a/crates/core/src/execution_result.rs +++ b/crates/core/src/execution_result.rs @@ -1,3 +1,8 @@ +//! # 执行结果公共字段 +//! +//! 提取工具结果与能力结果中共享的 `error / metadata / duration_ms / truncated` 字段, +//! 避免两套结果类型平行复制相同字段。 + use serde::{Deserialize, Serialize}; use serde_json::Value; diff --git a/crates/core/src/ids.rs b/crates/core/src/ids.rs index 93673817..15f423b1 100644 --- a/crates/core/src/ids.rs +++ b/crates/core/src/ids.rs @@ -1,7 +1,10 @@ -//! 强类型标识定义。 +//! # 强类型标识定义 //! -//! 这些 newtype 负责把会话、turn、agent、能力名称从裸字符串中剥离出来, -//! 避免跨层 API 继续依赖脆弱的字符串约定。 +//! 通过宏批量生成 newtype 包装,将 SessionId / TurnId / AgentId / SubRunId / +//! DeliveryId / CapabilityName 从裸字符串中剥离,避免跨层 API 依赖脆弱的字符串约定。 +//! +//! 每个 ID 类型实现了 Display、Deref、From、Serialize、Deserialize +//! 等标准 trait,可直接用于格式化、比较和序列化。 use std::fmt; diff --git a/crates/core/src/local_server.rs b/crates/core/src/local_server.rs index f6d015cc..54daed8c 100644 --- a/crates/core/src/local_server.rs +++ b/crates/core/src/local_server.rs @@ -1,3 +1,8 @@ +//! # Local Server 引导协议 +//! +//! 定义 Desktop shell 与 sidecar server 之间的发现和引导 DTO。 +//! 统一 `run.json` 文件写入和 stdout ready 行协议,避免两套近似字段随时间漂移。 + use serde::{Deserialize, Serialize}; /// Desktop shell 和 sidecar server 之间用于发现和引导的共享载荷。 @@ -7,7 +12,7 @@ use serde::{Deserialize, Serialize}; /// - `astrcode-server` 通过 stdout 发出 ready 事件,供桌面端进程同步等待 /// /// 统一 DTO 的目的是避免两个进程各自维护一套近似字段,随着时间漂移后 -/// 出现“run.json 能读、ready 行却解析失败”之类的隐蔽兼容问题。 +/// 出现”run.json 能读、ready 行却解析失败”之类的隐蔽兼容问题。 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct LocalServerInfo { diff --git a/crates/core/src/mode/mod.rs b/crates/core/src/mode/mod.rs index 3663b129..e407081d 100644 --- a/crates/core/src/mode/mod.rs +++ b/crates/core/src/mode/mod.rs @@ -1,3 +1,18 @@ +//! # 声明式治理模式系统 +//! +//! 定义运行时治理模式(Governance Mode)的完整 DSL: +//! +//! - **ModeId**: 模式唯一标识(内置 code / plan / review) +//! - **CapabilitySelector**: 能力选择器 DSL(支持 AllTools / Name / Kind / Tag / Union / +//! Intersection / Difference) +//! - **ActionPolicies**: 动作策略规则集(Allow / Deny / Ask 三种裁决效果) +//! - **GovernanceModeSpec**: 完整模式定义(能力表面 + 动作策略 + 子策略 + 执行策略 + 提示词程序 + +//! 转换策略) +//! - **ResolvedTurnEnvelope**: 运行时 turn 级解析后的完整治理信封 +//! +//! 模式由声明式配置文件加载,运行时通过 `GovernanceModeSpec::validate()` 校验后, +//! 由治理层解析为 `ResolvedTurnEnvelope` 注入每个 turn 的执行上下文。 + use serde::{Deserialize, Serialize}; use crate::{ diff --git a/crates/core/src/observability.rs b/crates/core/src/observability.rs index bf5a4a9a..50926799 100644 --- a/crates/core/src/observability.rs +++ b/crates/core/src/observability.rs @@ -1,3 +1,19 @@ +//! # 运行时可观测性 +//! +//! 定义运行时指标快照和记录接口,用于监控运行时健康状况和性能。 +//! +//! ## 快照类型 +//! +//! - `OperationMetricsSnapshot`: 单一操作的计数/耗时/失败率 +//! - `ReplayMetricsSnapshot`: SSE 回放操作的缓存命中/磁盘回退 +//! - `SubRunExecutionMetricsSnapshot`: 子执行域的完成/取消/token 超限统计 +//! - `ExecutionDiagnosticsSnapshot`: 子会话生命周期和缓存切换的结构化诊断 +//! - `AgentCollaborationScorecardSnapshot`: agent-tool 协作效果的评估读模型 +//! - `RuntimeObservabilitySnapshot`: 聚合所有指标的顶层快照 +//! +//! `RuntimeMetricsRecorder` 是窄写入接口,业务层只通过它记录事实, +//! 不反向依赖具体快照实现。 + use crate::{AgentCollaborationFact, AgentTurnOutcome, SubRunStorageMode}; /// 回放路径:优先缓存,不足时回退到磁盘。 diff --git a/crates/core/src/plugin/registry.rs b/crates/core/src/plugin/registry.rs index 4d8be38b..607e237a 100644 --- a/crates/core/src/plugin/registry.rs +++ b/crates/core/src/plugin/registry.rs @@ -190,8 +190,10 @@ impl PluginRegistry { /// 记录插件运行时失败事件。 /// - /// 失败计数递增;连续失败 3 次及以上时健康状态降级为 `Unavailable`, - /// 否则标记为 `Degraded`。用于实现渐进式健康度评估。 + /// 实现渐进式健康度评估: + /// - 1~2 次失败 → `Degraded`(降级但不完全禁用,后续成功可恢复) + /// - 3 次及以上 → `Unavailable`(完全禁用,需要人工或自动恢复机制介入) + /// - 非 Initialized 状态的插件 → 直接 `Unavailable` pub fn record_runtime_failure( &self, name: &str, diff --git a/crates/core/src/project.rs b/crates/core/src/project.rs index b5d967ef..66058192 100644 --- a/crates/core/src/project.rs +++ b/crates/core/src/project.rs @@ -86,14 +86,13 @@ fn normalize_project_identity(path: &Path) -> PathBuf { } } -/// 将路径组件转换为 kebab-case slug +/// 将路径组件转换为 kebab-case slug。 /// -/// ## 处理规则 -/// -/// - **Prefix**(Windows 盘符): `D:` → `D`,UNC 路径 → `server-share` -/// - **RootDir**: 根目录 → `root`(Windows 下后续会移除) -/// - **Normal**: 清理非法字符,连续非法字符合并为单个 `-` -/// - **CurDir/ParentDir**: 忽略(`.` 和 `..`) +/// 处理各种操作系统的路径怪异情况: +/// - **Prefix**(Windows 盘符): `D:` → `D`,UNC `\\server\share` → `server-share` +/// - **RootDir**: 根目录标记 → `root`(Windows 下后续会移除,因为盘符已够用) +/// - **Normal**: 将非法文件名字符替换为 `-`,连续非法字符合并为单个 `-` +/// - **CurDir/ParentDir**: 忽略(`.` 和 `..` 不参与 slug) fn components_to_slug(path: &Path) -> String { let mut segments = Vec::new(); @@ -185,6 +184,10 @@ fn sanitize_component(value: &str) -> String { .collect::() } +/// 基于路径生成稳定的 8 字符 hex hash。 +/// +/// 使用 UUID v5(namespace URL + 路径内容), +/// 相同路径始终产生相同 hash,用于超长路径名的唯一截断标识。 fn stable_project_hash(path: &Path) -> String { let source = path.to_string_lossy(); Uuid::new_v5(&Uuid::NAMESPACE_URL, source.as_bytes()) diff --git a/crates/core/src/projection/agent_state.rs b/crates/core/src/projection/agent_state.rs index 00cf4a77..9304face 100644 --- a/crates/core/src/projection/agent_state.rs +++ b/crates/core/src/projection/agent_state.rs @@ -257,6 +257,11 @@ impl AgentStateProjector { clone.state } + /// 将累积中的 assistant 内容刷入消息历史。 + /// + /// 在遇到 UserMessage / ToolResult / TurnDone / CompactApplied 时调用, + /// 确保前一轮 assistant 的文本和工具调用先落袋为安, + /// 再处理新消息类型的开始。 fn flush_pending_assistant(&mut self) { if self.pending_content.is_some() || !self.pending_tool_calls.is_empty() { let content = self.pending_content.take().unwrap_or_default(); @@ -268,6 +273,13 @@ impl AgentStateProjector { } } + /// 应用上下文压缩:将旧消息前缀替换为摘要,保留最近 N 轮。 + /// + /// 执行步骤: + /// 1. 确定裁剪位置(优先使用 `messages_removed` 精确回放,兼容旧日志回退到 + /// `preserved_recent_turns`) + /// 2. `split_off` 切分:前半段丢弃,后半段保留 + /// 3. 在头部插入 compact summary 消息作为上下文衔接 fn apply_compaction( &mut self, summary: &str, @@ -310,7 +322,10 @@ impl AgentStateProjector { } /// 从消息列表末尾向前扫描,找到第 N 个 User-origin 消息的位置。 -/// 用途:定义"保留最近 N 轮"的裁剪边界,User-origin 消息视为 turn 起点。 +/// +/// 用于定义"保留最近 N 轮"的裁剪边界: +/// 从末尾往前数第 `preserved_recent_turns` 个 User 消息即为保留起点。 +/// 如果不足 N 个 User 消息,返回第一个 User 消息的位置。 fn recent_turn_start_index( messages: &[LlmMessage], preserved_recent_turns: usize, @@ -337,8 +352,10 @@ fn recent_turn_start_index( last_index } -/// Pure function: project an event sequence into an AgentState. -/// No IO, no side effects. +/// 纯函数:将事件序列投影为 AgentState。 +/// +/// 无 IO、无副作用——相同输入总是产生相同输出。 +/// 适用于冷启动恢复、`/history` 回放和状态快照获取。 pub fn project(events: &[StorageEvent]) -> AgentState { AgentStateProjector::from_events(events).snapshot() } diff --git a/crates/core/src/registry/router.rs b/crates/core/src/registry/router.rs index bcc89215..85745f9f 100644 --- a/crates/core/src/registry/router.rs +++ b/crates/core/src/registry/router.rs @@ -136,10 +136,10 @@ impl CapabilityExecutionResult { } } - /// 转换为 LLM 工具执行结果。 + /// 将通用能力执行结果转换为 LLM 工具执行结果。 /// - /// 将通用的能力执行结果映射为 `ToolExecutionResult`, - /// 以便前端渲染工具调用卡片。 + /// 填充 tool_call_id 并将 JSON 输出序列化为可读文本, + /// 使结果能直接用于前端工具卡片渲染和 LLM 上下文回传。 pub fn into_tool_execution_result(self, tool_call_id: String) -> ToolExecutionResult { let output = self.output_text(); ToolExecutionResult { diff --git a/crates/core/src/runtime/coordinator.rs b/crates/core/src/runtime/coordinator.rs index cdbc6cd5..e3a6d6c3 100644 --- a/crates/core/src/runtime/coordinator.rs +++ b/crates/core/src/runtime/coordinator.rs @@ -103,11 +103,14 @@ impl RuntimeCoordinator { ) } - /// 原子替换运行时表面。 + /// 原子替换运行时表面(插件热重载核心方法)。 /// - /// 一次性更新插件注册表快照、能力列表和托管组件列表, - /// 用于插件热重载或运行时切换。返回旧的托管组件列表, - /// 调用方负责关闭这些旧组件。 + /// 一次性替换三样东西:插件注册表快照、能力描述符列表、托管组件列表。 + /// 返回旧的托管组件列表,调用方负责逐个关闭它们。 + /// + /// 为什么需要原子替换:如果逐项更新,中间状态会导致: + /// - 新插件已注册但旧能力描述符还在 → 路由找不到能力 + /// - 旧插件已清空但旧组件还在引用 → 悬垂引用 pub fn replace_runtime_surface( &self, plugin_entries: Vec, @@ -129,8 +132,9 @@ impl RuntimeCoordinator { /// 关闭运行时和所有托管组件。 /// - /// 按确定顺序执行:先关闭运行时句柄,再逐个关闭托管组件。 - /// 所有失败会被收集并合并为单个错误返回。 + /// 关闭顺序是确定性的:先关闭运行时句柄(停止接收新请求), + /// 再逐个关闭托管组件(释放资源)。所有失败会被收集并合并 + /// 为单个错误返回——即使某个组件关闭失败,仍会尝试关闭剩余组件。 pub async fn shutdown(&self, timeout_secs: u64) -> Result<()> { let mut failures = Vec::new(); diff --git a/crates/core/src/session_catalog.rs b/crates/core/src/session_catalog.rs index 4c299a7c..33504578 100644 --- a/crates/core/src/session_catalog.rs +++ b/crates/core/src/session_catalog.rs @@ -1,3 +1,8 @@ +//! # Session Catalog 事件 +//! +//! 定义会话目录变更通知事件,用于向前端和其他订阅者广播 +//! session 的创建、删除、分支等生命周期变化。 + use serde::{Deserialize, Serialize}; /// Session catalog 变更事件,用于通知外部订阅者 session 列表变化。 diff --git a/crates/core/src/shell.rs b/crates/core/src/shell.rs index 5b300497..5e8ceef9 100644 --- a/crates/core/src/shell.rs +++ b/crates/core/src/shell.rs @@ -1,3 +1,21 @@ +//! # Shell 检测与解析 +//! +//! 自动检测当前平台的默认 Shell,并支持用户指定的 Shell 覆盖。 +//! +//! ## 支持的 Shell 类型 +//! +//! - **PowerShell**: `pwsh` / `powershell` +//! - **Cmd**: `cmd` +//! - **Posix**: `bash` / `zsh` / `sh` +//! - **Wsl**: Windows WSL bash +//! +//! ## 检测策略(Windows 优先级) +//! +//! 1. `$env:SHELL` 环境变量(支持 Git Bash / WSL 环境检测) +//! 2. Git Bash 磁盘路径探测 +//! 3. `wsl.exe` / `wsl` 命令探测 +//! 4. `pwsh` / `powershell` 兜底 + #[cfg(windows)] use std::path::PathBuf; use std::{env, path::Path, process::Command, sync::OnceLock}; diff --git a/crates/core/src/store.rs b/crates/core/src/store.rs index 76656c56..f29669c1 100644 --- a/crates/core/src/store.rs +++ b/crates/core/src/store.rs @@ -76,16 +76,21 @@ pub trait EventLogWriter: Send + Sync { fn append(&mut self, event: &StorageEvent) -> StoreResult; } -/// 跨进程 session turn 执行租约。 +/// 跨进程 session turn 执行租约(RAII 语义)。 /// -/// 该 trait 故意不暴露行为方法,只依赖 RAII 语义:当租约对象被 drop 时, -/// 底层实现必须释放对应的跨进程 session 锁。这样 runtime 无需了解 -/// 文件锁、命名锁等具体机制,只需要持有租约直到 turn 结束。 +/// 该 trait 故意不暴露行为方法,完全依赖 RAII 语义: +/// 当租约对象被 drop 时,底层实现必须释放对应的跨进程 session 锁。 +/// 这样 runtime 无需了解文件锁、命名锁等具体机制, +/// 只需要持有租约直到 turn 结束即可保证互斥。 pub trait SessionTurnLease: Send + Sync {} /// 另一个执行者已经持有该 session 的 turn 执行权。 /// -/// `turn_id` 是 branch 逻辑的关键输入:后发请求需要从「最后一个稳定完成的 turn」 +/// 这是 **正常并发竞争**(不是错误):当多个进程或 tab 同时向同一 session +/// 发送 prompt 时,只有第一个获得锁,后续请求拿到 `Busy` 后 +/// 可以选择自动分叉新 session 继续执行。 +/// +/// `turn_id` 是分支逻辑的关键输入:后发请求需要从「最后一个稳定完成的 turn」 /// 分叉,所以必须知道当前正在进行的是哪个 turn,才能在复制历史时排除其事件。 #[derive(Debug, Clone, PartialEq, Eq)] pub struct SessionTurnBusy { @@ -94,6 +99,7 @@ pub struct SessionTurnBusy { pub acquired_at: DateTime, } +/// Turn 获取结果:要么成功拿到锁(Acquired),要么被另一个执行者占着(Busy)。 pub enum SessionTurnAcquireResult { Acquired(Box), Busy(SessionTurnBusy), @@ -119,6 +125,7 @@ pub trait SessionManager: Send + Sync { /// 尝试获取某个 session 的 turn 执行权。 /// /// 获取失败不算错误,而是返回 `Busy`,让调用方可以选择自动分叉新 session。 + /// 这是实现多 tab 并发 prompt 的核心机制。 fn try_acquire_turn( &self, session_id: &str, diff --git a/crates/core/src/tool_result_persist.rs b/crates/core/src/tool_result_persist.rs index f0b2999e..ab71c891 100644 --- a/crates/core/src/tool_result_persist.rs +++ b/crates/core/src/tool_result_persist.rs @@ -173,10 +173,13 @@ fn camel_to_screaming_snake(s: &str) -> String { /// 实际写磁盘操作。 /// -/// 包含完整的降级链路: -/// 1. `create_dir_all` 失败 → 截断预览 -/// 2. `fs::write` 失败 → 截断预览 +/// 包含完整的降级链路——任何一步失败都不会 panic: +/// 1. `create_dir_all` 失败 → 降级为截断预览 +/// 2. `fs::write` 失败 → 降级为截断预览 /// 3. 成功 → 生成 `` 短引用 + 结构化 persisted metadata +/// +/// 工具调用 ID 会被清洗(只保留字母数字和 `-_`,取前 64 字符), +/// 防止路径穿越攻击(如 `../../etc/passwd`)。 fn write_to_disk(session_dir: &Path, tool_call_id: &str, content: &str) -> PersistedToolResult { let content_bytes = content.len(); let results_dir = session_dir.join(TOOL_RESULTS_DIR); @@ -230,7 +233,11 @@ fn write_to_disk(session_dir: &Path, tool_call_id: &str, content: &str) -> Persi } } -/// 生成 `` 格式的短引用。 +/// 生成 `` 格式的短引用文本。 +/// +/// 该文本会替换原始工具结果进入消息历史,LLM 看到的是这段引用 +/// 而非完整内容。引用中包含路径、大小和建议的首次读取参数, +/// 引导 LLM 使用 readFile 按需读取。 fn format_persisted_output(persisted: &PersistedToolOutput) -> String { format!( "\nLarge tool output was saved to a file instead of being \ From d6190c0e7ff175595d7d7339f5d976356cbab6db Mon Sep 17 00:00:00 2001 From: whatevertogo Date: Sun, 19 Apr 2026 15:02:50 +0800 Subject: [PATCH 40/53] =?UTF-8?q?=E2=9C=A8=20feat(tools):=20=E5=85=81?= =?UTF-8?q?=E8=AE=B8=E5=B7=A5=E5=85=B7=E8=AE=BF=E9=97=AE=E5=B7=A5=E4=BD=9C?= =?UTF-8?q?=E5=8C=BA=E5=A4=96=E7=9A=84=E6=96=87=E4=BB=B6=E8=B7=AF=E5=BE=84?= =?UTF-8?q?=EF=BC=8C=E5=A2=9E=E5=BC=BA=E7=81=B5=E6=B4=BB=E6=80=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../adapter-agents/src/builtin_agents/plan.md | 92 ------------------- crates/adapter-agents/src/lib.rs | 4 - .../src/builtin_tools/apply_patch.rs | 36 +++++++- .../src/builtin_tools/edit_file.rs | 40 +++++++- .../src/builtin_tools/find_files.rs | 64 ++++++++++--- .../src/builtin_tools/fs_common.rs | 61 ++++++++---- .../adapter-tools/src/builtin_tools/grep.rs | 37 +++++++- .../src/builtin_tools/list_dir.rs | 34 ++++++- .../src/builtin_tools/read_file.rs | 34 +++++++ .../adapter-tools/src/builtin_tools/shell.rs | 47 +++++++++- .../src/builtin_tools/write_file.rs | 39 +++++++- docs/ideas/notes.md | 4 +- 12 files changed, 348 insertions(+), 144 deletions(-) delete mode 100644 crates/adapter-agents/src/builtin_agents/plan.md diff --git a/crates/adapter-agents/src/builtin_agents/plan.md b/crates/adapter-agents/src/builtin_agents/plan.md deleted file mode 100644 index bfd25c0c..00000000 --- a/crates/adapter-agents/src/builtin_agents/plan.md +++ /dev/null @@ -1,92 +0,0 @@ ---- -name: plan -description: 分析需求并生成计划,不执行改写。 -tools: ["readFile", "grep"] ---- -You are a PLANNING AGENT, pairing with the user to create a detailed, actionable plan. - -You research the codebase → clarify with the user → capture findings and decisions into a comprehensive plan. This iterative approach catches edge cases and non-obvious requirements BEFORE implementation begins. - -Your SOLE responsibility is planning. NEVER start implementation. - -**Current plan**: `.astrcode/plans/plan.md` - update or write it using tools. - - -- STOP if you consider running file editing tools — plans are for others to execute. The only write tool you have is #tool:vscode/memory for persisting plans. -- Use #tool:vscode/askQuestions freely to clarify requirements — don't make large assumptions -- Present a well-researched plan with loose ends tied BEFORE implementation - - - -Cycle through these phases based on user input. This is iterative, not linear. If the user task is highly ambiguous, do only *Discovery* to outline a draft plan, then move on to alignment before fleshing out the full plan. - -## 1. Discovery - -Run the *Explore* subagent to gather context, analogous existing features to use as implementation templates, and potential blockers or ambiguities. When the task spans multiple independent areas (e.g., frontend + backend, different features, separate repos), launch **2-3 *Explore* subagents in parallel** — one per area — to speed up discovery. - -Update the plan with your findings. - -## 2. Alignment - -If research reveals major ambiguities or if you need to validate assumptions: -- Use #tool:vscode/askQuestions to clarify intent with the user. -- Surface discovered technical constraints or alternative approaches -- If answers significantly change the scope, loop back to **Discovery** - -## 3. Design - -Once context is clear, draft a comprehensive implementation plan. - -The plan should reflect: -- Structured concise enough to be scannable and detailed enough for effective execution -- Step-by-step implementation with explicit dependencies — mark which steps can run in parallel vs. which block on prior steps -- For plans with many steps, group into named phases that are each independently verifiable -- Verification steps for validating the implementation, both automated and manual -- Critical architecture to reuse or use as reference — reference specific functions, types, or patterns, not just file names -- Critical files to be modified (with full paths) -- Explicit scope boundaries — what's included and what's deliberately excluded -- Reference decisions from the discussion -- Leave no ambiguity - -Save the comprehensive plan document to `/memories/session/plan.md` via #tool:vscode/memory, then show the scannable plan to the user for review. You MUST show plan to the user, as the plan file is for persistence only, not a substitute for showing it to the user. - -## 4. Refinement - -On user input after showing the plan: -- Changes requested → revise and present updated plan. Update `/memories/session/plan.md` to keep the documented plan in sync -- Questions asked → clarify, or use #tool:vscode/askQuestions for follow-ups -- Alternatives wanted → loop back to **Discovery** with new subagent -- Approval given → acknowledge, the user can now use handoff buttons - -Keep iterating until explicit approval or handoff. - - - -```markdown -## Plan: {Title (2-10 words)} - -{TL;DR - what, why, and how (your recommended approach).} - -**Steps** -1. {Implementation step-by-step — note dependency ("*depends on N*") or parallelism ("*parallel with step N*") when applicable} -2. {For plans with 5+ steps, group steps into named phases with enough detail to be independently actionable} - -**Relevant files** -- `{full/path/to/file}` — {what to modify or reuse, referencing specific functions/patterns} - -**Verification** -1. {Verification steps for validating the implementation (**Specific** tasks, tests, commands, MCP tools, etc; not generic statements)} - -**Decisions** (if applicable) -- {Decision, assumptions, and includes/excluded scope} - -**Further Considerations** (if applicable, 1-3 items) -1. {Clarifying question with recommendation. Option A / Option B / Option C} -2. {…} -``` - -Rules: -- NO code blocks — describe changes, link to files and specific symbols/functions -- NO blocking questions at the end — ask during workflow via #tool:vscode/askQuestions -- The plan MUST be presented to the user, don't just mention the plan file. - \ No newline at end of file diff --git a/crates/adapter-agents/src/lib.rs b/crates/adapter-agents/src/lib.rs index 365f94de..c0482b71 100644 --- a/crates/adapter-agents/src/lib.rs +++ b/crates/adapter-agents/src/lib.rs @@ -253,10 +253,6 @@ fn builtin_agents() -> &'static [BuiltinAgent] { path: "builtin://explore.md", content: include_str!("builtin_agents/explore.md"), }, - BuiltinAgent { - path: "builtin://plan.md", - content: include_str!("builtin_agents/plan.md"), - }, BuiltinAgent { path: "builtin://reviewer.md", content: include_str!("builtin_agents/reviewer.md"), diff --git a/crates/adapter-tools/src/builtin_tools/apply_patch.rs b/crates/adapter-tools/src/builtin_tools/apply_patch.rs index e62a6736..503a9c32 100644 --- a/crates/adapter-tools/src/builtin_tools/apply_patch.rs +++ b/crates/adapter-tools/src/builtin_tools/apply_patch.rs @@ -540,10 +540,13 @@ async fn apply_file_patch(file_patch: &FilePatch, ctx: &ToolContext) -> FileChan path: target_path_str.clone(), applied: false, summary: format!( - "refusing to patch symlink '{}' (symlinks may point outside working directory)", + "refusing to patch symlink '{}' (symlinks may point outside the intended target \ + path)", target_path.display() ), - error: Some("refusing to patch symlink (may point outside working directory)".into()), + error: Some( + "refusing to patch symlink (may point outside the intended target path)".into(), + ), }; } @@ -716,7 +719,7 @@ impl Tool for ApplyPatchTool { ToolCapabilityMetadata::builtin() .tags(["filesystem", "write", "patch", "diff"]) .permission("filesystem.write") - .side_effect(SideEffect::Workspace) + .side_effect(SideEffect::Local) .prompt( ToolPromptMetadata::new( "Apply a unified diff patch across one or more files.", @@ -1128,6 +1131,33 @@ mod tests { assert_eq!(content, "fn foo() {\r\n new();\r\n}\r\n"); } + #[tokio::test] + async fn apply_patch_allows_relative_path_outside_working_dir() { + let parent = tempfile::tempdir().expect("tempdir"); + let workspace = parent.path().join("workspace"); + tokio::fs::create_dir_all(&workspace) + .await + .expect("workspace should be created"); + let tool = ApplyPatchTool; + + let patch = "--- /dev/null\n+++ b/../outside.txt\n@@ -0,0 +1,1 @@\n+outside patch\n"; + + let result = tool + .execute( + "tc-patch-outside".into(), + json!({ "patch": patch }), + &test_tool_context_for(&workspace), + ) + .await + .expect("should execute"); + + assert!(result.ok, "should succeed: {}", result.output); + let content = tokio::fs::read_to_string(parent.path().join("outside.txt")) + .await + .expect("outside file should be readable"); + assert_eq!(content, "outside patch"); + } + #[tokio::test] async fn apply_patch_delete_validates_existing_content() { let temp = tempfile::tempdir().expect("tempdir"); diff --git a/crates/adapter-tools/src/builtin_tools/edit_file.rs b/crates/adapter-tools/src/builtin_tools/edit_file.rs index 12c83987..350297a6 100644 --- a/crates/adapter-tools/src/builtin_tools/edit_file.rs +++ b/crates/adapter-tools/src/builtin_tools/edit_file.rs @@ -143,7 +143,7 @@ impl Tool for EditFileTool { "properties": { "path": { "type": "string", - "description": "File path to edit (relative to workspace or absolute)." + "description": "File path to edit (relative to the current working directory or absolute)." }, "oldStr": { "type": "string", @@ -183,7 +183,7 @@ impl Tool for EditFileTool { ToolCapabilityMetadata::builtin() .tags(["filesystem", "write", "edit"]) .permission("filesystem.write") - .side_effect(SideEffect::Workspace) + .side_effect(SideEffect::Local) .prompt( ToolPromptMetadata::new( "Apply a narrow, safety-checked string replacement inside an existing file.", @@ -306,7 +306,8 @@ impl Tool for EditFileTool { ok: false, output: String::new(), error: Some(format!( - "refusing to edit symlink '{}' (symlinks may point outside working directory)", + "refusing to edit symlink '{}' (symlinks may point outside the intended \ + target path)", path.display() )), metadata: Some(json!({ @@ -906,6 +907,39 @@ mod tests { assert_eq!(meta["editsApplied"], json!(2)); } + #[tokio::test] + async fn edit_file_allows_relative_path_outside_working_dir() { + let parent = tempfile::tempdir().expect("tempdir should be created"); + let workspace = parent.path().join("workspace"); + let outside = parent.path().join("outside.txt"); + tokio::fs::create_dir_all(&workspace) + .await + .expect("workspace should be created"); + tokio::fs::write(&outside, "alpha beta") + .await + .expect("outside file should be written"); + let tool = EditFileTool; + + let result = tool + .execute( + "tc-edit-outside".to_string(), + json!({ + "path": "../outside.txt", + "oldStr": "alpha", + "newStr": "omega" + }), + &test_tool_context_for(&workspace), + ) + .await + .expect("editFile should execute"); + + assert!(result.ok); + let content = tokio::fs::read_to_string(&outside) + .await + .expect("outside file should be readable"); + assert_eq!(content, "omega beta"); + } + #[tokio::test] async fn edit_file_batch_edits_rejects_empty_array() { let temp = tempfile::tempdir().expect("tempdir should be created"); diff --git a/crates/adapter-tools/src/builtin_tools/find_files.rs b/crates/adapter-tools/src/builtin_tools/find_files.rs index 9fe4a919..8fbfad8b 100644 --- a/crates/adapter-tools/src/builtin_tools/find_files.rs +++ b/crates/adapter-tools/src/builtin_tools/find_files.rs @@ -73,7 +73,7 @@ impl Tool for FindFilesTool { }, "root": { "type": "string", - "description": "Root directory to search from (default: working directory)" + "description": "Root directory to search from (default: current working directory)" }, "maxResults": { "type": "integer", @@ -106,15 +106,15 @@ impl Tool for FindFilesTool { ToolPromptMetadata::new( "Find candidate files by glob when you know the filename pattern but not the \ exact path.", - "Find files by glob pattern inside the workspace. Use this before `grep` when \ - you only know a filename, extension, or glob. Supported common glob forms \ - include `**/*.rs` (recursive), `*.toml` (current dir), and `*.{json,toml}` \ - (alternation). When using `root`, the glob pattern is relative to that root, \ - not the workspace root. Results are sorted by modification time.", + "Find files by glob pattern under a known search root. Use this before `grep` \ + when you only know a filename, extension, or glob. Supported common glob \ + forms include `**/*.rs` (recursive), `*.toml` (current dir), and \ + `*.{json,toml}` (alternation). When using `root`, the glob pattern is \ + relative to that root. Results are sorted by modification time.", ) .caveat( - "Pattern must stay inside the workspace. Truncated at 200 results — narrow \ - with `root` or a more specific glob.", + "Pattern must stay relative to the search root. Truncated at 200 results — \ + narrow with `root` or a more specific glob.", ) .example( "Find all Cargo.toml: { pattern: \"**/Cargo.toml\" }. Limit to ./crates/: { \ @@ -300,7 +300,7 @@ fn collect_files_with_ignore( fn validate_glob_pattern(pattern: &str) -> Result<()> { if looks_like_windows_drive_relative_path(pattern) { return Err(AstrError::Validation(format!( - "glob pattern '{}' must stay within the working directory", + "glob pattern '{}' must stay relative to the search root", pattern ))); } @@ -308,7 +308,7 @@ fn validate_glob_pattern(pattern: &str) -> Result<()> { let path = Path::new(pattern); if path.is_absolute() { return Err(AstrError::Validation(format!( - "glob pattern '{}' must stay within the working directory", + "glob pattern '{}' must stay relative to the search root", pattern ))); } @@ -317,7 +317,7 @@ fn validate_glob_pattern(pattern: &str) -> Result<()> { match component { Component::ParentDir | Component::Prefix(_) | Component::RootDir => { return Err(AstrError::Validation(format!( - "glob pattern '{}' must stay within the working directory", + "glob pattern '{}' must stay relative to the search root", pattern ))); }, @@ -472,7 +472,7 @@ mod tests { assert!( error .to_string() - .contains("must stay within the working directory") + .contains("must stay relative to the search root") ); } @@ -585,6 +585,46 @@ mod tests { assert!(paths[1].ends_with("old.txt")); } + #[tokio::test] + async fn find_files_allows_root_outside_working_dir() { + let parent = tempfile::tempdir().expect("tempdir should be created"); + let workspace = parent.path().join("workspace"); + let outside = parent.path().join("outside"); + tokio::fs::create_dir_all(&workspace) + .await + .expect("workspace should be created"); + tokio::fs::create_dir_all(&outside) + .await + .expect("outside dir should be created"); + tokio::fs::write(outside.join("found.txt"), "hello") + .await + .expect("outside file should be written"); + let tool = FindFilesTool; + + let result = tool + .execute( + "tc-find-outside".to_string(), + json!({ + "pattern": "*.txt", + "root": "../outside" + }), + &test_tool_context_for(&workspace), + ) + .await + .expect("findFiles should succeed"); + + assert!(result.ok); + let paths: Vec = + serde_json::from_str(&result.output).expect("output should be valid json"); + assert_eq!(paths.len(), 1); + assert_eq!( + paths[0], + canonical_tool_path(outside.join("found.txt")) + .to_string_lossy() + .to_string() + ); + } + #[test] fn find_files_prompt_metadata_mentions_grep_hand_off() { let prompt = FindFilesTool diff --git a/crates/adapter-tools/src/builtin_tools/fs_common.rs b/crates/adapter-tools/src/builtin_tools/fs_common.rs index 7f0ba714..c81c4a2b 100644 --- a/crates/adapter-tools/src/builtin_tools/fs_common.rs +++ b/crates/adapter-tools/src/builtin_tools/fs_common.rs @@ -2,7 +2,7 @@ //! //! 提供所有文件工具共享的基础设施: //! -//! - **路径沙箱**: `resolve_path` 确保所有路径操作不逃逸工作目录 +//! - **路径解析**: `resolve_path` 将相对路径锚定到工作目录,并允许访问宿主机绝对路径 //! - **取消检查**: `check_cancel` 在长操作的关键节点检查用户取消 //! - **文件 I/O**: `read_utf8_file` / `write_text_file` 统一 UTF-8 读写 //! - **Diff 生成**: `build_text_change_report` 手工实现 unified diff @@ -80,19 +80,28 @@ pub fn is_symlink(path: &Path) -> Result { } } -/// 将路径解析为工作目录内的绝对路径,拒绝逃逸路径。 +/// 将路径解析为宿主机上的绝对路径。 /// -/// **为什么使用 `resolve_for_boundary_check` 而非 `fs::canonicalize`**: +/// 相对路径始终锚定到当前工具上下文的工作目录;绝对路径直接保留并解析。 +/// +/// **为什么使用 `resolve_for_host_access` 而非 `fs::canonicalize`**: /// canonicalize 要求路径在磁盘上存在,但 writeFile/editFile 经常操作 -/// 尚不存在的文件。resolve_for_boundary_check 从路径尾部向上找到第一个 +/// 尚不存在的文件。resolve_for_host_access 从路径尾部向上找到第一个 /// 存在的祖先进行 canonicalize,再拼回缺失部分。 pub fn resolve_path(ctx: &ToolContext, path: &Path) -> Result { - resolve_path_with_root( + let canonical_working_dir = canonicalize_path( ctx.working_dir(), - path, - "working directory", - "failed to canonicalize working directory", - ) + &format!( + "failed to canonicalize working directory '{}'", + ctx.working_dir().display() + ), + )?; + let base = if path.is_absolute() { + path.to_path_buf() + } else { + canonical_working_dir.join(path) + }; + resolve_for_host_access(&normalize_lexically(&base)) } #[derive(Debug, Clone, PartialEq, Eq)] @@ -147,7 +156,7 @@ fn resolve_session_tool_results_target( session_root.display() ), )?; - let resolved = resolve_for_boundary_check(&normalize_lexically(path))?; + let resolved = resolve_for_host_access(&normalize_lexically(path))?; if !is_path_within_root(&resolved, &canonical_tool_results_root) { return Ok(None); } @@ -566,7 +575,7 @@ fn resolve_path_with_root( canonical_root.join(path) }; - let resolved = resolve_for_boundary_check(&normalize_lexically(&base))?; + let resolved = resolve_for_host_access(&normalize_lexically(&base))?; if is_path_within_root(&resolved, &canonical_root) { return Ok(resolved); } @@ -583,7 +592,7 @@ fn resolve_path_with_root( /// /// 当路径尾部组件尚不存在时(如 writeFile 创建新文件), /// 向上找到第一个存在的祖先 canonicalize 后拼回缺失部分。 -fn resolve_for_boundary_check(path: &Path) -> Result { +fn resolve_for_host_access(path: &Path) -> Result { if path.exists() { return canonicalize_path( path, @@ -596,13 +605,13 @@ fn resolve_for_boundary_check(path: &Path) -> Result { while !current.exists() { let Some(name) = current.file_name() else { return Err(AstrError::Validation(format!( - "path '{}' cannot be resolved under the working directory", + "path '{}' cannot be resolved on the host filesystem", path.display() ))); }; let Some(parent) = current.parent() else { return Err(AstrError::Validation(format!( - "path '{}' cannot be resolved under the working directory", + "path '{}' cannot be resolved on the host filesystem", path.display() ))); }; @@ -729,11 +738,13 @@ mod tests { fs::create_dir_all(&working_dir).expect("workspace should be created"); let ctx = test_tool_context_for(&working_dir); - let err = resolve_path(&ctx, Path::new("../outside.txt")) - .expect_err("escaping path should be rejected"); + let resolved = + resolve_path(&ctx, Path::new("../outside.txt")).expect("outside path should resolve"); - assert!(matches!(err, AstrError::Validation(_))); - assert!(err.to_string().contains("escapes working directory")); + assert_eq!( + resolved, + canonical_tool_path(parent.path().join("outside.txt")) + ); } #[test] @@ -748,6 +759,20 @@ mod tests { assert_eq!(resolved, canonical_tool_path(&file)); } + #[test] + fn resolve_path_allows_absolute_path_outside_working_dir() { + let parent = tempfile::tempdir().expect("tempdir should be created"); + let workspace = parent.path().join("workspace"); + let outside = parent.path().join("outside.txt"); + fs::create_dir_all(&workspace).expect("workspace should be created"); + fs::write(&outside, "hello").expect("outside file should be created"); + let ctx = test_tool_context_for(&workspace); + + let resolved = resolve_path(&ctx, &outside).expect("absolute host path should resolve"); + + assert_eq!(resolved, canonical_tool_path(&outside)); + } + #[test] fn is_path_within_root_ignores_trailing_separators() { let temp = tempfile::tempdir().expect("tempdir should be created"); diff --git a/crates/adapter-tools/src/builtin_tools/grep.rs b/crates/adapter-tools/src/builtin_tools/grep.rs index e71bf4b1..1e6e130f 100644 --- a/crates/adapter-tools/src/builtin_tools/grep.rs +++ b/crates/adapter-tools/src/builtin_tools/grep.rs @@ -148,7 +148,7 @@ impl Tool for GrepTool { }, "path": { "type": "string", - "description": "Optional. File or directory to search in (defaults to current working directory)" + "description": "Optional. File or directory to search in (defaults to the current working directory)" }, "recursive": { "type": "boolean", @@ -250,8 +250,7 @@ impl Tool for GrepTool { .map_err(|e| AstrError::parse(explain_grep_args_error(&e), e))?; let path = match &args.path { Some(p) => resolve_path(ctx, p)?, - None => std::env::current_dir() - .map_err(|e| AstrError::io("failed to get current directory", e))?, + None => ctx.working_dir().to_path_buf(), }; let started_at = Instant::now(); let regex = RegexBuilder::new(&args.pattern) @@ -1448,6 +1447,38 @@ mod tests { assert!(matches[0].file.ends_with("main.rs")); } + #[tokio::test] + async fn grep_allows_path_outside_working_dir() { + let parent = tempfile::tempdir().expect("tempdir should be created"); + let workspace = parent.path().join("workspace"); + let outside = parent.path().join("outside.txt"); + tokio::fs::create_dir_all(&workspace) + .await + .expect("workspace should be created"); + tokio::fs::write(&outside, "needle outside\n") + .await + .expect("outside file should be written"); + let tool = GrepTool; + + let result = tool + .execute( + "tc-grep-outside".to_string(), + json!({ + "pattern": "needle", + "path": "../outside.txt" + }), + &test_tool_context_for(&workspace), + ) + .await + .expect("grep should succeed"); + + assert!(result.ok); + let matches: Vec = + serde_json::from_str(&result.output).expect("output should be valid json"); + assert_eq!(matches.len(), 1); + assert!(matches[0].file.ends_with("outside.txt")); + } + #[tokio::test] async fn grep_persists_large_results_and_read_file_can_open_them() { let temp = tempfile::tempdir().expect("tempdir should be created"); diff --git a/crates/adapter-tools/src/builtin_tools/list_dir.rs b/crates/adapter-tools/src/builtin_tools/list_dir.rs index f7bc764c..63837910 100644 --- a/crates/adapter-tools/src/builtin_tools/list_dir.rs +++ b/crates/adapter-tools/src/builtin_tools/list_dir.rs @@ -80,7 +80,7 @@ impl Tool for ListDirTool { "properties": { "path": { "type": "string", - "description": "Absolute or relative directory path. Defaults to working directory if omitted." + "description": "Absolute or relative directory path. Defaults to the current working directory if omitted." }, "maxEntries": { "type": "integer", @@ -425,4 +425,36 @@ mod tests { let metadata = result.metadata.expect("metadata should exist"); assert_eq!(metadata["message"], json!("Directory is empty.")); } + + #[tokio::test] + async fn list_dir_allows_relative_path_outside_working_dir() { + let parent = tempfile::tempdir().expect("tempdir should be created"); + let workspace = parent.path().join("workspace"); + let outside = parent.path().join("outside"); + tokio::fs::create_dir_all(&workspace) + .await + .expect("workspace should be created"); + tokio::fs::create_dir_all(&outside) + .await + .expect("outside dir should be created"); + tokio::fs::write(outside.join("note.txt"), "hello") + .await + .expect("outside file should be created"); + let tool = ListDirTool; + + let result = tool + .execute( + "tc-list-outside".to_string(), + json!({"path": "../outside"}), + &test_tool_context_for(&workspace), + ) + .await + .expect("listDir should succeed"); + + assert!(result.ok); + let entries: Vec = + serde_json::from_str(&result.output).expect("output should be valid json"); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0]["name"], "note.txt"); + } } diff --git a/crates/adapter-tools/src/builtin_tools/read_file.rs b/crates/adapter-tools/src/builtin_tools/read_file.rs index b5471120..f91df419 100644 --- a/crates/adapter-tools/src/builtin_tools/read_file.rs +++ b/crates/adapter-tools/src/builtin_tools/read_file.rs @@ -1041,6 +1041,40 @@ mod tests { assert!(metadata.get("fileType").is_none()); } + #[tokio::test] + async fn read_file_allows_relative_path_outside_working_dir() { + let parent = tempfile::tempdir().expect("tempdir should be created"); + let workspace = parent.path().join("workspace"); + let outside = parent.path().join("outside.txt"); + tokio::fs::create_dir_all(&workspace) + .await + .expect("workspace should be created"); + tokio::fs::write(&outside, "outside") + .await + .expect("outside file should be written"); + let tool = ReadFileTool; + + let result = tool + .execute( + "tc-read-outside".to_string(), + json!({ + "path": "../outside.txt", + "lineNumbers": false + }), + &test_tool_context_for(&workspace), + ) + .await + .expect("readFile should succeed"); + + assert!(result.ok); + assert_eq!(result.output, "outside"); + let metadata = result.metadata.expect("metadata should exist"); + assert_eq!( + metadata["path"], + json!(canonical_tool_path(&outside).to_string_lossy().to_string()) + ); + } + #[tokio::test] async fn read_file_reads_first_persisted_chunk_by_absolute_path() { let temp = tempfile::tempdir().expect("tempdir should be created"); diff --git a/crates/adapter-tools/src/builtin_tools/shell.rs b/crates/adapter-tools/src/builtin_tools/shell.rs index e878dba8..feb2a9fc 100644 --- a/crates/adapter-tools/src/builtin_tools/shell.rs +++ b/crates/adapter-tools/src/builtin_tools/shell.rs @@ -398,8 +398,8 @@ impl Tool for ShellTool { inspection commands. Prefer dedicated tools instead of `shell` for file \ reading (`readFile`), code search (`grep`/`findFiles`), and structured \ file edits (`editFile`/`apply_patch`). Keep commands scoped to the \ - workspace, explain risky commands before running them, and prefer \ - read-only inspection before mutation." + intended host paths, explain risky commands before running them, and \ + prefer read-only inspection before mutation." ), ) .caveat( @@ -894,6 +894,49 @@ mod tests { assert_eq!(read_metadata["persistedRead"], json!(true)); } + #[tokio::test] + async fn shell_allows_cwd_outside_working_dir() { + let parent = tempfile::tempdir().expect("tempdir should be created"); + let workspace = parent.path().join("workspace"); + let outside = parent.path().join("outside"); + tokio::fs::create_dir_all(&workspace) + .await + .expect("workspace should be created"); + tokio::fs::create_dir_all(&outside) + .await + .expect("outside dir should be created"); + let tool = ShellTool; + let args = if cfg!(windows) { + json!({ + "command": "(Get-Location).Path", + "shell": "pwsh", + "cwd": outside.to_string_lossy() + }) + } else { + json!({ + "command": "pwd", + "shell": "sh", + "cwd": outside.to_string_lossy() + }) + }; + + let result = tool + .execute( + "tc-shell-cwd-outside".to_string(), + args, + &test_tool_context_for(&workspace), + ) + .await + .expect("shell tool should execute"); + + assert!(result.ok); + let metadata = result.metadata.expect("metadata should exist"); + assert_eq!( + metadata["cwd"], + json!(outside.to_string_lossy().to_string()) + ); + } + #[tokio::test] async fn shell_tool_rejects_blank_command() { let tool = ShellTool; diff --git a/crates/adapter-tools/src/builtin_tools/write_file.rs b/crates/adapter-tools/src/builtin_tools/write_file.rs index 0ae18060..1b9f58b9 100644 --- a/crates/adapter-tools/src/builtin_tools/write_file.rs +++ b/crates/adapter-tools/src/builtin_tools/write_file.rs @@ -71,7 +71,7 @@ impl Tool for WriteFileTool { ToolCapabilityMetadata::builtin() .tags(["filesystem", "write"]) .permission("filesystem.write") - .side_effect(SideEffect::Workspace) + .side_effect(SideEffect::Local) .prompt( ToolPromptMetadata::new( "Create or fully replace a text file when the whole target content is known.", @@ -81,8 +81,8 @@ impl Tool for WriteFileTool { ) .caveat( "Overwrites the entire file. `createDirs` defaults to false — parent \ - directories must exist or set it to true. Path must stay inside the working \ - directory.", + directories must exist or set it to true. Relative paths resolve from the \ + current working directory; absolute host paths are allowed.", ) .caveat( "For small edits to existing files, prefer `editFile` or `apply_patch` to \ @@ -140,8 +140,8 @@ impl Tool for WriteFileTool { ok: false, output: String::new(), error: Some(format!( - "refusing to write to symlink '{}' (symlinks may point outside working \ - directory)", + "refusing to write to symlink '{}' (symlinks may point outside the intended \ + target path)", path.display() )), metadata: Some(json!({ @@ -320,4 +320,33 @@ mod tests { assert!(err.to_string().contains("failed writing file")); } + + #[tokio::test] + async fn write_file_allows_relative_path_outside_working_dir() { + let parent = tempfile::tempdir().expect("tempdir should be created"); + let workspace = parent.path().join("workspace"); + let outside = parent.path().join("outside.txt"); + tokio::fs::create_dir_all(&workspace) + .await + .expect("workspace should be created"); + let tool = WriteFileTool; + + let result = tool + .execute( + "tc-write-outside".to_string(), + json!({ + "path": "../outside.txt", + "content": "outside" + }), + &test_tool_context_for(&workspace), + ) + .await + .expect("writeFile should execute"); + + assert!(result.ok); + let content = tokio::fs::read_to_string(&outside) + .await + .expect("outside file should be readable"); + assert_eq!(content, "outside"); + } } diff --git a/docs/ideas/notes.md b/docs/ideas/notes.md index fd6f04f8..801b024c 100644 --- a/docs/ideas/notes.md +++ b/docs/ideas/notes.md @@ -13,4 +13,6 @@ 7. pending messages(完成部分) 8. 更好的compact功能 9. 多agent共享任务列表 -10. draft → test → review → improve → repeat \ No newline at end of file +10. draft → test → review → improve → repeat +11. 更安全更自由的权限,让agent能操控工作区以外的文件 + - TODO: v1 先默认全局放开文件工具对工作区的围栏,后续再补 Claude Code 风格的目录白名单、危险模式、审批规则与受保护路径。 From d68c4836c30417118896f363529ef57936618d68 Mon Sep 17 00:00:00 2001 From: whatevertogo Date: Sun, 19 Apr 2026 15:34:09 +0800 Subject: [PATCH 41/53] =?UTF-8?q?=E2=9C=A8=20feat(plan-mode):=20=E5=AE=9E?= =?UTF-8?q?=E7=8E=B0=20session=20=E8=AE=A1=E5=88=92=E5=B7=A5=E4=BB=B6?= =?UTF-8?q?=E7=B3=BB=E7=BB=9F=EF=BC=8C=E6=94=AF=E6=8C=81=E5=AE=A1=E6=89=B9?= =?UTF-8?q?=E6=B5=81=E7=A8=8B=E4=B8=8E=E8=87=AA=E5=8A=A8=E6=A8=A1=E5=BC=8F?= =?UTF-8?q?=E5=88=87=E6=8D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit crates/adapter-tools/src/builtin_tools/upsert_session_plan.rs - 新增 upsertSessionPlan 工具,作为 plan mode 唯一受限写入口 crates/application/src/session_plan.rs - 新增计划状态模型(Draft/AwaitingApproval/Approved/Superseded)与完整生命周期管理 - 实现中英文审批短语识别,检测到批准后自动切回 code mode - 支持计划 prompt 上下文注入(facts + reentry/exit/template 声明) crates/application/src/mode/builtin_prompts.rs + markdown 资源 - 将 mode prompt 从硬编码字符串提取为独立模块与 markdown 资源文件 crates/application/src/session_use_cases.rs - 集成审批流程:awaiting_approval 时检测用户批准短语,自动标记通过并切换模式 - fork session 时复制计划工件 crates/application/src/terminal/, crates/protocol/src/http/ - 新增 ActivePlanFacts / ConversationActivePlanDto,向前端暴露活跃计划状态 scripts/tauri-frontend.js - 优化 sidecar 拷贝:字节比对相同则跳过,减少开发时冗余 IO --- crates/adapter-tools/src/builtin_tools/mod.rs | 2 + .../src/builtin_tools/upsert_session_plan.rs | 367 ++++++++++++++ crates/application/src/lib.rs | 1 + .../application/src/mode/builtin_prompts.rs | 30 ++ .../src/mode/builtin_prompts/plan_mode.md | 11 + .../mode/builtin_prompts/plan_mode_exit.md | 6 + .../mode/builtin_prompts/plan_mode_reentry.md | 7 + .../src/mode/builtin_prompts/plan_template.md | 17 + crates/application/src/mode/catalog.rs | 37 +- crates/application/src/mode/mod.rs | 1 + crates/application/src/session_plan.rs | 466 ++++++++++++++++++ crates/application/src/session_use_cases.rs | 62 ++- crates/application/src/terminal/mod.rs | 10 + .../application/src/terminal_queries/mod.rs | 3 +- .../src/terminal_queries/resume.rs | 15 +- crates/cli/src/state/mod.rs | 2 + crates/protocol/src/http/conversation/v1.rs | 10 + crates/protocol/src/http/mod.rs | 12 +- .../tests/conversation_conformance.rs | 1 + crates/server/src/bootstrap/capabilities.rs | 2 + crates/server/src/http/routes/conversation.rs | 1 + crates/server/src/http/terminal_projection.rs | 25 +- scripts/tauri-frontend.js | 22 + 23 files changed, 1060 insertions(+), 50 deletions(-) create mode 100644 crates/adapter-tools/src/builtin_tools/upsert_session_plan.rs create mode 100644 crates/application/src/mode/builtin_prompts.rs create mode 100644 crates/application/src/mode/builtin_prompts/plan_mode.md create mode 100644 crates/application/src/mode/builtin_prompts/plan_mode_exit.md create mode 100644 crates/application/src/mode/builtin_prompts/plan_mode_reentry.md create mode 100644 crates/application/src/mode/builtin_prompts/plan_template.md create mode 100644 crates/application/src/session_plan.rs diff --git a/crates/adapter-tools/src/builtin_tools/mod.rs b/crates/adapter-tools/src/builtin_tools/mod.rs index 87cf57aa..fd0e2d99 100644 --- a/crates/adapter-tools/src/builtin_tools/mod.rs +++ b/crates/adapter-tools/src/builtin_tools/mod.rs @@ -27,5 +27,7 @@ pub mod shell; pub mod skill_tool; /// 外部工具搜索:按需展开 MCP/plugin 工具 schema pub mod tool_search; +/// session 计划工件写工具:仅允许写当前 session 的 plan 目录 +pub mod upsert_session_plan; /// 文件写入工具:创建/覆盖文本文件 pub mod write_file; diff --git a/crates/adapter-tools/src/builtin_tools/upsert_session_plan.rs b/crates/adapter-tools/src/builtin_tools/upsert_session_plan.rs new file mode 100644 index 00000000..b6cd6987 --- /dev/null +++ b/crates/adapter-tools/src/builtin_tools/upsert_session_plan.rs @@ -0,0 +1,367 @@ +//! `upsertSessionPlan` 工具。 +//! +//! 该工具只允许写当前 session 下的 `plan/` 目录和 `state.json`, +//! 作为 plan mode 唯一的受限写入口。 + +use std::{fs, path::PathBuf, time::Instant}; + +use astrcode_core::{ + AstrError, Result, SideEffect, Tool, ToolCapabilityMetadata, ToolContext, ToolDefinition, + ToolExecutionResult, ToolPromptMetadata, +}; +use async_trait::async_trait; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use serde_json::json; + +use crate::builtin_tools::fs_common::{check_cancel, session_dir_for_tool_results}; + +const PLAN_DIR_NAME: &str = "plan"; +const PLAN_STATE_FILE_NAME: &str = "state.json"; +const PLAN_PATH_TIMESTAMP_FORMAT: &str = "%Y%m%dT%H%M%SZ"; + +#[derive(Default)] +pub struct UpsertSessionPlanTool; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +enum SessionPlanStatus { + Draft, + AwaitingApproval, + Approved, + Superseded, +} + +impl SessionPlanStatus { + fn as_str(&self) -> &'static str { + match self { + Self::Draft => "draft", + Self::AwaitingApproval => "awaiting_approval", + Self::Approved => "approved", + Self::Superseded => "superseded", + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct SessionPlanState { + active_plan_slug: String, + title: String, + status: SessionPlanStatus, + created_at: DateTime, + updated_at: DateTime, + #[serde(default, skip_serializing_if = "Option::is_none")] + approved_at: Option>, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct UpsertSessionPlanArgs { + title: String, + content: String, + #[serde(default)] + topic: Option, + #[serde(default)] + slug: Option, + #[serde(default)] + status: Option, +} + +#[async_trait] +impl Tool for UpsertSessionPlanTool { + fn definition(&self) -> ToolDefinition { + ToolDefinition { + name: "upsertSessionPlan".to_string(), + description: "Create or overwrite the current session's plan artifact and state file." + .to_string(), + parameters: json!({ + "type": "object", + "properties": { + "title": { + "type": "string", + "description": "Human-readable plan title." + }, + "content": { + "type": "string", + "description": "Full markdown body to persist into the session plan file." + }, + "topic": { + "type": "string", + "description": "Optional task/topic text used to derive a slug when no active plan exists yet." + }, + "slug": { + "type": "string", + "description": "Optional explicit kebab-case slug. When omitted, the tool reuses the active session slug or derives one from topic/title." + }, + "status": { + "type": "string", + "enum": ["draft", "awaiting_approval", "approved", "superseded"], + "description": "Plan state to persist alongside the markdown artifact." + } + }, + "required": ["title", "content"], + "additionalProperties": false + }), + } + } + + fn capability_metadata(&self) -> ToolCapabilityMetadata { + ToolCapabilityMetadata::builtin() + .tags(["filesystem", "write", "plan"]) + .permission("filesystem.write") + .side_effect(SideEffect::Local) + .prompt( + ToolPromptMetadata::new( + "Create or update the current session's plan artifact.", + "Use `upsertSessionPlan` when plan mode needs to persist the canonical \ + session plan markdown and its `state.json` metadata. This tool can only \ + write inside the current session's `plan/` directory.", + ) + .caveat( + "This is the only write tool available in plan mode. It overwrites the whole \ + plan file content each time.", + ) + .example( + "{ title: \"Cleanup crates\", slug: \"cleanup-crates\", content: \"# Plan: \ + Cleanup crates\\n...\", status: \"draft\" }", + ) + .prompt_tag("plan") + .always_include(true), + ) + } + + async fn execute( + &self, + tool_call_id: String, + args: serde_json::Value, + ctx: &ToolContext, + ) -> Result { + check_cancel(ctx.cancel())?; + + let args: UpsertSessionPlanArgs = serde_json::from_value(args) + .map_err(|error| AstrError::parse("invalid args for upsertSessionPlan", error))?; + let title = args.title.trim(); + if title.is_empty() { + return Err(AstrError::Validation( + "plan title must not be empty".to_string(), + )); + } + let content = args.content.trim(); + if content.is_empty() { + return Err(AstrError::Validation( + "plan markdown content must not be empty".to_string(), + )); + } + + let started_at = Instant::now(); + let plan_dir = session_dir_for_tool_results(ctx)?.join(PLAN_DIR_NAME); + let state_path = plan_dir.join(PLAN_STATE_FILE_NAME); + let previous_state = load_state(&state_path)?; + let slug = resolve_slug(&args, previous_state.as_ref()); + let plan_path = plan_dir.join(format!("{slug}.md")); + let now = Utc::now(); + let status = args.status.unwrap_or(SessionPlanStatus::Draft); + let created_at = previous_state + .as_ref() + .filter(|state| state.active_plan_slug == slug) + .map(|state| state.created_at) + .unwrap_or(now); + let approved_at = if matches!(status, SessionPlanStatus::Approved) { + previous_state + .as_ref() + .and_then(|state| state.approved_at) + .or(Some(now)) + } else { + None + }; + let state = SessionPlanState { + active_plan_slug: slug.clone(), + title: title.to_string(), + status, + created_at, + updated_at: now, + approved_at, + }; + + fs::create_dir_all(&plan_dir).map_err(|error| { + AstrError::io( + format!( + "failed creating session plan directory '{}'", + plan_dir.display() + ), + error, + ) + })?; + fs::write(&plan_path, format!("{content}\n")).map_err(|error| { + AstrError::io( + format!("failed writing session plan file '{}'", plan_path.display()), + error, + ) + })?; + let state_content = serde_json::to_string_pretty(&state) + .map_err(|error| AstrError::parse("failed to serialize session plan state", error))?; + fs::write(&state_path, state_content).map_err(|error| { + AstrError::io( + format!( + "failed writing session plan state '{}'", + state_path.display() + ), + error, + ) + })?; + + Ok(ToolExecutionResult { + tool_call_id, + tool_name: "upsertSessionPlan".to_string(), + ok: true, + output: format!( + "updated session plan '{}' at {}", + title, + plan_path.display() + ), + error: None, + metadata: Some(json!({ + "planPath": plan_path.to_string_lossy(), + "slug": slug, + "status": state.status.as_str(), + "title": state.title, + "updatedAt": state.updated_at.to_rfc3339(), + })), + child_ref: None, + duration_ms: started_at.elapsed().as_millis() as u64, + truncated: false, + }) + } +} + +fn load_state(path: &PathBuf) -> Result> { + if !path.exists() { + return Ok(None); + } + let content = fs::read_to_string(path) + .map_err(|error| AstrError::io(format!("failed reading '{}'", path.display()), error))?; + let state = serde_json::from_str::(&content) + .map_err(|error| AstrError::parse("failed to parse session plan state", error))?; + Ok(Some(state)) +} + +fn resolve_slug(args: &UpsertSessionPlanArgs, previous_state: Option<&SessionPlanState>) -> String { + if let Some(slug) = args.slug.as_deref().and_then(normalize_slug) { + return slug; + } + if let Some(previous_state) = previous_state { + return previous_state.active_plan_slug.clone(); + } + args.topic + .as_deref() + .and_then(slugify) + .or_else(|| slugify(&args.title)) + .unwrap_or_else(|| format!("plan-{}", Utc::now().format(PLAN_PATH_TIMESTAMP_FORMAT))) +} + +fn normalize_slug(input: &str) -> Option { + let mut normalized = String::new(); + let mut last_dash = false; + for ch in input.chars().map(|ch| ch.to_ascii_lowercase()) { + if ch.is_ascii_alphanumeric() { + normalized.push(ch); + last_dash = false; + continue; + } + if (ch == '-' || ch == '_' || ch.is_whitespace()) && !last_dash && !normalized.is_empty() { + normalized.push('-'); + last_dash = true; + } + } + let normalized = normalized.trim_matches('-').to_string(); + if normalized.is_empty() { + None + } else { + Some(normalized) + } +} + +fn slugify(input: &str) -> Option { + normalize_slug(input) +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::*; + use crate::test_support::test_tool_context_for; + + #[tokio::test] + async fn upsert_session_plan_creates_markdown_and_state() { + let temp = tempfile::tempdir().expect("tempdir should exist"); + let tool = UpsertSessionPlanTool; + let result = tool + .execute( + "tc-plan-create".to_string(), + json!({ + "title": "Cleanup crates", + "content": "# Plan: Cleanup crates\n\n## Context", + "slug": "cleanup-crates", + "status": "draft" + }), + &test_tool_context_for(temp.path()), + ) + .await + .expect("tool should execute"); + + assert!(result.ok); + let plan_dir = temp + .path() + .join(".astrcode-test-state") + .join("sessions") + .join("session-test") + .join("plan"); + assert!(plan_dir.join("cleanup-crates.md").exists()); + assert!(plan_dir.join("state.json").exists()); + assert_eq!( + result.metadata.expect("metadata should exist")["slug"], + json!("cleanup-crates") + ); + } + + #[tokio::test] + async fn upsert_session_plan_reuses_existing_slug_when_omitted() { + let temp = tempfile::tempdir().expect("tempdir should exist"); + let tool = UpsertSessionPlanTool; + let ctx = test_tool_context_for(temp.path()); + + tool.execute( + "tc-plan-initial".to_string(), + json!({ + "title": "Cleanup crates", + "content": "# Plan: Cleanup crates", + "slug": "cleanup-crates", + "status": "draft" + }), + &ctx, + ) + .await + .expect("initial write should work"); + + let result = tool + .execute( + "tc-plan-update".to_string(), + json!({ + "title": "Cleanup crates revised", + "content": "# Plan: Cleanup crates revised", + "status": "awaiting_approval" + }), + &ctx, + ) + .await + .expect("update should execute"); + + assert!(result.ok); + assert_eq!( + result.metadata.expect("metadata should exist")["slug"], + json!("cleanup-crates") + ); + } +} diff --git a/crates/application/src/lib.rs b/crates/application/src/lib.rs index 357b62b4..7291738a 100644 --- a/crates/application/src/lib.rs +++ b/crates/application/src/lib.rs @@ -17,6 +17,7 @@ use crate::config::ConfigService; mod agent_use_cases; mod governance_surface; mod ports; +mod session_plan; mod session_use_cases; mod terminal_queries; #[cfg(test)] diff --git a/crates/application/src/mode/builtin_prompts.rs b/crates/application/src/mode/builtin_prompts.rs new file mode 100644 index 00000000..ce54845c --- /dev/null +++ b/crates/application/src/mode/builtin_prompts.rs @@ -0,0 +1,30 @@ +//! 内置 mode prompt 资源。 +//! +//! 计划模式的主流程和模板拆成 markdown 资源文件,便于后续直接微调文本, +//! 避免继续把完整提示词硬编码在 Rust 字符串里。 + +pub(crate) fn code_mode_prompt() -> &'static str { + "You are in execution mode. Prefer direct progress, make concrete code changes when needed, \ + and use delegation only when isolation or parallelism materially helps." +} + +pub(crate) fn review_mode_prompt() -> &'static str { + "You are in review mode. Prioritize findings, risks, regressions, and verification gaps. Stay \ + read-only and avoid speculative edits." +} + +pub(crate) fn plan_mode_prompt() -> &'static str { + include_str!("builtin_prompts/plan_mode.md") +} + +pub(crate) fn plan_mode_reentry_prompt() -> &'static str { + include_str!("builtin_prompts/plan_mode_reentry.md") +} + +pub(crate) fn plan_mode_exit_prompt() -> &'static str { + include_str!("builtin_prompts/plan_mode_exit.md") +} + +pub(crate) fn plan_template_prompt() -> &'static str { + include_str!("builtin_prompts/plan_template.md") +} diff --git a/crates/application/src/mode/builtin_prompts/plan_mode.md b/crates/application/src/mode/builtin_prompts/plan_mode.md new file mode 100644 index 00000000..987827a2 --- /dev/null +++ b/crates/application/src/mode/builtin_prompts/plan_mode.md @@ -0,0 +1,11 @@ +You are in plan mode. + +Your job is to produce and maintain a session-scoped plan artifact before implementation. + +Plan mode contract: +- Use `upsertSessionPlan` to create or update the session plan artifact. +- Keep the plan scoped to one concrete task or change topic. +- Ask concise clarification questions when missing details would materially change scope or design. +- Do not perform implementation work in this mode. +- After the plan is complete, ask the user to review it and approve it in plain language. +- Do not silently switch to execution. Execution starts only after the user explicitly approves the plan. diff --git a/crates/application/src/mode/builtin_prompts/plan_mode_exit.md b/crates/application/src/mode/builtin_prompts/plan_mode_exit.md new file mode 100644 index 00000000..51f48731 --- /dev/null +++ b/crates/application/src/mode/builtin_prompts/plan_mode_exit.md @@ -0,0 +1,6 @@ +The session has exited plan mode and is now back in code mode. + +Execution contract: +- Use the approved session plan artifact as the primary implementation reference. +- The user approval already happened; do not ask for plan approval again. +- Start implementation immediately unless the user message clearly requests more planning. diff --git a/crates/application/src/mode/builtin_prompts/plan_mode_reentry.md b/crates/application/src/mode/builtin_prompts/plan_mode_reentry.md new file mode 100644 index 00000000..9320f59b --- /dev/null +++ b/crates/application/src/mode/builtin_prompts/plan_mode_reentry.md @@ -0,0 +1,7 @@ +An active session plan artifact already exists for this session. + +Re-entry contract: +- Read the existing plan artifact first. +- Prefer revising the current plan instead of creating a new file. +- Only create a new slug when the user explicitly changes the task/topic. +- Preserve the same plan file while iterating on the same topic. diff --git a/crates/application/src/mode/builtin_prompts/plan_template.md b/crates/application/src/mode/builtin_prompts/plan_template.md new file mode 100644 index 00000000..cb7be199 --- /dev/null +++ b/crates/application/src/mode/builtin_prompts/plan_template.md @@ -0,0 +1,17 @@ +# Plan: + +## Context + +## Goal + +## Scope + +## Non-Goals + +## Existing Code To Reuse + +## Implementation Steps + +## Verification + +## Open Questions diff --git a/crates/application/src/mode/catalog.rs b/crates/application/src/mode/catalog.rs index ccffcadd..2c3491be 100644 --- a/crates/application/src/mode/catalog.rs +++ b/crates/application/src/mode/catalog.rs @@ -21,6 +21,8 @@ use astrcode_core::{ SubmitBusyPolicy, TransitionPolicySpec, }; +use super::builtin_prompts::{code_mode_prompt, plan_mode_prompt, review_mode_prompt}; + #[derive(Debug, Clone, PartialEq, Eq)] pub struct ModeSummary { pub id: ModeId, @@ -154,10 +156,7 @@ fn builtin_mode_specs() -> Vec<GovernanceModeSpec> { prompt_program: vec![PromptProgramEntry { block_id: "mode.code".to_string(), title: "Execution Mode".to_string(), - content: "You are in execution mode. Prefer direct progress, make concrete code \ - changes when needed, and use delegation only when isolation or \ - parallelism materially helps." - .to_string(), + content: code_mode_prompt().to_string(), priority_hint: Some(600), }], transition_policy: transitions.clone(), @@ -166,15 +165,18 @@ fn builtin_mode_specs() -> Vec<GovernanceModeSpec> { id: ModeId::plan(), name: "Plan".to_string(), description: "规划模式,只暴露只读工具并禁止继续委派。".to_string(), - capability_selector: CapabilitySelector::Difference { - base: Box::new(CapabilitySelector::AllTools), - subtract: Box::new(CapabilitySelector::Union(vec![ - CapabilitySelector::SideEffect(astrcode_core::SideEffect::Local), - CapabilitySelector::SideEffect(astrcode_core::SideEffect::Workspace), - CapabilitySelector::SideEffect(astrcode_core::SideEffect::External), - CapabilitySelector::Tag("agent".to_string()), - ])), - }, + capability_selector: CapabilitySelector::Union(vec![ + CapabilitySelector::Difference { + base: Box::new(CapabilitySelector::AllTools), + subtract: Box::new(CapabilitySelector::Union(vec![ + CapabilitySelector::SideEffect(astrcode_core::SideEffect::Local), + CapabilitySelector::SideEffect(astrcode_core::SideEffect::Workspace), + CapabilitySelector::SideEffect(astrcode_core::SideEffect::External), + CapabilitySelector::Tag("agent".to_string()), + ])), + }, + CapabilitySelector::Name("upsertSessionPlan".to_string()), + ]), action_policies: ActionPolicies { default_effect: ActionPolicyEffect::Allow, rules: vec![ActionPolicyRule { @@ -201,10 +203,7 @@ fn builtin_mode_specs() -> Vec<GovernanceModeSpec> { prompt_program: vec![PromptProgramEntry { block_id: "mode.plan".to_string(), title: "Planning Mode".to_string(), - content: "You are in planning mode. Focus on analysis, proposals, sequencing, and \ - constraints. Do not write files or perform side-effecting actions in \ - this turn." - .to_string(), + content: plan_mode_prompt().to_string(), priority_hint: Some(600), }], transition_policy: transitions.clone(), @@ -246,9 +245,7 @@ fn builtin_mode_specs() -> Vec<GovernanceModeSpec> { prompt_program: vec![PromptProgramEntry { block_id: "mode.review".to_string(), title: "Review Mode".to_string(), - content: "You are in review mode. Prioritize findings, risks, regressions, and \ - verification gaps. Stay read-only and avoid speculative edits." - .to_string(), + content: review_mode_prompt().to_string(), priority_hint: Some(600), }], transition_policy: transitions, diff --git a/crates/application/src/mode/mod.rs b/crates/application/src/mode/mod.rs index 1bb755bd..571eb83e 100644 --- a/crates/application/src/mode/mod.rs +++ b/crates/application/src/mode/mod.rs @@ -8,6 +8,7 @@ //! prompt) //! - `validator`:校验 mode 之间的合法转换 +pub(crate) mod builtin_prompts; mod catalog; mod compiler; mod validator; diff --git a/crates/application/src/session_plan.rs b/crates/application/src/session_plan.rs new file mode 100644 index 00000000..95742f3d --- /dev/null +++ b/crates/application/src/session_plan.rs @@ -0,0 +1,466 @@ +//! session 级计划工件。 +//! +//! 这里维护 session 下 `plan/` 目录的路径规则、状态模型、审批解析和 prompt 注入, +//! 保持 plan mode 的流程真相收敛在 application,而不是散落在 handler / tool / UI。 + +use std::{ + fmt, fs, + path::{Path, PathBuf}, +}; + +use astrcode_core::{ModeId, PromptDeclaration, project::project_dir}; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +use crate::{ApplicationError, mode::builtin_prompts}; + +const PLAN_DIR_NAME: &str = "plan"; +const PLAN_STATE_FILE_NAME: &str = "state.json"; +const PLAN_PATH_TIMESTAMP_FORMAT: &str = "%Y%m%dT%H%M%SZ"; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SessionPlanStatus { + Draft, + AwaitingApproval, + Approved, + Superseded, +} + +impl SessionPlanStatus { + pub fn as_str(&self) -> &'static str { + match self { + Self::Draft => "draft", + Self::AwaitingApproval => "awaiting_approval", + Self::Approved => "approved", + Self::Superseded => "superseded", + } + } +} + +impl fmt::Display for SessionPlanStatus { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SessionPlanState { + pub active_plan_slug: String, + pub title: String, + pub status: SessionPlanStatus, + pub created_at: DateTime<Utc>, + pub updated_at: DateTime<Utc>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub approved_at: Option<DateTime<Utc>>, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ActivePlanSummary { + pub path: String, + pub status: String, + pub title: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PlanPromptContext { + pub plan_path: String, + pub plan_exists: bool, + pub plan_status: Option<SessionPlanStatus>, + pub plan_title: Option<String>, + pub plan_slug: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PlanApprovalParseResult { + pub approved: bool, + pub matched_phrase: Option<&'static str>, +} + +fn io_error(action: &str, path: &Path, error: std::io::Error) -> ApplicationError { + ApplicationError::Internal(format!("{action} '{}' failed: {error}", path.display())) +} + +pub(crate) fn session_plan_dir( + session_id: &str, + working_dir: &Path, +) -> Result<PathBuf, ApplicationError> { + Ok(project_dir(working_dir) + .map_err(|error| { + ApplicationError::Internal(format!( + "failed to resolve project directory for '{}': {error}", + working_dir.display() + )) + })? + .join("sessions") + .join(session_id) + .join(PLAN_DIR_NAME)) +} + +fn session_plan_state_path( + session_id: &str, + working_dir: &Path, +) -> Result<PathBuf, ApplicationError> { + Ok(session_plan_dir(session_id, working_dir)?.join(PLAN_STATE_FILE_NAME)) +} + +fn session_plan_markdown_path( + session_id: &str, + working_dir: &Path, + slug: &str, +) -> Result<PathBuf, ApplicationError> { + Ok(session_plan_dir(session_id, working_dir)?.join(format!("{slug}.md"))) +} + +pub(crate) fn load_session_plan_state( + session_id: &str, + working_dir: &Path, +) -> Result<Option<SessionPlanState>, ApplicationError> { + let path = session_plan_state_path(session_id, working_dir)?; + if !path.exists() { + return Ok(None); + } + let content = fs::read_to_string(&path).map_err(|error| io_error("reading", &path, error))?; + let state = serde_json::from_str::<SessionPlanState>(&content).map_err(|error| { + ApplicationError::Internal(format!( + "failed to parse session plan state '{}': {error}", + path.display() + )) + })?; + Ok(Some(state)) +} + +pub(crate) fn active_plan_summary( + session_id: &str, + working_dir: &Path, +) -> Result<Option<ActivePlanSummary>, ApplicationError> { + let Some(state) = load_session_plan_state(session_id, working_dir)? else { + return Ok(None); + }; + let path = session_plan_markdown_path(session_id, working_dir, &state.active_plan_slug)?; + Ok(Some(ActivePlanSummary { + path: path.display().to_string(), + status: state.status.to_string(), + title: state.title, + })) +} + +pub(crate) fn build_plan_prompt_context( + session_id: &str, + working_dir: &Path, + user_text: &str, +) -> Result<PlanPromptContext, ApplicationError> { + if let Some(state) = load_session_plan_state(session_id, working_dir)? { + let path = session_plan_markdown_path(session_id, working_dir, &state.active_plan_slug)?; + return Ok(PlanPromptContext { + plan_path: path.display().to_string(), + plan_exists: path.exists(), + plan_status: Some(state.status), + plan_title: Some(state.title), + plan_slug: state.active_plan_slug, + }); + } + + let suggested_slug = slugify_plan_topic(user_text) + .unwrap_or_else(|| format!("plan-{}", Utc::now().format(PLAN_PATH_TIMESTAMP_FORMAT))); + let path = session_plan_markdown_path(session_id, working_dir, &suggested_slug)?; + Ok(PlanPromptContext { + plan_path: path.display().to_string(), + plan_exists: false, + plan_status: None, + plan_title: None, + plan_slug: suggested_slug, + }) +} + +pub(crate) fn build_plan_prompt_declarations( + session_id: &str, + context: &PlanPromptContext, +) -> Vec<PromptDeclaration> { + let mut declarations = vec![PromptDeclaration { + block_id: format!("session.plan.facts.{session_id}"), + title: "Session Plan Artifact".to_string(), + content: format!( + "Session plan facts:\n- planPath: {}\n- planExists: {}\n- planSlug: {}\n- planStatus: \ + {}\n- planTitle: {}\n\nUse `upsertSessionPlan` to create or update this \ + session-scoped plan artifact. When the plan does not exist yet, create the first \ + draft at the provided path using the provided slug. Keep revising the same file \ + while the topic stays the same.", + context.plan_path, + context.plan_exists, + context.plan_slug, + context + .plan_status + .as_ref() + .map(SessionPlanStatus::as_str) + .unwrap_or("missing"), + context.plan_title.as_deref().unwrap_or("(none)") + ), + render_target: astrcode_core::PromptDeclarationRenderTarget::System, + layer: astrcode_core::SystemPromptLayer::Dynamic, + kind: astrcode_core::PromptDeclarationKind::ExtensionInstruction, + priority_hint: Some(605), + always_include: true, + source: astrcode_core::PromptDeclarationSource::Builtin, + capability_name: None, + origin: Some("session-plan:facts".to_string()), + }]; + + if context.plan_exists { + declarations.push(PromptDeclaration { + block_id: format!("session.plan.reentry.{session_id}"), + title: "Plan Re-entry".to_string(), + content: builtin_prompts::plan_mode_reentry_prompt().to_string(), + render_target: astrcode_core::PromptDeclarationRenderTarget::System, + layer: astrcode_core::SystemPromptLayer::Dynamic, + kind: astrcode_core::PromptDeclarationKind::ExtensionInstruction, + priority_hint: Some(604), + always_include: true, + source: astrcode_core::PromptDeclarationSource::Builtin, + capability_name: None, + origin: Some("session-plan:reentry".to_string()), + }); + } else { + declarations.push(PromptDeclaration { + block_id: format!("session.plan.template.{session_id}"), + title: "Plan Template".to_string(), + content: builtin_prompts::plan_template_prompt().to_string(), + render_target: astrcode_core::PromptDeclarationRenderTarget::System, + layer: astrcode_core::SystemPromptLayer::Dynamic, + kind: astrcode_core::PromptDeclarationKind::ExtensionInstruction, + priority_hint: Some(604), + always_include: true, + source: astrcode_core::PromptDeclarationSource::Builtin, + capability_name: None, + origin: Some("session-plan:template".to_string()), + }); + } + + declarations +} + +pub(crate) fn build_plan_exit_declaration( + session_id: &str, + summary: &ActivePlanSummary, +) -> PromptDeclaration { + PromptDeclaration { + block_id: format!("session.plan.exit.{session_id}"), + title: "Plan Mode Exit".to_string(), + content: format!( + "{}\n\nApproved plan artifact:\n- path: {}\n- title: {}\n- status: {}", + builtin_prompts::plan_mode_exit_prompt(), + summary.path, + summary.title, + summary.status + ), + render_target: astrcode_core::PromptDeclarationRenderTarget::System, + layer: astrcode_core::SystemPromptLayer::Dynamic, + kind: astrcode_core::PromptDeclarationKind::ExtensionInstruction, + priority_hint: Some(605), + always_include: true, + source: astrcode_core::PromptDeclarationSource::Builtin, + capability_name: None, + origin: Some("session-plan:exit".to_string()), + } +} + +pub(crate) fn parse_plan_approval(text: &str) -> PlanApprovalParseResult { + let normalized_english = text + .to_ascii_lowercase() + .split_whitespace() + .collect::<Vec<_>>() + .join(" "); + for phrase in ["approved", "go ahead", "implement it"] { + if normalized_english == phrase + || (phrase != "implement it" && normalized_english.starts_with(&format!("{phrase} "))) + { + return PlanApprovalParseResult { + approved: true, + matched_phrase: Some(phrase), + }; + } + } + + let normalized_chinese = text + .chars() + .filter(|ch| !ch.is_whitespace() && !is_common_punctuation(*ch)) + .collect::<String>(); + for phrase in ["同意", "可以", "按这个做", "开始实现"] { + let matched = match phrase { + "同意" | "可以" => normalized_chinese == phrase, + _ => normalized_chinese == phrase || normalized_chinese.starts_with(phrase), + }; + if matched { + return PlanApprovalParseResult { + approved: true, + matched_phrase: Some(phrase), + }; + } + } + + PlanApprovalParseResult { + approved: false, + matched_phrase: None, + } +} + +pub(crate) fn mark_session_plan_approved( + session_id: &str, + working_dir: &Path, +) -> Result<Option<SessionPlanState>, ApplicationError> { + let Some(mut state) = load_session_plan_state(session_id, working_dir)? else { + return Ok(None); + }; + let path = session_plan_state_path(session_id, working_dir)?; + let now = Utc::now(); + state.status = SessionPlanStatus::Approved; + state.updated_at = now; + state.approved_at = Some(now); + persist_plan_state(&path, &state)?; + Ok(Some(state)) +} + +pub(crate) fn copy_session_plan_artifacts( + source_session_id: &str, + target_session_id: &str, + working_dir: &Path, +) -> Result<(), ApplicationError> { + let source_dir = session_plan_dir(source_session_id, working_dir)?; + if !source_dir.exists() { + return Ok(()); + } + let target_dir = session_plan_dir(target_session_id, working_dir)?; + copy_dir_recursive(&source_dir, &target_dir) +} + +pub(crate) fn current_mode_requires_plan_context(mode_id: &ModeId) -> bool { + mode_id == &ModeId::plan() +} + +fn persist_plan_state(path: &Path, state: &SessionPlanState) -> Result<(), ApplicationError> { + let Some(parent) = path.parent() else { + return Err(ApplicationError::Internal(format!( + "session plan state '{}' has no parent directory", + path.display() + ))); + }; + fs::create_dir_all(parent).map_err(|error| io_error("creating directory", parent, error))?; + let content = serde_json::to_string_pretty(state).map_err(|error| { + ApplicationError::Internal(format!( + "failed to serialize session plan state '{}': {error}", + path.display() + )) + })?; + fs::write(path, content).map_err(|error| io_error("writing", path, error)) +} + +fn copy_dir_recursive(source: &Path, target: &Path) -> Result<(), ApplicationError> { + fs::create_dir_all(target).map_err(|error| io_error("creating directory", target, error))?; + for entry in + fs::read_dir(source).map_err(|error| io_error("reading directory", source, error))? + { + let entry = entry.map_err(|error| io_error("reading directory entry", source, error))?; + let source_path = entry.path(); + let target_path = target.join(entry.file_name()); + let file_type = entry + .file_type() + .map_err(|error| io_error("reading file type", &source_path, error))?; + if file_type.is_dir() { + copy_dir_recursive(&source_path, &target_path)?; + } else { + fs::copy(&source_path, &target_path) + .map_err(|error| io_error("copying file", &source_path, error))?; + } + } + Ok(()) +} + +fn is_common_punctuation(ch: char) -> bool { + matches!( + ch, + ',' | '.' | ';' | ':' | '!' | '?' | ',' | '。' | ';' | ':' | '!' | '?' | '、' + ) +} + +fn slugify_plan_topic(input: &str) -> Option<String> { + let mut slug = String::new(); + let mut last_dash = false; + for ch in input.chars().map(|ch| ch.to_ascii_lowercase()) { + if ch.is_ascii_alphanumeric() { + slug.push(ch); + last_dash = false; + continue; + } + if !last_dash && !slug.is_empty() { + slug.push('-'); + last_dash = true; + } + if slug.len() >= 48 { + break; + } + } + let slug = slug.trim_matches('-').to_string(); + if slug.is_empty() { None } else { Some(slug) } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_plan_approval_is_conservative() { + assert!(parse_plan_approval("同意").approved); + assert!(parse_plan_approval("按这个做,开始吧").approved); + assert!(parse_plan_approval("approved please continue").approved); + assert!(!parse_plan_approval("可以再想想").approved); + assert!(!parse_plan_approval("don't implement it yet").approved); + } + + #[test] + fn copy_session_plan_artifacts_ignores_missing_source() { + let temp = tempfile::tempdir().expect("tempdir should exist"); + copy_session_plan_artifacts("session-a", "session-b", temp.path()) + .expect("missing source should be ignored"); + } + + #[test] + fn session_plan_state_round_trips_through_json_schema() { + let state = SessionPlanState { + active_plan_slug: "cleanup-crates".to_string(), + title: "Cleanup crates".to_string(), + status: SessionPlanStatus::AwaitingApproval, + created_at: Utc::now(), + updated_at: Utc::now(), + approved_at: None, + }; + + let encoded = serde_json::to_string(&state).expect("state should serialize"); + let decoded = + serde_json::from_str::<SessionPlanState>(&encoded).expect("state should deserialize"); + assert_eq!(decoded.active_plan_slug, "cleanup-crates"); + assert_eq!(decoded.status, SessionPlanStatus::AwaitingApproval); + } + + #[test] + fn build_plan_prompt_declarations_include_facts_and_template() { + let declarations = build_plan_prompt_declarations( + "session-a", + &PlanPromptContext { + plan_path: "/tmp/cleanup-crates.md".to_string(), + plan_exists: false, + plan_status: None, + plan_title: None, + plan_slug: "cleanup-crates".to_string(), + }, + ); + + assert_eq!(declarations.len(), 2); + assert!( + declarations[0] + .content + .contains("planPath: /tmp/cleanup-crates.md") + ); + assert!(declarations[1].content.contains("## Implementation Steps")); + } +} diff --git a/crates/application/src/session_use_cases.rs b/crates/application/src/session_use_cases.rs index c59b2a73..9d48c73f 100644 --- a/crates/application/src/session_use_cases.rs +++ b/crates/application/src/session_use_cases.rs @@ -19,6 +19,12 @@ use crate::{ }, format_local_rfc3339, governance_surface::{GovernanceBusyPolicy, SessionGovernanceInput}, + session_plan::{ + active_plan_summary, build_plan_exit_declaration, build_plan_prompt_context, + build_plan_prompt_declarations, copy_session_plan_artifacts, + current_mode_requires_plan_context, load_session_plan_state, mark_session_plan_approved, + parse_plan_approval, + }, }; impl App { @@ -54,6 +60,10 @@ impl App { fork_point: astrcode_session_runtime::ForkPoint, ) -> Result<SessionMeta, ApplicationError> { self.validate_non_empty("sessionId", session_id)?; + let source_working_dir = self + .session_runtime + .get_session_working_dir(session_id) + .await?; let normalized_session_id = astrcode_session_runtime::normalize_session_id(session_id); let result = self .session_runtime @@ -76,6 +86,11 @@ impl App { result.new_session_id )) })?; + copy_session_plan_artifacts( + session_id, + result.new_session_id.as_str(), + Path::new(&source_working_dir), + )?; Ok(meta) } @@ -136,14 +151,42 @@ impl App { .config_service .load_resolved_runtime_config(Some(Path::new(&working_dir)))?; let root_agent = self.ensure_session_root_agent_context(session_id).await?; - let prompt_declarations = - match skill_invocation { - Some(skill_invocation) => vec![self.build_submission_skill_declaration( + let mut current_mode_id = self + .session_runtime + .session_mode_state(session_id) + .await + .map_err(ApplicationError::from)? + .current_mode_id; + let mut prompt_declarations = Vec::new(); + + if current_mode_id == ModeId::plan() { + let plan_state = load_session_plan_state(session_id, Path::new(&working_dir))?; + if plan_state + .as_ref() + .is_some_and(|state| state.status.as_str() == "awaiting_approval") + && parse_plan_approval(&text).approved + { + let _ = mark_session_plan_approved(session_id, Path::new(&working_dir))?; + self.switch_mode(session_id, ModeId::code()).await?; + current_mode_id = ModeId::code(); + if let Some(summary) = active_plan_summary(session_id, Path::new(&working_dir))? { + prompt_declarations.push(build_plan_exit_declaration(session_id, &summary)); + } + } else if current_mode_requires_plan_context(¤t_mode_id) { + let context = + build_plan_prompt_context(session_id, Path::new(&working_dir), &text)?; + prompt_declarations.extend(build_plan_prompt_declarations(session_id, &context)); + } + } + + if let Some(skill_invocation) = skill_invocation { + prompt_declarations.push( + self.build_submission_skill_declaration( Path::new(&working_dir), &skill_invocation, - )?], - None => Vec::new(), - }; + )?, + ); + } let surface = self.governance_surface.session_surface( self.kernel.as_ref(), SessionGovernanceInput { @@ -154,12 +197,7 @@ impl App { .agent_profile .clone() .unwrap_or_else(|| IMPLICIT_ROOT_PROFILE_ID.to_string()), - mode_id: self - .session_runtime - .session_mode_state(session_id) - .await - .map_err(ApplicationError::from)? - .current_mode_id, + mode_id: current_mode_id, runtime, control, extra_prompt_declarations: prompt_declarations, diff --git a/crates/application/src/terminal/mod.rs b/crates/application/src/terminal/mod.rs index e33cb29a..6fd06040 100644 --- a/crates/application/src/terminal/mod.rs +++ b/crates/application/src/terminal/mod.rs @@ -27,6 +27,13 @@ pub struct TerminalLastCompactMetaFacts { pub meta: CompactAppliedMeta, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ActivePlanFacts { + pub path: String, + pub status: String, + pub title: String, +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct ConversationControlSummary { pub phase: Phase, @@ -36,6 +43,7 @@ pub struct ConversationControlSummary { pub compacting: bool, pub active_turn_id: Option<String>, pub last_compact_meta: Option<TerminalLastCompactMetaFacts>, + pub active_plan: Option<ActivePlanFacts>, } #[derive(Debug, Clone)] @@ -45,6 +53,7 @@ pub struct TerminalControlFacts { pub manual_compact_pending: bool, pub compacting: bool, pub last_compact_meta: Option<TerminalLastCompactMetaFacts>, + pub active_plan: Option<ActivePlanFacts>, } pub type ConversationControlFacts = TerminalControlFacts; @@ -210,6 +219,7 @@ pub fn summarize_conversation_control( compacting: control.compacting, active_turn_id: control.active_turn_id.clone(), last_compact_meta: control.last_compact_meta.clone(), + active_plan: control.active_plan.clone(), } } diff --git a/crates/application/src/terminal_queries/mod.rs b/crates/application/src/terminal_queries/mod.rs index c2716776..abb42923 100644 --- a/crates/application/src/terminal_queries/mod.rs +++ b/crates/application/src/terminal_queries/mod.rs @@ -15,7 +15,7 @@ mod tests; use astrcode_session_runtime::SessionControlStateSnapshot; -use crate::terminal::{TerminalControlFacts, TerminalLastCompactMetaFacts}; +use crate::terminal::{ActivePlanFacts, TerminalControlFacts, TerminalLastCompactMetaFacts}; fn map_control_facts(control: SessionControlStateSnapshot) -> TerminalControlFacts { TerminalControlFacts { @@ -29,5 +29,6 @@ fn map_control_facts(control: SessionControlStateSnapshot) -> TerminalControlFac trigger: meta.trigger, meta: meta.meta, }), + active_plan: None::<ActivePlanFacts>, } } diff --git a/crates/application/src/terminal_queries/resume.rs b/crates/application/src/terminal_queries/resume.rs index 78c5b800..81082031 100644 --- a/crates/application/src/terminal_queries/resume.rs +++ b/crates/application/src/terminal_queries/resume.rs @@ -7,6 +7,7 @@ use std::{cmp::Reverse, collections::HashSet, path::Path}; use crate::{ App, ApplicationError, ComposerOptionKind, ComposerOptionsRequest, SessionMeta, + session_plan::active_plan_summary, terminal::{ ConversationAuthoritativeSummary, ConversationFocus, TerminalChildSummaryFacts, TerminalControlFacts, TerminalResumeCandidateFacts, TerminalSlashAction, @@ -156,7 +157,19 @@ impl App { .session_runtime .session_control_state(session_id) .await?; - Ok(super::map_control_facts(control)) + let mut facts = super::map_control_facts(control); + let working_dir = self + .session_runtime + .get_session_working_dir(session_id) + .await?; + facts.active_plan = active_plan_summary(session_id, Path::new(&working_dir))?.map(|plan| { + crate::terminal::ActivePlanFacts { + path: plan.path, + status: plan.status, + title: plan.title, + } + }); + Ok(facts) } pub async fn conversation_authoritative_summary( diff --git a/crates/cli/src/state/mod.rs b/crates/cli/src/state/mod.rs index d085c162..d37dd252 100644 --- a/crates/cli/src/state/mod.rs +++ b/crates/cli/src/state/mod.rs @@ -477,6 +477,7 @@ mod tests { compacting: false, active_turn_id: None, last_compact_meta: None, + active_plan: None, }, blocks: vec![AstrcodeConversationBlockDto::Assistant( AstrcodeConversationAssistantBlockDto { @@ -623,6 +624,7 @@ mod tests { compacting: false, active_turn_id: Some("turn-1".to_string()), last_compact_meta: None, + active_plan: None, }); let frame = state.thinking_playback.frame; state.advance_thinking_playback(); diff --git a/crates/protocol/src/http/conversation/v1.rs b/crates/protocol/src/http/conversation/v1.rs index 843e9c78..f2de8d3a 100644 --- a/crates/protocol/src/http/conversation/v1.rs +++ b/crates/protocol/src/http/conversation/v1.rs @@ -318,6 +318,8 @@ pub struct ConversationControlStateDto { pub active_turn_id: Option<String>, #[serde(default, skip_serializing_if = "Option::is_none")] pub last_compact_meta: Option<ConversationLastCompactMetaDto>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub active_plan: Option<ConversationActivePlanDto>, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] @@ -328,6 +330,14 @@ pub struct ConversationLastCompactMetaDto { pub meta: ConversationCompactMetaDto, } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct ConversationActivePlanDto { + pub path: String, + pub status: String, + pub title: String, +} + #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum ConversationBannerErrorCodeDto { diff --git a/crates/protocol/src/http/mod.rs b/crates/protocol/src/http/mod.rs index aae66fd9..6a9b7bb6 100644 --- a/crates/protocol/src/http/mod.rs +++ b/crates/protocol/src/http/mod.rs @@ -43,12 +43,12 @@ pub use config::{ TestConnectionRequest, TestResultDto, }; pub use conversation::v1::{ - ConversationAssistantBlockDto, ConversationBannerDto, ConversationBannerErrorCodeDto, - ConversationBlockDto, ConversationBlockPatchDto, ConversationBlockStatusDto, - ConversationChildHandoffBlockDto, ConversationChildHandoffKindDto, ConversationChildSummaryDto, - ConversationControlStateDto, ConversationCursorDto, ConversationDeltaDto, - ConversationErrorBlockDto, ConversationErrorEnvelopeDto, ConversationLastCompactMetaDto, - ConversationSlashActionKindDto, ConversationSlashCandidateDto, + ConversationActivePlanDto, ConversationAssistantBlockDto, ConversationBannerDto, + ConversationBannerErrorCodeDto, ConversationBlockDto, ConversationBlockPatchDto, + ConversationBlockStatusDto, ConversationChildHandoffBlockDto, ConversationChildHandoffKindDto, + ConversationChildSummaryDto, ConversationControlStateDto, ConversationCursorDto, + ConversationDeltaDto, ConversationErrorBlockDto, ConversationErrorEnvelopeDto, + ConversationLastCompactMetaDto, ConversationSlashActionKindDto, ConversationSlashCandidateDto, ConversationSlashCandidatesResponseDto, ConversationSnapshotResponseDto, ConversationStreamEnvelopeDto, ConversationSystemNoteBlockDto, ConversationSystemNoteKindDto, ConversationThinkingBlockDto, ConversationToolCallBlockDto, ConversationToolStreamsDto, diff --git a/crates/protocol/tests/conversation_conformance.rs b/crates/protocol/tests/conversation_conformance.rs index 9d74ddcf..feac3536 100644 --- a/crates/protocol/tests/conversation_conformance.rs +++ b/crates/protocol/tests/conversation_conformance.rs @@ -37,6 +37,7 @@ fn conversation_snapshot_fixture_freezes_authoritative_tool_block_shape() { compacting: false, active_turn_id: Some("turn-42".to_string()), last_compact_meta: None, + active_plan: None, }, blocks: vec![ConversationBlockDto::ToolCall( ConversationToolCallBlockDto { diff --git a/crates/server/src/bootstrap/capabilities.rs b/crates/server/src/bootstrap/capabilities.rs index 6165d974..72376131 100644 --- a/crates/server/src/bootstrap/capabilities.rs +++ b/crates/server/src/bootstrap/capabilities.rs @@ -22,6 +22,7 @@ use astrcode_adapter_tools::{ shell::ShellTool, skill_tool::SkillTool, tool_search::{ToolSearchIndex, ToolSearchTool}, + upsert_session_plan::UpsertSessionPlanTool, write_file::WriteFileTool, }, }; @@ -52,6 +53,7 @@ pub(crate) fn build_core_tool_invokers( Arc::new(ShellTool), Arc::new(ToolSearchTool::new(tool_search_index)), Arc::new(SkillTool::new(skill_catalog)), + Arc::new(UpsertSessionPlanTool), ]; let invokers = tools diff --git a/crates/server/src/http/routes/conversation.rs b/crates/server/src/http/routes/conversation.rs index d92ec215..b6f8e646 100644 --- a/crates/server/src/http/routes/conversation.rs +++ b/crates/server/src/http/routes/conversation.rs @@ -811,6 +811,7 @@ mod tests { manual_compact_pending: false, compacting: false, last_compact_meta: None, + active_plan: None, }, child_summaries: Vec::new(), slash_candidates: Vec::new(), diff --git a/crates/server/src/http/terminal_projection.rs b/crates/server/src/http/terminal_projection.rs index 46a8738c..e4038d27 100644 --- a/crates/server/src/http/terminal_projection.rs +++ b/crates/server/src/http/terminal_projection.rs @@ -9,16 +9,16 @@ use astrcode_application::terminal::{ }; use astrcode_core::ChildAgentRef; use astrcode_protocol::http::{ - ChildAgentRefDto, ConversationAssistantBlockDto, ConversationBannerDto, - ConversationBannerErrorCodeDto, ConversationBlockDto, ConversationBlockPatchDto, - ConversationBlockStatusDto, ConversationChildHandoffBlockDto, ConversationChildHandoffKindDto, - ConversationChildSummaryDto, ConversationControlStateDto, ConversationCursorDto, - ConversationDeltaDto, ConversationErrorBlockDto, ConversationErrorEnvelopeDto, - ConversationLastCompactMetaDto, ConversationSlashActionKindDto, ConversationSlashCandidateDto, - ConversationSlashCandidatesResponseDto, ConversationSnapshotResponseDto, - ConversationStreamEnvelopeDto, ConversationSystemNoteBlockDto, ConversationSystemNoteKindDto, - ConversationThinkingBlockDto, ConversationToolCallBlockDto, ConversationToolStreamsDto, - ConversationTranscriptErrorCodeDto, ConversationUserBlockDto, + ChildAgentRefDto, ConversationActivePlanDto, ConversationAssistantBlockDto, + ConversationBannerDto, ConversationBannerErrorCodeDto, ConversationBlockDto, + ConversationBlockPatchDto, ConversationBlockStatusDto, ConversationChildHandoffBlockDto, + ConversationChildHandoffKindDto, ConversationChildSummaryDto, ConversationControlStateDto, + ConversationCursorDto, ConversationDeltaDto, ConversationErrorBlockDto, + ConversationErrorEnvelopeDto, ConversationLastCompactMetaDto, ConversationSlashActionKindDto, + ConversationSlashCandidateDto, ConversationSlashCandidatesResponseDto, + ConversationSnapshotResponseDto, ConversationStreamEnvelopeDto, ConversationSystemNoteBlockDto, + ConversationSystemNoteKindDto, ConversationThinkingBlockDto, ConversationToolCallBlockDto, + ConversationToolStreamsDto, ConversationTranscriptErrorCodeDto, ConversationUserBlockDto, }; use astrcode_session_runtime::{ ConversationBlockFacts, ConversationBlockPatchFacts, ConversationBlockStatus, @@ -428,6 +428,11 @@ fn to_conversation_control_state_dto( trigger: meta.trigger, meta: meta.meta, }), + active_plan: summary.active_plan.map(|plan| ConversationActivePlanDto { + path: plan.path, + status: plan.status, + title: plan.title, + }), } } diff --git a/scripts/tauri-frontend.js b/scripts/tauri-frontend.js index 569f64a0..c6856e47 100644 --- a/scripts/tauri-frontend.js +++ b/scripts/tauri-frontend.js @@ -61,6 +61,22 @@ function bundledSidecarName(targetTriple) { : `astrcode-server-${targetTriple}`; } +function filesAreByteEqual(sourcePath, targetPath) { + if (!fs.existsSync(targetPath)) { + return false; + } + + const sourceStat = fs.statSync(sourcePath); + const targetStat = fs.statSync(targetPath); + if (sourceStat.size !== targetStat.size) { + return false; + } + + const sourceBuffer = fs.readFileSync(sourcePath); + const targetBuffer = fs.readFileSync(targetPath); + return sourceBuffer.equals(targetBuffer); +} + function prepareSidecar(currentMode) { const targetTriple = resolveTargetTriple(); const release = currentMode === "build"; @@ -95,6 +111,12 @@ function prepareSidecar(currentMode) { fs.mkdirSync(sidecarDir, { recursive: true }); try { + if (filesAreByteEqual(sourceBinary, targetBinary)) { + console.log( + `[tauri-frontend] Sidecar unchanged, skipped copy: ${targetBinary}`, + ); + return; + } fs.copyFileSync(sourceBinary, targetBinary); // Preserve executable permissions on Unix-like systems if (process.platform !== "win32") { From 4f060a2b441227b311a1b50cec25cdacb03c5bc6 Mon Sep 17 00:00:00 2001 From: whatevertogo <whatevertogo@users.noreply.github.com> Date: Sun, 19 Apr 2026 20:10:19 +0800 Subject: [PATCH 42/53] =?UTF-8?q?=E2=9C=A8=20feat(plan-mode):=20=E5=AE=8C?= =?UTF-8?q?=E5=96=84=20plan=20mode=20=E6=98=BE=E5=BC=8F=E5=88=87=E6=8D=A2?= =?UTF-8?q?=E3=80=81canonical=20plan=20=E5=B7=A5=E4=BB=B6=E4=B8=8E?= =?UTF-8?q?=E5=AE=A1=E6=89=B9=E5=BD=92=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit crates/core/src/session_plan.rs - 新增 session plan 领域模型(SessionPlanState/Status)和确定性 FNV-1a digest, 替代原先散落在 adapter-tools 和 application 的重复定义 crates/adapter-tools/src/builtin_tools/{enter,exit}_plan_mode.rs, mode_transition.rs - 新增 enterPlanMode/exitPlanMode 工具,让模型通过工具调用显式切换 plan/code mode, exitPlanMode 强制 plan 结构校验 + final-review 双重门控 crates/adapter-tools/src/builtin_tools/fs_common.rs, write_file.rs, edit_file.rs, apply_patch.rs - 通用文件写工具拦截 canonical plan 目录的直写,强制走 upsertSessionPlan crates/adapter-tools/src/builtin_tools/upsert_session_plan.rs, session_plan.rs - 重构为共享 session_plan 辅助模块,简化参数(移除 slug/topic),优先复用已有 slug, 新增 legacy digest 兼容迁移 crates/application/src/session_plan.rs, session_use_cases.rs - 审批时自动归档 plan snapshot 到 plan-archives/,基于 content digest 去重, 支持 legacy digest 无缝迁移 crates/core/src/compact_summary.rs, session-runtime/compaction, paths.rs - compact 摘要注入旧 session event log 路径提示,解决压缩后历史丢失问题 crates/protocol/, crates/server/terminal_projection.rs, crates/cli/state/ - 新增 Plan block 类型投影到前端,control state 增加 currentModeId 和 plan slug, CLI 将 Plan block 映射为 transcript cell crates/application/src/terminal/, terminal_queries/resume.rs, summary.rs - Terminal 层 active_plan 改为 PlanReferenceFacts(含 slug), 新增 session_plan_control_summary 聚合查询 crates/kernel/, session-runtime/turn/tool_cycle.rs - CapabilityContext/ToolContext 透传 current_mode_id 到工具层 frontend/ - 新增 PlanMessage/PlanSurface 组件、plan block 渲染、API 类型与归档查询接口 --- .../src/builtin_tools/apply_patch.rs | 44 +- .../src/builtin_tools/edit_file.rs | 47 +- .../src/builtin_tools/enter_plan_mode.rs | 198 +++++++ .../src/builtin_tools/exit_plan_mode.rs | 495 ++++++++++++++++ .../src/builtin_tools/fs_common.rs | 35 ++ crates/adapter-tools/src/builtin_tools/mod.rs | 8 + .../src/builtin_tools/mode_transition.rs | 33 ++ .../src/builtin_tools/session_plan.rs | 74 +++ .../src/builtin_tools/upsert_session_plan.rs | 315 ++++++----- .../src/builtin_tools/write_file.rs | 34 +- crates/application/Cargo.toml | 1 + crates/application/src/agent/test_support.rs | 5 + crates/application/src/lib.rs | 1 + .../application/src/mode/builtin_prompts.rs | 4 +- .../src/mode/builtin_prompts/plan_mode.md | 28 +- .../mode/builtin_prompts/plan_mode_reentry.md | 9 +- crates/application/src/mode/catalog.rs | 1 + crates/application/src/session_plan.rs | 529 ++++++++++++++---- crates/application/src/session_use_cases.rs | 57 +- crates/application/src/terminal/mod.rs | 10 +- .../application/src/terminal_queries/mod.rs | 5 +- .../src/terminal_queries/resume.rs | 19 +- .../src/terminal_queries/summary.rs | 20 +- crates/cli/src/app/coordinator.rs | 11 +- crates/cli/src/app/mod.rs | 14 +- crates/cli/src/state/conversation.rs | 4 + crates/cli/src/state/mod.rs | 2 + crates/cli/src/state/transcript_cell.rs | 16 + crates/core/src/compact_summary.rs | 68 ++- crates/core/src/lib.rs | 2 + crates/core/src/registry/router.rs | 7 +- crates/core/src/session_plan.rs | 80 +++ crates/core/src/tool.rs | 22 +- crates/kernel/src/registry/tool.rs | 5 + crates/protocol/src/http/conversation/v1.rs | 69 ++- crates/protocol/src/http/mod.rs | 14 +- .../tests/conversation_conformance.rs | 47 +- .../fixtures/conversation/v1/snapshot.json | 1 + crates/server/src/bootstrap/capabilities.rs | 4 + crates/server/src/http/routes/conversation.rs | 1 + crates/server/src/http/terminal_projection.rs | 84 ++- .../src/context_window/compaction.rs | 35 +- .../context_window/templates/compact/base.md | 4 +- crates/session-runtime/src/lib.rs | 14 +- .../session-runtime/src/query/conversation.rs | 334 ++++++++++- crates/session-runtime/src/query/mod.rs | 10 +- crates/session-runtime/src/state/mod.rs | 1 + crates/session-runtime/src/state/paths.rs | 53 +- .../src/turn/compaction_cycle.rs | 5 + .../src/turn/manual_compact.rs | 8 +- crates/session-runtime/src/turn/request.rs | 5 + .../session-runtime/src/turn/test_support.rs | 2 +- crates/session-runtime/src/turn/tool_cycle.rs | 7 + docs/ideas/notes.md | 2 +- frontend/src/App.tsx | 56 ++ .../src/components/Chat/ChatScreenContext.tsx | 2 + frontend/src/components/Chat/InputBar.tsx | 11 + frontend/src/components/Chat/MessageList.tsx | 19 +- .../src/components/Chat/PlanMessage.test.tsx | 61 ++ frontend/src/components/Chat/PlanMessage.tsx | 51 ++ frontend/src/components/Chat/PlanSurface.tsx | 123 ++++ .../components/Chat/ToolCallBlock.test.tsx | 97 ++++ .../src/components/Chat/ToolCallBlock.tsx | 45 +- frontend/src/components/Chat/TopBar.tsx | 15 + frontend/src/hooks/useAgent.ts | 16 + frontend/src/lib/api/conversation.test.ts | 48 ++ frontend/src/lib/api/conversation.ts | 75 ++- frontend/src/lib/api/sessions.ts | 16 + frontend/src/lib/sessionFork.test.ts | 2 + frontend/src/lib/subRunView.ts | 3 + frontend/src/lib/toolDisplay.ts | 130 +++++ frontend/src/types.ts | 55 ++ 72 files changed, 3359 insertions(+), 369 deletions(-) create mode 100644 crates/adapter-tools/src/builtin_tools/enter_plan_mode.rs create mode 100644 crates/adapter-tools/src/builtin_tools/exit_plan_mode.rs create mode 100644 crates/adapter-tools/src/builtin_tools/mode_transition.rs create mode 100644 crates/adapter-tools/src/builtin_tools/session_plan.rs create mode 100644 crates/core/src/session_plan.rs create mode 100644 frontend/src/components/Chat/PlanMessage.test.tsx create mode 100644 frontend/src/components/Chat/PlanMessage.tsx create mode 100644 frontend/src/components/Chat/PlanSurface.tsx diff --git a/crates/adapter-tools/src/builtin_tools/apply_patch.rs b/crates/adapter-tools/src/builtin_tools/apply_patch.rs index 503a9c32..927711a0 100644 --- a/crates/adapter-tools/src/builtin_tools/apply_patch.rs +++ b/crates/adapter-tools/src/builtin_tools/apply_patch.rs @@ -26,8 +26,8 @@ use serde::Deserialize; use serde_json::{Map, json}; use crate::builtin_tools::fs_common::{ - build_text_change_report, check_cancel, is_symlink, is_unc_path, read_utf8_file, resolve_path, - write_text_file, + build_text_change_report, check_cancel, ensure_not_canonical_session_plan_write_target, + is_symlink, is_unc_path, read_utf8_file, resolve_path, write_text_file, }; /// ApplyPatch 工具实现。 @@ -516,6 +516,17 @@ async fn apply_file_patch(file_patch: &FilePatch, ctx: &ToolContext) -> FileChan }; }, }; + if let Err(error) = + ensure_not_canonical_session_plan_write_target(ctx, &target_path, "apply_patch") + { + return FileChange { + change_type: change_type.into(), + path: target_path_str.clone(), + applied: false, + summary: error.to_string(), + error: Some(error.to_string()), + }; + } // UNC 路径检查:防止 Windows NTLM 凭据泄露 if is_unc_path(&target_path) { @@ -787,6 +798,7 @@ impl Tool for ApplyPatchTool { let applied = results.iter().filter(|r| r.applied).count(); let failed = total_files - applied; + let first_error = results.iter().find_map(|result| result.error.clone()); let (ok, output, error) = if failed == 0 { ( true, @@ -797,7 +809,7 @@ impl Tool for ApplyPatchTool { ( false, format!("apply_patch: all {total_files} file(s) failed to apply"), - Some(format!("{failed} file(s) failed to apply")), + first_error.or(Some(format!("{failed} file(s) failed to apply"))), ) } else { ( @@ -1184,4 +1196,30 @@ mod tests { "file should remain when delete validation fails" ); } + + #[tokio::test] + async fn apply_patch_rejects_canonical_session_plan_targets() { + let temp = tempfile::tempdir().expect("tempdir"); + let tool = ApplyPatchTool; + let patch = "--- /dev/null\n+++ \ + b/.astrcode-test-state/sessions/session-test/plan/cleanup-crates.md\n@@ -0,0 \ + +1,1 @@\n+# Plan: Cleanup crates\n"; + + let result = tool + .execute( + "tc-patch-plan".into(), + json!({ "patch": patch }), + &test_tool_context_for(temp.path()), + ) + .await + .expect("should return result"); + + assert!(!result.ok); + assert!( + result + .error + .unwrap_or_default() + .contains("upsertSessionPlan") + ); + } } diff --git a/crates/adapter-tools/src/builtin_tools/edit_file.rs b/crates/adapter-tools/src/builtin_tools/edit_file.rs index 350297a6..cce2794b 100644 --- a/crates/adapter-tools/src/builtin_tools/edit_file.rs +++ b/crates/adapter-tools/src/builtin_tools/edit_file.rs @@ -32,9 +32,10 @@ use serde::Deserialize; use serde_json::json; use crate::builtin_tools::fs_common::{ - build_text_change_report, capture_file_observation, check_cancel, file_observation_matches, - is_symlink, is_unc_path, load_file_observation, read_utf8_file, remember_file_observation, - resolve_path, write_text_file, + build_text_change_report, capture_file_observation, check_cancel, + ensure_not_canonical_session_plan_write_target, file_observation_matches, is_symlink, + is_unc_path, load_file_observation, read_utf8_file, remember_file_observation, resolve_path, + write_text_file, }; /// 可编辑文件的最大大小(1 GiB)。 @@ -275,6 +276,7 @@ impl Tool for EditFileTool { let started_at = Instant::now(); let path = resolve_path(ctx, &args.path)?; + ensure_not_canonical_session_plan_write_target(ctx, &path, "editFile")?; // UNC 路径检查:防止 Windows NTLM 凭据泄露 if is_unc_path(&path) { @@ -964,6 +966,45 @@ mod tests { assert!(result.unwrap_err().to_string().contains("cannot be empty")); } + #[tokio::test] + async fn edit_file_rejects_canonical_session_plan_targets() { + let temp = tempfile::tempdir().expect("tempdir should be created"); + let file = temp + .path() + .join(".astrcode-test-state") + .join("sessions") + .join("session-test") + .join("plan") + .join("cleanup-crates.md"); + tokio::fs::create_dir_all(file.parent().expect("plan file should have a parent")) + .await + .expect("plan dir should be created"); + tokio::fs::write(&file, "# Plan: Cleanup crates\n") + .await + .expect("seed write should work"); + let tool = EditFileTool; + + let result = tool + .execute( + "tc-edit-plan".to_string(), + json!({ + "path": file.to_string_lossy(), + "oldStr": "Cleanup crates", + "newStr": "Prompt governance" + }), + &test_tool_context_for(temp.path()), + ) + .await; + + assert!(result.is_err()); + assert!( + result + .unwrap_err() + .to_string() + .contains("upsertSessionPlan") + ); + } + #[tokio::test] async fn edit_file_cannot_mix_single_and_batch() { let temp = tempfile::tempdir().expect("tempdir should be created"); diff --git a/crates/adapter-tools/src/builtin_tools/enter_plan_mode.rs b/crates/adapter-tools/src/builtin_tools/enter_plan_mode.rs new file mode 100644 index 00000000..2942c4a5 --- /dev/null +++ b/crates/adapter-tools/src/builtin_tools/enter_plan_mode.rs @@ -0,0 +1,198 @@ +//! `enterPlanMode` 工具。 +//! +//! 允许模型在执行 mode 中显式切换到 plan mode,把“先规划再执行”做成正式状态迁移, +//! 而不是只靠提示词暗示。 + +use std::time::Instant; + +use astrcode_core::{ + AstrError, ModeId, Result, SideEffect, Tool, ToolCapabilityMetadata, ToolContext, + ToolDefinition, ToolExecutionResult, ToolPromptMetadata, +}; +use async_trait::async_trait; +use serde::Deserialize; +use serde_json::{Value, json}; + +use crate::builtin_tools::mode_transition::emit_mode_changed; + +#[derive(Default)] +pub struct EnterPlanModeTool; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct EnterPlanModeArgs { + #[serde(default)] + reason: Option<String>, +} + +#[async_trait] +impl Tool for EnterPlanModeTool { + fn definition(&self) -> ToolDefinition { + ToolDefinition { + name: "enterPlanMode".to_string(), + description: "Switch the current session into plan mode before doing planning-heavy \ + work." + .to_string(), + parameters: json!({ + "type": "object", + "properties": { + "reason": { + "type": "string", + "description": "Optional short reason for switching into plan mode." + } + }, + "additionalProperties": false + }), + } + } + + fn capability_metadata(&self) -> ToolCapabilityMetadata { + ToolCapabilityMetadata::builtin() + .tags(["plan", "mode", "session"]) + .side_effect(SideEffect::Local) + .prompt( + ToolPromptMetadata::new( + "Switch the current session into plan mode.", + "Use `enterPlanMode` when the task needs an explicit planning phase before \ + execution, or when the user directly asks for a plan. After entering, \ + inspect the relevant code and tests, keep updating the session plan artifact \ + until it is executable, then use `exitPlanMode` to present the finalized \ + plan.", + ) + .example( + "{ reason: \"Need to inspect the codebase and propose a safe refactor plan\" }", + ) + .prompt_tag("plan"), + ) + } + + async fn execute( + &self, + tool_call_id: String, + args: Value, + ctx: &ToolContext, + ) -> Result<ToolExecutionResult> { + let started_at = Instant::now(); + let args: EnterPlanModeArgs = serde_json::from_value(args) + .map_err(|error| AstrError::parse("invalid args for enterPlanMode", error))?; + let from_mode_id = ctx.current_mode_id().clone(); + let to_mode_id = ModeId::plan(); + + if from_mode_id == to_mode_id { + return Ok(ToolExecutionResult { + tool_call_id, + tool_name: "enterPlanMode".to_string(), + ok: true, + output: "session is already in plan mode".to_string(), + error: None, + metadata: Some(mode_metadata( + &from_mode_id, + &to_mode_id, + false, + args.reason.as_deref(), + )), + child_ref: None, + duration_ms: started_at.elapsed().as_millis() as u64, + truncated: false, + }); + } + + emit_mode_changed( + ctx, + "enterPlanMode", + from_mode_id.clone(), + to_mode_id.clone(), + ) + .await?; + + Ok(ToolExecutionResult { + tool_call_id, + tool_name: "enterPlanMode".to_string(), + ok: true, + output: "entered plan mode; inspect the relevant code and tests, keep refining the \ + session plan artifact until it is executable, then use exitPlanMode to \ + present it for user review." + .to_string(), + error: None, + metadata: Some(mode_metadata( + &from_mode_id, + &to_mode_id, + true, + args.reason.as_deref(), + )), + child_ref: None, + duration_ms: started_at.elapsed().as_millis() as u64, + truncated: false, + }) + } +} + +fn mode_metadata( + from_mode_id: &ModeId, + to_mode_id: &ModeId, + changed: bool, + reason: Option<&str>, +) -> Value { + json!({ + "schema": "modeTransition", + "fromModeId": from_mode_id.as_str(), + "toModeId": to_mode_id.as_str(), + "modeChanged": changed, + "reason": reason, + }) +} + +#[cfg(test)] +mod tests { + use std::sync::{Arc, Mutex}; + + use astrcode_core::{StorageEvent, StorageEventPayload}; + + use super::*; + use crate::test_support::test_tool_context_for; + + struct RecordingSink { + events: Arc<Mutex<Vec<StorageEvent>>>, + } + + #[async_trait] + impl astrcode_core::ToolEventSink for RecordingSink { + async fn emit(&self, event: StorageEvent) -> Result<()> { + self.events + .lock() + .expect("recording sink lock should work") + .push(event); + Ok(()) + } + } + + #[tokio::test] + async fn enter_plan_mode_emits_mode_change_event() { + let tool = EnterPlanModeTool; + let events = Arc::new(Mutex::new(Vec::new())); + let ctx = test_tool_context_for(std::env::temp_dir()) + .with_current_mode_id(ModeId::code()) + .with_event_sink(Arc::new(RecordingSink { + events: Arc::clone(&events), + })); + + let result = tool + .execute( + "tc-enter-plan".to_string(), + json!({ "reason": "Need a plan first" }), + &ctx, + ) + .await + .expect("enterPlanMode should execute"); + + assert!(result.ok); + let events = events.lock().expect("recording sink lock should work"); + assert!(matches!( + events.as_slice(), + [StorageEvent { + payload: StorageEventPayload::ModeChanged { from, to, .. }, + .. + }] if *from == ModeId::code() && *to == ModeId::plan() + )); + } +} diff --git a/crates/adapter-tools/src/builtin_tools/exit_plan_mode.rs b/crates/adapter-tools/src/builtin_tools/exit_plan_mode.rs new file mode 100644 index 00000000..c4411af1 --- /dev/null +++ b/crates/adapter-tools/src/builtin_tools/exit_plan_mode.rs @@ -0,0 +1,495 @@ +//! `exitPlanMode` 工具。 +//! +//! 当计划已经被打磨到可执行程度时,使用该工具把 canonical plan 工件正式呈递给前端, +//! 同时把 session 切回 code mode,等待用户批准或要求修订。 + +use std::{fs, path::Path, time::Instant}; + +use astrcode_core::{ + AstrError, ModeId, Result, SideEffect, Tool, ToolCapabilityMetadata, ToolContext, + ToolDefinition, ToolExecutionResult, ToolPromptMetadata, session_plan_content_digest, +}; +use async_trait::async_trait; +use chrono::Utc; +use serde_json::json; + +use crate::builtin_tools::{ + mode_transition::emit_mode_changed, + session_plan::{ + SessionPlanStatus, load_session_plan_state, persist_session_plan_state, + session_plan_markdown_path, session_plan_paths, + }, +}; + +#[derive(Default)] +pub struct ExitPlanModeTool; + +const REQUIRED_PLAN_HEADINGS: &[&str] = &[ + "## Context", + "## Goal", + "## Existing Code To Reuse", + "## Implementation Steps", + "## Verification", +]; +const FINAL_REVIEW_CHECKLIST: &[&str] = &[ + "Re-check assumptions against the code you already inspected.", + "Look for missing edge cases, affected files, and integration boundaries.", + "Confirm the verification steps are specific enough to prove the change works.", + "If the plan changes, persist it with upsertSessionPlan before retrying exitPlanMode.", +]; + +#[derive(Debug, Clone, PartialEq, Eq)] +struct PlanExitBlockers { + missing_headings: Vec<String>, + invalid_sections: Vec<String>, +} + +impl PlanExitBlockers { + fn is_empty(&self) -> bool { + self.missing_headings.is_empty() && self.invalid_sections.is_empty() + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ReviewPendingKind { + RevisePlan, + FinalReview, +} + +#[async_trait] +impl Tool for ExitPlanModeTool { + fn definition(&self) -> ToolDefinition { + ToolDefinition { + name: "exitPlanMode".to_string(), + description: "Present the current session plan to the user and switch back to code \ + mode." + .to_string(), + parameters: json!({ + "type": "object", + "additionalProperties": false + }), + } + } + + fn capability_metadata(&self) -> ToolCapabilityMetadata { + ToolCapabilityMetadata::builtin() + .tags(["plan", "mode", "session"]) + .side_effect(SideEffect::Local) + .prompt( + ToolPromptMetadata::new( + "Present the current session plan to the user and leave plan mode.", + "Only use `exitPlanMode` after you have inspected the code, persisted the \ + current canonical plan artifact, and refined it until it is executable. If \ + the plan is still vague, missing risks, or lacking verification steps, keep \ + updating `upsertSessionPlan` instead of exiting.", + ) + .caveat( + "`exitPlanMode` first checks whether the current plan is executable, then \ + enforces one internal final-review checkpoint before it actually exits. Keep \ + that review out of the plan artifact itself unless the user explicitly asks \ + for it.", + ) + .example("{}") + .prompt_tag("plan") + .always_include(true), + ) + } + + async fn execute( + &self, + tool_call_id: String, + args: serde_json::Value, + ctx: &ToolContext, + ) -> Result<ToolExecutionResult> { + let started_at = Instant::now(); + if !args.is_null() && args != json!({}) { + return Err(AstrError::Validation( + "exitPlanMode does not accept arguments".to_string(), + )); + } + + if ctx.current_mode_id() != &ModeId::plan() { + return Err(AstrError::Validation(format!( + "exitPlanMode can only be called from plan mode, current mode is '{}'", + ctx.current_mode_id() + ))); + } + + let paths = session_plan_paths(ctx)?; + let Some(mut state) = load_session_plan_state(&paths.state_path)? else { + return Err(AstrError::Validation( + "cannot exit plan mode because no session plan artifact exists yet".to_string(), + )); + }; + + let current_plan_slug = state.active_plan_slug.clone(); + let current_plan_title = state.title.clone(); + let plan_path = session_plan_markdown_path(&paths.plan_dir, ¤t_plan_slug); + let plan_content = fs::read_to_string(&plan_path).map_err(|error| { + AstrError::io( + format!("failed reading session plan file '{}'", plan_path.display()), + error, + ) + })?; + let blockers = validate_plan_readiness(&plan_content); + if !blockers.is_empty() { + return Ok(review_pending_result( + tool_call_id, + started_at, + ¤t_plan_title, + &plan_path, + &blockers, + ReviewPendingKind::RevisePlan, + )); + } + + let plan_digest = session_plan_content_digest(plan_content.trim()); + if state.reviewed_plan_digest.as_deref() != Some(plan_digest.as_str()) { + // 这里故意不立刻退出 plan mode。 + // 设计目标是把“最后一次自审”保留为内部流程,而不是把 review 段落写进计划正文: + // 当前计划版本第一次调用 exitPlanMode 只登记一个自审检查点; + // 如果模型自审后认为计划无需再改,再次调用 exitPlanMode 才真正呈递给前端。 + state.reviewed_plan_digest = Some(plan_digest); + persist_session_plan_state(&paths.state_path, &state)?; + return Ok(review_pending_result( + tool_call_id, + started_at, + ¤t_plan_title, + &plan_path, + &blockers, + ReviewPendingKind::FinalReview, + )); + } + + let now = Utc::now(); + state.status = SessionPlanStatus::AwaitingApproval; + state.updated_at = now; + state.approved_at = None; + persist_session_plan_state(&paths.state_path, &state)?; + + emit_mode_changed(ctx, "exitPlanMode", ModeId::plan(), ModeId::code()).await?; + + Ok(ToolExecutionResult { + tool_call_id, + tool_name: "exitPlanMode".to_string(), + ok: true, + output: format!( + "presented the session plan '{}' for user review from {}.\n\n{}", + state.title, + plan_path.display(), + plan_content.trim() + ), + error: None, + metadata: Some(json!({ + "schema": "sessionPlanExit", + "mode": { + "fromModeId": "plan", + "toModeId": "code", + "modeChanged": true, + }, + "plan": { + "title": state.title, + "status": state.status.as_str(), + "slug": current_plan_slug, + "planPath": plan_path.to_string_lossy(), + "content": plan_content.trim(), + "updatedAt": state.updated_at.to_rfc3339(), + } + })), + child_ref: None, + duration_ms: started_at.elapsed().as_millis() as u64, + truncated: false, + }) + } +} + +fn validate_plan_readiness(content: &str) -> PlanExitBlockers { + let trimmed = content.trim(); + if trimmed.is_empty() { + return PlanExitBlockers { + missing_headings: REQUIRED_PLAN_HEADINGS + .iter() + .map(|heading| (*heading).to_string()) + .collect(), + invalid_sections: Vec::new(), + }; + } + + let missing_headings = REQUIRED_PLAN_HEADINGS + .iter() + .copied() + .filter(|heading| !trimmed.contains(heading)) + .map(str::to_string) + .collect::<Vec<_>>(); + + let mut invalid_sections = Vec::new(); + if let Err(error) = ensure_actionable_section(trimmed, "## Implementation Steps") { + invalid_sections.push(error); + } + if let Err(error) = ensure_actionable_section(trimmed, "## Verification") { + invalid_sections.push(error); + } + + PlanExitBlockers { + missing_headings, + invalid_sections, + } +} + +fn ensure_actionable_section(content: &str, heading: &str) -> std::result::Result<(), String> { + let section = section_body(content, heading) + .ok_or_else(|| format!("session plan is missing required section '{}'", heading))?; + let has_actionable_line = section.lines().map(str::trim).any(|line| { + !line.is_empty() + && (line.starts_with("- ") + || line.starts_with("* ") + || line.chars().next().is_some_and(|ch| ch.is_ascii_digit())) + }); + if has_actionable_line { + return Ok(()); + } + Err(format!( + "session plan section '{}' must contain concrete actionable items before exiting plan mode", + heading + )) +} + +fn section_body<'a>(content: &'a str, heading: &str) -> Option<&'a str> { + let start = content.find(heading)?; + let after_heading = &content[start + heading.len()..]; + let next_heading_offset = after_heading.find("\n## "); + Some(match next_heading_offset { + Some(offset) => &after_heading[..offset], + None => after_heading, + }) +} + +fn review_pending_result( + tool_call_id: String, + started_at: Instant, + title: &str, + plan_path: &Path, + blockers: &PlanExitBlockers, + kind: ReviewPendingKind, +) -> ToolExecutionResult { + let mut checklist = match kind { + ReviewPendingKind::RevisePlan => vec![ + "The plan is not executable yet. Revise the canonical session plan before exiting \ + plan mode." + .to_string(), + ], + ReviewPendingKind::FinalReview => vec![ + "Run one internal final review before exiting plan mode. Keep that review out of the \ + plan artifact itself." + .to_string(), + "If the review changes the plan, persist the updated plan with upsertSessionPlan and \ + retry exitPlanMode later." + .to_string(), + ], + }; + checklist.push("Final review checklist:".to_string()); + checklist.extend( + FINAL_REVIEW_CHECKLIST + .iter() + .enumerate() + .map(|(index, item)| format!("{}. {}", index + 1, item)), + ); + + if !blockers.missing_headings.is_empty() { + checklist.push(format!( + "Missing sections: {}", + blockers.missing_headings.join(", ") + )); + } + if !blockers.invalid_sections.is_empty() { + checklist.push(format!( + "Sections to strengthen: {}", + blockers.invalid_sections.join("; ") + )); + } + + ToolExecutionResult { + tool_call_id, + tool_name: "exitPlanMode".to_string(), + ok: true, + output: checklist.join("\n"), + error: None, + metadata: Some(json!({ + "schema": "sessionPlanExitReviewPending", + "plan": { + "title": title, + "planPath": plan_path.to_string_lossy(), + }, + "review": { + "kind": match kind { + ReviewPendingKind::RevisePlan => "revise_plan", + ReviewPendingKind::FinalReview => "final_review", + }, + "checklist": FINAL_REVIEW_CHECKLIST, + }, + "blockers": { + "missingHeadings": blockers.missing_headings, + "invalidSections": blockers.invalid_sections, + } + })), + child_ref: None, + duration_ms: started_at.elapsed().as_millis() as u64, + truncated: false, + } +} + +#[cfg(test)] +mod tests { + use std::sync::{Arc, Mutex}; + + use astrcode_core::{StorageEvent, StorageEventPayload}; + + use super::*; + use crate::{ + builtin_tools::upsert_session_plan::UpsertSessionPlanTool, + test_support::test_tool_context_for, + }; + + struct RecordingSink { + events: Arc<Mutex<Vec<StorageEvent>>>, + } + + #[async_trait] + impl astrcode_core::ToolEventSink for RecordingSink { + async fn emit(&self, event: StorageEvent) -> Result<()> { + self.events + .lock() + .expect("recording sink lock should work") + .push(event); + Ok(()) + } + } + + #[tokio::test] + async fn exit_plan_mode_requires_internal_review_before_presenting_plan() { + let temp = tempfile::tempdir().expect("tempdir should exist"); + let upsert = UpsertSessionPlanTool; + let events = Arc::new(Mutex::new(Vec::new())); + let ctx = test_tool_context_for(temp.path()) + .with_current_mode_id(ModeId::plan()) + .with_event_sink(Arc::new(RecordingSink { + events: Arc::clone(&events), + })); + + upsert + .execute( + "tc-plan-seed".to_string(), + json!({ + "title": "Cleanup crates", + "status": "draft", + "content": "# Plan: Cleanup crates\n\n## Context\n- current crates are inconsistent\n\n## Goal\n- align crate boundaries\n\n## Scope\n- runtime and adapter cleanup\n\n## Non-Goals\n- change transport protocol\n\n## Existing Code To Reuse\n- reuse current capability routing\n\n## Implementation Steps\n- audit crate dependencies\n- introduce shared plan tools\n\n## Verification\n- run targeted Rust and frontend checks\n\n## Open Questions\n- none" + }), + &ctx, + ) + .await + .expect("seed plan should succeed"); + + let first_attempt = ExitPlanModeTool + .execute("tc-plan-exit-1".to_string(), json!({}), &ctx) + .await + .expect("first exitPlanMode call should return review pending"); + + assert!(first_attempt.ok); + let first_metadata = first_attempt.metadata.expect("metadata should exist"); + assert_eq!( + first_metadata["schema"], + json!("sessionPlanExitReviewPending") + ); + assert_eq!(first_metadata["review"]["kind"], json!("final_review")); + + let result = ExitPlanModeTool + .execute("tc-plan-exit-2".to_string(), json!({}), &ctx) + .await + .expect("second exitPlanMode call should succeed"); + + assert!(result.ok); + let metadata = result.metadata.expect("metadata should exist"); + assert_eq!(metadata["schema"], json!("sessionPlanExit")); + assert_eq!(metadata["plan"]["status"], json!("awaiting_approval")); + + let state_path = session_plan_paths(&ctx) + .expect("plan paths should resolve") + .state_path; + let state = load_session_plan_state(&state_path) + .expect("state should load") + .expect("state should exist"); + assert_eq!(state.status, SessionPlanStatus::AwaitingApproval); + assert!(state.reviewed_plan_digest.is_some()); + + let events = events.lock().expect("recording sink lock should work"); + assert!(matches!( + events.as_slice(), + [StorageEvent { + payload: StorageEventPayload::ModeChanged { from, to, .. }, + .. + }] if *from == ModeId::plan() && *to == ModeId::code() + )); + } + + #[tokio::test] + async fn exit_plan_mode_returns_review_pending_for_incomplete_plan() { + let temp = tempfile::tempdir().expect("tempdir should exist"); + let upsert = UpsertSessionPlanTool; + let ctx = test_tool_context_for(temp.path()) + .with_current_mode_id(ModeId::plan()) + .with_event_sink(Arc::new(RecordingSink { + events: Arc::new(Mutex::new(Vec::new())), + })); + + upsert + .execute( + "tc-plan-seed".to_string(), + json!({ + "title": "Cleanup crates", + "status": "draft", + "content": "# Plan: Cleanup crates\n\n## Context\n- current crates are inconsistent\n\n## Goal\n- align crate boundaries\n\n## Implementation Steps\n- audit crate dependencies\n\n## Verification\nrun targeted Rust checks" + }), + &ctx, + ) + .await + .expect("seed plan should succeed"); + + let result = ExitPlanModeTool + .execute("tc-plan-exit".to_string(), json!({}), &ctx) + .await + .expect("tool should return a review-pending result"); + + assert!(result.ok); + let metadata = result.metadata.expect("metadata should exist"); + assert_eq!(metadata["schema"], json!("sessionPlanExitReviewPending")); + assert_eq!(metadata["review"]["kind"], json!("revise_plan")); + assert_eq!( + metadata["blockers"]["missingHeadings"][0], + json!("## Existing Code To Reuse") + ); + assert!(result.output.contains("not executable yet")); + } + + #[test] + fn validate_plan_readiness_accepts_plan_without_plan_review_section() { + let content = "# Plan: Cleanup crates + +## Context +- current crates are inconsistent + +## Goal +- align crate boundaries + +## Existing Code To Reuse +- reuse current capability routing + +## Implementation Steps +- audit crate dependencies + +## Verification +- run targeted Rust checks +"; + + assert!(validate_plan_readiness(content).is_empty()); + } +} diff --git a/crates/adapter-tools/src/builtin_tools/fs_common.rs b/crates/adapter-tools/src/builtin_tools/fs_common.rs index c81c4a2b..782a48ee 100644 --- a/crates/adapter-tools/src/builtin_tools/fs_common.rs +++ b/crates/adapter-tools/src/builtin_tools/fs_common.rs @@ -240,6 +240,41 @@ pub fn session_dir_for_tool_results(ctx: &ToolContext) -> Result<PathBuf> { Ok(project_dir.join("sessions").join(ctx.session_id())) } +/// 拒绝通用文件写工具直接修改 canonical session plan。 +/// +/// session plan 的正式写入口必须统一走 `upsertSessionPlan`,否则会让 `state.json` +/// 与 markdown artifact 脱节,并污染 conversation 对 canonical plan 的投影语义。 +pub fn ensure_not_canonical_session_plan_write_target( + ctx: &ToolContext, + path: &Path, + tool_name: &str, +) -> Result<()> { + let plan_dir = resolve_for_host_access(&normalize_lexically( + &session_dir_for_tool_results(ctx)?.join("plan"), + ))?; + if !is_path_within_root(path, &plan_dir) { + return Ok(()); + } + + let is_canonical_plan_file = path + .extension() + .and_then(|value| value.to_str()) + .is_some_and(|value| value.eq_ignore_ascii_case("md")) + || path + .file_name() + .and_then(|value| value.to_str()) + .is_some_and(|value| value.eq_ignore_ascii_case("state.json")); + if !is_canonical_plan_file { + return Ok(()); + } + + Err(AstrError::Validation(format!( + "`{tool_name}` cannot modify canonical session plan artifacts under '{}'; use \ + upsertSessionPlan instead", + plan_dir.display() + ))) +} + /// 文件观察快照。 /// /// `readFile` 成功后记录当前版本,`editFile` 写入前用它检测文件是否已被外部修改。 diff --git a/crates/adapter-tools/src/builtin_tools/mod.rs b/crates/adapter-tools/src/builtin_tools/mod.rs index fd0e2d99..705acafe 100644 --- a/crates/adapter-tools/src/builtin_tools/mod.rs +++ b/crates/adapter-tools/src/builtin_tools/mod.rs @@ -11,6 +11,10 @@ pub mod apply_patch; /// 文件编辑工具:唯一字符串替换 pub mod edit_file; +/// 进入 plan mode:让模型显式切换到规划阶段 +pub mod enter_plan_mode; +/// 退出 plan mode:把计划正式呈递给前端并切回 code +pub mod exit_plan_mode; /// 文件查找工具:glob 模式匹配 pub mod find_files; /// 文件系统公共工具:路径解析、取消检查、diff 生成 @@ -19,8 +23,12 @@ pub mod fs_common; pub mod grep; /// 目录列表工具:浅层条目枚举 pub mod list_dir; +/// mode 切换共享辅助 +pub mod mode_transition; /// 文件读取工具:UTF-8 文本读取 pub mod read_file; +/// session 计划工件共享读写辅助 +pub mod session_plan; /// Shell 命令执行工具:流式 stdout/stderr pub mod shell; /// 技能工具:按需加载 skill 指令 diff --git a/crates/adapter-tools/src/builtin_tools/mode_transition.rs b/crates/adapter-tools/src/builtin_tools/mode_transition.rs new file mode 100644 index 00000000..b011ad8f --- /dev/null +++ b/crates/adapter-tools/src/builtin_tools/mode_transition.rs @@ -0,0 +1,33 @@ +//! mode 切换工具共享辅助。 +//! +//! `enterPlanMode` 与 `exitPlanMode` 都需要发出相同的 `ModeChanged` 事件, +//! 这里集中实现,避免工具层对同一条领域事件各自维护一份写法。 + +use astrcode_core::{ + AgentEventContext, AstrError, ModeId, Result, StorageEvent, StorageEventPayload, ToolContext, +}; +use chrono::Utc; + +pub async fn emit_mode_changed( + ctx: &ToolContext, + tool_name: &'static str, + from: ModeId, + to: ModeId, +) -> Result<()> { + let Some(event_sink) = ctx.event_sink() else { + return Err(AstrError::Internal(format!( + "{tool_name} requires an attached tool event sink" + ))); + }; + event_sink + .emit(StorageEvent { + turn_id: None, + agent: AgentEventContext::default(), + payload: StorageEventPayload::ModeChanged { + from, + to, + timestamp: Utc::now(), + }, + }) + .await +} diff --git a/crates/adapter-tools/src/builtin_tools/session_plan.rs b/crates/adapter-tools/src/builtin_tools/session_plan.rs new file mode 100644 index 00000000..45896a1a --- /dev/null +++ b/crates/adapter-tools/src/builtin_tools/session_plan.rs @@ -0,0 +1,74 @@ +//! session 计划工件的共享读写辅助。 +//! +//! `upsertSessionPlan`、`exitPlanMode` 等工具都需要读写同一份单 plan 状态; +//! 这里集中维护状态结构和路径规则,避免多处各自漂移。 + +use std::{ + fs, + path::{Path, PathBuf}, +}; + +use astrcode_core::{AstrError, Result, ToolContext}; +pub use astrcode_core::{SessionPlanState, SessionPlanStatus}; + +use crate::builtin_tools::fs_common::session_dir_for_tool_results; + +pub const PLAN_DIR_NAME: &str = "plan"; +pub const PLAN_STATE_FILE_NAME: &str = "state.json"; +pub const PLAN_PATH_TIMESTAMP_FORMAT: &str = "%Y%m%dT%H%M%SZ"; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SessionPlanPaths { + pub plan_dir: PathBuf, + pub state_path: PathBuf, +} + +pub fn session_plan_paths(ctx: &ToolContext) -> Result<SessionPlanPaths> { + let plan_dir = session_dir_for_tool_results(ctx)?.join(PLAN_DIR_NAME); + Ok(SessionPlanPaths { + state_path: plan_dir.join(PLAN_STATE_FILE_NAME), + plan_dir, + }) +} + +pub fn session_plan_markdown_path(plan_dir: &Path, slug: &str) -> PathBuf { + plan_dir.join(format!("{slug}.md")) +} + +pub fn load_session_plan_state(path: &Path) -> Result<Option<SessionPlanState>> { + if !path.exists() { + return Ok(None); + } + let content = fs::read_to_string(path) + .map_err(|error| AstrError::io(format!("failed reading '{}'", path.display()), error))?; + serde_json::from_str::<SessionPlanState>(&content) + .map(Some) + .map_err(|error| AstrError::parse("failed to parse session plan state", error)) +} + +pub fn persist_session_plan_state(path: &Path, state: &SessionPlanState) -> Result<()> { + let Some(parent) = path.parent() else { + return Err(AstrError::Internal(format!( + "session plan state '{}' has no parent directory", + path.display() + ))); + }; + fs::create_dir_all(parent).map_err(|error| { + AstrError::io( + format!( + "failed creating session plan directory '{}'", + parent.display() + ), + error, + ) + })?; + let content = serde_json::to_string_pretty(state) + .map_err(|error| AstrError::parse("failed to serialize session plan state", error))?; + fs::write(path, content).map_err(|error| { + AstrError::io( + format!("failed writing session plan state '{}'", path.display()), + error, + ) + })?; + Ok(()) +} diff --git a/crates/adapter-tools/src/builtin_tools/upsert_session_plan.rs b/crates/adapter-tools/src/builtin_tools/upsert_session_plan.rs index b6cd6987..7a13e812 100644 --- a/crates/adapter-tools/src/builtin_tools/upsert_session_plan.rs +++ b/crates/adapter-tools/src/builtin_tools/upsert_session_plan.rs @@ -1,70 +1,36 @@ //! `upsertSessionPlan` 工具。 //! //! 该工具只允许写当前 session 下的 `plan/` 目录和 `state.json`, -//! 作为 plan mode 唯一的受限写入口。 +//! 作为 canonical session plan 的唯一受限写入口。 -use std::{fs, path::PathBuf, time::Instant}; +use std::{fs, time::Instant}; use astrcode_core::{ - AstrError, Result, SideEffect, Tool, ToolCapabilityMetadata, ToolContext, ToolDefinition, - ToolExecutionResult, ToolPromptMetadata, + AstrError, Result, SessionPlanState, SessionPlanStatus, SideEffect, Tool, + ToolCapabilityMetadata, ToolContext, ToolDefinition, ToolExecutionResult, ToolPromptMetadata, }; use async_trait::async_trait; -use chrono::{DateTime, Utc}; -use serde::{Deserialize, Serialize}; +use chrono::Utc; +use serde::Deserialize; use serde_json::json; -use crate::builtin_tools::fs_common::{check_cancel, session_dir_for_tool_results}; - -const PLAN_DIR_NAME: &str = "plan"; -const PLAN_STATE_FILE_NAME: &str = "state.json"; -const PLAN_PATH_TIMESTAMP_FORMAT: &str = "%Y%m%dT%H%M%SZ"; +use crate::builtin_tools::{ + fs_common::check_cancel, + session_plan::{ + PLAN_PATH_TIMESTAMP_FORMAT, load_session_plan_state, persist_session_plan_state, + session_plan_markdown_path, session_plan_paths, + }, +}; #[derive(Default)] pub struct UpsertSessionPlanTool; -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -enum SessionPlanStatus { - Draft, - AwaitingApproval, - Approved, - Superseded, -} - -impl SessionPlanStatus { - fn as_str(&self) -> &'static str { - match self { - Self::Draft => "draft", - Self::AwaitingApproval => "awaiting_approval", - Self::Approved => "approved", - Self::Superseded => "superseded", - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -struct SessionPlanState { - active_plan_slug: String, - title: String, - status: SessionPlanStatus, - created_at: DateTime<Utc>, - updated_at: DateTime<Utc>, - #[serde(default, skip_serializing_if = "Option::is_none")] - approved_at: Option<DateTime<Utc>>, -} - #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct UpsertSessionPlanArgs { title: String, content: String, #[serde(default)] - topic: Option<String>, - #[serde(default)] - slug: Option<String>, - #[serde(default)] status: Option<SessionPlanStatus>, } @@ -73,7 +39,7 @@ impl Tool for UpsertSessionPlanTool { fn definition(&self) -> ToolDefinition { ToolDefinition { name: "upsertSessionPlan".to_string(), - description: "Create or overwrite the current session's plan artifact and state file." + description: "Create or overwrite the canonical session plan artifact and its state." .to_string(), parameters: json!({ "type": "object", @@ -84,19 +50,11 @@ impl Tool for UpsertSessionPlanTool { }, "content": { "type": "string", - "description": "Full markdown body to persist into the session plan file." - }, - "topic": { - "type": "string", - "description": "Optional task/topic text used to derive a slug when no active plan exists yet." - }, - "slug": { - "type": "string", - "description": "Optional explicit kebab-case slug. When omitted, the tool reuses the active session slug or derives one from topic/title." + "description": "Full markdown body to persist into the canonical session plan file." }, "status": { "type": "string", - "enum": ["draft", "awaiting_approval", "approved", "superseded"], + "enum": ["draft", "awaiting_approval", "approved", "completed", "superseded"], "description": "Plan state to persist alongside the markdown artifact." } }, @@ -113,18 +71,19 @@ impl Tool for UpsertSessionPlanTool { .side_effect(SideEffect::Local) .prompt( ToolPromptMetadata::new( - "Create or update the current session's plan artifact.", + "Create or update the canonical session plan artifact.", "Use `upsertSessionPlan` when plan mode needs to persist the canonical \ - session plan markdown and its `state.json` metadata. This tool can only \ - write inside the current session's `plan/` directory.", + session plan markdown and its `state.json`. This tool is the only supported \ + writer for `sessions/<id>/plan/**`.", ) .caveat( - "This is the only write tool available in plan mode. It overwrites the whole \ - plan file content each time.", + "A session has exactly one canonical plan. Revise that plan for the same \ + task; if the task changes, overwrite the current canonical plan instead of \ + creating another one.", ) .example( - "{ title: \"Cleanup crates\", slug: \"cleanup-crates\", content: \"# Plan: \ - Cleanup crates\\n...\", status: \"draft\" }", + "{ title: \"Cleanup crates\", content: \"# Plan: Cleanup crates\\n...\", \ + status: \"draft\" }", ) .prompt_tag("plan") .always_include(true), @@ -155,40 +114,22 @@ impl Tool for UpsertSessionPlanTool { } let started_at = Instant::now(); - let plan_dir = session_dir_for_tool_results(ctx)?.join(PLAN_DIR_NAME); - let state_path = plan_dir.join(PLAN_STATE_FILE_NAME); - let previous_state = load_state(&state_path)?; - let slug = resolve_slug(&args, previous_state.as_ref()); - let plan_path = plan_dir.join(format!("{slug}.md")); + let paths = session_plan_paths(ctx)?; let now = Utc::now(); - let status = args.status.unwrap_or(SessionPlanStatus::Draft); - let created_at = previous_state + let existing = load_session_plan_state(&paths.state_path)?; + let slug = existing .as_ref() - .filter(|state| state.active_plan_slug == slug) - .map(|state| state.created_at) - .unwrap_or(now); - let approved_at = if matches!(status, SessionPlanStatus::Approved) { - previous_state - .as_ref() - .and_then(|state| state.approved_at) - .or(Some(now)) - } else { - None - }; - let state = SessionPlanState { - active_plan_slug: slug.clone(), - title: title.to_string(), - status, - created_at, - updated_at: now, - approved_at, - }; + .map(|state| state.active_plan_slug.clone()) + .or_else(|| slugify(&args.title)) + .unwrap_or_else(|| format!("plan-{}", Utc::now().format(PLAN_PATH_TIMESTAMP_FORMAT))); + let plan_path = session_plan_markdown_path(&paths.plan_dir, &slug); + let status = args.status.unwrap_or(SessionPlanStatus::Draft); - fs::create_dir_all(&plan_dir).map_err(|error| { + fs::create_dir_all(&paths.plan_dir).map_err(|error| { AstrError::io( format!( "failed creating session plan directory '{}'", - plan_dir.display() + paths.plan_dir.display() ), error, ) @@ -199,17 +140,30 @@ impl Tool for UpsertSessionPlanTool { error, ) })?; - let state_content = serde_json::to_string_pretty(&state) - .map_err(|error| AstrError::parse("failed to serialize session plan state", error))?; - fs::write(&state_path, state_content).map_err(|error| { - AstrError::io( - format!( - "failed writing session plan state '{}'", - state_path.display() - ), - error, - ) - })?; + + let state = SessionPlanState { + active_plan_slug: slug.clone(), + title: title.to_string(), + status: status.clone(), + created_at: existing + .as_ref() + .map(|state| state.created_at) + .unwrap_or(now), + updated_at: now, + reviewed_plan_digest: None, + approved_at: match status { + SessionPlanStatus::Approved => existing + .as_ref() + .and_then(|state| state.approved_at) + .or(Some(now)), + _ => None, + }, + archived_plan_digest: existing + .as_ref() + .and_then(|state| state.archived_plan_digest.clone()), + archived_at: existing.as_ref().and_then(|state| state.archived_at), + }; + persist_session_plan_state(&paths.state_path, &state)?; Ok(ToolExecutionResult { tool_call_id, @@ -235,32 +189,7 @@ impl Tool for UpsertSessionPlanTool { } } -fn load_state(path: &PathBuf) -> Result<Option<SessionPlanState>> { - if !path.exists() { - return Ok(None); - } - let content = fs::read_to_string(path) - .map_err(|error| AstrError::io(format!("failed reading '{}'", path.display()), error))?; - let state = serde_json::from_str::<SessionPlanState>(&content) - .map_err(|error| AstrError::parse("failed to parse session plan state", error))?; - Ok(Some(state)) -} - -fn resolve_slug(args: &UpsertSessionPlanArgs, previous_state: Option<&SessionPlanState>) -> String { - if let Some(slug) = args.slug.as_deref().and_then(normalize_slug) { - return slug; - } - if let Some(previous_state) = previous_state { - return previous_state.active_plan_slug.clone(); - } - args.topic - .as_deref() - .and_then(slugify) - .or_else(|| slugify(&args.title)) - .unwrap_or_else(|| format!("plan-{}", Utc::now().format(PLAN_PATH_TIMESTAMP_FORMAT))) -} - -fn normalize_slug(input: &str) -> Option<String> { +fn slugify(input: &str) -> Option<String> { let mut normalized = String::new(); let mut last_dash = false; for ch in input.chars().map(|ch| ch.to_ascii_lowercase()) { @@ -282,10 +211,6 @@ fn normalize_slug(input: &str) -> Option<String> { } } -fn slugify(input: &str) -> Option<String> { - normalize_slug(input) -} - #[cfg(test)] mod tests { use serde_json::json; @@ -294,7 +219,7 @@ mod tests { use crate::test_support::test_tool_context_for; #[tokio::test] - async fn upsert_session_plan_creates_markdown_and_state() { + async fn upsert_session_plan_creates_canonical_plan_state() { let temp = tempfile::tempdir().expect("tempdir should exist"); let tool = UpsertSessionPlanTool; let result = tool @@ -303,7 +228,6 @@ mod tests { json!({ "title": "Cleanup crates", "content": "# Plan: Cleanup crates\n\n## Context", - "slug": "cleanup-crates", "status": "draft" }), &test_tool_context_for(temp.path()), @@ -318,40 +242,136 @@ mod tests { .join("sessions") .join("session-test") .join("plan"); - assert!(plan_dir.join("cleanup-crates.md").exists()); + let metadata = result.metadata.expect("metadata should exist"); + let slug = metadata["slug"].as_str().expect("slug should exist"); + assert!(plan_dir.join(format!("{slug}.md")).exists()); assert!(plan_dir.join("state.json").exists()); + } + + #[tokio::test] + async fn upsert_session_plan_reuses_existing_slug() { + let temp = tempfile::tempdir().expect("tempdir should exist"); + let tool = UpsertSessionPlanTool; + let ctx = test_tool_context_for(temp.path()); + + let first = tool + .execute( + "tc-plan-initial".to_string(), + json!({ + "title": "Cleanup crates", + "content": "# Plan: Cleanup crates", + "status": "draft" + }), + &ctx, + ) + .await + .expect("initial write should work"); + let first_slug = first + .metadata + .as_ref() + .and_then(|metadata| metadata["slug"].as_str()) + .expect("slug should exist") + .to_string(); + + let result = tool + .execute( + "tc-plan-update".to_string(), + json!({ + "title": "Cleanup crates revised", + "content": "# Plan: Cleanup crates revised", + "status": "awaiting_approval" + }), + &ctx, + ) + .await + .expect("update should execute"); + + assert!(result.ok); assert_eq!( result.metadata.expect("metadata should exist")["slug"], - json!("cleanup-crates") + json!(first_slug) ); } #[tokio::test] - async fn upsert_session_plan_reuses_existing_slug_when_omitted() { + async fn upsert_session_plan_preserves_archive_markers() { let temp = tempfile::tempdir().expect("tempdir should exist"); let tool = UpsertSessionPlanTool; let ctx = test_tool_context_for(temp.path()); tool.execute( - "tc-plan-initial".to_string(), + "tc-plan-first".to_string(), json!({ "title": "Cleanup crates", "content": "# Plan: Cleanup crates", - "slug": "cleanup-crates", + "status": "approved" + }), + &ctx, + ) + .await + .expect("first plan should work"); + + let state_path = session_plan_paths(&ctx) + .expect("plan paths should resolve") + .state_path; + let mut state = load_session_plan_state(&state_path) + .expect("state should load") + .expect("state should exist"); + state.archived_plan_digest = Some("digest-a".to_string()); + state.archived_at = Some(Utc::now()); + persist_session_plan_state(&state_path, &state).expect("state should persist"); + + tool.execute( + "tc-plan-second".to_string(), + json!({ + "title": "Cleanup crates revised", + "content": "# Plan: Cleanup crates revised", "status": "draft" }), &ctx, ) .await - .expect("initial write should work"); + .expect("second write should work"); + + let state = load_session_plan_state(&state_path) + .expect("state should load") + .expect("state should exist"); + assert_eq!(state.archived_plan_digest.as_deref(), Some("digest-a")); + assert!(state.reviewed_plan_digest.is_none()); + } + + #[tokio::test] + async fn upsert_session_plan_preserves_existing_custom_slug_from_state() { + let temp = tempfile::tempdir().expect("tempdir should exist"); + let tool = UpsertSessionPlanTool; + let ctx = test_tool_context_for(temp.path()); + let paths = session_plan_paths(&ctx).expect("plan paths should resolve"); + let now = Utc::now(); + let existing_slug = "my-custom-slug".to_string(); + + persist_session_plan_state( + &paths.state_path, + &SessionPlanState { + active_plan_slug: existing_slug.clone(), + title: "Existing title".to_string(), + status: SessionPlanStatus::Draft, + created_at: now, + updated_at: now, + reviewed_plan_digest: None, + approved_at: None, + archived_plan_digest: None, + archived_at: None, + }, + ) + .expect("existing state should persist"); let result = tool .execute( - "tc-plan-update".to_string(), + "tc-plan-custom-slug".to_string(), json!({ - "title": "Cleanup crates revised", - "content": "# Plan: Cleanup crates revised", - "status": "awaiting_approval" + "title": "Completely different title", + "content": "# Plan: revised", + "status": "draft" }), &ctx, ) @@ -361,7 +381,8 @@ mod tests { assert!(result.ok); assert_eq!( result.metadata.expect("metadata should exist")["slug"], - json!("cleanup-crates") + json!(existing_slug) ); + assert!(paths.plan_dir.join("my-custom-slug.md").exists()); } } diff --git a/crates/adapter-tools/src/builtin_tools/write_file.rs b/crates/adapter-tools/src/builtin_tools/write_file.rs index 1b9f58b9..a8bbd2dc 100644 --- a/crates/adapter-tools/src/builtin_tools/write_file.rs +++ b/crates/adapter-tools/src/builtin_tools/write_file.rs @@ -19,8 +19,9 @@ use serde::Deserialize; use serde_json::json; use crate::builtin_tools::fs_common::{ - TextChangeReport, build_text_change_report, check_cancel, is_symlink, is_unc_path, - read_utf8_file, resolve_path, write_text_file, + TextChangeReport, build_text_change_report, check_cancel, + ensure_not_canonical_session_plan_write_target, is_symlink, is_unc_path, read_utf8_file, + resolve_path, write_text_file, }; /// WriteFile 工具实现。 @@ -109,6 +110,7 @@ impl Tool for WriteFileTool { .map_err(|e| AstrError::parse("invalid args for writeFile", e))?; let started_at = Instant::now(); let path = resolve_path(ctx, &args.path)?; + ensure_not_canonical_session_plan_write_target(ctx, &path, "writeFile")?; // UNC 路径检查:防止 Windows NTLM 凭据泄露 if is_unc_path(&path) { @@ -349,4 +351,32 @@ mod tests { .expect("outside file should be readable"); assert_eq!(content, "outside"); } + + #[tokio::test] + async fn write_file_rejects_canonical_session_plan_targets() { + let temp = tempfile::tempdir().expect("tempdir should be created"); + let tool = WriteFileTool; + let target = temp + .path() + .join(".astrcode-test-state") + .join("sessions") + .join("session-test") + .join("plan") + .join("cleanup-crates.md"); + + let err = tool + .execute( + "tc-write-plan".to_string(), + json!({ + "path": target.to_string_lossy(), + "content": "# Plan: Cleanup crates", + "createDirs": true + }), + &test_tool_context_for(temp.path()), + ) + .await + .expect_err("canonical plan writes should be rejected"); + + assert!(err.to_string().contains("upsertSessionPlan")); + } } diff --git a/crates/application/Cargo.toml b/crates/application/Cargo.toml index 1499ae3c..b31a8352 100644 --- a/crates/application/Cargo.toml +++ b/crates/application/Cargo.toml @@ -20,4 +20,5 @@ log.workspace = true uuid.workspace = true [dev-dependencies] +astrcode-core = { path = "../core", features = ["test-support"] } tempfile.workspace = true diff --git a/crates/application/src/agent/test_support.rs b/crates/application/src/agent/test_support.rs index 759629f3..99c71e35 100644 --- a/crates/application/src/agent/test_support.rs +++ b/crates/application/src/agent/test_support.rs @@ -119,6 +119,7 @@ pub(crate) fn sample_profile(id: &str) -> AgentProfile { } pub(crate) struct AgentTestEnvGuard { + _lock: std::sync::MutexGuard<'static, ()>, _temp_home: tempfile::TempDir, previous_test_home: Option<std::ffi::OsString>, } @@ -170,6 +171,9 @@ impl ConfigStore for TestConfigStore { impl AgentTestEnvGuard { fn new() -> Self { + let lock = astrcode_core::test_support::env_lock() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); let temp_home = tempfile::tempdir().expect("temp home should be created"); let previous_test_home = std::env::var_os(astrcode_core::home::ASTRCODE_TEST_HOME_ENV); std::env::set_var( @@ -177,6 +181,7 @@ impl AgentTestEnvGuard { temp_home.path(), ); Self { + _lock: lock, _temp_home: temp_home, previous_test_home, } diff --git a/crates/application/src/lib.rs b/crates/application/src/lib.rs index 7291738a..9e329402 100644 --- a/crates/application/src/lib.rs +++ b/crates/application/src/lib.rs @@ -86,6 +86,7 @@ pub use ports::{ AgentKernelPort, AgentSessionPort, AppKernelPort, AppSessionPort, ComposerResolvedSkill, ComposerSkillPort, }; +pub use session_plan::{ProjectPlanArchiveDetail, ProjectPlanArchiveSummary}; pub use session_use_cases::summarize_session_meta; pub use watch::{WatchEvent, WatchPort, WatchService, WatchSource}; diff --git a/crates/application/src/mode/builtin_prompts.rs b/crates/application/src/mode/builtin_prompts.rs index ce54845c..50725cdd 100644 --- a/crates/application/src/mode/builtin_prompts.rs +++ b/crates/application/src/mode/builtin_prompts.rs @@ -5,7 +5,9 @@ pub(crate) fn code_mode_prompt() -> &'static str { "You are in execution mode. Prefer direct progress, make concrete code changes when needed, \ - and use delegation only when isolation or parallelism materially helps." + and use delegation only when isolation or parallelism materially helps. If the user \ + explicitly wants a plan or the task clearly needs up-front planning, call `enterPlanMode` \ + first instead of mixing planning into execution." } pub(crate) fn review_mode_prompt() -> &'static str { diff --git a/crates/application/src/mode/builtin_prompts/plan_mode.md b/crates/application/src/mode/builtin_prompts/plan_mode.md index 987827a2..7bb986f1 100644 --- a/crates/application/src/mode/builtin_prompts/plan_mode.md +++ b/crates/application/src/mode/builtin_prompts/plan_mode.md @@ -4,8 +4,34 @@ Your job is to produce and maintain a session-scoped plan artifact before implem Plan mode contract: - Use `upsertSessionPlan` to create or update the session plan artifact. +- `upsertSessionPlan` is the only canonical writer for `sessions/<id>/plan/**`. +- A session has exactly one canonical plan artifact. +- While you are still working on the same task, keep revising that single plan. +- If the user clearly changed the task/topic inside the same session, overwrite the current plan instead of creating another canonical plan. +- Stay in this mode until the plan is concrete enough to execute; if it is still vague, incomplete, or risky, keep revising the artifact instead of exiting. - Keep the plan scoped to one concrete task or change topic. +- Plan in this mode should follow this order: + 1. inspect the relevant code and tests enough to understand the current behavior and constraints + 2. draft the plan artifact + 3. reflect on the draft, tighten weak steps, and check for missing risks or validation gaps + 4. if the plan is still not executable, update the artifact again and repeat the review loop + 5. only then call `exitPlanMode` to present the finalized plan to the user for approval +- Do not skip the code-reading phase before drafting the plan. +- Keep the code inspection relevant and sufficient; read enough to ground the plan in the actual implementation instead of guessing. +- Before showing the plan to the user, critique it yourself: + 1. look for incorrect assumptions + 2. look for missing edge cases or affected files + 3. look for weak verification steps + 4. revise the plan artifact if needed +- Treat every `exitPlanMode` attempt as a final-review gate: + 1. before calling it, internally review the plan against assumptions, edge cases, affected files, and verification strength + 2. keep that review out of the plan artifact itself unless the user explicitly asks to see it + 3. if the review changes the plan, update the artifact with `upsertSessionPlan` + 4. the first `exitPlanMode` call for a given plan revision may return a review-pending result as a normal checkpoint + 5. after that internal review pass, call `exitPlanMode` again only if the plan is still executable +- The first user-visible response should usually come after you have both inspected the code and updated the plan artifact. - Ask concise clarification questions when missing details would materially change scope or design. - Do not perform implementation work in this mode. -- After the plan is complete, ask the user to review it and approve it in plain language. +- Do not call `exitPlanMode` until the plan contains concrete implementation steps and verification steps. +- After `exitPlanMode`, summarize the plan plainly and ask the user to approve it or request revisions. - Do not silently switch to execution. Execution starts only after the user explicitly approves the plan. diff --git a/crates/application/src/mode/builtin_prompts/plan_mode_reentry.md b/crates/application/src/mode/builtin_prompts/plan_mode_reentry.md index 9320f59b..3371c492 100644 --- a/crates/application/src/mode/builtin_prompts/plan_mode_reentry.md +++ b/crates/application/src/mode/builtin_prompts/plan_mode_reentry.md @@ -1,7 +1,6 @@ -An active session plan artifact already exists for this session. +The session already has a canonical plan artifact. Re-entry contract: -- Read the existing plan artifact first. -- Prefer revising the current plan instead of creating a new file. -- Only create a new slug when the user explicitly changes the task/topic. -- Preserve the same plan file while iterating on the same topic. +- Read the current plan artifact first. +- If the user is continuing the same task, revise the current plan. +- If the user clearly changed the task/topic, overwrite the current plan instead of creating another canonical plan. diff --git a/crates/application/src/mode/catalog.rs b/crates/application/src/mode/catalog.rs index 2c3491be..fa4f153f 100644 --- a/crates/application/src/mode/catalog.rs +++ b/crates/application/src/mode/catalog.rs @@ -175,6 +175,7 @@ fn builtin_mode_specs() -> Vec<GovernanceModeSpec> { CapabilitySelector::Tag("agent".to_string()), ])), }, + CapabilitySelector::Name("exitPlanMode".to_string()), CapabilitySelector::Name("upsertSessionPlan".to_string()), ]), action_policies: ActionPolicies { diff --git a/crates/application/src/session_plan.rs b/crates/application/src/session_plan.rs index 95742f3d..1bbb72fd 100644 --- a/crates/application/src/session_plan.rs +++ b/crates/application/src/session_plan.rs @@ -1,81 +1,82 @@ //! session 级计划工件。 //! -//! 这里维护 session 下 `plan/` 目录的路径规则、状态模型、审批解析和 prompt 注入, +//! 这里维护 session 下唯一 canonical plan 的路径规则、状态模型、审批归档和 prompt 注入, //! 保持 plan mode 的流程真相收敛在 application,而不是散落在 handler / tool / UI。 use std::{ - fmt, fs, + fs, path::{Path, PathBuf}, }; -use astrcode_core::{ModeId, PromptDeclaration, project::project_dir}; +use astrcode_core::{ + ModeId, PromptDeclaration, SessionPlanState, SessionPlanStatus, project::project_dir, + session_plan_content_digest, +}; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use crate::{ApplicationError, mode::builtin_prompts}; const PLAN_DIR_NAME: &str = "plan"; +const PLAN_ARCHIVE_DIR_NAME: &str = "plan-archives"; const PLAN_STATE_FILE_NAME: &str = "state.json"; +const PLAN_ARCHIVE_FILE_NAME: &str = "plan.md"; +const PLAN_ARCHIVE_METADATA_FILE_NAME: &str = "metadata.json"; const PLAN_PATH_TIMESTAMP_FORMAT: &str = "%Y%m%dT%H%M%SZ"; -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum SessionPlanStatus { - Draft, - AwaitingApproval, - Approved, - Superseded, -} - -impl SessionPlanStatus { - pub fn as_str(&self) -> &'static str { - match self { - Self::Draft => "draft", - Self::AwaitingApproval => "awaiting_approval", - Self::Approved => "approved", - Self::Superseded => "superseded", - } - } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SessionPlanSummary { + pub slug: String, + pub path: String, + pub status: String, + pub title: String, + pub updated_at: DateTime<Utc>, } -impl fmt::Display for SessionPlanStatus { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.write_str(self.as_str()) - } +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct SessionPlanControlSummary { + pub active_plan: Option<SessionPlanSummary>, } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct SessionPlanState { - pub active_plan_slug: String, - pub title: String, - pub status: SessionPlanStatus, - pub created_at: DateTime<Utc>, - pub updated_at: DateTime<Utc>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub approved_at: Option<DateTime<Utc>>, +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PlanPromptContext { + pub target_plan_path: String, + pub target_plan_exists: bool, + pub target_plan_slug: String, + pub active_plan: Option<SessionPlanSummary>, } #[derive(Debug, Clone, PartialEq, Eq)] -pub struct ActivePlanSummary { - pub path: String, - pub status: String, +pub struct PlanApprovalParseResult { + pub approved: bool, + pub matched_phrase: Option<&'static str>, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ProjectPlanArchiveMetadata { + pub archive_id: String, pub title: String, + pub source_session_id: String, + pub source_plan_slug: String, + pub source_plan_path: String, + pub approved_at: DateTime<Utc>, + pub archived_at: DateTime<Utc>, + pub status: String, + pub content_digest: String, } #[derive(Debug, Clone, PartialEq, Eq)] -pub struct PlanPromptContext { +pub struct ProjectPlanArchiveSummary { + pub metadata: ProjectPlanArchiveMetadata, + pub archive_dir: String, pub plan_path: String, - pub plan_exists: bool, - pub plan_status: Option<SessionPlanStatus>, - pub plan_title: Option<String>, - pub plan_slug: String, } #[derive(Debug, Clone, PartialEq, Eq)] -pub struct PlanApprovalParseResult { - pub approved: bool, - pub matched_phrase: Option<&'static str>, +pub struct ProjectPlanArchiveDetail { + pub summary: ProjectPlanArchiveSummary, + pub content: String, } fn io_error(action: &str, path: &Path, error: std::io::Error) -> ApplicationError { @@ -98,6 +99,17 @@ pub(crate) fn session_plan_dir( .join(PLAN_DIR_NAME)) } +fn project_plan_archive_dir(working_dir: &Path) -> Result<PathBuf, ApplicationError> { + Ok(project_dir(working_dir) + .map_err(|error| { + ApplicationError::Internal(format!( + "failed to resolve project directory for '{}': {error}", + working_dir.display() + )) + })? + .join(PLAN_ARCHIVE_DIR_NAME)) +} + fn session_plan_state_path( session_id: &str, working_dir: &Path, @@ -113,6 +125,19 @@ fn session_plan_markdown_path( Ok(session_plan_dir(session_id, working_dir)?.join(format!("{slug}.md"))) } +fn archive_paths( + working_dir: &Path, + archive_id: &str, +) -> Result<(PathBuf, PathBuf, PathBuf), ApplicationError> { + validate_archive_id(archive_id)?; + let archive_dir = project_plan_archive_dir(working_dir)?.join(archive_id); + Ok(( + archive_dir.clone(), + archive_dir.join(PLAN_ARCHIVE_FILE_NAME), + archive_dir.join(PLAN_ARCHIVE_METADATA_FILE_NAME), + )) +} + pub(crate) fn load_session_plan_state( session_id: &str, working_dir: &Path, @@ -122,28 +147,33 @@ pub(crate) fn load_session_plan_state( return Ok(None); } let content = fs::read_to_string(&path).map_err(|error| io_error("reading", &path, error))?; - let state = serde_json::from_str::<SessionPlanState>(&content).map_err(|error| { - ApplicationError::Internal(format!( - "failed to parse session plan state '{}': {error}", - path.display() - )) - })?; - Ok(Some(state)) + serde_json::from_str::<SessionPlanState>(&content) + .map(Some) + .map_err(|error| { + ApplicationError::Internal(format!( + "failed to parse session plan state '{}': {error}", + path.display() + )) + }) +} + +pub(crate) fn session_plan_control_summary( + session_id: &str, + working_dir: &Path, +) -> Result<SessionPlanControlSummary, ApplicationError> { + Ok(SessionPlanControlSummary { + active_plan: active_plan_summary(session_id, working_dir)?, + }) } pub(crate) fn active_plan_summary( session_id: &str, working_dir: &Path, -) -> Result<Option<ActivePlanSummary>, ApplicationError> { +) -> Result<Option<SessionPlanSummary>, ApplicationError> { let Some(state) = load_session_plan_state(session_id, working_dir)? else { return Ok(None); }; - let path = session_plan_markdown_path(session_id, working_dir, &state.active_plan_slug)?; - Ok(Some(ActivePlanSummary { - path: path.display().to_string(), - status: state.status.to_string(), - title: state.title, - })) + Ok(Some(plan_summary(session_id, working_dir, &state)?)) } pub(crate) fn build_plan_prompt_context( @@ -151,14 +181,12 @@ pub(crate) fn build_plan_prompt_context( working_dir: &Path, user_text: &str, ) -> Result<PlanPromptContext, ApplicationError> { - if let Some(state) = load_session_plan_state(session_id, working_dir)? { - let path = session_plan_markdown_path(session_id, working_dir, &state.active_plan_slug)?; + if let Some(active_plan) = active_plan_summary(session_id, working_dir)? { return Ok(PlanPromptContext { - plan_path: path.display().to_string(), - plan_exists: path.exists(), - plan_status: Some(state.status), - plan_title: Some(state.title), - plan_slug: state.active_plan_slug, + target_plan_path: active_plan.path.clone(), + target_plan_exists: Path::new(&active_plan.path).exists(), + target_plan_slug: active_plan.slug.clone(), + active_plan: Some(active_plan), }); } @@ -166,11 +194,10 @@ pub(crate) fn build_plan_prompt_context( .unwrap_or_else(|| format!("plan-{}", Utc::now().format(PLAN_PATH_TIMESTAMP_FORMAT))); let path = session_plan_markdown_path(session_id, working_dir, &suggested_slug)?; Ok(PlanPromptContext { - plan_path: path.display().to_string(), - plan_exists: false, - plan_status: None, - plan_title: None, - plan_slug: suggested_slug, + target_plan_path: path.display().to_string(), + target_plan_exists: false, + target_plan_slug: suggested_slug, + active_plan: None, }) } @@ -178,24 +205,30 @@ pub(crate) fn build_plan_prompt_declarations( session_id: &str, context: &PlanPromptContext, ) -> Vec<PromptDeclaration> { + let active_plan_line = context + .active_plan + .as_ref() + .map(|plan| { + format!( + "- activePlan: slug={}, title={}, status={}, path={}", + plan.slug, plan.title, plan.status, plan.path + ) + }) + .unwrap_or_else(|| "- activePlan: (none)".to_string()); let mut declarations = vec![PromptDeclaration { block_id: format!("session.plan.facts.{session_id}"), title: "Session Plan Artifact".to_string(), content: format!( - "Session plan facts:\n- planPath: {}\n- planExists: {}\n- planSlug: {}\n- planStatus: \ - {}\n- planTitle: {}\n\nUse `upsertSessionPlan` to create or update this \ - session-scoped plan artifact. When the plan does not exist yet, create the first \ - draft at the provided path using the provided slug. Keep revising the same file \ - while the topic stays the same.", - context.plan_path, - context.plan_exists, - context.plan_slug, - context - .plan_status - .as_ref() - .map(SessionPlanStatus::as_str) - .unwrap_or("missing"), - context.plan_title.as_deref().unwrap_or("(none)") + "Session plan facts:\n- targetPlanPath: {}\n- targetPlanExists: {}\n- targetPlanSlug: \ + {}\n{}\n\nUse `upsertSessionPlan` as the only canonical write path for session \ + plans. This session has exactly one canonical plan artifact. When continuing the \ + same task, revise the current plan. When the user clearly changes tasks, overwrite \ + the current plan instead of creating another canonical plan. Only call \ + `exitPlanMode` after the current plan is executable and ready for user review.", + context.target_plan_path, + context.target_plan_exists, + context.target_plan_slug, + active_plan_line, ), render_target: astrcode_core::PromptDeclarationRenderTarget::System, layer: astrcode_core::SystemPromptLayer::Dynamic, @@ -207,7 +240,7 @@ pub(crate) fn build_plan_prompt_declarations( origin: Some("session-plan:facts".to_string()), }]; - if context.plan_exists { + if context.active_plan.is_some() { declarations.push(PromptDeclaration { block_id: format!("session.plan.reentry.{session_id}"), title: "Plan Re-entry".to_string(), @@ -242,15 +275,16 @@ pub(crate) fn build_plan_prompt_declarations( pub(crate) fn build_plan_exit_declaration( session_id: &str, - summary: &ActivePlanSummary, + summary: &SessionPlanSummary, ) -> PromptDeclaration { PromptDeclaration { block_id: format!("session.plan.exit.{session_id}"), title: "Plan Mode Exit".to_string(), content: format!( - "{}\n\nApproved plan artifact:\n- path: {}\n- title: {}\n- status: {}", + "{}\n\nApproved plan artifact:\n- path: {}\n- slug: {}\n- title: {}\n- status: {}", builtin_prompts::plan_mode_exit_prompt(), summary.path, + summary.slug, summary.title, summary.status ), @@ -305,20 +339,46 @@ pub(crate) fn parse_plan_approval(text: &str) -> PlanApprovalParseResult { } } -pub(crate) fn mark_session_plan_approved( +pub(crate) fn active_plan_requires_approval(state: Option<&SessionPlanState>) -> bool { + state.is_some_and(|state| state.status == SessionPlanStatus::AwaitingApproval) +} + +pub(crate) fn mark_active_session_plan_approved( session_id: &str, working_dir: &Path, -) -> Result<Option<SessionPlanState>, ApplicationError> { +) -> Result<Option<SessionPlanSummary>, ApplicationError> { let Some(mut state) = load_session_plan_state(session_id, working_dir)? else { return Ok(None); }; - let path = session_plan_state_path(session_id, working_dir)?; + if state.status != SessionPlanStatus::AwaitingApproval { + return Ok(None); + } + + let plan_path = session_plan_markdown_path(session_id, working_dir, &state.active_plan_slug)?; + let plan_content = + fs::read_to_string(&plan_path).map_err(|error| io_error("reading", &plan_path, error))?; + let plan_content = plan_content.trim().to_string(); + let content_digest = session_plan_content_digest(&plan_content); let now = Utc::now(); + state.status = SessionPlanStatus::Approved; state.updated_at = now; state.approved_at = Some(now); - persist_plan_state(&path, &state)?; - Ok(Some(state)) + if state.archived_plan_digest.as_deref() != Some(content_digest.as_str()) { + let archive_summary = write_plan_archive_snapshot( + session_id, + working_dir, + &state, + &plan_path, + &plan_content, + &content_digest, + now, + )?; + state.archived_plan_digest = Some(content_digest); + state.archived_at = Some(archive_summary.metadata.archived_at); + } + persist_plan_state(&session_plan_state_path(session_id, working_dir)?, &state)?; + Ok(Some(plan_summary(session_id, working_dir, &state)?)) } pub(crate) fn copy_session_plan_artifacts( @@ -338,6 +398,88 @@ pub(crate) fn current_mode_requires_plan_context(mode_id: &ModeId) -> bool { mode_id == &ModeId::plan() } +pub(crate) fn list_project_plan_archives( + working_dir: &Path, +) -> Result<Vec<ProjectPlanArchiveSummary>, ApplicationError> { + let archive_root = project_plan_archive_dir(working_dir)?; + if !archive_root.exists() { + return Ok(Vec::new()); + } + let mut items = Vec::new(); + for entry in fs::read_dir(&archive_root) + .map_err(|error| io_error("reading directory", &archive_root, error))? + { + let entry = + entry.map_err(|error| io_error("reading directory entry", &archive_root, error))?; + let archive_dir = entry.path(); + if !entry + .file_type() + .map_err(|error| io_error("reading file type", &archive_dir, error))? + .is_dir() + { + continue; + } + let metadata_path = archive_dir.join(PLAN_ARCHIVE_METADATA_FILE_NAME); + let plan_path = archive_dir.join(PLAN_ARCHIVE_FILE_NAME); + if !metadata_path.exists() || !plan_path.exists() { + continue; + } + let metadata = fs::read_to_string(&metadata_path) + .map_err(|error| io_error("reading", &metadata_path, error)) + .and_then(|content| { + serde_json::from_str::<ProjectPlanArchiveMetadata>(&content).map_err(|error| { + ApplicationError::Internal(format!( + "failed to parse plan archive metadata '{}': {error}", + metadata_path.display() + )) + }) + })?; + items.push(ProjectPlanArchiveSummary { + archive_dir: archive_dir.display().to_string(), + plan_path: plan_path.display().to_string(), + metadata, + }); + } + items.sort_by(|left, right| { + right + .metadata + .archived_at + .cmp(&left.metadata.archived_at) + .then_with(|| left.metadata.archive_id.cmp(&right.metadata.archive_id)) + }); + Ok(items) +} + +pub(crate) fn read_project_plan_archive( + working_dir: &Path, + archive_id: &str, +) -> Result<Option<ProjectPlanArchiveDetail>, ApplicationError> { + let (archive_dir, plan_path, metadata_path) = archive_paths(working_dir, archive_id)?; + if !plan_path.exists() || !metadata_path.exists() { + return Ok(None); + } + let metadata = fs::read_to_string(&metadata_path) + .map_err(|error| io_error("reading", &metadata_path, error)) + .and_then(|content| { + serde_json::from_str::<ProjectPlanArchiveMetadata>(&content).map_err(|error| { + ApplicationError::Internal(format!( + "failed to parse plan archive metadata '{}': {error}", + metadata_path.display() + )) + }) + })?; + let content = + fs::read_to_string(&plan_path).map_err(|error| io_error("reading", &plan_path, error))?; + Ok(Some(ProjectPlanArchiveDetail { + summary: ProjectPlanArchiveSummary { + metadata, + archive_dir: archive_dir.display().to_string(), + plan_path: plan_path.display().to_string(), + }, + content, + })) +} + fn persist_plan_state(path: &Path, state: &SessionPlanState) -> Result<(), ApplicationError> { let Some(parent) = path.parent() else { return Err(ApplicationError::Internal(format!( @@ -355,6 +497,113 @@ fn persist_plan_state(path: &Path, state: &SessionPlanState) -> Result<(), Appli fs::write(path, content).map_err(|error| io_error("writing", path, error)) } +fn plan_summary( + session_id: &str, + working_dir: &Path, + state: &SessionPlanState, +) -> Result<SessionPlanSummary, ApplicationError> { + Ok(SessionPlanSummary { + slug: state.active_plan_slug.clone(), + path: session_plan_markdown_path(session_id, working_dir, &state.active_plan_slug)? + .display() + .to_string(), + status: state.status.to_string(), + title: state.title.clone(), + updated_at: state.updated_at, + }) +} + +fn write_plan_archive_snapshot( + session_id: &str, + working_dir: &Path, + state: &SessionPlanState, + plan_path: &Path, + plan_content: &str, + content_digest: &str, + approved_at: DateTime<Utc>, +) -> Result<ProjectPlanArchiveSummary, ApplicationError> { + let archived_at = Utc::now(); + let archive_root = project_plan_archive_dir(working_dir)?; + fs::create_dir_all(&archive_root) + .map_err(|error| io_error("creating directory", &archive_root, error))?; + let archive_id = reserve_archive_id(&archive_root, approved_at, &state.active_plan_slug)?; + let (archive_dir, archive_plan_path, metadata_path) = archive_paths(working_dir, &archive_id)?; + fs::create_dir_all(&archive_dir) + .map_err(|error| io_error("creating directory", &archive_dir, error))?; + fs::write(&archive_plan_path, format!("{plan_content}\n")) + .map_err(|error| io_error("writing", &archive_plan_path, error))?; + let metadata = ProjectPlanArchiveMetadata { + archive_id: archive_id.clone(), + title: state.title.clone(), + source_session_id: session_id.to_string(), + source_plan_slug: state.active_plan_slug.clone(), + source_plan_path: plan_path.display().to_string(), + approved_at, + archived_at, + status: SessionPlanStatus::Approved.to_string(), + content_digest: content_digest.to_string(), + }; + let metadata_content = serde_json::to_string_pretty(&metadata).map_err(|error| { + ApplicationError::Internal(format!( + "failed to serialize plan archive metadata '{}': {error}", + metadata_path.display() + )) + })?; + fs::write(&metadata_path, metadata_content) + .map_err(|error| io_error("writing", &metadata_path, error))?; + Ok(ProjectPlanArchiveSummary { + metadata, + archive_dir: archive_dir.display().to_string(), + plan_path: archive_plan_path.display().to_string(), + }) +} + +fn reserve_archive_id( + archive_root: &Path, + approved_at: DateTime<Utc>, + slug: &str, +) -> Result<String, ApplicationError> { + let base = format!( + "{}-{}", + approved_at.format(PLAN_PATH_TIMESTAMP_FORMAT), + slug + ); + for attempt in 0..=99 { + let candidate = if attempt == 0 { + base.clone() + } else { + format!("{base}-{attempt}") + }; + if !archive_root.join(&candidate).exists() { + return Ok(candidate); + } + } + Err(ApplicationError::Internal(format!( + "failed to reserve a unique plan archive id for slug '{}'", + slug + ))) +} + +fn validate_archive_id(archive_id: &str) -> Result<(), ApplicationError> { + let archive_id = archive_id.trim(); + if archive_id.is_empty() { + return Err(ApplicationError::InvalidArgument( + "archiveId must not be empty".to_string(), + )); + } + if archive_id.contains("..") + || archive_id.contains('/') + || archive_id.contains('\\') + || Path::new(archive_id).is_absolute() + { + return Err(ApplicationError::InvalidArgument(format!( + "archiveId '{}' is invalid", + archive_id + ))); + } + Ok(()) +} + fn copy_dir_recursive(source: &Path, target: &Path) -> Result<(), ApplicationError> { fs::create_dir_all(target).map_err(|error| io_error("creating directory", target, error))?; for entry in @@ -432,26 +681,28 @@ mod tests { status: SessionPlanStatus::AwaitingApproval, created_at: Utc::now(), updated_at: Utc::now(), + reviewed_plan_digest: Some("abc".to_string()), approved_at: None, + archived_plan_digest: Some("def".to_string()), + archived_at: None, }; let encoded = serde_json::to_string(&state).expect("state should serialize"); let decoded = serde_json::from_str::<SessionPlanState>(&encoded).expect("state should deserialize"); assert_eq!(decoded.active_plan_slug, "cleanup-crates"); - assert_eq!(decoded.status, SessionPlanStatus::AwaitingApproval); + assert_eq!(decoded.archived_plan_digest.as_deref(), Some("def")); } #[test] - fn build_plan_prompt_declarations_include_facts_and_template() { + fn build_plan_prompt_declarations_include_single_plan_facts() { let declarations = build_plan_prompt_declarations( "session-a", &PlanPromptContext { - plan_path: "/tmp/cleanup-crates.md".to_string(), - plan_exists: false, - plan_status: None, - plan_title: None, - plan_slug: "cleanup-crates".to_string(), + target_plan_path: "/tmp/cleanup-crates.md".to_string(), + target_plan_exists: false, + target_plan_slug: "cleanup-crates".to_string(), + active_plan: None, }, ); @@ -459,8 +710,82 @@ mod tests { assert!( declarations[0] .content - .contains("planPath: /tmp/cleanup-crates.md") + .contains("targetPlanPath: /tmp/cleanup-crates.md") + ); + assert!( + declarations[0] + .content + .contains("overwrite the current plan instead of creating another canonical plan") ); assert!(declarations[1].content.contains("## Implementation Steps")); } + + #[test] + fn reserve_archive_id_adds_suffix_on_collision() { + let temp = tempfile::tempdir().expect("tempdir should exist"); + let root = temp.path(); + fs::create_dir_all(root.join("20260419T000000Z-cleanup-crates")) + .expect("seed dir should exist"); + let candidate = reserve_archive_id( + root, + DateTime::parse_from_rfc3339("2026-04-19T00:00:00Z") + .expect("datetime should parse") + .with_timezone(&Utc), + "cleanup-crates", + ) + .expect("candidate should be reserved"); + assert_eq!(candidate, "20260419T000000Z-cleanup-crates-1"); + } + + #[test] + fn read_project_plan_archive_returns_saved_content() { + let _guard = astrcode_core::test_support::TestEnvGuard::new(); + let working_dir = _guard.home_dir().join("workspace"); + fs::create_dir_all(&working_dir).expect("workspace should exist"); + let archive_root = + project_plan_archive_dir(&working_dir).expect("archive root should resolve"); + fs::create_dir_all(archive_root.join("archive-a")).expect("archive dir should exist"); + fs::write( + archive_root.join("archive-a").join(PLAN_ARCHIVE_FILE_NAME), + "# Plan\n", + ) + .expect("plan should be written"); + fs::write( + archive_root + .join("archive-a") + .join(PLAN_ARCHIVE_METADATA_FILE_NAME), + serde_json::to_string_pretty(&ProjectPlanArchiveMetadata { + archive_id: "archive-a".to_string(), + title: "Cleanup crates".to_string(), + source_session_id: "session-a".to_string(), + source_plan_slug: "cleanup-crates".to_string(), + source_plan_path: "/tmp/cleanup-crates.md".to_string(), + approved_at: Utc::now(), + archived_at: Utc::now(), + status: "approved".to_string(), + content_digest: "abc".to_string(), + }) + .expect("metadata should serialize"), + ) + .expect("metadata should be written"); + + let archive = read_project_plan_archive(&working_dir, "archive-a") + .expect("archive should load") + .expect("archive should exist"); + assert_eq!(archive.summary.metadata.archive_id, "archive-a"); + assert_eq!(archive.content, "# Plan\n"); + } + + #[test] + fn read_project_plan_archive_rejects_path_traversal_archive_id() { + let temp = tempfile::tempdir().expect("tempdir should exist"); + let working_dir = temp.path().join("workspace"); + fs::create_dir_all(&working_dir).expect("workspace should exist"); + + let error = read_project_plan_archive(&working_dir, "../secrets") + .expect_err("path traversal archive id should be rejected"); + + assert!(matches!(error, ApplicationError::InvalidArgument(_))); + assert!(error.to_string().contains("archiveId")); + } } diff --git a/crates/application/src/session_use_cases.rs b/crates/application/src/session_use_cases.rs index 9d48c73f..f4e0dbfa 100644 --- a/crates/application/src/session_use_cases.rs +++ b/crates/application/src/session_use_cases.rs @@ -12,18 +12,19 @@ use astrcode_core::{ use crate::{ App, ApplicationError, CompactSessionAccepted, CompactSessionSummary, ExecutionControl, - ModeSummary, PromptAcceptedSummary, PromptSkillInvocation, SessionControlStateSnapshot, - SessionListSummary, SessionReplay, SessionTranscriptSnapshot, + ModeSummary, ProjectPlanArchiveDetail, ProjectPlanArchiveSummary, PromptAcceptedSummary, + PromptSkillInvocation, SessionControlStateSnapshot, SessionListSummary, SessionReplay, + SessionTranscriptSnapshot, agent::{ IMPLICIT_ROOT_PROFILE_ID, implicit_session_root_agent_id, root_execution_event_context, }, format_local_rfc3339, governance_surface::{GovernanceBusyPolicy, SessionGovernanceInput}, session_plan::{ - active_plan_summary, build_plan_exit_declaration, build_plan_prompt_context, + active_plan_requires_approval, build_plan_exit_declaration, build_plan_prompt_context, build_plan_prompt_declarations, copy_session_plan_artifacts, - current_mode_requires_plan_context, load_session_plan_state, mark_session_plan_approved, - parse_plan_approval, + current_mode_requires_plan_context, list_project_plan_archives, load_session_plan_state, + mark_active_session_plan_approved, parse_plan_approval, read_project_plan_archive, }, }; @@ -104,6 +105,22 @@ impl App { .map_err(ApplicationError::from) } + pub fn list_project_plan_archives( + &self, + working_dir: &Path, + ) -> Result<Vec<ProjectPlanArchiveSummary>, ApplicationError> { + list_project_plan_archives(working_dir) + } + + pub fn read_project_plan_archive( + &self, + working_dir: &Path, + archive_id: &str, + ) -> Result<Option<ProjectPlanArchiveDetail>, ApplicationError> { + self.validate_non_empty("archiveId", archive_id)?; + read_project_plan_archive(working_dir, archive_id) + } + pub async fn submit_prompt( &self, session_id: &str, @@ -158,25 +175,25 @@ impl App { .map_err(ApplicationError::from)? .current_mode_id; let mut prompt_declarations = Vec::new(); + let plan_state = load_session_plan_state(session_id, Path::new(&working_dir))?; + let plan_approval = parse_plan_approval(&text); - if current_mode_id == ModeId::plan() { - let plan_state = load_session_plan_state(session_id, Path::new(&working_dir))?; - if plan_state - .as_ref() - .is_some_and(|state| state.status.as_str() == "awaiting_approval") - && parse_plan_approval(&text).approved - { - let _ = mark_session_plan_approved(session_id, Path::new(&working_dir))?; + if active_plan_requires_approval(plan_state.as_ref()) && plan_approval.approved { + let approved_plan = + mark_active_session_plan_approved(session_id, Path::new(&working_dir))?; + if current_mode_id == ModeId::plan() { self.switch_mode(session_id, ModeId::code()).await?; current_mode_id = ModeId::code(); - if let Some(summary) = active_plan_summary(session_id, Path::new(&working_dir))? { - prompt_declarations.push(build_plan_exit_declaration(session_id, &summary)); - } - } else if current_mode_requires_plan_context(¤t_mode_id) { - let context = - build_plan_prompt_context(session_id, Path::new(&working_dir), &text)?; - prompt_declarations.extend(build_plan_prompt_declarations(session_id, &context)); } + if let Some(summary) = approved_plan { + prompt_declarations.push(build_plan_exit_declaration(session_id, &summary)); + } + } else if current_mode_id == ModeId::plan() + && current_mode_requires_plan_context(¤t_mode_id) + && !plan_approval.approved + { + let context = build_plan_prompt_context(session_id, Path::new(&working_dir), &text)?; + prompt_declarations.extend(build_plan_prompt_declarations(session_id, &context)); } if let Some(skill_invocation) = skill_invocation { diff --git a/crates/application/src/terminal/mod.rs b/crates/application/src/terminal/mod.rs index 6fd06040..c5e3b52f 100644 --- a/crates/application/src/terminal/mod.rs +++ b/crates/application/src/terminal/mod.rs @@ -28,7 +28,8 @@ pub struct TerminalLastCompactMetaFacts { } #[derive(Debug, Clone, PartialEq, Eq)] -pub struct ActivePlanFacts { +pub struct PlanReferenceFacts { + pub slug: String, pub path: String, pub status: String, pub title: String, @@ -43,7 +44,8 @@ pub struct ConversationControlSummary { pub compacting: bool, pub active_turn_id: Option<String>, pub last_compact_meta: Option<TerminalLastCompactMetaFacts>, - pub active_plan: Option<ActivePlanFacts>, + pub current_mode_id: String, + pub active_plan: Option<PlanReferenceFacts>, } #[derive(Debug, Clone)] @@ -53,7 +55,8 @@ pub struct TerminalControlFacts { pub manual_compact_pending: bool, pub compacting: bool, pub last_compact_meta: Option<TerminalLastCompactMetaFacts>, - pub active_plan: Option<ActivePlanFacts>, + pub current_mode_id: String, + pub active_plan: Option<PlanReferenceFacts>, } pub type ConversationControlFacts = TerminalControlFacts; @@ -219,6 +222,7 @@ pub fn summarize_conversation_control( compacting: control.compacting, active_turn_id: control.active_turn_id.clone(), last_compact_meta: control.last_compact_meta.clone(), + current_mode_id: control.current_mode_id.clone(), active_plan: control.active_plan.clone(), } } diff --git a/crates/application/src/terminal_queries/mod.rs b/crates/application/src/terminal_queries/mod.rs index abb42923..2634c81a 100644 --- a/crates/application/src/terminal_queries/mod.rs +++ b/crates/application/src/terminal_queries/mod.rs @@ -15,7 +15,7 @@ mod tests; use astrcode_session_runtime::SessionControlStateSnapshot; -use crate::terminal::{ActivePlanFacts, TerminalControlFacts, TerminalLastCompactMetaFacts}; +use crate::terminal::{PlanReferenceFacts, TerminalControlFacts, TerminalLastCompactMetaFacts}; fn map_control_facts(control: SessionControlStateSnapshot) -> TerminalControlFacts { TerminalControlFacts { @@ -29,6 +29,7 @@ fn map_control_facts(control: SessionControlStateSnapshot) -> TerminalControlFac trigger: meta.trigger, meta: meta.meta, }), - active_plan: None::<ActivePlanFacts>, + current_mode_id: control.current_mode_id.to_string(), + active_plan: None::<PlanReferenceFacts>, } } diff --git a/crates/application/src/terminal_queries/resume.rs b/crates/application/src/terminal_queries/resume.rs index 81082031..f1b09e46 100644 --- a/crates/application/src/terminal_queries/resume.rs +++ b/crates/application/src/terminal_queries/resume.rs @@ -7,7 +7,7 @@ use std::{cmp::Reverse, collections::HashSet, path::Path}; use crate::{ App, ApplicationError, ComposerOptionKind, ComposerOptionsRequest, SessionMeta, - session_plan::active_plan_summary, + session_plan::session_plan_control_summary, terminal::{ ConversationAuthoritativeSummary, ConversationFocus, TerminalChildSummaryFacts, TerminalControlFacts, TerminalResumeCandidateFacts, TerminalSlashAction, @@ -162,13 +162,16 @@ impl App { .session_runtime .get_session_working_dir(session_id) .await?; - facts.active_plan = active_plan_summary(session_id, Path::new(&working_dir))?.map(|plan| { - crate::terminal::ActivePlanFacts { - path: plan.path, - status: plan.status, - title: plan.title, - } - }); + let plan_summary = session_plan_control_summary(session_id, Path::new(&working_dir))?; + facts.active_plan = + plan_summary + .active_plan + .map(|plan| crate::terminal::PlanReferenceFacts { + slug: plan.slug, + path: plan.path, + status: plan.status, + title: plan.title, + }); Ok(facts) } diff --git a/crates/application/src/terminal_queries/summary.rs b/crates/application/src/terminal_queries/summary.rs index f56a7433..fb3a87bb 100644 --- a/crates/application/src/terminal_queries/summary.rs +++ b/crates/application/src/terminal_queries/summary.rs @@ -6,7 +6,8 @@ use astrcode_session_runtime::{ ConversationBlockFacts, ConversationChildHandoffBlockFacts, ConversationErrorBlockFacts, - ConversationSnapshotFacts, ConversationSystemNoteBlockFacts, ToolCallBlockFacts, + ConversationPlanBlockFacts, ConversationSnapshotFacts, ConversationSystemNoteBlockFacts, + ToolCallBlockFacts, }; use crate::terminal::{latest_transcript_cursor, truncate_terminal_summary}; @@ -23,6 +24,7 @@ pub(super) fn latest_terminal_summary(snapshot: &ConversationSnapshotFacts) -> O fn summary_from_block(block: &ConversationBlockFacts) -> Option<String> { match block { ConversationBlockFacts::Assistant(block) => summary_from_markdown(&block.markdown), + ConversationBlockFacts::Plan(block) => summary_from_plan_block(block), ConversationBlockFacts::ToolCall(block) => summary_from_tool_call(block), ConversationBlockFacts::ChildHandoff(block) => summary_from_child_handoff(block), ConversationBlockFacts::Error(block) => summary_from_error_block(block), @@ -52,6 +54,22 @@ fn summary_from_tool_call(block: &ToolCallBlockFacts) -> Option<String> { .or_else(|| summary_from_markdown(&block.streams.stdout)) } +fn summary_from_plan_block(block: &ConversationPlanBlockFacts) -> Option<String> { + block + .summary + .as_deref() + .filter(|summary| !summary.trim().is_empty()) + .map(truncate_terminal_summary) + .or_else(|| { + block + .content + .as_deref() + .filter(|content| !content.trim().is_empty()) + .map(truncate_terminal_summary) + }) + .or_else(|| summary_from_markdown(&block.title)) +} + fn summary_from_child_handoff(block: &ConversationChildHandoffBlockFacts) -> Option<String> { block .message diff --git a/crates/cli/src/app/coordinator.rs b/crates/cli/src/app/coordinator.rs index e975a479..1bd14b67 100644 --- a/crates/cli/src/app/coordinator.rs +++ b/crates/cli/src/app/coordinator.rs @@ -9,9 +9,9 @@ use astrcode_client::{ }; use super::{ - Action, AppController, filter_model_options, filter_resume_sessions, model_query_from_input, - required_working_dir, resume_query_from_input, slash_candidates_with_local_commands, - slash_query_from_input, + Action, AppController, SnapshotLoadedAction, filter_model_options, filter_resume_sessions, + model_query_from_input, required_working_dir, resume_query_from_input, + slash_candidates_with_local_commands, slash_query_from_input, }; use crate::{ command::{Command, InputAction, PaletteAction, classify_input, filter_slash_candidates}, @@ -231,7 +231,10 @@ where let client = self.client.clone(); self.dispatch_async(async move { let result = client.fetch_conversation_snapshot(&session_id, None).await; - Some(Action::SnapshotLoaded { session_id, result }) + Some(Action::SnapshotLoaded(Box::new(SnapshotLoadedAction { + session_id, + result, + }))) }); } diff --git a/crates/cli/src/app/mod.rs b/crates/cli/src/app/mod.rs index 1c9a8ee9..ee6a4382 100644 --- a/crates/cli/src/app/mod.rs +++ b/crates/cli/src/app/mod.rs @@ -64,6 +64,12 @@ struct CliArgs { server_binary: Option<PathBuf>, } +#[derive(Debug)] +struct SnapshotLoadedAction { + session_id: String, + result: Result<AstrcodeConversationSnapshotResponseDto, AstrcodeClientError>, +} + #[derive(Debug)] enum Action { Tick, @@ -77,10 +83,7 @@ enum Action { Quit, SessionsRefreshed(Result<Vec<AstrcodeSessionListItem>, AstrcodeClientError>), SessionCreated(Result<AstrcodeSessionListItem, AstrcodeClientError>), - SnapshotLoaded { - session_id: String, - result: Result<AstrcodeConversationSnapshotResponseDto, AstrcodeClientError>, - }, + SnapshotLoaded(Box<SnapshotLoadedAction>), StreamBatch { session_id: String, items: Vec<ConversationStreamItem>, @@ -444,7 +447,8 @@ where self.consume_bootstrap_refresh().await; }, }, - Action::SnapshotLoaded { session_id, result } => { + Action::SnapshotLoaded(payload) => { + let SnapshotLoadedAction { session_id, result } = *payload; if !self.pending_session_matches(session_id.as_str()) { return Ok(()); } diff --git a/crates/cli/src/state/conversation.rs b/crates/cli/src/state/conversation.rs index 0157de7b..2730400a 100644 --- a/crates/cli/src/state/conversation.rs +++ b/crates/cli/src/state/conversation.rs @@ -197,6 +197,7 @@ fn block_id_of(block: &AstrcodeConversationBlockDto) -> &str { AstrcodeConversationBlockDto::User(block) => &block.id, AstrcodeConversationBlockDto::Assistant(block) => &block.id, AstrcodeConversationBlockDto::Thinking(block) => &block.id, + AstrcodeConversationBlockDto::Plan(block) => &block.id, AstrcodeConversationBlockDto::ToolCall(block) => &block.id, AstrcodeConversationBlockDto::Error(block) => &block.id, AstrcodeConversationBlockDto::SystemNote(block) => &block.id, @@ -222,6 +223,7 @@ fn apply_block_patch( AstrcodeConversationBlockDto::User(block) => { normalize_markdown_append(&mut block.markdown, &markdown) }, + AstrcodeConversationBlockDto::Plan(_) => false, AstrcodeConversationBlockDto::ToolCall(_) | AstrcodeConversationBlockDto::Error(_) | AstrcodeConversationBlockDto::ChildHandoff(_) => false, @@ -239,6 +241,7 @@ fn apply_block_patch( AstrcodeConversationBlockDto::User(block) => { replace_if_changed(&mut block.markdown, markdown) }, + AstrcodeConversationBlockDto::Plan(_) => false, AstrcodeConversationBlockDto::ToolCall(_) | AstrcodeConversationBlockDto::Error(_) | AstrcodeConversationBlockDto::ChildHandoff(_) => false, @@ -380,6 +383,7 @@ fn set_block_status( AstrcodeConversationBlockDto::Thinking(block) => { replace_if_changed(&mut block.status, status) }, + AstrcodeConversationBlockDto::Plan(_) => false, AstrcodeConversationBlockDto::ToolCall(block) => { replace_if_changed(&mut block.status, status) }, diff --git a/crates/cli/src/state/mod.rs b/crates/cli/src/state/mod.rs index d37dd252..fdc7b530 100644 --- a/crates/cli/src/state/mod.rs +++ b/crates/cli/src/state/mod.rs @@ -475,6 +475,7 @@ mod tests { can_request_compact: true, compact_pending: false, compacting: false, + current_mode_id: "code".to_string(), active_turn_id: None, last_compact_meta: None, active_plan: None, @@ -622,6 +623,7 @@ mod tests { can_request_compact: true, compact_pending: false, compacting: false, + current_mode_id: "code".to_string(), active_turn_id: Some("turn-1".to_string()), last_compact_meta: None, active_plan: None, diff --git a/crates/cli/src/state/transcript_cell.rs b/crates/cli/src/state/transcript_cell.rs index badf90fb..a74a61e7 100644 --- a/crates/cli/src/state/transcript_cell.rs +++ b/crates/cli/src/state/transcript_cell.rs @@ -71,6 +71,7 @@ impl TranscriptCell { AstrcodeConversationBlockDto::User(block) => block.id.clone(), AstrcodeConversationBlockDto::Assistant(block) => block.id.clone(), AstrcodeConversationBlockDto::Thinking(block) => block.id.clone(), + AstrcodeConversationBlockDto::Plan(block) => block.id.clone(), AstrcodeConversationBlockDto::ToolCall(block) => block.id.clone(), AstrcodeConversationBlockDto::Error(block) => block.id.clone(), AstrcodeConversationBlockDto::SystemNote(block) => block.id.clone(), @@ -106,6 +107,21 @@ impl TranscriptCell { status: block.status.into(), }, }, + AstrcodeConversationBlockDto::Plan(block) => Self { + id, + expanded, + kind: TranscriptCellKind::SystemNote { + note_kind: format!( + "plan:{}", + enum_wire_name(&block.event_kind).unwrap_or_else(|| "saved".to_string()) + ), + markdown: block + .summary + .clone() + .or_else(|| block.content.clone()) + .unwrap_or_else(|| format!("{} ({})", block.title, block.plan_path)), + }, + }, AstrcodeConversationBlockDto::ToolCall(block) => Self { id, expanded, diff --git a/crates/core/src/compact_summary.rs b/crates/core/src/compact_summary.rs index c0e9b4ff..6eba56cf 100644 --- a/crates/core/src/compact_summary.rs +++ b/crates/core/src/compact_summary.rs @@ -6,6 +6,10 @@ /// compact 摘要消息的人类可读前缀。 pub const COMPACT_SUMMARY_PREFIX: &str = "[Auto-compact summary]\n"; +/// compact 摘要正文里的旧历史回读提示。 +pub const COMPACT_SUMMARY_HISTORY_NOTE_PREFIX: &str = + "\n\nIf more detail from before compaction is needed, read the earlier session event log at:\n"; + /// compact 摘要消息尾部的继续提示。 pub const COMPACT_SUMMARY_CONTINUATION: &str = "\n\nContinue from this summary without repeating it to the user."; @@ -14,12 +18,35 @@ pub const COMPACT_SUMMARY_CONTINUATION: &str = #[derive(Debug, Clone, PartialEq, Eq)] pub struct CompactSummaryEnvelope { pub summary: String, + pub history_path: Option<String>, } impl CompactSummaryEnvelope { pub fn new(summary: impl Into<String>) -> Self { Self { summary: summary.into().trim().to_string(), + history_path: None, + } + } + + pub fn with_history_path(mut self, history_path: impl Into<String>) -> Self { + let history_path = history_path.into().trim().to_string(); + if !history_path.is_empty() { + self.history_path = Some(history_path); + } + self + } + + /// 渲染 compact 摘要正文(不含外层前后缀)。 + pub fn render_body(&self) -> String { + match self.history_path.as_deref() { + Some(history_path) => { + format!( + "{}{COMPACT_SUMMARY_HISTORY_NOTE_PREFIX}{history_path}", + self.summary + ) + }, + None => self.summary.clone(), } } @@ -30,7 +57,7 @@ impl CompactSummaryEnvelope { pub fn render(&self) -> String { format!( "{COMPACT_SUMMARY_PREFIX}{}{COMPACT_SUMMARY_CONTINUATION}", - self.summary + self.render_body() ) } } @@ -46,11 +73,31 @@ pub fn format_compact_summary(summary: &str) -> String { /// 返回 `None`,让调用方自行决定是否继续走其他分支。 pub fn parse_compact_summary_message(content: &str) -> Option<CompactSummaryEnvelope> { let summary_with_suffix = content.strip_prefix(COMPACT_SUMMARY_PREFIX)?; - let summary = summary_with_suffix + let summary_body = summary_with_suffix .strip_suffix(COMPACT_SUMMARY_CONTINUATION) .unwrap_or(summary_with_suffix) .trim(); - (!summary.is_empty()).then(|| CompactSummaryEnvelope::new(summary)) + if summary_body.is_empty() { + return None; + } + + let (summary, history_path) = if let Some((summary, history_path)) = summary_body + .rsplit_once(COMPACT_SUMMARY_HISTORY_NOTE_PREFIX) + .filter(|(_, history_path)| !history_path.trim().is_empty()) + { + ( + summary.trim().to_string(), + Some(history_path.trim().to_string()), + ) + } else { + (summary_body.to_string(), None) + }; + + let mut envelope = CompactSummaryEnvelope::new(summary); + if let Some(history_path) = history_path { + envelope = envelope.with_history_path(history_path); + } + Some(envelope) } #[cfg(test)] @@ -63,6 +110,21 @@ mod tests { let parsed = parse_compact_summary_message(&rendered).expect("summary should parse"); assert_eq!(parsed.summary, "summary body"); + assert_eq!(parsed.history_path, None); + } + + #[test] + fn compact_summary_round_trip_preserves_history_path() { + let rendered = CompactSummaryEnvelope::new("summary body") + .with_history_path("~/.astrcode/projects/demo/sessions/abc/session-abc.jsonl") + .render(); + let parsed = parse_compact_summary_message(&rendered).expect("summary should parse"); + + assert_eq!(parsed.summary, "summary body"); + assert_eq!( + parsed.history_path.as_deref(), + Some("~/.astrcode/projects/demo/sessions/abc/session-abc.jsonl") + ); } #[test] diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index 5d70a929..7357ff67 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -41,6 +41,7 @@ pub mod registry; pub mod runtime; pub mod session; mod session_catalog; +mod session_plan; mod shell; pub mod store; mod time; @@ -146,6 +147,7 @@ pub use runtime::{ }; pub use session::{DeleteProjectResult, SessionEventRecord, SessionMeta}; pub use session_catalog::SessionCatalogEvent; +pub use session_plan::{SessionPlanState, SessionPlanStatus, session_plan_content_digest}; pub use shell::{ ResolvedShell, ShellFamily, default_shell_label, detect_shell_family, resolve_shell, }; diff --git a/crates/core/src/registry/router.rs b/crates/core/src/registry/router.rs index 85745f9f..994b0de2 100644 --- a/crates/core/src/registry/router.rs +++ b/crates/core/src/registry/router.rs @@ -10,8 +10,8 @@ use serde_json::Value; use tokio::sync::mpsc::UnboundedSender; use crate::{ - AgentEventContext, CancelToken, CapabilitySpec, ExecutionOwner, ExecutionResultCommon, Result, - SessionId, ToolEventSink, ToolExecutionResult, ToolOutputDelta, + AgentEventContext, CancelToken, CapabilitySpec, ExecutionOwner, ExecutionResultCommon, ModeId, + Result, SessionId, ToolEventSink, ToolExecutionResult, ToolOutputDelta, }; /// 能力调用的上下文信息。 @@ -31,6 +31,8 @@ pub struct CapabilityContext { pub turn_id: Option<String>, /// 当前调用所属 Agent 元数据。 pub agent: AgentEventContext, + /// 当前调用开始时的治理 mode。 + pub current_mode_id: ModeId, /// 当前调用所属执行 owner。 pub execution_owner: Option<ExecutionOwner>, /// 当前使用的 profile 名称 @@ -55,6 +57,7 @@ impl fmt::Debug for CapabilityContext { .field("cancel", &self.cancel) .field("turn_id", &self.turn_id) .field("agent", &self.agent) + .field("current_mode_id", &self.current_mode_id) .field("execution_owner", &self.execution_owner) .field("profile", &self.profile) .field("profile_context", &self.profile_context) diff --git a/crates/core/src/session_plan.rs b/crates/core/src/session_plan.rs new file mode 100644 index 00000000..acb22522 --- /dev/null +++ b/crates/core/src/session_plan.rs @@ -0,0 +1,80 @@ +//! session plan 领域模型。 +//! +//! application 与 adapter-tools 需要读写同一份 `state.json`,状态结构和内容摘要算法 +//! 必须保持单一真相,避免跨 crate 漂移。 + +use std::fmt; + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SessionPlanStatus { + Draft, + AwaitingApproval, + Approved, + Completed, + Superseded, +} + +impl SessionPlanStatus { + pub fn as_str(&self) -> &'static str { + match self { + Self::Draft => "draft", + Self::AwaitingApproval => "awaiting_approval", + Self::Approved => "approved", + Self::Completed => "completed", + Self::Superseded => "superseded", + } + } +} + +impl fmt::Display for SessionPlanStatus { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SessionPlanState { + pub active_plan_slug: String, + pub title: String, + pub status: SessionPlanStatus, + pub created_at: DateTime<Utc>, + pub updated_at: DateTime<Utc>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reviewed_plan_digest: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub approved_at: Option<DateTime<Utc>>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub archived_plan_digest: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub archived_at: Option<DateTime<Utc>>, +} + +pub fn session_plan_content_digest(content: &str) -> String { + const FNV_OFFSET_BASIS: u64 = 0xcbf29ce484222325; + const FNV_PRIME: u64 = 0x100000001b3; + + let mut hash = FNV_OFFSET_BASIS; + for byte in content.as_bytes() { + hash ^= u64::from(*byte); + hash = hash.wrapping_mul(FNV_PRIME); + } + format!("fnv1a64:{hash:016x}") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn session_plan_digest_is_stable_and_versioned() { + assert_eq!( + session_plan_content_digest("plan body"), + "fnv1a64:58c14a8c5354d2ea" + ); + } +} diff --git a/crates/core/src/tool.rs b/crates/core/src/tool.rs index ffb0efb8..bd41bb9f 100644 --- a/crates/core/src/tool.rs +++ b/crates/core/src/tool.rs @@ -17,9 +17,9 @@ use tokio::sync::mpsc::UnboundedSender; use crate::{ AgentEventContext, CancelToken, CapabilityKind, CapabilitySpec, CapabilitySpecBuildError, - InvocationKind, InvocationMode, PermissionSpec, Result, SessionId, SideEffect, Stability, - StorageEvent, ToolDefinition, ToolExecutionResult, ToolOutputDelta, ToolOutputStream, TurnId, - tool_result_persist::DEFAULT_TOOL_RESULT_INLINE_LIMIT, + InvocationKind, InvocationMode, ModeId, PermissionSpec, Result, SessionId, SideEffect, + Stability, StorageEvent, ToolDefinition, ToolExecutionResult, ToolOutputDelta, + ToolOutputStream, TurnId, tool_result_persist::DEFAULT_TOOL_RESULT_INLINE_LIMIT, }; /// 工具执行的默认最大输出大小(1 MB) @@ -109,6 +109,8 @@ pub struct ToolContext { /// /// 使用 Arc 避免 ToolContext 在高频 clone 时反复复制整块 AgentEventContext。 agent: Arc<AgentEventContext>, + /// 工具执行开始时当前会话的治理 mode。 + current_mode_id: ModeId, /// Maximum output size in bytes. Defaults to 1MB. max_output_size: usize, /// Optional override for session-scoped persisted tool artifacts. @@ -150,6 +152,7 @@ impl ToolContext { turn_id: None, tool_call_id: None, agent: Arc::new(AgentEventContext::default()), + current_mode_id: ModeId::default(), max_output_size: DEFAULT_MAX_OUTPUT_SIZE, session_storage_root: None, tool_output_sender: None, @@ -201,6 +204,12 @@ impl ToolContext { self } + /// 为工具上下文注入当前治理 mode。 + pub fn with_current_mode_id(mut self, current_mode_id: ModeId) -> Self { + self.current_mode_id = current_mode_id; + self + } + /// 为工具上下文注入 turn 事件发射器。 pub fn with_event_sink(mut self, event_sink: Arc<dyn ToolEventSink>) -> Self { self.event_sink = Some(event_sink); @@ -251,6 +260,11 @@ impl ToolContext { self.agent.as_ref() } + /// 返回工具执行开始时当前会话的治理 mode。 + pub fn current_mode_id(&self) -> &ModeId { + &self.current_mode_id + } + /// Returns the maximum output size in bytes. pub fn max_output_size(&self) -> usize { self.max_output_size @@ -326,6 +340,7 @@ impl Clone for ToolContext { turn_id: self.turn_id.clone(), tool_call_id: self.tool_call_id.clone(), agent: self.agent.clone(), + current_mode_id: self.current_mode_id.clone(), max_output_size: self.max_output_size, session_storage_root: self.session_storage_root.clone(), tool_output_sender: self.tool_output_sender.clone(), @@ -344,6 +359,7 @@ impl fmt::Debug for ToolContext { .field("cancel", &self.cancel) .field("turn_id", &self.turn_id) .field("agent", self.agent.as_ref()) + .field("current_mode_id", &self.current_mode_id) .field("max_output_size", &self.max_output_size) .field("session_storage_root", &self.session_storage_root) .field( diff --git a/crates/kernel/src/registry/tool.rs b/crates/kernel/src/registry/tool.rs index 8d7cba71..e0a111cb 100644 --- a/crates/kernel/src/registry/tool.rs +++ b/crates/kernel/src/registry/tool.rs @@ -114,6 +114,7 @@ struct ToolBridgeContext { turn_id: Option<String>, request_id: Option<String>, agent: AgentEventContext, + current_mode_id: astrcode_core::ModeId, execution_owner: Option<ExecutionOwner>, tool_output_sender: Option<UnboundedSender<ToolOutputDelta>>, event_sink: Option<Arc<dyn ToolEventSink>>, @@ -128,6 +129,7 @@ impl ToolBridgeContext { turn_id: ctx.turn_id().map(ToString::to_string), request_id: None, agent: ctx.agent_context().clone(), + current_mode_id: ctx.current_mode_id().clone(), execution_owner: ctx.execution_owner().cloned(), tool_output_sender: ctx.tool_output_sender(), event_sink: ctx.event_sink(), @@ -142,6 +144,7 @@ impl ToolBridgeContext { turn_id: ctx.turn_id.clone(), request_id: ctx.request_id.clone(), agent: ctx.agent.clone(), + current_mode_id: ctx.current_mode_id.clone(), execution_owner: ctx.execution_owner.clone(), tool_output_sender: ctx.tool_output_sender.clone(), event_sink: ctx.event_sink.clone(), @@ -159,6 +162,7 @@ impl ToolBridgeContext { cancel: self.cancel, turn_id: self.turn_id, agent: self.agent, + current_mode_id: self.current_mode_id, execution_owner: self.execution_owner, profile: default_tool_capability_profile().to_string(), profile_context, @@ -177,6 +181,7 @@ impl ToolBridgeContext { tool_ctx = tool_ctx.with_tool_call_id(tool_call_id); } tool_ctx = tool_ctx.with_agent_context(self.agent); + tool_ctx = tool_ctx.with_current_mode_id(self.current_mode_id); if let Some(sender) = self.tool_output_sender { tool_ctx = tool_ctx.with_tool_output_sender(sender); } diff --git a/crates/protocol/src/http/conversation/v1.rs b/crates/protocol/src/http/conversation/v1.rs index f2de8d3a..b80c5d2a 100644 --- a/crates/protocol/src/http/conversation/v1.rs +++ b/crates/protocol/src/http/conversation/v1.rs @@ -143,6 +143,7 @@ pub enum ConversationBlockDto { User(ConversationUserBlockDto), Assistant(ConversationAssistantBlockDto), Thinking(ConversationThinkingBlockDto), + Plan(ConversationPlanBlockDto), ToolCall(ConversationToolCallBlockDto), Error(ConversationErrorBlockDto), SystemNote(ConversationSystemNoteBlockDto), @@ -178,6 +179,68 @@ pub struct ConversationThinkingBlockDto { pub markdown: String, } +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum ConversationPlanEventKindDto { + Saved, + ReviewPending, + Presented, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum ConversationPlanReviewKindDto { + RevisePlan, + FinalReview, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct ConversationPlanReviewDto { + pub kind: ConversationPlanReviewKindDto, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub checklist: Vec<String>, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] +#[serde(rename_all = "camelCase")] +pub struct ConversationPlanBlockersDto { + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub missing_headings: Vec<String>, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub invalid_sections: Vec<String>, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct ConversationPlanBlockDto { + pub id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub turn_id: Option<String>, + pub tool_call_id: String, + pub event_kind: ConversationPlanEventKindDto, + pub title: String, + pub plan_path: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub summary: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub status: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub slug: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub updated_at: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub content: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub review: Option<ConversationPlanReviewDto>, + #[serde(default, skip_serializing_if = "is_empty_plan_blockers")] + pub blockers: ConversationPlanBlockersDto, +} + +fn is_empty_plan_blockers(blockers: &ConversationPlanBlockersDto) -> bool { + blockers.missing_headings.is_empty() && blockers.invalid_sections.is_empty() +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] #[serde(rename_all = "camelCase")] pub struct ConversationToolStreamsDto { @@ -314,12 +377,13 @@ pub struct ConversationControlStateDto { pub compact_pending: bool, #[serde(default)] pub compacting: bool, + pub current_mode_id: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub active_turn_id: Option<String>, #[serde(default, skip_serializing_if = "Option::is_none")] pub last_compact_meta: Option<ConversationLastCompactMetaDto>, #[serde(default, skip_serializing_if = "Option::is_none")] - pub active_plan: Option<ConversationActivePlanDto>, + pub active_plan: Option<ConversationPlanReferenceDto>, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] @@ -332,7 +396,8 @@ pub struct ConversationLastCompactMetaDto { #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] -pub struct ConversationActivePlanDto { +pub struct ConversationPlanReferenceDto { + pub slug: String, pub path: String, pub status: String, pub title: String, diff --git a/crates/protocol/src/http/mod.rs b/crates/protocol/src/http/mod.rs index 6a9b7bb6..ef0a1055 100644 --- a/crates/protocol/src/http/mod.rs +++ b/crates/protocol/src/http/mod.rs @@ -43,12 +43,14 @@ pub use config::{ TestConnectionRequest, TestResultDto, }; pub use conversation::v1::{ - ConversationActivePlanDto, ConversationAssistantBlockDto, ConversationBannerDto, - ConversationBannerErrorCodeDto, ConversationBlockDto, ConversationBlockPatchDto, - ConversationBlockStatusDto, ConversationChildHandoffBlockDto, ConversationChildHandoffKindDto, - ConversationChildSummaryDto, ConversationControlStateDto, ConversationCursorDto, - ConversationDeltaDto, ConversationErrorBlockDto, ConversationErrorEnvelopeDto, - ConversationLastCompactMetaDto, ConversationSlashActionKindDto, ConversationSlashCandidateDto, + ConversationAssistantBlockDto, ConversationBannerDto, ConversationBannerErrorCodeDto, + ConversationBlockDto, ConversationBlockPatchDto, ConversationBlockStatusDto, + ConversationChildHandoffBlockDto, ConversationChildHandoffKindDto, ConversationChildSummaryDto, + ConversationControlStateDto, ConversationCursorDto, ConversationDeltaDto, + ConversationErrorBlockDto, ConversationErrorEnvelopeDto, ConversationLastCompactMetaDto, + ConversationPlanBlockDto, ConversationPlanBlockersDto, ConversationPlanEventKindDto, + ConversationPlanReferenceDto, ConversationPlanReviewDto, ConversationPlanReviewKindDto, + ConversationSlashActionKindDto, ConversationSlashCandidateDto, ConversationSlashCandidatesResponseDto, ConversationSnapshotResponseDto, ConversationStreamEnvelopeDto, ConversationSystemNoteBlockDto, ConversationSystemNoteKindDto, ConversationThinkingBlockDto, ConversationToolCallBlockDto, ConversationToolStreamsDto, diff --git a/crates/protocol/tests/conversation_conformance.rs b/crates/protocol/tests/conversation_conformance.rs index feac3536..67898b98 100644 --- a/crates/protocol/tests/conversation_conformance.rs +++ b/crates/protocol/tests/conversation_conformance.rs @@ -2,9 +2,10 @@ use astrcode_protocol::http::{ AgentLifecycleDto, ChildAgentRefDto, ChildSessionLineageKindDto, ConversationBannerErrorCodeDto, ConversationBlockDto, ConversationBlockPatchDto, ConversationBlockStatusDto, ConversationControlStateDto, ConversationCursorDto, - ConversationDeltaDto, ConversationErrorEnvelopeDto, ConversationSnapshotResponseDto, - ConversationStreamEnvelopeDto, ConversationToolCallBlockDto, ConversationToolStreamsDto, - PhaseDto, + ConversationDeltaDto, ConversationErrorEnvelopeDto, ConversationPlanBlockDto, + ConversationPlanBlockersDto, ConversationPlanEventKindDto, ConversationPlanReviewDto, + ConversationPlanReviewKindDto, ConversationSnapshotResponseDto, ConversationStreamEnvelopeDto, + ConversationToolCallBlockDto, ConversationToolStreamsDto, PhaseDto, }; use serde_json::json; @@ -35,6 +36,7 @@ fn conversation_snapshot_fixture_freezes_authoritative_tool_block_shape() { can_request_compact: true, compact_pending: false, compacting: false, + current_mode_id: "code".to_string(), active_turn_id: Some("turn-42".to_string()), last_compact_meta: None, active_plan: None, @@ -135,3 +137,42 @@ fn conversation_delta_fixtures_freeze_tool_patch_and_rehydrate_shapes() { rehydrate_fixture ); } + +#[test] +fn conversation_plan_block_round_trips_with_review_details() { + let block = ConversationBlockDto::Plan(ConversationPlanBlockDto { + id: "plan-block-1".to_string(), + turn_id: Some("turn-42".to_string()), + tool_call_id: "call-plan-exit".to_string(), + event_kind: ConversationPlanEventKindDto::ReviewPending, + title: "Cleanup crates".to_string(), + plan_path: "D:/demo/.astrcode/projects/demo/sessions/session-1/plan/cleanup-crates.md" + .to_string(), + summary: Some("正在做退出前自审".to_string()), + status: None, + slug: None, + updated_at: None, + content: None, + review: Some(ConversationPlanReviewDto { + kind: ConversationPlanReviewKindDto::FinalReview, + checklist: vec![ + "Re-check assumptions against the code you already inspected.".to_string(), + ], + }), + blockers: ConversationPlanBlockersDto { + missing_headings: vec!["## Verification".to_string()], + invalid_sections: vec![ + "session plan section '## Verification' must contain concrete actionable items" + .to_string(), + ], + }, + }); + + let encoded = serde_json::to_value(&block).expect("plan block should encode"); + let decoded: ConversationBlockDto = + serde_json::from_value(encoded.clone()).expect("plan block should decode"); + + assert_eq!(decoded, block); + assert_eq!(encoded["kind"], "plan"); + assert_eq!(encoded["eventKind"], "review_pending"); +} diff --git a/crates/protocol/tests/fixtures/conversation/v1/snapshot.json b/crates/protocol/tests/fixtures/conversation/v1/snapshot.json index 9a41ccc3..f9c74ed5 100644 --- a/crates/protocol/tests/fixtures/conversation/v1/snapshot.json +++ b/crates/protocol/tests/fixtures/conversation/v1/snapshot.json @@ -9,6 +9,7 @@ "canRequestCompact": true, "compactPending": false, "compacting": false, + "currentModeId": "code", "activeTurnId": "turn-42" }, "blocks": [ diff --git a/crates/server/src/bootstrap/capabilities.rs b/crates/server/src/bootstrap/capabilities.rs index 72376131..f894dfc5 100644 --- a/crates/server/src/bootstrap/capabilities.rs +++ b/crates/server/src/bootstrap/capabilities.rs @@ -15,6 +15,8 @@ use astrcode_adapter_tools::{ builtin_tools::{ apply_patch::ApplyPatchTool, edit_file::EditFileTool, + enter_plan_mode::EnterPlanModeTool, + exit_plan_mode::ExitPlanModeTool, find_files::FindFilesTool, grep::GrepTool, list_dir::ListDirTool, @@ -51,6 +53,8 @@ pub(crate) fn build_core_tool_invokers( Arc::new(FindFilesTool), Arc::new(GrepTool), Arc::new(ShellTool), + Arc::new(EnterPlanModeTool), + Arc::new(ExitPlanModeTool), Arc::new(ToolSearchTool::new(tool_search_index)), Arc::new(SkillTool::new(skill_catalog)), Arc::new(UpsertSessionPlanTool), diff --git a/crates/server/src/http/routes/conversation.rs b/crates/server/src/http/routes/conversation.rs index b6f8e646..fc76b552 100644 --- a/crates/server/src/http/routes/conversation.rs +++ b/crates/server/src/http/routes/conversation.rs @@ -811,6 +811,7 @@ mod tests { manual_compact_pending: false, compacting: false, last_compact_meta: None, + current_mode_id: "code".to_string(), active_plan: None, }, child_summaries: Vec::new(), diff --git a/crates/server/src/http/terminal_projection.rs b/crates/server/src/http/terminal_projection.rs index e4038d27..9575d61f 100644 --- a/crates/server/src/http/terminal_projection.rs +++ b/crates/server/src/http/terminal_projection.rs @@ -9,21 +9,24 @@ use astrcode_application::terminal::{ }; use astrcode_core::ChildAgentRef; use astrcode_protocol::http::{ - ChildAgentRefDto, ConversationActivePlanDto, ConversationAssistantBlockDto, - ConversationBannerDto, ConversationBannerErrorCodeDto, ConversationBlockDto, - ConversationBlockPatchDto, ConversationBlockStatusDto, ConversationChildHandoffBlockDto, - ConversationChildHandoffKindDto, ConversationChildSummaryDto, ConversationControlStateDto, - ConversationCursorDto, ConversationDeltaDto, ConversationErrorBlockDto, - ConversationErrorEnvelopeDto, ConversationLastCompactMetaDto, ConversationSlashActionKindDto, - ConversationSlashCandidateDto, ConversationSlashCandidatesResponseDto, - ConversationSnapshotResponseDto, ConversationStreamEnvelopeDto, ConversationSystemNoteBlockDto, - ConversationSystemNoteKindDto, ConversationThinkingBlockDto, ConversationToolCallBlockDto, - ConversationToolStreamsDto, ConversationTranscriptErrorCodeDto, ConversationUserBlockDto, + ChildAgentRefDto, ConversationAssistantBlockDto, ConversationBannerDto, + ConversationBannerErrorCodeDto, ConversationBlockDto, ConversationBlockPatchDto, + ConversationBlockStatusDto, ConversationChildHandoffBlockDto, ConversationChildHandoffKindDto, + ConversationChildSummaryDto, ConversationControlStateDto, ConversationCursorDto, + ConversationDeltaDto, ConversationErrorBlockDto, ConversationErrorEnvelopeDto, + ConversationLastCompactMetaDto, ConversationPlanBlockDto, ConversationPlanBlockersDto, + ConversationPlanEventKindDto, ConversationPlanReferenceDto, ConversationPlanReviewDto, + ConversationPlanReviewKindDto, ConversationSlashActionKindDto, ConversationSlashCandidateDto, + ConversationSlashCandidatesResponseDto, ConversationSnapshotResponseDto, + ConversationStreamEnvelopeDto, ConversationSystemNoteBlockDto, ConversationSystemNoteKindDto, + ConversationThinkingBlockDto, ConversationToolCallBlockDto, ConversationToolStreamsDto, + ConversationTranscriptErrorCodeDto, ConversationUserBlockDto, }; use astrcode_session_runtime::{ ConversationBlockFacts, ConversationBlockPatchFacts, ConversationBlockStatus, ConversationChildHandoffBlockFacts, ConversationChildHandoffKind, ConversationDeltaFacts, - ConversationDeltaFrameFacts, ConversationSystemNoteKind, ConversationTranscriptErrorKind, + ConversationDeltaFrameFacts, ConversationPlanBlockFacts, ConversationPlanEventKind, + ConversationPlanReviewKind, ConversationSystemNoteKind, ConversationTranscriptErrorKind, ToolCallBlockFacts, }; @@ -287,6 +290,9 @@ fn project_block( markdown: block.markdown.clone(), }) }, + ConversationBlockFacts::Plan(block) => { + ConversationBlockDto::Plan(project_plan_block(block.as_ref())) + }, ConversationBlockFacts::ToolCall(block) => { ConversationBlockDto::ToolCall(project_tool_call_block(block)) }, @@ -324,6 +330,44 @@ fn project_block( } } +fn project_plan_block(block: &ConversationPlanBlockFacts) -> ConversationPlanBlockDto { + ConversationPlanBlockDto { + id: block.id.clone(), + turn_id: block.turn_id.clone(), + tool_call_id: block.tool_call_id.clone(), + event_kind: match block.event_kind { + ConversationPlanEventKind::Saved => ConversationPlanEventKindDto::Saved, + ConversationPlanEventKind::ReviewPending => ConversationPlanEventKindDto::ReviewPending, + ConversationPlanEventKind::Presented => ConversationPlanEventKindDto::Presented, + }, + title: block.title.clone(), + plan_path: block.plan_path.clone(), + summary: block.summary.clone(), + status: block.status.clone(), + slug: block.slug.clone(), + updated_at: block.updated_at.clone(), + content: block.content.clone(), + review: block + .review + .as_ref() + .map(|review| ConversationPlanReviewDto { + kind: match review.kind { + ConversationPlanReviewKind::RevisePlan => { + ConversationPlanReviewKindDto::RevisePlan + }, + ConversationPlanReviewKind::FinalReview => { + ConversationPlanReviewKindDto::FinalReview + }, + }, + checklist: review.checklist.clone(), + }), + blockers: ConversationPlanBlockersDto { + missing_headings: block.blockers.missing_headings.clone(), + invalid_sections: block.blockers.invalid_sections.clone(), + }, + } +} + fn project_tool_call_block(block: &ToolCallBlockFacts) -> ConversationToolCallBlockDto { ConversationToolCallBlockDto { id: block.id.clone(), @@ -421,6 +465,7 @@ fn to_conversation_control_state_dto( can_request_compact: summary.can_request_compact, compact_pending: summary.compact_pending, compacting: summary.compacting, + current_mode_id: summary.current_mode_id, active_turn_id: summary.active_turn_id, last_compact_meta: summary .last_compact_meta @@ -428,11 +473,18 @@ fn to_conversation_control_state_dto( trigger: meta.trigger, meta: meta.meta, }), - active_plan: summary.active_plan.map(|plan| ConversationActivePlanDto { - path: plan.path, - status: plan.status, - title: plan.title, - }), + active_plan: summary.active_plan.map(to_plan_reference_dto), + } +} + +fn to_plan_reference_dto( + plan: astrcode_application::terminal::PlanReferenceFacts, +) -> ConversationPlanReferenceDto { + ConversationPlanReferenceDto { + slug: plan.slug, + path: plan.path, + status: plan.status, + title: plan.title, } } diff --git a/crates/session-runtime/src/context_window/compaction.rs b/crates/session-runtime/src/context_window/compaction.rs index 72e6eac6..009ea611 100644 --- a/crates/session-runtime/src/context_window/compaction.rs +++ b/crates/session-runtime/src/context_window/compaction.rs @@ -16,8 +16,9 @@ use std::sync::OnceLock; use astrcode_core::{ - AstrError, CancelToken, CompactAppliedMeta, CompactMode, LlmMessage, LlmRequest, ModelLimits, - Result, UserMessageOrigin, format_compact_summary, parse_compact_summary_message, + AstrError, CancelToken, CompactAppliedMeta, CompactMode, CompactSummaryEnvelope, LlmMessage, + LlmRequest, ModelLimits, Result, UserMessageOrigin, format_compact_summary, + parse_compact_summary_message, tool_result_persist::{is_persisted_output, persisted_output_absolute_path}, }; use astrcode_kernel::KernelGateway; @@ -40,6 +41,8 @@ pub(crate) struct CompactConfig { pub summary_reserve_tokens: usize, /// compact 允许的最大裁剪重试次数。 pub max_retry_attempts: usize, + /// compact 后注入给模型的旧历史 event log 路径提示。 + pub history_path: Option<String>, /// 仅对手动 compact 生效的附加指令。 pub custom_instructions: Option<String>, } @@ -170,7 +173,16 @@ pub async fn auto_compact( } }; - let summary = sanitize_compact_summary(&parsed_output.summary); + let summary = { + let summary = sanitize_compact_summary(&parsed_output.summary); + if let Some(history_path) = config.history_path.as_deref() { + CompactSummaryEnvelope::new(summary) + .with_history_path(history_path) + .render_body() + } else { + summary + } + }; let output_summary_chars = summary.chars().count().min(u32::MAX as usize) as u32; let compacted_messages = compacted_messages(&summary, split.suffix); let post_tokens_estimate = estimate_request_tokens(&compacted_messages, compact_prompt_context); @@ -977,6 +989,7 @@ mod tests { trigger: astrcode_core::CompactTrigger::Manual, summary_reserve_tokens: 20_000, max_retry_attempts: 3, + history_path: None, custom_instructions: None, } } @@ -1166,6 +1179,22 @@ mod tests { assert_eq!(compacted.len(), 1); } + #[test] + fn prepare_compact_input_strips_history_note_from_previous_summary() { + let filtered = prepare_compact_input(&[LlmMessage::User { + content: CompactSummaryEnvelope::new("older summary") + .with_history_path("~/.astrcode/projects/demo/sessions/abc/session-abc.jsonl") + .render(), + origin: UserMessageOrigin::CompactSummary, + }]); + + assert!(matches!( + filtered.prompt_mode, + CompactPromptMode::Incremental { ref previous_summary } + if previous_summary == "older summary" + )); + } + #[test] fn prepare_compact_input_skips_synthetic_user_messages() { let filtered = prepare_compact_input(&[ diff --git a/crates/session-runtime/src/context_window/templates/compact/base.md b/crates/session-runtime/src/context_window/templates/compact/base.md index 759fa0f5..5fbd522c 100644 --- a/crates/session-runtime/src/context_window/templates/compact/base.md +++ b/crates/session-runtime/src/context_window/templates/compact/base.md @@ -1,5 +1,5 @@ You are a context summarization assistant for a coding-agent session. -Your summary will replace earlier conversation history so another agent can continue seamlessly. +Your summary will be placed at the start of a continuing session so another agent can continue seamlessly. ## CRITICAL RULES **DO NOT CALL ANY TOOLS.** This is for summary generation only. @@ -74,7 +74,7 @@ Return exactly two XML blocks: - **Cause**: [Root cause] - **Fix**: [How it was resolved] -## Next Steps +## Context for Continuing Work 1. [Ordered list of what should happen next] ## Critical Context diff --git a/crates/session-runtime/src/lib.rs b/crates/session-runtime/src/lib.rs index a5663b97..33f9ec13 100644 --- a/crates/session-runtime/src/lib.rs +++ b/crates/session-runtime/src/lib.rs @@ -36,12 +36,14 @@ pub use query::{ AgentObserveSnapshot, ConversationAssistantBlockFacts, ConversationBlockFacts, ConversationBlockPatchFacts, ConversationBlockStatus, ConversationChildHandoffBlockFacts, ConversationChildHandoffKind, ConversationDeltaFacts, ConversationDeltaFrameFacts, - ConversationDeltaProjector, ConversationErrorBlockFacts, ConversationSnapshotFacts, - ConversationStreamProjector, ConversationStreamReplayFacts, ConversationSystemNoteBlockFacts, - ConversationSystemNoteKind, ConversationThinkingBlockFacts, ConversationTranscriptErrorKind, - ConversationUserBlockFacts, LastCompactMetaSnapshot, ProjectedTurnOutcome, - SessionControlStateSnapshot, SessionModeSnapshot, SessionReplay, SessionTranscriptSnapshot, - ToolCallBlockFacts, ToolCallStreamsFacts, TurnTerminalSnapshot, recoverable_parent_deliveries, + ConversationDeltaProjector, ConversationErrorBlockFacts, ConversationPlanBlockFacts, + ConversationPlanBlockersFacts, ConversationPlanEventKind, ConversationPlanReviewFacts, + ConversationPlanReviewKind, ConversationSnapshotFacts, ConversationStreamProjector, + ConversationStreamReplayFacts, ConversationSystemNoteBlockFacts, ConversationSystemNoteKind, + ConversationThinkingBlockFacts, ConversationTranscriptErrorKind, ConversationUserBlockFacts, + LastCompactMetaSnapshot, ProjectedTurnOutcome, SessionControlStateSnapshot, + SessionModeSnapshot, SessionReplay, SessionTranscriptSnapshot, ToolCallBlockFacts, + ToolCallStreamsFacts, TurnTerminalSnapshot, recoverable_parent_deliveries, }; pub(crate) use state::{InputQueueEventAppend, SessionStateEventSink, append_input_queue_event}; pub use state::{ diff --git a/crates/session-runtime/src/query/conversation.rs b/crates/session-runtime/src/query/conversation.rs index 6aa4e9e2..b4045419 100644 --- a/crates/session-runtime/src/query/conversation.rs +++ b/crates/session-runtime/src/query/conversation.rs @@ -43,6 +43,19 @@ pub enum ConversationTranscriptErrorKind { RateLimit, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ConversationPlanEventKind { + Saved, + ReviewPending, + Presented, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ConversationPlanReviewKind { + RevisePlan, + FinalReview, +} + #[derive(Debug, Clone, PartialEq, Eq, Default)] pub struct ToolCallStreamsFacts { pub stdout: String, @@ -72,6 +85,35 @@ pub struct ConversationThinkingBlockFacts { pub markdown: String, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ConversationPlanReviewFacts { + pub kind: ConversationPlanReviewKind, + pub checklist: Vec<String>, +} + +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct ConversationPlanBlockersFacts { + pub missing_headings: Vec<String>, + pub invalid_sections: Vec<String>, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ConversationPlanBlockFacts { + pub id: String, + pub turn_id: Option<String>, + pub tool_call_id: String, + pub event_kind: ConversationPlanEventKind, + pub title: String, + pub plan_path: String, + pub summary: Option<String>, + pub status: Option<String>, + pub slug: Option<String>, + pub updated_at: Option<String>, + pub content: Option<String>, + pub review: Option<ConversationPlanReviewFacts>, + pub blockers: ConversationPlanBlockersFacts, +} + #[derive(Debug, Clone, PartialEq)] pub struct ToolCallBlockFacts { pub id: String, @@ -119,6 +161,7 @@ pub enum ConversationBlockFacts { User(ConversationUserBlockFacts), Assistant(ConversationAssistantBlockFacts), Thinking(ConversationThinkingBlockFacts), + Plan(Box<ConversationPlanBlockFacts>), ToolCall(Box<ToolCallBlockFacts>), Error(ConversationErrorBlockFacts), SystemNote(ConversationSystemNoteBlockFacts), @@ -429,7 +472,13 @@ impl ConversationDeltaProjector { tool_name, input, .. - } => self.start_tool_call(turn_id, tool_call_id, tool_name, Some(input), source), + } => { + if should_suppress_tool_call_block(tool_name, Some(input)) { + Vec::new() + } else { + self.start_tool_call(turn_id, tool_call_id, tool_name, Some(input), source) + } + }, AgentEvent::ToolCallDelta { turn_id, tool_call_id, @@ -440,7 +489,13 @@ impl ConversationDeltaProjector { } => self.append_tool_stream(turn_id, tool_call_id, tool_name, *stream, delta, source), AgentEvent::ToolCallResult { turn_id, result, .. - } => self.complete_tool_call(turn_id, result, source), + } => { + if let Some(block) = plan_block_from_tool_result(turn_id, result) { + self.push_block(ConversationBlockFacts::Plan(Box::new(block))) + } else { + self.complete_tool_call(turn_id, result, source) + } + }, AgentEvent::CompactApplied { turn_id, trigger, @@ -1256,6 +1311,7 @@ fn block_id(block: &ConversationBlockFacts) -> &str { ConversationBlockFacts::User(block) => &block.id, ConversationBlockFacts::Assistant(block) => &block.id, ConversationBlockFacts::Thinking(block) => &block.id, + ConversationBlockFacts::Plan(block) => &block.id, ConversationBlockFacts::ToolCall(block) => &block.id, ConversationBlockFacts::Error(block) => &block.id, ConversationBlockFacts::SystemNote(block) => &block.id, @@ -1263,6 +1319,144 @@ fn block_id(block: &ConversationBlockFacts) -> &str { } } +fn should_suppress_tool_call_block(tool_name: &str, _input: Option<&Value>) -> bool { + matches!(tool_name, "upsertSessionPlan" | "exitPlanMode") +} + +fn plan_block_from_tool_result( + turn_id: &str, + result: &ToolExecutionResult, +) -> Option<ConversationPlanBlockFacts> { + if !result.ok { + return None; + } + + let metadata = result.metadata.as_ref()?.as_object()?; + match result.tool_name.as_str() { + "upsertSessionPlan" => { + let title = json_string(metadata, "title")?; + let plan_path = json_string(metadata, "planPath")?; + Some(ConversationPlanBlockFacts { + id: format!("plan:{}:saved", result.tool_call_id), + turn_id: Some(turn_id.to_string()), + tool_call_id: result.tool_call_id.clone(), + event_kind: ConversationPlanEventKind::Saved, + title, + plan_path, + summary: Some(tool_result_summary(result)), + status: json_string(metadata, "status"), + slug: json_string(metadata, "slug"), + updated_at: json_string(metadata, "updatedAt"), + content: None, + review: None, + blockers: ConversationPlanBlockersFacts::default(), + }) + }, + "exitPlanMode" => match json_string(metadata, "schema").as_deref() { + Some("sessionPlanExit") => plan_presented_block(turn_id, result, metadata), + Some("sessionPlanExitReviewPending") | Some("sessionPlanExitBlocked") => { + plan_review_pending_block(turn_id, result, metadata) + }, + _ => None, + }, + _ => None, + } +} + +fn plan_presented_block( + turn_id: &str, + result: &ToolExecutionResult, + metadata: &serde_json::Map<String, Value>, +) -> Option<ConversationPlanBlockFacts> { + let plan = metadata.get("plan")?.as_object()?; + Some(ConversationPlanBlockFacts { + id: format!("plan:{}:presented", result.tool_call_id), + turn_id: Some(turn_id.to_string()), + tool_call_id: result.tool_call_id.clone(), + event_kind: ConversationPlanEventKind::Presented, + title: json_string(plan, "title")?, + plan_path: json_string(plan, "planPath")?, + summary: Some("计划已呈递".to_string()), + status: json_string(plan, "status"), + slug: json_string(plan, "slug"), + updated_at: json_string(plan, "updatedAt"), + content: json_string(plan, "content"), + review: None, + blockers: ConversationPlanBlockersFacts::default(), + }) +} + +fn plan_review_pending_block( + turn_id: &str, + result: &ToolExecutionResult, + metadata: &serde_json::Map<String, Value>, +) -> Option<ConversationPlanBlockFacts> { + let plan = metadata.get("plan")?.as_object()?; + let review = metadata + .get("review") + .and_then(Value::as_object) + .and_then(|review| { + let kind = match json_string(review, "kind").as_deref() { + Some("revise_plan") => ConversationPlanReviewKind::RevisePlan, + Some("final_review") => ConversationPlanReviewKind::FinalReview, + _ => return None, + }; + Some(ConversationPlanReviewFacts { + kind, + checklist: json_string_array(review, "checklist"), + }) + }); + let blockers = metadata + .get("blockers") + .and_then(Value::as_object) + .map(|blockers| ConversationPlanBlockersFacts { + missing_headings: json_string_array(blockers, "missingHeadings"), + invalid_sections: json_string_array(blockers, "invalidSections"), + }) + .unwrap_or_default(); + + Some(ConversationPlanBlockFacts { + id: format!("plan:{}:review-pending", result.tool_call_id), + turn_id: Some(turn_id.to_string()), + tool_call_id: result.tool_call_id.clone(), + event_kind: ConversationPlanEventKind::ReviewPending, + title: json_string(plan, "title")?, + plan_path: json_string(plan, "planPath")?, + summary: Some(match review.as_ref().map(|review| review.kind) { + Some(ConversationPlanReviewKind::RevisePlan) => "正在修计划".to_string(), + Some(ConversationPlanReviewKind::FinalReview) => "正在做退出前自审".to_string(), + None => "继续完善中".to_string(), + }), + status: None, + slug: None, + updated_at: None, + content: None, + review, + blockers, + }) +} + +fn json_string(container: &serde_json::Map<String, Value>, key: &str) -> Option<String> { + container + .get(key) + .and_then(Value::as_str) + .map(ToString::to_string) +} + +fn json_string_array(container: &serde_json::Map<String, Value>, key: &str) -> Vec<String> { + container + .get(key) + .and_then(Value::as_array) + .map(|items| { + items + .iter() + .filter_map(Value::as_str) + .map(ToString::to_string) + .collect() + }) + .unwrap_or_default() +} + fn tool_result_summary(result: &ToolExecutionResult) -> String { const MAX_SUMMARY_CHARS: usize = 120; @@ -1317,7 +1511,7 @@ mod tests { use super::{ ConversationBlockFacts, ConversationBlockPatchFacts, ConversationBlockStatus, ConversationChildHandoffKind, ConversationDeltaFacts, ConversationDeltaProjector, - ConversationStreamProjector, ConversationStreamReplayFacts, + ConversationPlanEventKind, ConversationStreamProjector, ConversationStreamReplayFacts, build_conversation_replay_frames, fallback_live_cursor, project_conversation_snapshot, }; use crate::{ @@ -1454,6 +1648,140 @@ mod tests { assert_eq!(tool.duration_ms, Some(127)); } + #[test] + fn snapshot_projects_plan_blocks_in_durable_event_order() { + let records = vec![ + record( + "1.1", + AgentEvent::ToolCallStart { + turn_id: "turn-1".to_string(), + agent: sample_agent_context(), + tool_call_id: "call-plan-save".to_string(), + tool_name: "upsertSessionPlan".to_string(), + input: json!({ + "title": "Cleanup crates", + "content": "# Plan: Cleanup crates" + }), + }, + ), + record( + "1.2", + AgentEvent::ToolCallResult { + turn_id: "turn-1".to_string(), + agent: sample_agent_context(), + result: ToolExecutionResult { + tool_call_id: "call-plan-save".to_string(), + tool_name: "upsertSessionPlan".to_string(), + ok: true, + output: "updated session plan".to_string(), + error: None, + metadata: Some(json!({ + "planPath": "C:/Users/demo/.astrcode/projects/demo/sessions/session-1/plan/cleanup-crates.md", + "slug": "cleanup-crates", + "status": "draft", + "title": "Cleanup crates", + "updatedAt": "2026-04-19T09:00:00Z" + })), + child_ref: None, + duration_ms: 7, + truncated: false, + }, + }, + ), + record( + "1.3", + AgentEvent::ToolCallStart { + turn_id: "turn-1".to_string(), + agent: sample_agent_context(), + tool_call_id: "call-shell".to_string(), + tool_name: "shell_command".to_string(), + input: json!({ "command": "pwd" }), + }, + ), + record( + "1.4", + AgentEvent::ToolCallResult { + turn_id: "turn-1".to_string(), + agent: sample_agent_context(), + result: ToolExecutionResult { + tool_call_id: "call-shell".to_string(), + tool_name: "shell_command".to_string(), + ok: true, + output: "D:/GitObjectsOwn/Astrcode".to_string(), + error: None, + metadata: None, + child_ref: None, + duration_ms: 9, + truncated: false, + }, + }, + ), + record( + "1.5", + AgentEvent::ToolCallStart { + turn_id: "turn-1".to_string(), + agent: sample_agent_context(), + tool_call_id: "call-plan-exit".to_string(), + tool_name: "exitPlanMode".to_string(), + input: json!({}), + }, + ), + record( + "1.6", + AgentEvent::ToolCallResult { + turn_id: "turn-1".to_string(), + agent: sample_agent_context(), + result: ToolExecutionResult { + tool_call_id: "call-plan-exit".to_string(), + tool_name: "exitPlanMode".to_string(), + ok: true, + output: "Before exiting plan mode, do one final self-review.".to_string(), + error: None, + metadata: Some(json!({ + "schema": "sessionPlanExitReviewPending", + "plan": { + "title": "Cleanup crates", + "planPath": "C:/Users/demo/.astrcode/projects/demo/sessions/session-1/plan/cleanup-crates.md" + }, + "review": { + "kind": "final_review", + "checklist": [ + "Re-check assumptions against the code you already inspected." + ] + }, + "blockers": { + "missingHeadings": ["## Verification"], + "invalidSections": [] + } + })), + child_ref: None, + duration_ms: 5, + truncated: false, + }, + }, + ), + ]; + + let snapshot = project_conversation_snapshot(&records, Phase::Idle); + assert_eq!(snapshot.blocks.len(), 3); + assert!(matches!( + &snapshot.blocks[0], + ConversationBlockFacts::Plan(block) + if block.tool_call_id == "call-plan-save" + && block.event_kind == ConversationPlanEventKind::Saved + )); + assert!(matches!( + &snapshot.blocks[1], + ConversationBlockFacts::ToolCall(block) if block.tool_call_id == "call-shell" + )); + assert!(matches!( + &snapshot.blocks[2], + ConversationBlockFacts::Plan(block) + if block.tool_call_id == "call-plan-exit" + && block.event_kind == ConversationPlanEventKind::ReviewPending + )); + } + #[test] fn live_then_durable_tool_delta_dedupes_chunk_on_same_tool_block() { let facts = sample_stream_replay_facts( diff --git a/crates/session-runtime/src/query/mod.rs b/crates/session-runtime/src/query/mod.rs index 13a3f7ac..87b8fd0d 100644 --- a/crates/session-runtime/src/query/mod.rs +++ b/crates/session-runtime/src/query/mod.rs @@ -17,10 +17,12 @@ pub use conversation::{ ConversationAssistantBlockFacts, ConversationBlockFacts, ConversationBlockPatchFacts, ConversationBlockStatus, ConversationChildHandoffBlockFacts, ConversationChildHandoffKind, ConversationDeltaFacts, ConversationDeltaFrameFacts, ConversationDeltaProjector, - ConversationErrorBlockFacts, ConversationSnapshotFacts, ConversationStreamProjector, - ConversationStreamReplayFacts, ConversationSystemNoteBlockFacts, ConversationSystemNoteKind, - ConversationThinkingBlockFacts, ConversationTranscriptErrorKind, ConversationUserBlockFacts, - ToolCallBlockFacts, ToolCallStreamsFacts, + ConversationErrorBlockFacts, ConversationPlanBlockFacts, ConversationPlanBlockersFacts, + ConversationPlanEventKind, ConversationPlanReviewFacts, ConversationPlanReviewKind, + ConversationSnapshotFacts, ConversationStreamProjector, ConversationStreamReplayFacts, + ConversationSystemNoteBlockFacts, ConversationSystemNoteKind, ConversationThinkingBlockFacts, + ConversationTranscriptErrorKind, ConversationUserBlockFacts, ToolCallBlockFacts, + ToolCallStreamsFacts, }; pub use input_queue::recoverable_parent_deliveries; pub(crate) use service::SessionQueries; diff --git a/crates/session-runtime/src/state/mod.rs b/crates/session-runtime/src/state/mod.rs index b7ac39cc..92338804 100644 --- a/crates/session-runtime/src/state/mod.rs +++ b/crates/session-runtime/src/state/mod.rs @@ -31,6 +31,7 @@ use chrono::{DateTime, Utc}; pub(crate) use execution::SessionStateEventSink; pub use execution::{append_and_broadcast, complete_session_execution, prepare_session_execution}; pub(crate) use input_queue::{InputQueueEventAppend, append_input_queue_event}; +pub(crate) use paths::compact_history_event_log_path; pub use paths::{display_name_from_working_dir, normalize_session_id, normalize_working_dir}; use tokio::sync::broadcast; pub(crate) use writer::SessionWriter; diff --git a/crates/session-runtime/src/state/paths.rs b/crates/session-runtime/src/state/paths.rs index d5effd4f..747aafa0 100644 --- a/crates/session-runtime/src/state/paths.rs +++ b/crates/session-runtime/src/state/paths.rs @@ -2,7 +2,13 @@ use std::path::{Path, PathBuf}; -use astrcode_core::AstrError; +use astrcode_core::{ + AstrError, + home::resolve_home_dir, + project::{project_dir_name, projects_dir}, +}; + +const SESSIONS_DIR_NAME: &str = "sessions"; /// 规范化会话 ID,去除首尾空白并剥离最外层 `session-` 前缀。 pub fn normalize_session_id(session_id: &str) -> String { @@ -13,6 +19,24 @@ pub fn normalize_session_id(session_id: &str) -> String { .to_string() } +/// 生成供 compact 摘要引用的 session event log 路径提示。 +/// +/// Why: 路径协议应该收口在单一 helper 中,而不是散落在 compact prompt 拼接逻辑里。 +pub(crate) fn compact_history_event_log_path( + session_id: &str, + working_dir: &Path, +) -> Result<String, AstrError> { + let session_id = normalize_session_id(session_id); + let path = projects_dir() + .map_err(|error| AstrError::Internal(format!("failed to resolve projects dir: {error}")))? + .join(project_dir_name(working_dir)) + .join(SESSIONS_DIR_NAME) + .join(&session_id) + .join(format!("session-{session_id}.jsonl")); + + Ok(render_home_relative_path(&path)) +} + /// 规范化工作目录路径,要求路径存在且必须是目录。 pub fn normalize_working_dir(working_dir: PathBuf) -> Result<PathBuf, AstrError> { let path = if working_dir.is_absolute() { @@ -56,13 +80,28 @@ pub fn display_name_from_working_dir(path: &Path) -> String { .to_string() } +fn render_home_relative_path(path: &Path) -> String { + let display = resolve_home_dir() + .ok() + .and_then(|home| { + path.strip_prefix(home) + .ok() + .map(|relative| PathBuf::from("~").join(relative)) + }) + .unwrap_or_else(|| path.to_path_buf()); + display.to_string_lossy().replace('\\', "/") +} + #[cfg(test)] mod tests { use std::path::Path; use astrcode_core::AstrError; - use super::{display_name_from_working_dir, normalize_session_id, normalize_working_dir}; + use super::{ + compact_history_event_log_path, display_name_from_working_dir, normalize_session_id, + normalize_working_dir, + }; #[test] fn normalize_session_id_only_removes_outer_prefix() { @@ -79,6 +118,16 @@ mod tests { assert_eq!(normalize_session_id(" abc "), "abc"); } + #[test] + fn compact_history_event_log_path_uses_tilde_and_canonical_session_file_name() { + let temp_dir = tempfile::tempdir().expect("tempdir should be created"); + let path = compact_history_event_log_path("session-abc", temp_dir.path()) + .expect("compact history path should build"); + + assert!(path.starts_with("~/.astrcode/projects/")); + assert!(path.ends_with("/sessions/abc/session-abc.jsonl")); + } + #[test] fn normalize_working_dir_rejects_file_paths() { let temp_dir = tempfile::tempdir().expect("tempdir should be created"); diff --git a/crates/session-runtime/src/turn/compaction_cycle.rs b/crates/session-runtime/src/turn/compaction_cycle.rs index bc70dee4..6b4b0b91 100644 --- a/crates/session-runtime/src/turn/compaction_cycle.rs +++ b/crates/session-runtime/src/turn/compaction_cycle.rs @@ -23,6 +23,7 @@ use crate::{ compaction::{CompactConfig, CompactResult, auto_compact}, file_access::FileAccessTracker, }, + state::compact_history_event_log_path, turn::{ events::{CompactAppliedStats, compact_applied_event}, request::{PromptOutputRequest, build_prompt_output}, @@ -114,6 +115,10 @@ pub async fn try_reactive_compact( trigger: CompactTrigger::Auto, summary_reserve_tokens: ctx.settings.summary_reserve_tokens, max_retry_attempts: ctx.settings.compact_max_retry_attempts, + history_path: Some(compact_history_event_log_path( + ctx.session_id, + std::path::Path::new(ctx.working_dir), + )?), custom_instructions: None, }, ctx.cancel.clone(), diff --git a/crates/session-runtime/src/turn/manual_compact.rs b/crates/session-runtime/src/turn/manual_compact.rs index d22dd156..a3d2fd58 100644 --- a/crates/session-runtime/src/turn/manual_compact.rs +++ b/crates/session-runtime/src/turn/manual_compact.rs @@ -14,6 +14,7 @@ use crate::{ compaction::{CompactConfig, auto_compact}, file_access::FileAccessTracker, }, + state::compact_history_event_log_path, turn::{ events::{CompactAppliedStats, compact_applied_event}, request::{PromptOutputRequest, build_prompt_output}, @@ -65,6 +66,10 @@ pub(crate) async fn build_manual_compact_events( trigger: request.trigger, summary_reserve_tokens: settings.summary_reserve_tokens, max_retry_attempts: settings.compact_max_retry_attempts, + history_path: Some(compact_history_event_log_path( + request.session_id, + request.working_dir, + )?), custom_instructions: request.instructions.map(str::to_string), }, CancelToken::new(), @@ -277,7 +282,8 @@ mod tests { assert!(matches!( &events[0].payload, StorageEventPayload::CompactApplied { summary, meta, .. } - if summary == "manual compact summary" + if summary.contains("manual compact summary") + && summary.contains("session-1.jsonl") && meta.mode == CompactMode::Full && meta.instructions_present )); diff --git a/crates/session-runtime/src/turn/request.rs b/crates/session-runtime/src/turn/request.rs index fdd25995..17e87768 100644 --- a/crates/session-runtime/src/turn/request.rs +++ b/crates/session-runtime/src/turn/request.rs @@ -21,6 +21,7 @@ use crate::{ micro_compact::MicroCompactState, token_usage::{TokenUsageTracker, build_prompt_snapshot, should_compact}, }, + state::compact_history_event_log_path, turn::{ events::{CompactAppliedStats, compact_applied_event, prompt_metrics_event}, tool_result_budget::{ @@ -150,6 +151,10 @@ pub async fn assemble_prompt_request( trigger: CompactTrigger::Auto, summary_reserve_tokens: request.settings.summary_reserve_tokens, max_retry_attempts: request.settings.compact_max_retry_attempts, + history_path: Some(compact_history_event_log_path( + request.session_id, + request.working_dir, + )?), custom_instructions: None, }, request.cancel.clone(), diff --git a/crates/session-runtime/src/turn/test_support.rs b/crates/session-runtime/src/turn/test_support.rs index 96218191..4b1d2de2 100644 --- a/crates/session-runtime/src/turn/test_support.rs +++ b/crates/session-runtime/src/turn/test_support.rs @@ -386,7 +386,7 @@ pub(crate) fn assert_contains_compact_summary(events: &[StoredEvent], expected_s assert!( events.iter().any(|stored| matches!( &stored.event.payload, - StorageEventPayload::CompactApplied { summary, .. } if summary == expected_summary + StorageEventPayload::CompactApplied { summary, .. } if summary.contains(expected_summary) )), "expected stored events to contain CompactApplied('{expected_summary}')" ); diff --git a/crates/session-runtime/src/turn/tool_cycle.rs b/crates/session-runtime/src/turn/tool_cycle.rs index f4055bb9..c38a0885 100644 --- a/crates/session-runtime/src/turn/tool_cycle.rs +++ b/crates/session-runtime/src/turn/tool_cycle.rs @@ -415,6 +415,13 @@ async fn invoke_single_tool( tool_result_inline_limit, )) .with_tool_output_sender(tool_output_tx.clone()); + let tool_ctx = match session_state.current_mode_id() { + Ok(current_mode_id) => tool_ctx.with_current_mode_id(current_mode_id), + Err(error) => { + log::warn!("failed to read current mode before tool execution: {error}"); + tool_ctx + }, + }; let tool_ctx = if let Some(sink) = &event_sink { tool_ctx.with_event_sink(Arc::clone(sink)) } else { diff --git a/docs/ideas/notes.md b/docs/ideas/notes.md index 801b024c..2248d3f5 100644 --- a/docs/ideas/notes.md +++ b/docs/ideas/notes.md @@ -8,7 +8,7 @@ --- 4. 我想到了个设计agent company,一个部门审查其他部门的内容,其他部门自己干自己的事情,每个部门都是一个agent team.每个部门的leader会将自己队员做了的事情发在leaders session里面,由leaders自行编排逻辑,只有所有leaders都同意才能完成plan编排部门teammates工作,这样工作流基本就被废弃了,全依靠agent的自己的能力 -5. 终端的输入输出功能 +5. 终端工具的输入输出功能 6. fork agent 7. pending messages(完成部分) 8. 更好的compact功能 diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 48370fe2..eb4b67ca 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -23,6 +23,7 @@ export default function App() { const [state, dispatch] = useReducer(reducer, undefined, makeInitialState); const [showSettings, setShowSettings] = useState(false); const [modelRefreshKey, setModelRefreshKey] = useState(0); + const [activeModeId, setActiveModeId] = useState<string | null>(null); // 确认对话框状态(替代 window.confirm) const [confirmDialog, setConfirmDialog] = useState<ConfirmDialogState | null>(null); const activeSessionIdRef = useRef<string | null>(state.activeSessionId); @@ -72,6 +73,7 @@ export default function App() { interrupt, cancelSubRun, compactSession, + getSessionMode, deleteSession, deleteProject, listComposerOptions, @@ -81,6 +83,7 @@ export default function App() { setModel, getCurrentModel, listAvailableModels, + switchSessionMode, testConnection, openConfigInEditor, selectDirectory, @@ -132,6 +135,39 @@ export default function App() { activeProject?.sessions.find((session) => session.id === state.activeSessionId) ?? null; const activeSubRunThreadTree = activeSession?.subRunThreadTree ?? null; + useEffect(() => { + const sessionId = activeSession?.id; + if (!sessionId) { + setActiveModeId(null); + return; + } + + let cancelled = false; + void (async () => { + try { + const mode = await getSessionMode(sessionId); + if (!cancelled) { + setActiveModeId(mode.currentModeId); + } + } catch (error) { + if (!cancelled) { + logger.warn('App', 'Failed to load session mode:', error); + setActiveModeId(null); + } + } + })(); + + return () => { + cancelled = true; + }; + }, [activeSession?.id, getSessionMode]); + + useEffect(() => { + if (activeConversationControl?.currentModeId) { + setActiveModeId(activeConversationControl.currentModeId); + } + }, [activeConversationControl?.currentModeId]); + useEffect(() => { if (!activeSubRunThreadTree) { return; @@ -232,6 +268,22 @@ export default function App() { [loadAndActivateSession] ); + const handleSwitchMode = useCallback( + async (modeId: string) => { + const sessionId = activeSessionIdRef.current; + if (!sessionId) { + return; + } + try { + const mode = await switchSessionMode(sessionId, modeId); + setActiveModeId(mode.currentModeId); + } catch (error) { + logger.error('App', 'Failed to switch session mode:', error); + } + }, + [switchSessionMode] + ); + const { handleOpenSubRun, handleCloseSubRun, handleNavigateSubRunPath, handleOpenChildSession } = useSubRunNavigation({ activeProjectId: state.activeProjectId, @@ -270,6 +322,7 @@ export default function App() { projectName: activeProject?.name ?? null, sessionId: activeSession?.id ?? null, sessionTitle: activeSession?.title ?? null, + currentModeId: activeConversationControl?.currentModeId ?? activeModeId, isChildSession: activeSession?.parentSessionId !== undefined, workingDir: activeProject?.workingDir ?? '', phase: state.phase, @@ -285,6 +338,7 @@ export default function App() { onOpenChildSession: handleOpenChildSession, onForkFromTurn: handleForkFromTurn, onSubmitPrompt: handleSubmit, + onSwitchMode: handleSwitchMode, onInterrupt: handleInterrupt, onCancelSubRun: cancelSubRun, listComposerOptions, @@ -297,6 +351,7 @@ export default function App() { activeProject?.name, activeProject?.workingDir, activeConversationControl, + activeModeId, activeSession?.id, activeSession?.parentSessionId, activeSession?.title, @@ -311,6 +366,7 @@ export default function App() { handleForkFromTurn, handleOpenSubRun, handleSubmit, + handleSwitchMode, isSidebarOpen, listAvailableModels, listComposerOptions, diff --git a/frontend/src/components/Chat/ChatScreenContext.tsx b/frontend/src/components/Chat/ChatScreenContext.tsx index 24b7f25d..3646fd4d 100644 --- a/frontend/src/components/Chat/ChatScreenContext.tsx +++ b/frontend/src/components/Chat/ChatScreenContext.tsx @@ -11,6 +11,7 @@ export interface ChatScreenContextValue { projectName: string | null; sessionId: string | null; sessionTitle: string | null; + currentModeId: string | null; isChildSession: boolean; workingDir: string; phase: Phase; @@ -26,6 +27,7 @@ export interface ChatScreenContextValue { onOpenChildSession: (childSessionId: string) => void | Promise<void>; onForkFromTurn: (turnId: string) => void | Promise<void>; onSubmitPrompt: (text: string) => void | Promise<void>; + onSwitchMode: (modeId: string) => void | Promise<void>; onInterrupt: () => void | Promise<void>; onCancelSubRun: (sessionId: string, subRunId: string) => void | Promise<void>; listComposerOptions: ( diff --git a/frontend/src/components/Chat/InputBar.tsx b/frontend/src/components/Chat/InputBar.tsx index 342b532e..966f1d07 100644 --- a/frontend/src/components/Chat/InputBar.tsx +++ b/frontend/src/components/Chat/InputBar.tsx @@ -27,7 +27,9 @@ export default function InputBar() { sessionId, workingDir, phase, + currentModeId, onSubmitPrompt, + onSwitchMode, onInterrupt, listComposerOptions, modelRefreshKey, @@ -192,6 +194,15 @@ export default function InputBar() { }; const handleKeyDown = (event: React.KeyboardEvent<HTMLTextAreaElement>) => { + if (event.key === 'Tab' && event.shiftKey) { + event.preventDefault(); + if (!slashTriggerVisible && sessionId) { + const nextModeId = currentModeId === 'plan' ? 'code' : 'plan'; + void onSwitchMode(nextModeId); + } + return; + } + if (slashTriggerVisible) { switch (event.key) { case 'Escape': diff --git a/frontend/src/components/Chat/MessageList.tsx b/frontend/src/components/Chat/MessageList.tsx index b5587324..34f2949f 100644 --- a/frontend/src/components/Chat/MessageList.tsx +++ b/frontend/src/components/Chat/MessageList.tsx @@ -11,6 +11,7 @@ import { useContextMenu } from '../../hooks/useContextMenu'; import { resolveForkTurnIdFromMessage } from '../../lib/sessionFork'; import AssistantMessage from './AssistantMessage'; import CompactMessage from './CompactMessage'; +import PlanMessage from './PlanMessage'; import SubRunBlock from './SubRunBlock'; import ToolCallBlock from './ToolCallBlock'; import UserMessage from './UserMessage'; @@ -73,6 +74,19 @@ class MessageBoundary extends Component<MessageBoundaryProps, MessageBoundarySta <pre className="m-0 whitespace-pre-wrap overflow-wrap-anywhere text-xs leading-relaxed"> {message.summary} </pre> + ) : message.kind === 'plan' ? ( + <pre className="m-0 whitespace-pre-wrap overflow-wrap-anywhere text-xs leading-relaxed"> + {JSON.stringify( + { + toolCallId: message.toolCallId, + eventKind: message.eventKind, + title: message.title, + planPath: message.planPath, + }, + null, + 2 + )} + </pre> ) : message.kind === 'promptMetrics' ? ( <pre className="m-0 whitespace-pre-wrap overflow-wrap-anywhere text-xs leading-relaxed"> {JSON.stringify( @@ -140,7 +154,7 @@ class MessageBoundary extends Component<MessageBoundaryProps, MessageBoundarySta } function isAssistantLike(message: Message): boolean { - return message.kind === 'assistant' || message.kind === 'toolCall'; + return message.kind === 'assistant' || message.kind === 'plan' || message.kind === 'toolCall'; } function isRowNested(options?: { nested?: boolean }): boolean { @@ -280,6 +294,9 @@ export default function MessageList({ /> ); } + if (msg.kind === 'plan') { + return <PlanMessage message={msg} />; + } if (msg.kind === 'toolCall') { return <ToolCallBlock message={msg} />; } diff --git a/frontend/src/components/Chat/PlanMessage.test.tsx b/frontend/src/components/Chat/PlanMessage.test.tsx new file mode 100644 index 00000000..dfe85ed9 --- /dev/null +++ b/frontend/src/components/Chat/PlanMessage.test.tsx @@ -0,0 +1,61 @@ +import { renderToStaticMarkup } from 'react-dom/server'; +import { describe, expect, it } from 'vitest'; + +import PlanMessage from './PlanMessage'; + +describe('PlanMessage', () => { + it('renders a presented plan card', () => { + const html = renderToStaticMarkup( + <PlanMessage + message={{ + id: 'plan-1', + kind: 'plan', + toolCallId: 'call-plan-presented', + eventKind: 'presented', + title: 'Cleanup crates', + planPath: + 'D:/GitObjectsOwn/Astrcode/.astrcode/projects/demo/sessions/session-1/plan/cleanup-crates.md', + status: 'awaiting_approval', + content: '# Plan: Cleanup crates\n\n## Context\n- current crates are inconsistent', + blockers: { + missingHeadings: [], + invalidSections: [], + }, + timestamp: Date.now(), + }} + /> + ); + + expect(html).toContain('计划已呈递'); + expect(html).toContain('待确认'); + expect(html).toContain('Cleanup crates'); + expect(html).toContain('cleanup-crates.md'); + }); + + it('renders a plan update card for saved plan artifacts', () => { + const html = renderToStaticMarkup( + <PlanMessage + message={{ + id: 'plan-2', + kind: 'plan', + toolCallId: 'call-plan-save', + eventKind: 'saved', + title: 'Cleanup crates', + planPath: + 'D:/GitObjectsOwn/Astrcode/.astrcode/projects/demo/sessions/session-1/plan/cleanup-crates.md', + status: 'draft', + summary: 'updated session plan', + blockers: { + missingHeadings: [], + invalidSections: [], + }, + timestamp: Date.now(), + }} + /> + ); + + expect(html).toContain('计划已更新'); + expect(html).toContain('草稿'); + expect(html).toContain('updated session plan'); + }); +}); diff --git a/frontend/src/components/Chat/PlanMessage.tsx b/frontend/src/components/Chat/PlanMessage.tsx new file mode 100644 index 00000000..41793255 --- /dev/null +++ b/frontend/src/components/Chat/PlanMessage.tsx @@ -0,0 +1,51 @@ +import { memo } from 'react'; + +import type { PlanMessage as PlanMessageType } from '../../types'; +import { pillNeutral } from '../../lib/styles'; +import { PresentedPlanSurface, ReviewPendingPlanSurface, planStatusLabel } from './PlanSurface'; + +interface PlanMessageProps { + message: PlanMessageType; +} + +function PlanMessage({ message }: PlanMessageProps) { + return ( + <section className="mb-2 ml-[var(--chat-assistant-content-offset)] block min-w-0 max-w-full animate-block-enter motion-reduce:animate-none"> + {message.eventKind === 'presented' && message.content ? ( + <PresentedPlanSurface + title={message.title} + status={message.status} + planPath={message.planPath} + content={message.content} + /> + ) : message.eventKind === 'review_pending' ? ( + <ReviewPendingPlanSurface + title={message.title} + planPath={message.planPath} + review={message.review} + blockers={message.blockers} + /> + ) : ( + <section className="flex min-w-0 flex-col gap-3 rounded-2xl border border-border bg-white/80 px-4 py-3.5 shadow-[0_12px_28px_rgba(15,23,42,0.05)]"> + <div className="flex flex-wrap items-center gap-2 text-xs text-text-secondary"> + <span className={pillNeutral}>计划已更新</span> + {message.status ? ( + <span className={pillNeutral}>{planStatusLabel(message.status)}</span> + ) : null} + </div> + <div className="space-y-1"> + <div className="text-sm font-semibold text-text-primary">{message.title}</div> + <div className="break-all font-mono text-[12px] text-text-muted"> + {message.planPath} + </div> + <div className="text-[13px] leading-relaxed text-text-secondary"> + {message.summary ?? 'canonical session plan 已同步。'} + </div> + </div> + </section> + )} + </section> + ); +} + +export default memo(PlanMessage); diff --git a/frontend/src/components/Chat/PlanSurface.tsx b/frontend/src/components/Chat/PlanSurface.tsx new file mode 100644 index 00000000..79be1ea7 --- /dev/null +++ b/frontend/src/components/Chat/PlanSurface.tsx @@ -0,0 +1,123 @@ +import ReactMarkdown from 'react-markdown'; +import remarkGfm from 'remark-gfm'; + +import { pillNeutral, pillSuccess } from '../../lib/styles'; +import type { PlanReviewState } from '../../types'; + +interface PlanSurfaceModeTransition { + fromModeId: string; + toModeId: string; + modeChanged: boolean; +} + +interface PlanSurfaceBlockers { + missingHeadings: string[]; + invalidSections: string[]; +} + +export function planStatusLabel(status: string): string { + switch (status) { + case 'awaiting_approval': + return '待确认'; + case 'approved': + return '已批准'; + case 'completed': + return '已完成'; + case 'draft': + return '草稿'; + case 'superseded': + return '已替换'; + default: + return status; + } +} + +export function PresentedPlanSurface({ + title, + status, + planPath, + content, + mode, +}: { + title: string; + status?: string; + planPath: string; + content: string; + mode?: PlanSurfaceModeTransition; +}) { + return ( + <section className="flex min-w-0 flex-col gap-3 rounded-2xl border border-success/25 bg-success-soft px-4 py-3.5 shadow-[0_12px_28px_rgba(15,23,42,0.05)]"> + <div className="flex flex-wrap items-center gap-2 text-xs text-text-secondary"> + <span className={pillSuccess}>计划已呈递</span> + {status ? <span className={pillNeutral}>{planStatusLabel(status)}</span> : null} + {mode?.modeChanged ? ( + <span className={pillNeutral}> + {mode.fromModeId} -> {mode.toModeId} + </span> + ) : null} + </div> + <div className="space-y-1"> + <div className="text-sm font-semibold text-text-primary">{title}</div> + <div className="break-all font-mono text-[12px] text-text-muted">{planPath}</div> + <div className="text-[13px] leading-relaxed text-text-secondary"> + 计划已经提交给你审核。你可以直接批准,或者要求继续修订。 + </div> + </div> + <div className="min-w-0 max-w-full break-words rounded-[18px] border border-border bg-white/80 px-4 py-3 text-sm leading-[1.7] text-text-primary prose-chat [&_ol]:my-[0.4rem] [&_ol]:pl-[1.25rem] [&_p:first-child]:mt-0 [&_p:last-child]:mb-0 [&_ul]:my-[0.4rem] [&_ul]:pl-[1.25rem]"> + <ReactMarkdown remarkPlugins={[remarkGfm]}>{content}</ReactMarkdown> + </div> + </section> + ); +} + +export function ReviewPendingPlanSurface({ + title, + planPath, + review, + blockers, +}: { + title: string; + planPath: string; + review?: PlanReviewState; + blockers: PlanSurfaceBlockers; +}) { + return ( + <section className="flex min-w-0 flex-col gap-3 rounded-2xl border border-border bg-white/80 px-4 py-3.5 shadow-[0_12px_28px_rgba(15,23,42,0.05)]"> + <div className="flex flex-wrap items-center gap-2 text-xs text-text-secondary"> + <span className={pillNeutral}>继续完善中</span> + <span className={pillNeutral}> + {review?.kind === 'revise_plan' ? '正在修计划' : '正在做退出前自审'} + </span> + </div> + <div className="space-y-1"> + <div className="text-sm font-semibold text-text-primary">{title}</div> + <div className="break-all font-mono text-[12px] text-text-muted">{planPath}</div> + <div className="text-[13px] leading-relaxed text-text-secondary"> + {review?.kind === 'revise_plan' + ? '当前计划还没达到可执行程度。模型会先补强计划,再重新尝试退出 plan mode。' + : '模型正在做退出前的内部最终自审。这是正常流程,不会把 review 段落写进计划正文。'} + </div> + </div> + <div className="space-y-2 text-[13px] leading-relaxed text-text-primary"> + {review?.checklist && review.checklist.length > 0 ? ( + <div> + <div className="text-xs text-text-secondary">自审检查项</div> + <div>{review.checklist.join(';')}</div> + </div> + ) : null} + {blockers.missingHeadings.length > 0 ? ( + <div> + <div className="text-xs text-text-secondary">缺失章节</div> + <div>{blockers.missingHeadings.join(',')}</div> + </div> + ) : null} + {blockers.invalidSections.length > 0 ? ( + <div> + <div className="text-xs text-text-secondary">需要加强</div> + <div>{blockers.invalidSections.join(';')}</div> + </div> + ) : null} + </div> + </section> + ); +} diff --git a/frontend/src/components/Chat/ToolCallBlock.test.tsx b/frontend/src/components/Chat/ToolCallBlock.test.tsx index a3c821b6..2a988670 100644 --- a/frontend/src/components/Chat/ToolCallBlock.test.tsx +++ b/frontend/src/components/Chat/ToolCallBlock.test.tsx @@ -8,6 +8,7 @@ const chatContextValue: ChatScreenContextValue = { projectName: 'Astrcode', sessionId: 'session-1', sessionTitle: 'Test Session', + currentModeId: 'code', isChildSession: false, workingDir: 'D:/GitObjectsOwn/Astrcode', phase: 'idle', @@ -23,6 +24,7 @@ const chatContextValue: ChatScreenContextValue = { onOpenChildSession: () => {}, onForkFromTurn: () => {}, onSubmitPrompt: () => {}, + onSwitchMode: () => {}, onInterrupt: () => {}, onCancelSubRun: () => {}, listComposerOptions: () => Promise.resolve([]), @@ -218,6 +220,101 @@ describe('ToolCallBlock', () => { expect(html).toContain('失败'); }); + it('renders a dedicated plan review card for exitPlanMode metadata', () => { + const html = renderToStaticMarkup( + <ChatScreenProvider value={chatContextValue}> + <ToolCallBlock + message={{ + id: 'tool-call-plan-exit', + kind: 'toolCall', + toolCallId: 'call-plan-exit', + toolName: 'exitPlanMode', + status: 'ok', + args: {}, + metadata: { + schema: 'sessionPlanExit', + mode: { + fromModeId: 'plan', + toModeId: 'code', + modeChanged: true, + }, + plan: { + title: 'Cleanup crates', + status: 'awaiting_approval', + slug: 'cleanup-crates', + planPath: + 'D:/GitObjectsOwn/Astrcode/.astrcode/projects/demo/sessions/session-1/plan/cleanup-crates.md', + content: + '# Plan: Cleanup crates\n\n## Context\n- current crates are inconsistent\n\n## Goal\n- align crate boundaries', + }, + }, + streams: { + stdout: '', + stderr: '', + }, + timestamp: Date.now(), + }} + /> + </ChatScreenProvider> + ); + + expect(html).toContain('计划已呈递'); + expect(html).toContain('待确认'); + expect(html).toContain('Cleanup crates'); + expect(html).toContain('cleanup-crates.md'); + expect(html).toContain('计划已经提交给你审核'); + }); + + it('renders a dedicated review-pending card when exitPlanMode requires another reflection pass', () => { + const html = renderToStaticMarkup( + <ChatScreenProvider value={chatContextValue}> + <ToolCallBlock + message={{ + id: 'tool-call-plan-blocked', + kind: 'toolCall', + toolCallId: 'call-plan-blocked', + toolName: 'exitPlanMode', + status: 'ok', + args: {}, + metadata: { + schema: 'sessionPlanExitReviewPending', + plan: { + title: 'Cleanup crates', + planPath: + 'D:/GitObjectsOwn/Astrcode/.astrcode/projects/demo/sessions/session-1/plan/cleanup-crates.md', + }, + review: { + kind: 'final_review', + checklist: [ + 'Re-check assumptions against the code you already inspected.', + 'Look for missing edge cases, affected files, and integration boundaries.', + ], + }, + blockers: { + missingHeadings: ['## Existing Code To Reuse'], + invalidSections: [ + "session plan section '## Verification' must contain concrete actionable items before exiting plan mode", + ], + }, + }, + streams: { + stdout: '', + stderr: '', + }, + timestamp: Date.now(), + }} + /> + </ChatScreenProvider> + ); + + expect(html).toContain('继续完善中'); + expect(html).toContain('正在做退出前自审'); + expect(html).toContain('自审检查项'); + expect(html).toContain('缺失章节'); + expect(html).toContain('## Existing Code To Reuse'); + expect(html).toContain('Re-check assumptions against the code you already inspected.'); + }); + it('renders explicit tool error even when stdout is present', () => { const html = renderToStaticMarkup( <ChatScreenProvider value={chatContextValue}> diff --git a/frontend/src/components/Chat/ToolCallBlock.tsx b/frontend/src/components/Chat/ToolCallBlock.tsx index 5386a2bb..df24c226 100644 --- a/frontend/src/components/Chat/ToolCallBlock.tsx +++ b/frontend/src/components/Chat/ToolCallBlock.tsx @@ -1,8 +1,9 @@ import { memo, useEffect, useRef, useState } from 'react'; - import type { ToolCallMessage } from '../../types'; import { extractPersistedToolOutput, + extractSessionPlanExit, + extractSessionPlanExitReviewPending, extractStructuredArgs, extractStructuredJsonOutput, extractToolMetadataSummary, @@ -12,6 +13,7 @@ import { import { chevronIcon, infoButton, pillDanger, pillNeutral, pillSuccess } from '../../lib/styles'; import { cn } from '../../lib/utils'; import { useChatScreenContext } from './ChatScreenContext'; +import { PresentedPlanSurface, ReviewPendingPlanSurface } from './PlanSurface'; import ToolCodePanel from './ToolCodePanel'; import ToolJsonView from './ToolJsonView'; import { useNestedScrollContainment } from './useNestedScrollContainment'; @@ -118,12 +120,47 @@ function persistedToolResultSurface( ); } +function planExitSurface(message: ToolCallMessage) { + const planExit = extractSessionPlanExit(message.metadata); + if (!planExit) { + return null; + } + + return ( + <PresentedPlanSurface + title={planExit.plan.title} + status={planExit.plan.status} + planPath={planExit.plan.planPath} + content={planExit.plan.content} + mode={planExit.mode} + /> + ); +} + +function planExitReviewPendingSurface(message: ToolCallMessage) { + const pending = extractSessionPlanExitReviewPending(message.metadata); + if (!pending) { + return null; + } + + return ( + <ReviewPendingPlanSurface + title={pending.plan.title} + planPath={pending.plan.planPath} + review={pending.review} + blockers={pending.blockers} + /> + ); +} + function ToolCallBlock({ message }: ToolCallBlockProps) { const { onOpenChildSession, onOpenSubRun } = useChatScreenContext(); const viewportRef = useRef<HTMLDivElement>(null); useNestedScrollContainment(viewportRef); const shellDisplay = extractToolShellDisplay(message.metadata); const persistedOutput = extractPersistedToolOutput(message.metadata); + const planExit = planExitSurface(message); + const planExitReviewPending = planExitReviewPendingSurface(message); const summary = formatToolCallSummary( message.toolName, message.args, @@ -207,7 +244,11 @@ function ToolCallBlock({ message }: ToolCallBlockProps) { className="min-w-0 overflow-y-auto overscroll-contain pr-1 max-h-[min(58vh,560px)]" > <div className="flex min-w-0 flex-col gap-3"> - {streamSections.length > 0 ? ( + {planExit ? ( + planExit + ) : planExitReviewPending ? ( + planExitReviewPending + ) : streamSections.length > 0 ? ( <> {streamSections.map((streamMessage) => ( <section key={streamMessage.id} className="flex min-w-0 flex-col gap-2"> diff --git a/frontend/src/components/Chat/TopBar.tsx b/frontend/src/components/Chat/TopBar.tsx index bd9a37e0..d380211d 100644 --- a/frontend/src/components/Chat/TopBar.tsx +++ b/frontend/src/components/Chat/TopBar.tsx @@ -6,6 +6,7 @@ export default function TopBar() { const { projectName, sessionTitle, + currentModeId, conversationControl, activeSubRunPath, activeSubRunBreadcrumbs, @@ -14,6 +15,7 @@ export default function TopBar() { onCloseSubRun, onNavigateSubRunPath, } = useChatScreenContext(); + const activePlan = conversationControl?.activePlan; return ( <div className={topBarShell}> @@ -95,6 +97,19 @@ export default function TopBar() { </div> {conversationControl && ( <div className="ml-3 flex shrink-0 items-center gap-2"> + {currentModeId && ( + <span className="inline-flex items-center rounded-full border border-border bg-surface px-2.5 py-1 text-[11px] font-medium uppercase tracking-wide text-text-secondary"> + {currentModeId} + </span> + )} + {activePlan ? ( + <span + className="inline-flex max-w-[220px] items-center truncate rounded-full border border-emerald-300/50 bg-emerald-50 px-2.5 py-1 text-[11px] font-medium text-emerald-900" + title={`当前计划: ${activePlan.title} (${activePlan.status})`} + > + 当前计划 · {activePlan.title} + </span> + ) : null} {conversationControl.compacting ? ( <span className="inline-flex items-center rounded-full border border-amber-300/50 bg-amber-100/70 px-2.5 py-1 text-[11px] font-medium text-amber-900"> 正在 compact diff --git a/frontend/src/hooks/useAgent.ts b/frontend/src/hooks/useAgent.ts index d9d9ba74..08d5043e 100644 --- a/frontend/src/hooks/useAgent.ts +++ b/frontend/src/hooks/useAgent.ts @@ -25,9 +25,11 @@ import { deleteProject, deleteSession, forkSession, + getSessionMode, interruptSession, listSessionsWithMeta, submitPrompt, + switchSessionMode, } from '../lib/api/sessions'; import { getConfig, reloadConfig, saveActiveSelection } from '../lib/api/config'; import { getCurrentModel, listAvailableModels, testConnection } from '../lib/api/models'; @@ -38,6 +40,7 @@ import type { DeleteProjectResult, ExecutionControl, ModelOption, + SessionModeState, SessionMeta, TestResult, } from '../types'; @@ -429,6 +432,17 @@ export function useAgent() { [] ); + const handleGetSessionMode = useCallback(async (sessionId: string): Promise<SessionModeState> => { + return getSessionMode(sessionId); + }, []); + + const handleSwitchSessionMode = useCallback( + async (sessionId: string, modeId: string): Promise<SessionModeState> => { + return switchSessionMode(sessionId, modeId); + }, + [] + ); + const handleCancelSubRun = useCallback( async (sessionId: string, agentId: string): Promise<void> => { try { @@ -525,6 +539,8 @@ export function useAgent() { interrupt: handleInterrupt, cancelSubRun: handleCancelSubRun, compactSession: handleCompactSession, + getSessionMode: handleGetSessionMode, + switchSessionMode: handleSwitchSessionMode, deleteSession: handleDeleteSession, deleteProject: handleDeleteProject, listComposerOptions: handleListComposerOptions, diff --git a/frontend/src/lib/api/conversation.test.ts b/frontend/src/lib/api/conversation.test.ts index fcd29c16..b02d8d8f 100644 --- a/frontend/src/lib/api/conversation.test.ts +++ b/frontend/src/lib/api/conversation.test.ts @@ -9,6 +9,8 @@ const baseControl = { canRequestCompact: true, compactPending: false, compacting: false, + currentModeId: 'code', + activePlan: undefined, }; describe('projectConversationState', () => { @@ -563,4 +565,50 @@ describe('projectConversationState', () => { trigger: 'auto', }); }); + + it('projects plan blocks as first-class plan messages', () => { + const state: ConversationSnapshotState = { + cursor: 'cursor-plan-1', + phase: 'done', + blocks: [ + { + id: 'plan-1', + kind: 'plan', + turnId: 'turn-plan-1', + toolCallId: 'call-plan-exit', + eventKind: 'review_pending', + title: 'Cleanup crates', + planPath: + 'D:/GitObjectsOwn/Astrcode/.astrcode/projects/demo/sessions/session-1/plan/cleanup-crates.md', + summary: '正在做退出前自审', + review: { + kind: 'final_review', + checklist: ['Re-check assumptions against the code you already inspected.'], + }, + blockers: { + missingHeadings: ['## Verification'], + invalidSections: ['session plan needs more verification detail'], + }, + }, + ], + control: { ...baseControl, phase: 'done' as const }, + childSummaries: [], + }; + + const projection = projectConversationState(state); + + expect(projection.messages).toHaveLength(1); + expect(projection.messages[0]).toMatchObject({ + kind: 'plan', + toolCallId: 'call-plan-exit', + eventKind: 'review_pending', + title: 'Cleanup crates', + blockers: { + missingHeadings: ['## Verification'], + }, + review: { + kind: 'final_review', + }, + }); + }); }); diff --git a/frontend/src/lib/api/conversation.ts b/frontend/src/lib/api/conversation.ts index 533b4207..1d4f4178 100644 --- a/frontend/src/lib/api/conversation.ts +++ b/frontend/src/lib/api/conversation.ts @@ -4,6 +4,7 @@ import type { ChildSessionNotificationMessage, CompactMeta, ConversationControlState, + ConversationPlanReference, LastCompactMeta, Message, ParentDelivery, @@ -197,14 +198,27 @@ function parseConversationControlState(record: ConversationRecord): Conversation const controlRecord = asRecord(record.control); const phase = parsePhase(controlRecord?.phase ?? record.phase); const lastCompactMeta = parseLastCompactMeta(controlRecord?.lastCompactMeta); + const parsePlanReference = (value: unknown): ConversationPlanReference | undefined => { + const plan = asRecord(value); + const slug = pickString(plan ?? {}, 'slug'); + const path = pickString(plan ?? {}, 'path'); + const status = pickString(plan ?? {}, 'status'); + const title = pickString(plan ?? {}, 'title'); + if (!slug || !path || !status || !title) { + return undefined; + } + return { slug, path, status, title }; + }; return { phase, canSubmitPrompt: controlRecord?.canSubmitPrompt !== false, canRequestCompact: controlRecord?.canRequestCompact !== false, compactPending: controlRecord?.compactPending === true, compacting: controlRecord?.compacting === true, + currentModeId: pickString(controlRecord ?? {}, 'currentModeId') ?? 'code', activeTurnId: pickOptionalString(controlRecord ?? {}, 'activeTurnId') ?? undefined, lastCompactMeta, + activePlan: parsePlanReference(controlRecord?.activePlan), }; } @@ -359,6 +373,55 @@ function projectConversationMessages( }); return; + case 'plan': { + const blockers = asRecord(block.blockers); + const review = asRecord(block.review); + messages.push({ + id: `conversation-plan:${id}`, + kind: 'plan', + turnId, + toolCallId: pickString(block, 'toolCallId') ?? id, + eventKind: + pickString(block, 'eventKind') === 'review_pending' + ? 'review_pending' + : pickString(block, 'eventKind') === 'presented' + ? 'presented' + : 'saved', + title: pickString(block, 'title') ?? 'Session Plan', + planPath: pickString(block, 'planPath') ?? '', + summary: pickOptionalString(block, 'summary') ?? undefined, + status: pickOptionalString(block, 'status') ?? undefined, + slug: pickOptionalString(block, 'slug') ?? undefined, + updatedAt: pickOptionalString(block, 'updatedAt') ?? undefined, + content: pickOptionalString(block, 'content') ?? undefined, + review: + review && + (pickString(review, 'kind') === 'revise_plan' || + pickString(review, 'kind') === 'final_review') + ? { + kind: pickString(review, 'kind') as 'revise_plan' | 'final_review', + checklist: Array.isArray(review.checklist) + ? review.checklist.filter((value): value is string => typeof value === 'string') + : [], + } + : undefined, + blockers: { + missingHeadings: Array.isArray(blockers?.missingHeadings) + ? blockers.missingHeadings.filter( + (value): value is string => typeof value === 'string' + ) + : [], + invalidSections: Array.isArray(blockers?.invalidSections) + ? blockers.invalidSections.filter( + (value): value is string => typeof value === 'string' + ) + : [], + }, + timestamp: index, + }); + return; + } + case 'tool_call': { const toolCallId = pickOptionalString(block, 'toolCallId') ?? id; const streams = asRecord(block.streams); @@ -390,7 +453,7 @@ function projectConversationMessages( return; } - case 'system_note': + case 'system_note': { if (pickString(block, 'noteKind') !== 'compact') { return; } @@ -419,6 +482,7 @@ function projectConversationMessages( timestamp: index, }); return; + } case 'child_handoff': { const child = asRecord(block.child); @@ -707,8 +771,17 @@ export function applyConversationEnvelope( canRequestCompact: control.canRequestCompact !== false, compactPending: control.compactPending === true, compacting: control.compacting === true, + currentModeId: pickString(control, 'currentModeId') ?? state.control.currentModeId, activeTurnId: pickOptionalString(control, 'activeTurnId') ?? undefined, lastCompactMeta: parseLastCompactMeta(control.lastCompactMeta), + activePlan: (() => { + const plan = asRecord(control.activePlan); + const slug = pickString(plan ?? {}, 'slug'); + const path = pickString(plan ?? {}, 'path'); + const status = pickString(plan ?? {}, 'status'); + const title = pickString(plan ?? {}, 'title'); + return slug && path && status && title ? { slug, path, status, title } : undefined; + })(), }; state.phase = state.control.phase; } diff --git a/frontend/src/lib/api/sessions.ts b/frontend/src/lib/api/sessions.ts index db75960e..16b8d98d 100644 --- a/frontend/src/lib/api/sessions.ts +++ b/frontend/src/lib/api/sessions.ts @@ -6,6 +6,7 @@ import type { AgentLifecycle, DeleteProjectResult, ExecutionControl, + SessionModeState, SessionMeta, } from '../../types'; import { request, requestJson } from './client'; @@ -131,6 +132,21 @@ export async function compactSession( ); } +export async function getSessionMode(sessionId: string): Promise<SessionModeState> { + return requestJson<SessionModeState>(`/api/sessions/${encodeURIComponent(sessionId)}/mode`); +} + +export async function switchSessionMode( + sessionId: string, + modeId: string +): Promise<SessionModeState> { + return requestJson<SessionModeState>(`/api/sessions/${encodeURIComponent(sessionId)}/mode`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ modeId }), + }); +} + export async function deleteSession(sessionId: string): Promise<void> { await request(`/api/sessions/${encodeURIComponent(sessionId)}`, { method: 'DELETE', diff --git a/frontend/src/lib/sessionFork.test.ts b/frontend/src/lib/sessionFork.test.ts index b7af909b..9ecc18ed 100644 --- a/frontend/src/lib/sessionFork.test.ts +++ b/frontend/src/lib/sessionFork.test.ts @@ -9,6 +9,8 @@ const baseControl = { canRequestCompact: true, compactPending: false, compacting: false, + currentModeId: 'code', + activePlan: undefined, }; describe('resolveForkTurnIdFromMessage', () => { diff --git a/frontend/src/lib/subRunView.ts b/frontend/src/lib/subRunView.ts index b7c6c581..726928a7 100644 --- a/frontend/src/lib/subRunView.ts +++ b/frontend/src/lib/subRunView.ts @@ -114,6 +114,9 @@ function buildMessageFingerprint(message: Message): string { message.streaming ? 1 : 0 }`; } + if (message.kind === 'plan') { + return `${message.id}:plan:${message.eventKind}:${message.title.length}:${message.planPath.length}:${message.content?.length ?? 0}:${message.review?.kind ?? ''}:${message.blockers.missingHeadings.length}:${message.blockers.invalidSections.length}`; + } if (message.kind === 'toolCall') { return `${message.id}:tool:${message.status}:${message.output?.length ?? 0}:${ message.error?.length ?? 0 diff --git a/frontend/src/lib/toolDisplay.ts b/frontend/src/lib/toolDisplay.ts index 88cee45a..b837b1e2 100644 --- a/frontend/src/lib/toolDisplay.ts +++ b/frontend/src/lib/toolDisplay.ts @@ -28,6 +28,39 @@ export interface PersistedToolOutputMetadata { previewBytes: number; } +export interface SessionPlanExitMetadata { + schema: 'sessionPlanExit'; + mode: { + fromModeId: string; + toModeId: string; + modeChanged: boolean; + }; + plan: { + title: string; + status: string; + slug: string; + planPath: string; + content: string; + updatedAt?: string; + }; +} + +export interface SessionPlanExitReviewPendingMetadata { + schema: 'sessionPlanExitReviewPending' | 'sessionPlanExitBlocked'; + plan: { + title: string; + planPath: string; + }; + review?: { + kind: 'revise_plan' | 'final_review'; + checklist: string[]; + }; + blockers: { + missingHeadings: string[]; + invalidSections: string[]; + }; +} + export interface StructuredJsonOutput { value: UnknownRecord | unknown[]; summary: string; @@ -135,6 +168,103 @@ export function extractPersistedToolOutput(metadata: unknown): PersistedToolOutp }; } +export function extractSessionPlanExit(metadata: unknown): SessionPlanExitMetadata | null { + const container = asRecord(metadata); + const plan = asRecord(container?.plan); + const mode = asRecord(container?.mode); + const title = pickString(plan ?? {}, 'title'); + const status = pickString(plan ?? {}, 'status'); + const slug = pickString(plan ?? {}, 'slug'); + const planPath = pickString(plan ?? {}, 'planPath'); + const content = pickString(plan ?? {}, 'content'); + const fromModeId = pickString(mode ?? {}, 'fromModeId'); + const toModeId = pickString(mode ?? {}, 'toModeId'); + const modeChanged = pickBoolean(mode ?? {}, 'modeChanged'); + + if ( + container?.schema !== 'sessionPlanExit' || + !title || + !status || + !slug || + !planPath || + !content || + !fromModeId || + !toModeId || + modeChanged === undefined + ) { + return null; + } + + return { + schema: 'sessionPlanExit', + mode: { + fromModeId, + toModeId, + modeChanged, + }, + plan: { + title, + status, + slug, + planPath, + content, + updatedAt: pickString(plan ?? {}, 'updatedAt'), + }, + }; +} + +export function extractSessionPlanExitReviewPending( + metadata: unknown +): SessionPlanExitReviewPendingMetadata | null { + const container = asRecord(metadata); + const plan = asRecord(container?.plan); + const review = asRecord(container?.review); + const blockers = asRecord(container?.blockers); + const title = pickString(plan ?? {}, 'title'); + const planPath = pickString(plan ?? {}, 'planPath'); + const reviewKind = pickString(review ?? {}, 'kind'); + const reviewChecklist = Array.isArray(review?.checklist) + ? review.checklist.filter((value): value is string => typeof value === 'string') + : []; + const missingHeadings = Array.isArray(blockers?.missingHeadings) + ? blockers.missingHeadings.filter((value): value is string => typeof value === 'string') + : []; + const invalidSections = Array.isArray(blockers?.invalidSections) + ? blockers.invalidSections.filter((value): value is string => typeof value === 'string') + : []; + + if ( + (container?.schema !== 'sessionPlanExitReviewPending' && + container?.schema !== 'sessionPlanExitBlocked') || + !title || + !planPath + ) { + return null; + } + + return { + schema: + container.schema === 'sessionPlanExitBlocked' + ? 'sessionPlanExitBlocked' + : 'sessionPlanExitReviewPending', + plan: { + title, + planPath, + }, + review: + reviewKind === 'revise_plan' || reviewKind === 'final_review' + ? { + kind: reviewKind, + checklist: reviewChecklist, + } + : undefined, + blockers: { + missingHeadings, + invalidSections, + }, + }; +} + export function formatToolShellPreview( display: ToolShellDisplayMetadata | null, fallbackToolName: string, diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 9918ab51..00f1a0d9 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -148,14 +148,23 @@ export interface LastCompactMeta { meta: CompactMeta; } +export interface ConversationPlanReference { + slug: string; + path: string; + status: string; + title: string; +} + export interface ConversationControlState { phase: Phase; canSubmitPrompt: boolean; canRequestCompact: boolean; compactPending: boolean; compacting: boolean; + currentModeId: string; activeTurnId?: string; lastCompactMeta?: LastCompactMeta; + activePlan?: ConversationPlanReference; } export type SubRunResult = @@ -248,6 +257,46 @@ export interface AssistantMessage { timestamp: number; } +export type PlanEventKind = 'saved' | 'review_pending' | 'presented'; +export type PlanReviewKind = 'revise_plan' | 'final_review'; + +export interface PlanReviewState { + kind: PlanReviewKind; + checklist: string[]; +} + +export interface PlanBlockers { + missingHeadings: string[]; + invalidSections: string[]; +} + +export interface PlanMessage { + id: string; + kind: 'plan'; + turnId?: string | null; + agentId?: string; + parentTurnId?: string; + parentSubRunId?: string; + agentProfile?: string; + subRunId?: string; + executionId?: string; + invocationKind?: InvocationKind; + storageMode?: SubRunStorageMode; + childSessionId?: string; + toolCallId: string; + eventKind: PlanEventKind; + title: string; + planPath: string; + summary?: string; + status?: string; + slug?: string; + updatedAt?: string; + content?: string; + review?: PlanReviewState; + blockers: PlanBlockers; + timestamp: number; +} + export type ToolStatus = 'running' | 'ok' | 'fail'; export interface ToolChildRef { @@ -408,6 +457,7 @@ export interface ChildSessionNotificationMessage { export type Message = | UserMessage | AssistantMessage + | PlanMessage | ToolCallMessage | PromptMetricsMessage | CompactMessage @@ -587,6 +637,11 @@ export interface CurrentModelInfo { providerKind: string; } +export interface SessionModeState { + currentModeId: string; + lastModeChangedAt?: string; +} + export interface ModelOption { profileName: string; model: string; From 615724edc9c733c6694adf937e192902b4cd8421 Mon Sep 17 00:00:00 2001 From: whatevertogo <whatevertogo@users.noreply.github.com> Date: Sun, 19 Apr 2026 23:38:59 +0800 Subject: [PATCH 43/53] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor(core,applic?= =?UTF-8?q?ation,session-runtime):=20=E7=B1=BB=E5=9E=8B=E7=BB=9F=E4=B8=80?= =?UTF-8?q?=E4=B8=8A=E7=A7=BB=E3=80=81=E6=A8=A1=E5=9D=97=E6=8B=86=E5=88=86?= =?UTF-8?q?=E4=B8=8E=20compaction=20=E5=A2=9E=E5=BC=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit core, adapter-llm - 将 FinishReason/LlmUsage/ModelLimits/LlmProvider trait 等重复类型统一收归 core::ports - 删除 adapter-llm/core_port.rs 与 adapter-storage/core_port.rs 桥接层 - adapter-llm 改为 re-export core 类型,消除双向转换开销 - 新增 max_output_tokens_override 支持请求级输出 token 上限覆盖 application - 引入 AppAgentPromptSubmission 替代直接依赖 session-runtime::AgentPromptSubmission - 拆分 routing.rs 为 child_send/parent_delivery/collaboration_flow 子模块 - 治理面与端口统一使用应用层提交载荷 session-runtime - 拆分 compaction.rs 为 protocol/sanitize/xml_parsing/tests 子模块 - 拆分 conversation.rs 为 facts/plan_projection/projection_support/tests 子模块 - 新增 keep_recent_user_messages、compact_max_output_tokens 配置 - compact 后注入 RecentUserContextDigest/RecentUserContext 保留用户上下文 adapter-prompt - 新增 ResponseStyleContributor 提供输出风格指导 - 更新 identity 默认文案 docs - 移除 ASTRCODE_EXPLORATION_REPORT 中过时章节,删除 CODE_REVIEW_ISSUES.md - 新增 openspec async-shell-terminal-sessions 变更提案 --- ASTRCODE_EXPLORATION_REPORT.md | 35 - CODE_REVIEW_ISSUES.md | 52 - README.md | 32 +- crates/adapter-llm/src/anthropic/provider.rs | 17 +- crates/adapter-llm/src/anthropic/request.rs | 31 + crates/adapter-llm/src/core_port.rs | 141 -- crates/adapter-llm/src/lib.rs | 222 +-- crates/adapter-llm/src/openai.rs | 39 +- .../src/contributors/identity.rs | 12 +- crates/adapter-prompt/src/contributors/mod.rs | 3 + .../src/contributors/response_style.rs | 83 ++ crates/adapter-prompt/src/layered_builder.rs | 1 + crates/adapter-storage/src/core_port.rs | 6 - crates/adapter-storage/src/lib.rs | 1 - crates/application/src/agent/routing.rs | 1306 +---------------- .../src/agent/routing/child_send.rs | 323 ++++ .../src/agent/routing/parent_delivery.rs | 197 +++ crates/application/src/agent/routing/tests.rs | 642 ++++++++ .../src/agent/routing_collaboration_flow.rs | 129 ++ crates/application/src/agent/wake.rs | 3 +- .../application/src/governance_surface/mod.rs | 15 +- crates/application/src/lib.rs | 4 +- crates/application/src/ports/agent_session.rs | 27 +- crates/application/src/ports/app_session.rs | 14 +- crates/application/src/ports/mod.rs | 2 + .../src/ports/session_submission.rs | 44 + crates/application/src/test_support.rs | 17 +- crates/core/src/action.rs | 4 + crates/core/src/config.rs | 31 + crates/core/src/event/phase.rs | 4 +- crates/core/src/event/translate.rs | 2 + crates/core/src/ports.rs | 34 + crates/server/src/bootstrap/providers.rs | 16 +- crates/server/src/bootstrap/runtime.rs | 8 +- .../src/context_window/compaction.rs | 1075 ++------------ .../src/context_window/compaction/protocol.rs | 275 ++++ .../src/context_window/compaction/sanitize.rs | 219 +++ .../src/context_window/compaction/tests.rs | 478 ++++++ .../context_window/compaction/xml_parsing.rs | 234 +++ .../src/context_window/settings.rs | 6 + .../context_window/templates/compact/base.md | 21 +- .../src/context_window/token_usage.rs | 2 + .../session-runtime/src/query/conversation.rs | 1192 +-------------- .../src/query/conversation/facts.rs | 231 +++ .../src/query/conversation/plan_projection.rs | 135 ++ .../query/conversation/projection_support.rs | 195 +++ .../src/query/conversation/tests.rs | 634 ++++++++ .../src/turn/compaction_cycle.rs | 31 +- .../src/turn/manual_compact.rs | 27 +- crates/session-runtime/src/turn/request.rs | 26 +- .../.openspec.yaml | 2 + .../async-shell-terminal-sessions/design.md | 186 +++ .../async-shell-terminal-sessions/proposal.md | 41 + .../specs/async-tool-execution/spec.md | 43 + .../specs/session-runtime/spec.md | 42 + .../specs/terminal-chat-read-model/spec.md | 32 + .../specs/terminal-tool-sessions/spec.md | 60 + .../async-shell-terminal-sessions/tasks.md | 23 + 58 files changed, 4704 insertions(+), 4003 deletions(-) delete mode 100644 CODE_REVIEW_ISSUES.md delete mode 100644 crates/adapter-llm/src/core_port.rs create mode 100644 crates/adapter-prompt/src/contributors/response_style.rs delete mode 100644 crates/adapter-storage/src/core_port.rs create mode 100644 crates/application/src/agent/routing/child_send.rs create mode 100644 crates/application/src/agent/routing/parent_delivery.rs create mode 100644 crates/application/src/agent/routing/tests.rs create mode 100644 crates/application/src/agent/routing_collaboration_flow.rs create mode 100644 crates/application/src/ports/session_submission.rs create mode 100644 crates/session-runtime/src/context_window/compaction/protocol.rs create mode 100644 crates/session-runtime/src/context_window/compaction/sanitize.rs create mode 100644 crates/session-runtime/src/context_window/compaction/tests.rs create mode 100644 crates/session-runtime/src/context_window/compaction/xml_parsing.rs create mode 100644 crates/session-runtime/src/query/conversation/facts.rs create mode 100644 crates/session-runtime/src/query/conversation/plan_projection.rs create mode 100644 crates/session-runtime/src/query/conversation/projection_support.rs create mode 100644 crates/session-runtime/src/query/conversation/tests.rs create mode 100644 openspec/changes/async-shell-terminal-sessions/.openspec.yaml create mode 100644 openspec/changes/async-shell-terminal-sessions/design.md create mode 100644 openspec/changes/async-shell-terminal-sessions/proposal.md create mode 100644 openspec/changes/async-shell-terminal-sessions/specs/async-tool-execution/spec.md create mode 100644 openspec/changes/async-shell-terminal-sessions/specs/session-runtime/spec.md create mode 100644 openspec/changes/async-shell-terminal-sessions/specs/terminal-chat-read-model/spec.md create mode 100644 openspec/changes/async-shell-terminal-sessions/specs/terminal-tool-sessions/spec.md create mode 100644 openspec/changes/async-shell-terminal-sessions/tasks.md diff --git a/ASTRCODE_EXPLORATION_REPORT.md b/ASTRCODE_EXPLORATION_REPORT.md index 18e74c2e..512da7c2 100644 --- a/ASTRCODE_EXPLORATION_REPORT.md +++ b/ASTRCODE_EXPLORATION_REPORT.md @@ -280,41 +280,6 @@ pub enum LlmEvent { - DeepSeek API - 运行时模型切换 -## 发现的问题和建议 - -### 1. 架构层面的优势 - -**优点**: -- ✅ 清晰的分层架构,职责分离明确 -- ✅ 严格的依赖管理,防止架构腐烂 -- ✅ 类型安全的领域建模 -- ✅ 事件驱动架构,支持时间旅行 -- ✅ 组合根模式,依赖关系清晰 - -### 2. 潜在的技术债务 - -**中等优先级**: -- ⚠️ `upstream_collaboration_context` 中的 parent_turn_id 回退可能使用过期值 -- ⚠️ 一些模块仍然较大,可能需要进一步拆分 -- ⚠️ 测试覆盖率有待提高 - -**低优先级**: -- ℹ️ 文档可以更加完善 -- ℹ️ 某些错误处理可以更加精细 - -### 3. 设计决策的观察 - -**值得学习的设计**: -1. **无兼容层策略**:不维护向后兼容,优先良好架构 -2. **组合根模式**:所有依赖在一个地方装配 -3. **事件优先架构**:状态变更通过事件流表达 -4. **能力统一模型**:所有扩展点通过能力系统表达 - -**可能的改进空间**: -1. **性能优化**:某些热点路径可以进一步优化 -2. **错误恢复**:增强错误恢复和重试机制 -3. **可观测性**:增加更详细的指标和追踪 - ## 项目规模评估 ### 代码规模 diff --git a/CODE_REVIEW_ISSUES.md b/CODE_REVIEW_ISSUES.md deleted file mode 100644 index 741c27c1..00000000 --- a/CODE_REVIEW_ISSUES.md +++ /dev/null @@ -1,52 +0,0 @@ -# Code Review — dev (working tree) - -## Summary -Files reviewed: 4 | New issues: 1 (0 critical, 0 high, 1 medium, 0 low) | Perspectives: 4/4 - ---- - -## Security - -No security issues found. All changes are TUI rendering/layout logic with no external input sinks. - ---- - -## Code Quality - -| Sev | Issue | File:Line | Consequence | -|-----|-------|-----------|-------------| -| Medium | `nav_visible_for_width` uses magic number `96` without named constant | state/mod.rs:316 | Threshold meaning is opaque; other layout thresholds in the same file may diverge silently | - -No other quality issues. The scroll-offset logic is correct: `saturating_add`/`saturating_sub` prevent underflow, `.min(max_scroll)` caps the result. The `selected_line_range` 0-based inclusive indexing is consistent between producer (`transcript.rs`) and consumer (`render/mod.rs`). - ---- - -## Tests - -**Run results**: 19 passed, 0 failed, 0 skipped (all 3 test suites in `astrcode-cli`) - -| Sev | Untested scenario | Location | -|-----|-------------------|----------| -| Medium | `transcript_scroll_offset` "scroll down" branch — when selected range is below viewport (`selected_end >= top_offset + viewport_height`) | render/mod.rs:181-184 | -| Medium | `transcript_scroll_offset` with `selection_drives_scroll = false` — should not adjust offset | render/mod.rs:175 | -| Medium | `transcript_scroll_offset` with `selected_line_range = None` — should behave like original | render/mod.rs:176 | - -The two existing scroll-offset tests both exercise only the "scroll up" branch (`selected_start < top_offset`). The "scroll down" branch (`selected_end >= top_offset + viewport_height`) and the no-op paths are untested. - ---- - -## Architecture - -No cross-layer inconsistencies. The `TranscriptRenderOutput` struct cleanly extends the existing return type without breaking callers. The `nav_visible` propagation from `CliState` through `InteractionState` follows the existing dependency direction. - ---- - -## Must Fix Before Merge - -None. - ---- - -## Pre-Existing Issues (not blocking) - -- `CODE_REVIEW_ISSUES.md` file exists at repo root — consider `.gitignore`ing it if it's local-only diff --git a/README.md b/README.md index 9087b897..1bda441e 100644 --- a/README.md +++ b/README.md @@ -186,11 +186,21 @@ cd frontend && npm run build ```json { "runtime": { - "maxToolConcurrency": 10 + "maxToolConcurrency": 10, + "compactKeepRecentTurns": 4, + "compactKeepRecentUserMessages": 8, + "compactMaxOutputTokens": 20000 } } ``` +| 参数 | 默认值 | 说明 | +|------|--------|------| +| `maxToolConcurrency` | 10 | 并发工具上限 | +| `compactKeepRecentTurns` | 4 | 压缩时保留最近的 turn 数 | +| `compactKeepRecentUserMessages` | 8 | 压缩时额外保留最近真实用户消息的数量(原文重新注入) | +| `compactMaxOutputTokens` | 20000 | 压缩请求的最大输出 token 上限(自动取模型限制的较小值) | + ### 内建环境变量 项目自定义环境变量按类别集中维护在 `crates/application/src/config/constants.rs`: @@ -276,14 +286,14 @@ AstrCode/ - **`core`**:领域语义、强类型 ID、端口契约、`CapabilitySpec`、稳定配置模型。不依赖传输层或具体实现;`CapabilitySpec` 是运行时内部的能力语义真相。 - **`protocol`**:HTTP/SSE/Plugin 的 DTO 与 wire 类型,仅依赖 `core`;其中 `CapabilityWireDescriptor` 只承担协议边界传输职责,不是运行时内部的能力真相。 - **`kernel`**:全局控制面 — capability router/registry、agent tree、统一事件协调。 -- **`session-runtime`**:单会话真相 — turn 执行、事件回放、compact、context window、input queue 推进。 -- **`application`**:用例编排入口(`App`)+ 治理入口(`AppGovernance`),负责参数校验、权限、策略、reload 编排。 +- **`session-runtime`**:单会话真相 — turn 执行、事件回放、compact(保留最近用户消息 + 摘要 + 输出上限控制)、context window、input queue 推进。 +- **`application`**:用例编排入口(`App`)+ 治理入口(`AppGovernance`),负责参数校验、权限、策略、reload 编排。通过 `AppAgentPromptSubmission` 端口向 session-runtime 提交 turn。 - **`server`**:HTTP/SSE 边界与唯一组合根(`bootstrap/runtime.rs`),只负责 DTO 映射和装配。 -- **`adapter-*`**:端口实现层,不持有业务真相,不偷渡业务策略。 +- **`adapter-*`**:端口实现层,不持有业务真相,不偷渡业务策略。核心类型(`LlmProvider`、`LlmRequest`、`EventStore` 等)统一在 `core` 定义,adapter 仅提供具体实现。 ### Agent 协作 -- 内置 Agent profile:explore、plan、reviewer、execute +- 内置 Agent profile:explore、reviewer、execute - Agent 文件来源:builtin + 用户级(`~/.astrcode/agents`)+ 项目级(`.astrcode/agents`,祖先链扫描) - 子 Agent spawn 时按 task-scoped capability grant 裁剪能力面 - Agent 工具链:`spawn` -> `send` -> `observe` -> `close` 全生命周期管理 @@ -311,13 +321,21 @@ AstrCode/ - 提供 Rust SDK(`crates/sdk`),包含 `ToolHandler`、`HookRegistry`、`PluginContext`、`StreamWriter` - 插件握手交换的是 `CapabilityWireDescriptor`;宿主内部消费和决策始终基于 `CapabilitySpec` -### 会话持久化 +### 会话持久化与上下文压缩 - JSONL 格式追加写入(append-only event log) - 存储路径:`~/.astrcode/projects/<project>/sessions/<session-id>/` - 文件锁并发保护(`active-turn.lock`) - Query / Command 逻辑分离 +**上下文压缩(Compact)**: + +- 触发方式:自动(token 阈值触发)和手动(`/compact` 命令或 API) +- 压缩策略:保留最近 N 个 turn 的完整上下文,对更早的历史生成结构化摘要 +- 最近用户消息保留:压缩后原样重新注入最近 N 条真实用户消息,确保模型不会丢失当前意图 +- 用户上下文摘要:为保留的用户消息生成极短目的摘要(`recent_user_context_digest`),帮助模型快速定位目标 +- 输出控制:压缩请求有独立的 `max_output_tokens` 上限,防止压缩本身消耗过多 token + ### 治理与重载 - `POST /api/config/reload` 走统一治理入口,串起:配置重载 -> MCP 刷新 -> plugin 重新发现 -> skill 更新 -> kernel capability surface 原子替换 @@ -365,6 +383,8 @@ Tauri 仅作为"薄壳",负责: | `assistantMessage` | 最终助手消息 | | `toolCallStart` | 工具调用开始 | | `toolCallResult` | 工具调用结果 | +| `promptMetrics` | 回合级 token / 缓存命中率指标 | +| `compactApplied` | 上下文压缩完成,携带压缩摘要信息 | | `turnDone` | 对话回合结束 | | `error` | 错误信息 | diff --git a/crates/adapter-llm/src/anthropic/provider.rs b/crates/adapter-llm/src/anthropic/provider.rs index c5a9f175..099652ca 100644 --- a/crates/adapter-llm/src/anthropic/provider.rs +++ b/crates/adapter-llm/src/anthropic/provider.rs @@ -23,7 +23,7 @@ use super::{ }; use crate::{ EventSink, FinishReason, LlmAccumulator, LlmClientConfig, LlmOutput, LlmProvider, LlmRequest, - LlmUsage, ModelLimits, Utf8StreamDecoder, build_http_client, cache_tracker::CacheTracker, + ModelLimits, Utf8StreamDecoder, build_http_client, cache_tracker::CacheTracker, classify_http_error, is_retryable_status, wait_retry_delay, }; @@ -103,8 +103,12 @@ impl AnthropicProvider { tools: &[ToolDefinition], system_prompt: Option<&str>, system_prompt_blocks: &[SystemPromptBlock], + max_output_tokens_override: Option<usize>, stream: bool, ) -> AnthropicRequest { + let effective_max_output_tokens = max_output_tokens_override + .unwrap_or(self.limits.max_output_tokens) + .min(self.limits.max_output_tokens); let use_official_endpoint = is_official_anthropic_api_url(&self.messages_api_url); let use_automatic_cache = use_official_endpoint; let mut remaining_cache_breakpoints = ANTHROPIC_CACHE_BREAKPOINT_LIMIT; @@ -138,7 +142,7 @@ impl AnthropicProvider { AnthropicRequest { model: self.model.clone(), - max_tokens: self.limits.max_output_tokens.min(u32::MAX as usize) as u32, + max_tokens: effective_max_output_tokens.min(u32::MAX as usize) as u32, cache_control: request_cache_control, messages: anthropic_messages, system, @@ -149,7 +153,7 @@ impl AnthropicProvider { thinking: if use_official_endpoint { thinking_config_for_model( &self.model, - self.limits.max_output_tokens.min(u32::MAX as usize) as u32, + effective_max_output_tokens.min(u32::MAX as usize) as u32, ) } else { None @@ -282,12 +286,6 @@ impl LlmProvider for AnthropicProvider { true } - fn prompt_metrics_input_tokens(&self, usage: LlmUsage) -> usize { - usage - .input_tokens - .saturating_add(usage.cache_read_input_tokens) - } - async fn generate(&self, request: LlmRequest, sink: Option<EventSink>) -> Result<LlmOutput> { let cancel = request.cancel; @@ -309,6 +307,7 @@ impl LlmProvider for AnthropicProvider { &request.tools, request.system_prompt.as_deref(), &request.system_prompt_blocks, + request.max_output_tokens_override, sink.is_some(), ); let response = self.send_request(&body, cancel.clone()).await?; diff --git a/crates/adapter-llm/src/anthropic/request.rs b/crates/adapter-llm/src/anthropic/request.rs index 1bb3bc35..42551eee 100644 --- a/crates/adapter-llm/src/anthropic/request.rs +++ b/crates/adapter-llm/src/anthropic/request.rs @@ -494,6 +494,7 @@ mod tests { &[], Some("Follow the rules"), &[], + None, true, ); let body = serde_json::to_value(&request).expect("request should serialize"); @@ -556,6 +557,7 @@ mod tests { &tools, None, &system_blocks, + None, false, ); let body = serde_json::to_value(&request).expect("request should serialize"); @@ -601,6 +603,7 @@ mod tests { &[], None, &[], + None, false, ); let body = serde_json::to_value(&request).expect("request should serialize"); @@ -642,6 +645,7 @@ mod tests { &[], None, &[], + None, false, ); let body = serde_json::to_value(&request).expect("request should serialize"); @@ -677,6 +681,7 @@ mod tests { cache_boundary: true, layer: SystemPromptLayer::Stable, }], + None, false, ); let body = serde_json::to_value(&request).expect("request should serialize"); @@ -752,6 +757,7 @@ mod tests { layer: SystemPromptLayer::Dynamic, }, ], + None, false, ); let body = serde_json::to_value(&request).expect("request should serialize"); @@ -809,4 +815,29 @@ mod tests { "dynamic1 should not have cache_control (Dynamic layer is not cached)" ); } + + #[test] + fn build_request_honors_request_level_max_output_tokens_override() { + let provider = AnthropicProvider::new( + "https://api.anthropic.com/v1/messages".to_string(), + "sk-ant-test".to_string(), + "claude-sonnet-4-5".to_string(), + ModelLimits { + context_window: 200_000, + max_output_tokens: 8096, + }, + LlmClientConfig::default(), + ) + .expect("provider should build"); + let messages = [LlmMessage::User { + content: "hi".to_string(), + origin: UserMessageOrigin::User, + }]; + + let capped = provider.build_request(&messages, &[], None, &[], Some(2048), false); + let clamped = provider.build_request(&messages, &[], None, &[], Some(16_000), false); + + assert_eq!(capped.max_tokens, 2048); + assert_eq!(clamped.max_tokens, 8096); + } } diff --git a/crates/adapter-llm/src/core_port.rs b/crates/adapter-llm/src/core_port.rs deleted file mode 100644 index 94162216..00000000 --- a/crates/adapter-llm/src/core_port.rs +++ /dev/null @@ -1,141 +0,0 @@ -//! 桥接 `adapter-llm` 内部 trait 与 `core::ports::LlmProvider`。 -//! -//! adapter-llm 有自己的 LlmProvider trait 和配套类型(LlmRequest/LlmOutput/LlmEvent 等), -//! 它们与 `core::ports` 中定义的端口类型结构相同但类型路径不同。 -//! 本模块通过 trivial 转换让 OpenAiProvider / AnthropicProvider 同时满足 core 端口契约, -//! 使 server 组合根可以直接注入 adapter 实例而无需 Noop 替代。 - -use std::sync::Arc; - -use astrcode_core::ports::{self, LlmFinishReason}; -use async_trait::async_trait; - -use crate::{ - FinishReason, LlmEvent, LlmOutput, LlmProvider, anthropic::AnthropicProvider, - openai::OpenAiProvider, -}; - -// ── 类型转换辅助 ────────────────────────────────────────── - -/// `adapter-llm::LlmRequest` → `core::ports::LlmRequest` -fn convert_request(req: ports::LlmRequest) -> crate::LlmRequest { - crate::LlmRequest { - messages: req.messages, - tools: req.tools, - cancel: req.cancel, - system_prompt: req.system_prompt, - system_prompt_blocks: req.system_prompt_blocks, - } -} - -/// `adapter-llm::LlmOutput` → `core::ports::LlmOutput` -fn convert_output(out: LlmOutput) -> ports::LlmOutput { - ports::LlmOutput { - content: out.content, - tool_calls: out.tool_calls, - reasoning: out.reasoning, - usage: out.usage.map(convert_usage), - finish_reason: convert_finish_reason(out.finish_reason), - } -} - -fn convert_usage(u: crate::LlmUsage) -> ports::LlmUsage { - ports::LlmUsage { - input_tokens: u.input_tokens, - output_tokens: u.output_tokens, - cache_creation_input_tokens: u.cache_creation_input_tokens, - cache_read_input_tokens: u.cache_read_input_tokens, - } -} - -fn convert_finish_reason(r: FinishReason) -> LlmFinishReason { - match r { - FinishReason::Stop => LlmFinishReason::Stop, - FinishReason::MaxTokens => LlmFinishReason::MaxTokens, - FinishReason::ToolCalls => LlmFinishReason::ToolCalls, - FinishReason::Other(s) => LlmFinishReason::Other(s), - } -} - -/// `adapter-llm::LlmEvent` → `core::ports::LlmEvent` -fn convert_event(event: LlmEvent) -> ports::LlmEvent { - match event { - LlmEvent::TextDelta(t) => ports::LlmEvent::TextDelta(t), - LlmEvent::ThinkingDelta(t) => ports::LlmEvent::ThinkingDelta(t), - LlmEvent::ThinkingSignature(s) => ports::LlmEvent::ThinkingSignature(s), - LlmEvent::ToolCallDelta { - index, - id, - name, - arguments_delta, - } => ports::LlmEvent::ToolCallDelta { - index, - id, - name, - arguments_delta, - }, - } -} - -/// 将 core 端的 sink 包装为 adapter 内部的 EventSink。 -/// -/// adapter 内部 generate() 会产出 `adapter::LlmEvent`, -/// 我们把每个 event 转为 `core::ports::LlmEvent` 后转发给外部 sink。 -fn wrap_sink(core_sink: ports::LlmEventSink) -> crate::EventSink { - Arc::new(move |event: LlmEvent| { - core_sink(convert_event(event)); - }) -} - -fn convert_limits(limits: crate::ModelLimits) -> ports::ModelLimits { - ports::ModelLimits { - context_window: limits.context_window, - max_output_tokens: limits.max_output_tokens, - } -} - -// ── core::ports::LlmProvider 实现 ───────────────────────── - -#[async_trait] -impl ports::LlmProvider for OpenAiProvider { - async fn generate( - &self, - request: ports::LlmRequest, - sink: Option<ports::LlmEventSink>, - ) -> astrcode_core::Result<ports::LlmOutput> { - let internal_request = convert_request(request); - let internal_sink = sink.map(wrap_sink); - let output = LlmProvider::generate(self, internal_request, internal_sink).await?; - Ok(convert_output(output)) - } - - fn model_limits(&self) -> ports::ModelLimits { - convert_limits(LlmProvider::model_limits(self)) - } - - fn supports_cache_metrics(&self) -> bool { - LlmProvider::supports_cache_metrics(self) - } -} - -#[async_trait] -impl ports::LlmProvider for AnthropicProvider { - async fn generate( - &self, - request: ports::LlmRequest, - sink: Option<ports::LlmEventSink>, - ) -> astrcode_core::Result<ports::LlmOutput> { - let internal_request = convert_request(request); - let internal_sink = sink.map(wrap_sink); - let output = LlmProvider::generate(self, internal_request, internal_sink).await?; - Ok(convert_output(output)) - } - - fn model_limits(&self) -> ports::ModelLimits { - convert_limits(LlmProvider::model_limits(self)) - } - - fn supports_cache_metrics(&self) -> bool { - LlmProvider::supports_cache_metrics(self) - } -} diff --git a/crates/adapter-llm/src/lib.rs b/crates/adapter-llm/src/lib.rs index bdbe953d..d6297ac5 100644 --- a/crates/adapter-llm/src/lib.rs +++ b/crates/adapter-llm/src/lib.rs @@ -41,22 +41,22 @@ //! - [`anthropic`] — Anthropic Messages API 实现 //! - [`openai`] — OpenAI Chat Completions API 兼容实现 -use std::{collections::HashMap, sync::Arc, time::Duration}; +use std::{collections::HashMap, time::Duration}; -use astrcode_core::{ - AstrError, CancelToken, LlmMessage, ModelRequest, ReasoningContent, Result, SystemPromptBlock, - ToolCallRequest, ToolDefinition, -}; -use async_trait::async_trait; +use astrcode_core::{AstrError, CancelToken, LlmEvent, ReasoningContent, Result, ToolCallRequest}; use log::warn; use serde_json::Value; use tokio::{select, time::sleep}; pub mod anthropic; pub mod cache_tracker; -pub mod core_port; pub mod openai; +pub use astrcode_core::{ + LlmEventSink as EventSink, LlmFinishReason as FinishReason, LlmOutput, LlmProvider, LlmRequest, + LlmUsage, ModelLimits, +}; + // --------------------------------------------------------------------------- // Structured LLM error types (P4.3) // --------------------------------------------------------------------------- @@ -161,76 +161,6 @@ pub fn classify_http_error(status: u16, body: &str) -> LlmError { // Finish reason (P4.2) // --------------------------------------------------------------------------- -/// LLM 响应结束原因,用于判断是否需要自动继续生成。 -#[derive(Debug, Clone, PartialEq, Eq, Default)] -pub enum FinishReason { - /// 模型自然结束输出 - #[default] - Stop, - /// 输出被 max_tokens 限制截断,需要继续生成 - MaxTokens, - /// 模型调用了工具 - ToolCalls, - /// 其他未知原因 - Other(String), -} - -impl FinishReason { - /// 判断是否因 max_tokens 截断。 - pub fn is_max_tokens(&self) -> bool { - matches!(self, FinishReason::MaxTokens) - } - - /// 从 OpenAI/Anthropic API 返回的 finish_reason/stop_reason 字符串解析。 - /// - /// 支持两种 API 的值: - /// - OpenAI: `stop`, `max_tokens`, `length`, `tool_calls`, `content_filter` - /// - Anthropic: `end_turn`, `max_tokens`, `tool_use`, `stop_sequence` - pub fn from_api_value(value: &str) -> Self { - match value { - // OpenAI 值 - "stop" => FinishReason::Stop, - "max_tokens" | "length" => FinishReason::MaxTokens, - "tool_calls" => FinishReason::ToolCalls, - // Anthropic 值 - "end_turn" | "stop_sequence" => FinishReason::Stop, - "tool_use" => FinishReason::ToolCalls, - other => FinishReason::Other(other.to_string()), - } - } -} - -/// 模型能力限制,用于请求预算决策。 -/// -/// 包含上下文窗口大小和最大输出 token 数。它们在 provider 构造阶段就已经被解析为权威值 -/// 或本地手动值,后续的 agent loop 只消费这一份统一结果。 -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct ModelLimits { - /// 模型支持的上下文窗口大小(token 数) - pub context_window: usize, - /// 模型单次响应允许的最大输出 token 数 - pub max_output_tokens: usize, -} - -/// 模型调用的 token 用量统计。 -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct LlmUsage { - /// 输入(prompt)消耗的 token 数 - pub input_tokens: usize, - /// 输出(completion)消耗的 token 数 - pub output_tokens: usize, - /// 本次请求中新写入 provider cache 的输入 token 数。 - pub cache_creation_input_tokens: usize, - /// 本次请求从 provider cache 读取的输入 token 数。 - pub cache_read_input_tokens: usize, -} - -impl LlmUsage { - pub fn total_tokens(self) -> usize { - self.input_tokens.saturating_add(self.output_tokens) - } -} - // --------------------------------------------------------------------------- // Cancel helper (moved from runtime::cancel) // --------------------------------------------------------------------------- @@ -483,146 +413,12 @@ fn debug_utf8_bytes(bytes: &[u8], valid_up_to: usize, invalid_len: Option<usize> /// 返回的 sink 会将每个事件追加到提供的 `Mutex<Vec<LlmEvent>>` 中, /// 方便测试断言验证事件序列。 #[cfg(test)] -pub fn sink_collector(events: Arc<std::sync::Mutex<Vec<LlmEvent>>>) -> EventSink { - Arc::new(move |event| { +pub fn sink_collector(events: std::sync::Arc<std::sync::Mutex<Vec<LlmEvent>>>) -> EventSink { + std::sync::Arc::new(move |event| { events.lock().expect("lock").push(event); }) } -/// 运行时范围的模型调用请求。 -/// -/// 封装了模型调用所需的最小上下文:消息历史、可用工具定义、取消令牌和可选的系统提示。 -/// 不包含提供者发现、API 密钥管理等前置逻辑,这些由调用方在构造本结构体之前处理。 -#[derive(Clone, Debug)] -pub struct LlmRequest { - pub messages: Vec<LlmMessage>, - pub tools: Arc<[ToolDefinition]>, - pub cancel: CancelToken, - pub system_prompt: Option<String>, - pub system_prompt_blocks: Vec<SystemPromptBlock>, -} - -impl LlmRequest { - pub fn new( - messages: Vec<LlmMessage>, - tools: impl Into<Arc<[ToolDefinition]>>, - cancel: CancelToken, - ) -> Self { - Self { - messages, - tools: tools.into(), - cancel, - system_prompt: None, - system_prompt_blocks: Vec::new(), - } - } - - pub fn with_system(mut self, prompt: impl Into<String>) -> Self { - self.system_prompt = Some(prompt.into()); - self - } - - pub fn from_model_request(request: ModelRequest, cancel: CancelToken) -> Self { - Self { - messages: request.messages, - tools: request.tools.into(), - cancel, - system_prompt: request.system_prompt, - system_prompt_blocks: request.system_prompt_blocks, - } - } -} - -/// 流式 LLM 响应事件。 -/// -/// 每个事件代表响应流中的一个增量片段,由 [`LlmAccumulator`] 重新组装为完整输出。 -/// - `TextDelta`: 普通文本增量 -/// - `ThinkingDelta`: 推理过程增量(extended thinking / reasoning) -/// - `ThinkingSignature`: 推理签名(Anthropic 特有,用于验证 thinking 完整性) -/// - `ToolCallDelta`: 工具调用增量(id、name 在首个 delta 中出现,arguments 逐片段拼接) -#[derive(Clone, Debug)] -pub enum LlmEvent { - TextDelta(String), - ThinkingDelta(String), - ThinkingSignature(String), - ToolCallDelta { - index: usize, - id: Option<String>, - name: Option<String>, - arguments_delta: String, - }, -} - -/// 模型调用的完整输出。 -/// -/// 由 [`LlmAccumulator::finish`] 组装而成,包含所有文本内容、工具调用请求和推理内容。 -/// 非流式路径下,`usage` 字段会被填充;流式路径下 `usage` 为 `None`(Anthropic 流式 -/// 响应不返回用量,OpenAI 流式响应的用量在最后一个 chunk 中但当前未提取)。 -/// -/// `finish_reason` 字段用于判断输出是否被 max_tokens 截断 (P4.2)。 -#[derive(Clone, Debug, Default)] -pub struct LlmOutput { - pub content: String, - pub tool_calls: Vec<ToolCallRequest>, - pub reasoning: Option<ReasoningContent>, - pub usage: Option<LlmUsage>, - /// 输出结束原因,用于检测 max_tokens 截断。 - pub finish_reason: FinishReason, -} - -/// 事件回调类型别名。 -/// -/// 用于接收流式 [`LlmEvent`] 的异步回调,通常由前端或上层运行时订阅。 -pub type EventSink = Arc<dyn Fn(LlmEvent) + Send + Sync>; - -/// LLM 提供者 trait。 -/// -/// 这是运行时与 LLM 后端交互的核心抽象。每个实现封装了特定 API 的协议细节 -/// (认证、请求格式、SSE 解析等),对外暴露统一的调用接口。 -/// -/// ## 设计约束 -/// -/// - 不管理 API 密钥或模型发现,这些由调用方在构造具体提供者实例时处理 -/// - `generate()` 执行单次模型调用,不维护多轮对话状态 -/// - 流式路径下,事件通过 `sink` 实时发射,同时内部累加返回完整输出 -#[async_trait] -pub trait LlmProvider: Send + Sync { - /// 执行一次模型调用。 - /// - /// `sink` 参数控制调用模式: - /// - `None`: 非流式模式,等待完整响应后返回 - /// - `Some(sink)`: 流式模式,实时发射 [`LlmEvent`] 到 sink,同时累加返回完整输出 - /// - /// 取消令牌通过 `request.cancel` 传递,任何时刻取消都会立即中断请求。 - async fn generate(&self, request: LlmRequest, sink: Option<EventSink>) -> Result<LlmOutput>; - - /// 当前 provider 是否原生暴露缓存 token 指标。 - /// - /// OpenAI 兼容接口目前只依赖自动前缀缓存,缺少稳定的 token 统计;Anthropic 则会明确 - /// 返回 cache creation/read 字段。上层通过这个开关决定是否把 0 值解释成“真实指标” - /// 还是“provider 不支持”。 - fn supports_cache_metrics(&self) -> bool { - false - } - - /// 将 provider 原始 usage 里的输入 token 规范化成适合前端展示的 prompt 总量。 - /// - /// 某些 provider(如 Anthropic)会把缓存读取的 token 单独放在 - /// `cache_read_input_tokens`,而 `input_tokens` 只表示本次实际重新发送/计费的部分。 - /// 前端展示“缓存命中率”时需要一个统一口径的总输入值,因此默认直接回放 - /// `usage.input_tokens`,特殊 provider 再自行覆盖。 - fn prompt_metrics_input_tokens(&self, usage: LlmUsage) -> usize { - usage.input_tokens - } - - /// 返回模型的上下文窗口估算。 - /// - /// 用于调用方判断当前消息历史是否接近上下文限制,触发压缩或截断。 - /// - /// 返回值应已经是 provider 构造阶段解析好的稳定 limits,而不是在这里临时猜测。 - fn model_limits(&self) -> ModelLimits; -} - #[derive(Default)] pub struct LlmAccumulator { pub content: String, diff --git a/crates/adapter-llm/src/openai.rs b/crates/adapter-llm/src/openai.rs index a46cfe82..a307893e 100644 --- a/crates/adapter-llm/src/openai.rs +++ b/crates/adapter-llm/src/openai.rs @@ -118,8 +118,12 @@ impl OpenAiProvider { tools: &'a [ToolDefinition], system_prompt: Option<&'a str>, system_prompt_blocks: &'a [astrcode_core::SystemPromptBlock], + max_output_tokens_override: Option<usize>, stream: bool, ) -> OpenAiChatRequest<'a> { + let effective_max_output_tokens = max_output_tokens_override + .unwrap_or(self.limits.max_output_tokens) + .min(self.limits.max_output_tokens); let system_count = if !system_prompt_blocks.is_empty() { system_prompt_blocks.len() } else if system_prompt.is_some() { @@ -153,7 +157,7 @@ impl OpenAiProvider { OpenAiChatRequest { model: &self.model, - max_tokens: self.limits.max_output_tokens.min(u32::MAX as usize) as u32, + max_tokens: effective_max_output_tokens.min(u32::MAX as usize) as u32, messages: request_messages, prompt_cache_key: self.should_send_prompt_cache_key().then(|| { build_prompt_cache_key(&self.model, system_prompt, system_prompt_blocks, tools) @@ -305,6 +309,7 @@ impl LlmProvider for OpenAiProvider { &request.tools, request.system_prompt.as_deref(), &request.system_prompt_blocks, + request.max_output_tokens_override, sink.is_some(), ); let response = self.send_request(&req, cancel.clone()).await?; @@ -1031,7 +1036,8 @@ mod tests { content: "hi".to_string(), origin: UserMessageOrigin::User, }]; - let request = provider.build_request(&messages, &[], Some("Follow the rules"), &[], false); + let request = + provider.build_request(&messages, &[], Some("Follow the rules"), &[], None, false); assert_eq!(request.messages[0].role, "system"); assert_eq!( @@ -1085,7 +1091,7 @@ mod tests { layer: astrcode_core::SystemPromptLayer::Inherited, }, ]; - let request = provider.build_request(&messages, &[], None, &system_blocks, false); + let request = provider.build_request(&messages, &[], None, &system_blocks, None, false); let body = serde_json::to_value(&request).expect("request should serialize"); // 应该有 4 个 system 消息 + 1 个 user 消息,无 cache_control 字段 @@ -1145,6 +1151,7 @@ mod tests { &[], Some("Follow the rules"), &[], + None, false, )) .expect("request should serialize"); @@ -1153,6 +1160,7 @@ mod tests { &[], Some("Follow the rules"), &[], + None, false, )) .expect("request should serialize"); @@ -1167,6 +1175,31 @@ mod tests { assert!(compatible_body.get("prompt_cache_key").is_none()); } + #[test] + fn build_request_honors_request_level_max_output_tokens_override() { + let provider = OpenAiProvider::new( + "https://api.openai.com/v1/chat/completions".to_string(), + "sk-test".to_string(), + "gpt-4.1".to_string(), + ModelLimits { + context_window: 128_000, + max_output_tokens: 2048, + }, + LlmClientConfig::default(), + ) + .expect("provider should build"); + let messages = [LlmMessage::User { + content: "hi".to_string(), + origin: UserMessageOrigin::User, + }]; + + let capped = provider.build_request(&messages, &[], None, &[], Some(1024), false); + let clamped = provider.build_request(&messages, &[], None, &[], Some(4096), false); + + assert_eq!(capped.max_tokens, 1024); + assert_eq!(clamped.max_tokens, 2048); + } + #[tokio::test] async fn generate_non_streaming_parses_text_and_tool_calls() { let body = json!({ diff --git a/crates/adapter-prompt/src/contributors/identity.rs b/crates/adapter-prompt/src/contributors/identity.rs index eab22788..529debbb 100644 --- a/crates/adapter-prompt/src/contributors/identity.rs +++ b/crates/adapter-prompt/src/contributors/identity.rs @@ -20,12 +20,12 @@ use crate::{BlockKind, BlockSpec, PromptContext, PromptContribution, PromptContr /// 优先读取 `~/.astrcode/IDENTITY.md`,不存在时使用默认描述。 pub struct IdentityContributor; -const DEFAULT_IDENTITY: &str = "\ -You are AstrCode, a local AI coding agent running on the user's machine. You help with coding \ - tasks, file editing, and terminal commands. Work like a proactive \ - engineering partner: build context before acting, choose the \ - narrowest effective tool, and carry tasks through implementation \ - and verification when feasible."; +const DEFAULT_IDENTITY: &str = + "\ +You are AstrCode, a genius-level engineer and team leader. Code is your expression — correct, \ + maintainable. Thoroughly understand before precisely executing; pursue perfect and elegant \ + best practices, root-causing problems rather than patching symptoms. In complex tasks, \ + orchestrate agent-tool collaboration to coordinate resources and drive projects to success."; /// Returns the path to the user-wide IDENTITY.md file. pub fn user_identity_md_path() -> Option<PathBuf> { diff --git a/crates/adapter-prompt/src/contributors/mod.rs b/crates/adapter-prompt/src/contributors/mod.rs index 19ab41b7..a5e81af8 100644 --- a/crates/adapter-prompt/src/contributors/mod.rs +++ b/crates/adapter-prompt/src/contributors/mod.rs @@ -7,6 +7,7 @@ //! - [`AgentsMdContributor`]:用户和项目级 AGENTS.md 规则 //! - [`CapabilityPromptContributor`]:工具使用指南 //! - [`PromptDeclarationContributor`]:外部注入的 PromptDeclaration system blocks +//! - [`ResponseStyleContributor`]:用户可见输出风格与收尾格式约束 //! - [`SkillSummaryContributor`]:Skill 索引摘要 //! - [`WorkflowExamplesContributor`]:Few-shot 示例对话 @@ -16,6 +17,7 @@ pub mod capability_prompt; pub mod environment; pub mod identity; pub mod prompt_declaration; +pub mod response_style; pub mod shared; pub mod skill_summary; pub mod workflow_examples; @@ -26,6 +28,7 @@ pub use capability_prompt::CapabilityPromptContributor; pub use environment::EnvironmentContributor; pub use identity::{IdentityContributor, load_identity_md, user_identity_md_path}; pub use prompt_declaration::PromptDeclarationContributor; +pub use response_style::ResponseStyleContributor; pub use shared::{cache_marker_for_path, user_astrcode_file_path}; pub use skill_summary::SkillSummaryContributor; pub use workflow_examples::WorkflowExamplesContributor; diff --git a/crates/adapter-prompt/src/contributors/response_style.rs b/crates/adapter-prompt/src/contributors/response_style.rs new file mode 100644 index 00000000..8400d168 --- /dev/null +++ b/crates/adapter-prompt/src/contributors/response_style.rs @@ -0,0 +1,83 @@ +//! 响应风格贡献者。 +//! +//! 为模型补充稳定的用户沟通风格与收尾格式约束, +//! 避免输出退化成工具日志或未验证的结论。 + +use async_trait::async_trait; + +use crate::{BlockKind, BlockSpec, PromptContext, PromptContribution, PromptContributor}; + +pub struct ResponseStyleContributor; + +const RESPONSE_STYLE_GUIDANCE: &str = + "\ +Write for the user, not for a console log. Lead with the answer, action, or next step when it is \ + clear.\n\nWhen the task needs tools, multiple steps, or noticeable wait time:\n- Before the \ + first tool call, briefly state what you are going to do.\n- Give short progress updates when \ + you confirm something important, change direction, or make meaningful progress after a \ + stretch of silence.\n- Use complete sentences and enough context that the user can resume \ + cold.\n\nDo not present a guess, lead, or partial result as if it were confirmed. \ + Distinguish a suspicion from a supported finding, and distinguish both from the final \ + conclusion.\n\nPrefer clear prose over running debug-log narration. Use light structure only \ + when it improves readability.\n\nWhen closing out implementation work, briefly cover:\n- \ + what changed,\n- why this shape is correct,\n- what you verified,\n- any remaining risk or \ + next step if verification was partial."; + +#[async_trait] +impl PromptContributor for ResponseStyleContributor { + fn contributor_id(&self) -> &'static str { + "response-style" + } + + fn cache_version(&self) -> u64 { + 1 + } + + async fn contribute(&self, _ctx: &PromptContext) -> PromptContribution { + PromptContribution { + blocks: vec![ + BlockSpec::system_text( + "response-style", + BlockKind::SkillGuide, + "Response Style", + RESPONSE_STYLE_GUIDANCE, + ) + .with_category("communication") + .with_tag("source:builtin"), + ], + ..PromptContribution::default() + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::BlockContent; + + #[tokio::test] + async fn renders_response_style_guidance_block() { + let contribution = ResponseStyleContributor + .contribute(&PromptContext { + working_dir: "/workspace/demo".to_string(), + tool_names: Vec::new(), + capability_specs: Vec::new(), + prompt_declarations: Vec::new(), + agent_profiles: Vec::new(), + skills: Vec::new(), + step_index: 0, + turn_index: 0, + vars: Default::default(), + }) + .await; + + assert_eq!(contribution.blocks.len(), 1); + assert_eq!(contribution.blocks[0].kind, BlockKind::SkillGuide); + let BlockContent::Text(content) = &contribution.blocks[0].content else { + panic!("response style should render as text"); + }; + assert!(content.contains("Before the first tool call")); + assert!(content.contains("Do not present a guess")); + assert!(content.contains("what changed")); + } +} diff --git a/crates/adapter-prompt/src/layered_builder.rs b/crates/adapter-prompt/src/layered_builder.rs index 8c6d4b12..1943977f 100644 --- a/crates/adapter-prompt/src/layered_builder.rs +++ b/crates/adapter-prompt/src/layered_builder.rs @@ -331,6 +331,7 @@ pub fn default_layered_prompt_builder() -> LayeredPromptBuilder { .with_stable_layer(vec![ Arc::new(crate::contributors::IdentityContributor), Arc::new(crate::contributors::EnvironmentContributor), + Arc::new(crate::contributors::ResponseStyleContributor), ]) .with_semi_stable_layer(vec![ Arc::new(crate::contributors::AgentsMdContributor), diff --git a/crates/adapter-storage/src/core_port.rs b/crates/adapter-storage/src/core_port.rs deleted file mode 100644 index dfb371db..00000000 --- a/crates/adapter-storage/src/core_port.rs +++ /dev/null @@ -1,6 +0,0 @@ -//! `adapter-storage` 面向组合根暴露的 `EventStore` 入口。 -//! -//! `FileSystemSessionRepository` 现在直接实现 `core::ports::EventStore`, -//! 这里仅保留兼容名称,避免组合根继续感知旧的 `SessionManager` 桥接层。 - -pub use crate::session::FileSystemSessionRepository as FsEventStore; diff --git a/crates/adapter-storage/src/lib.rs b/crates/adapter-storage/src/lib.rs index 1b37a236..39a026cb 100644 --- a/crates/adapter-storage/src/lib.rs +++ b/crates/adapter-storage/src/lib.rs @@ -25,7 +25,6 @@ //! 以便竞争者获取当前持有者信息并做出相应处理。 pub mod config_store; -pub mod core_port; pub mod mcp_settings_store; pub mod session; diff --git a/crates/application/src/agent/routing.rs b/crates/application/src/agent/routing.rs index 61dfdf2c..03bf42e2 100644 --- a/crates/application/src/agent/routing.rs +++ b/crates/application/src/agent/routing.rs @@ -1,8 +1,9 @@ //! agent 协作路由与权限校验。 //! -//! 从旧 runtime/service/agent/routing.rs 迁入,去掉对 RuntimeService 的依赖, //! 改为通过 Kernel + SessionRuntime 完成所有操作。 +#[path = "routing_collaboration_flow.rs"] +mod collaboration_flow; use astrcode_core::{ AgentCollaborationActionKind, AgentCollaborationOutcomeKind, AgentInboxEnvelope, AgentLifecycleStatus, ChildAgentRef, ChildSessionNotification, ChildSessionNotificationKind, @@ -11,8 +12,12 @@ use astrcode_core::{ ParentDeliveryTerminalSemantics, SendAgentParams, SendToChildParams, SendToParentParams, SubRunHandle, }; +use collaboration_flow::parent_delivery_label; -use super::{AgentOrchestrationService, build_delegation_metadata, subrun_event_context}; +use super::{ + AgentOrchestrationError, AgentOrchestrationService, ToolCollaborationContext, + build_delegation_metadata, subrun_event_context, +}; use crate::governance_surface::{ GovernanceBusyPolicy, ResumedChildGovernanceInput, collaboration_policy_context, effective_allowed_tools_for_limits, @@ -445,1302 +450,7 @@ impl AgentOrchestrationService { }, )) } - - async fn build_explicit_parent_delivery_notification( - &self, - child: &SubRunHandle, - payload: &ParentDeliveryPayload, - ctx: &astrcode_core::ToolContext, - source_turn_id: &str, - ) -> ChildSessionNotification { - let status = self - .kernel - .get_lifecycle(&child.agent_id) - .await - .unwrap_or(child.lifecycle); - let notification_id = explicit_parent_delivery_id( - &child.sub_run_id, - source_turn_id, - ctx.tool_call_id().map(ToString::to_string).as_deref(), - payload, - ); - - ChildSessionNotification { - notification_id: notification_id.clone().into(), - child_ref: child.child_ref_with_status(status), - kind: parent_delivery_notification_kind(payload), - source_tool_call_id: ctx.tool_call_id().map(ToString::to_string).map(Into::into), - delivery: Some(ParentDelivery { - idempotency_key: notification_id, - origin: ParentDeliveryOrigin::Explicit, - terminal_semantics: parent_delivery_terminal_semantics(payload), - source_turn_id: Some(source_turn_id.to_string()), - payload: payload.clone(), - }), - } - } - - /// 关闭子 agent 及其整个子树(close 协作工具的业务逻辑)。 - /// - /// 流程: - /// 1. 验证调用者是目标子 agent 的直接父级 - /// 2. 收集子树所有 handle 用于 durable discard - /// 3. 持久化 InputDiscarded 事件,标记待处理消息为已丢弃 - /// 4. 执行 kernel.terminate_subtree() 级联终止 - /// 5. 记录 Close collaboration fact - pub async fn close_child( - &self, - params: CloseAgentParams, - ctx: &astrcode_core::ToolContext, - ) -> Result<CollaborationResult, super::AgentOrchestrationError> { - let collaboration = self.tool_collaboration_context(ctx).await?; - params - .validate() - .map_err(super::AgentOrchestrationError::from)?; - - let target = self - .require_direct_child_handle( - ¶ms.agent_id, - AgentCollaborationActionKind::Close, - ctx, - &collaboration, - ) - .await?; - - // 收集子树用于 durable discard - let subtree_handles = self.kernel.collect_subtree_handles(¶ms.agent_id).await; - let mut discard_targets = Vec::with_capacity(subtree_handles.len() + 1); - discard_targets.push(target.clone()); - discard_targets.extend(subtree_handles.iter().cloned()); - - self.append_durable_input_queue_discard_batch(&discard_targets, ctx) - .await?; - - // 执行 terminate - let cancelled = self - .kernel - .terminate_subtree(¶ms.agent_id) - .await - .ok_or_else(|| { - super::AgentOrchestrationError::NotFound(format!( - "agent '{}' terminate failed (not found or already finalized)", - params.agent_id - )) - })?; - - let subtree_count = subtree_handles.len(); - let summary = if subtree_count > 0 { - format!( - "已级联关闭子 Agent {} 及 {} 个后代。", - params.agent_id, subtree_count - ) - } else { - format!("已关闭子 Agent {}。", params.agent_id) - }; - self.record_fact_best_effort( - collaboration.runtime(), - collaboration - .fact( - AgentCollaborationActionKind::Close, - AgentCollaborationOutcomeKind::Closed, - ) - .child(&target) - .summary(summary.clone()), - ) - .await; - - Ok(CollaborationResult::Closed { - summary: Some(summary), - cascade: true, - closed_root_agent_id: cancelled.agent_id.clone(), - }) - } - - /// 从 SubRunHandle 构造 ChildAgentRef。 - pub(super) async fn build_child_ref_from_handle(&self, handle: &SubRunHandle) -> ChildAgentRef { - handle.child_ref() - } - - /// 用 live 控制面的最新 lifecycle 投影更新 ChildAgentRef。 - pub(super) async fn project_child_ref_status( - &self, - mut child_ref: ChildAgentRef, - ) -> ChildAgentRef { - let lifecycle = self.kernel.get_lifecycle(child_ref.agent_id()).await; - let last_turn_outcome = self.kernel.get_turn_outcome(child_ref.agent_id()).await; - if let Some(lifecycle) = lifecycle { - child_ref.status = - project_collaboration_lifecycle(lifecycle, last_turn_outcome, child_ref.status); - } - child_ref - } - - /// resume 失败时恢复之前 drain 出的 inbox 信封。 - /// - /// 必须在 resume 前先 drain(否则无法取到 pending 消息来组合 resume prompt), - /// 但如果 resume 本身失败,必须把信封放回去,避免消息丢失。 - async fn restore_pending_inbox(&self, agent_id: &str, pending: Vec<AgentInboxEnvelope>) { - for envelope in pending { - if self.kernel.deliver(agent_id, envelope).await.is_none() { - log::warn!( - "failed to restore drained inbox after resume error: agent='{}'", - agent_id - ); - break; - } - } - } - - async fn restore_pending_inbox_and_fail<T>( - &self, - agent_id: &str, - pending: Vec<AgentInboxEnvelope>, - message: String, - ) -> Result<T, super::AgentOrchestrationError> { - self.restore_pending_inbox(agent_id, pending).await; - Err(super::AgentOrchestrationError::Internal(message)) - } - - /// 如果子 agent 处于 Idle 且不占据并发槽位(如 Resume lineage), - /// 则尝试 resume 它以处理新消息,而非排队等待。 - /// - /// Resume 流程: - /// 1. 排空 inbox 中待处理消息 - /// 2. 将待处理消息与新的 send 输入拼接为 resume prompt - /// 3. 调用 kernel.resume() 重启子 agent turn - /// 4. 构建 resumed 治理面并提交 prompt - /// 5. 注册 turn terminal watcher 等待终态 - /// - /// 如果 resume 失败,会恢复之前排空的 inbox 避免消息丢失。 - async fn resume_idle_child_if_needed( - &self, - child: &SubRunHandle, - params: &SendToChildParams, - ctx: &astrcode_core::ToolContext, - collaboration: &super::ToolCollaborationContext, - lifecycle: Option<AgentLifecycleStatus>, - ) -> Result<Option<CollaborationResult>, super::AgentOrchestrationError> { - if !matches!(lifecycle, Some(AgentLifecycleStatus::Idle)) || child.lifecycle.occupies_slot() - { - return Ok(None); - } - - let pending = self - .kernel - .drain_inbox(&child.agent_id) - .await - .unwrap_or_default(); - let resume_message = compose_reusable_child_message(&pending, params); - let current_parent_turn_id = ctx.turn_id().unwrap_or(&child.parent_turn_id).to_string(); - - let Some(reused_handle) = self - .kernel - .resume(¶ms.agent_id, ¤t_parent_turn_id) - .await - else { - self.restore_pending_inbox(&child.agent_id, pending).await; - return Ok(None); - }; - - log::info!( - "send: reusable child agent '{}' restarted with new turn (subRunId='{}')", - params.agent_id, - reused_handle.sub_run_id - ); - - let Some(child_session_id) = reused_handle - .child_session_id - .as_ref() - .or(child.child_session_id.as_ref()) - else { - return self - .restore_pending_inbox_and_fail( - &child.agent_id, - pending, - format!( - "agent '{}' resume failed: missing child session id", - params.agent_id - ), - ) - .await; - }; - - let fallback_delegation = build_delegation_metadata( - "", - params.message.as_str(), - &reused_handle.resolved_limits, - false, - ); - let resume_delegation = reused_handle - .delegation - .clone() - .unwrap_or(fallback_delegation); - let runtime = match self - .resolve_runtime_config_for_session(child_session_id) - .await - { - Ok(runtime) => runtime, - Err(error) => { - return self - .restore_pending_inbox_and_fail( - &child.agent_id, - pending, - format!( - "agent '{}' resume runtime resolution failed: {error}", - params.agent_id - ), - ) - .await; - }, - }; - let working_dir = match self - .session_runtime - .get_session_working_dir(child_session_id) - .await - { - Ok(working_dir) => working_dir, - Err(error) => { - return self - .restore_pending_inbox_and_fail( - &child.agent_id, - pending, - format!( - "agent '{}' resume working directory resolution failed: {error}", - params.agent_id - ), - ) - .await; - }, - }; - let surface = match self.governance_surface.resumed_child_surface( - self.kernel.as_ref(), - ResumedChildGovernanceInput { - session_id: child.session_id.to_string(), - turn_id: current_parent_turn_id.clone(), - working_dir, - mode_id: collaboration.mode_id().clone(), - runtime, - allowed_tools: effective_allowed_tools_for_limits( - &self.kernel.gateway(), - &reused_handle.resolved_limits, - ), - resolved_limits: reused_handle.resolved_limits.clone(), - delegation: Some(resume_delegation.clone()), - message: params.message.clone(), - context: params.context.clone(), - busy_policy: GovernanceBusyPolicy::BranchOnBusy, - }, - ) { - Ok(surface) => surface, - Err(error) => { - return self - .restore_pending_inbox_and_fail( - &child.agent_id, - pending, - format!( - "agent '{}' resume governance surface failed: {error}", - params.agent_id - ), - ) - .await; - }, - }; - - let accepted = match self - .session_runtime - .submit_prompt_for_agent_with_submission( - child_session_id, - resume_message, - surface.runtime.clone(), - surface.into_submission( - astrcode_core::AgentEventContext::from(&reused_handle), - ctx.tool_call_id().map(ToString::to_string), - ), - ) - .await - { - Ok(accepted) => accepted, - Err(error) => { - return self - .restore_pending_inbox_and_fail( - &child.agent_id, - pending, - format!("agent '{}' resume submit failed: {error}", params.agent_id), - ) - .await; - }, - }; - self.spawn_child_turn_terminal_watcher( - reused_handle.clone(), - accepted.session_id.to_string(), - accepted.turn_id.to_string(), - ctx.session_id().to_string(), - current_parent_turn_id, - ctx.tool_call_id().map(ToString::to_string), - ); - - let child_ref = self.build_child_ref_from_handle(&reused_handle).await; - self.record_fact_best_effort( - collaboration.runtime(), - collaboration - .fact( - AgentCollaborationActionKind::Send, - AgentCollaborationOutcomeKind::Reused, - ) - .child(&reused_handle) - .summary("idle child resumed"), - ) - .await; - Ok(Some(CollaborationResult::Sent { - agent_ref: Some(self.project_child_ref_status(child_ref).await), - delivery_id: None, - summary: Some(format!( - "子 Agent {} 已恢复,并开始处理新的具体指令。", - params.agent_id - )), - delegation: reused_handle.delegation.clone(), - })) - } - - /// 向正在运行的子 agent 追加消息。 - /// - /// 子 agent 正忙时不能 resume,消息通过 inbox 机制排队: - /// 1. 持久化 InputQueued 事件(durable,crash 可恢复) - /// 2. 通过 kernel.deliver() 投递到内存 inbox - /// 3. 记录 collaboration fact(Queued outcome) - async fn queue_message_for_active_child( - &self, - child: &SubRunHandle, - params: &SendToChildParams, - ctx: &astrcode_core::ToolContext, - collaboration: &super::ToolCollaborationContext, - ) -> Result<CollaborationResult, super::AgentOrchestrationError> { - let delivery_id = format!("delivery-{}", uuid::Uuid::new_v4()); - let envelope = astrcode_core::AgentInboxEnvelope { - delivery_id: delivery_id.clone(), - from_agent_id: ctx - .agent_context() - .agent_id - .clone() - .unwrap_or_default() - .to_string(), - to_agent_id: params.agent_id.to_string(), - kind: InboxEnvelopeKind::ParentMessage, - message: params.message.clone(), - context: params.context.clone(), - is_final: false, - summary: None, - findings: Vec::new(), - artifacts: Vec::new(), - }; - self.append_durable_input_queue(child, &envelope, ctx) - .await?; - - self.kernel - .deliver(&child.agent_id, envelope) - .await - .ok_or_else(|| { - super::AgentOrchestrationError::NotFound(format!( - "agent '{}' inbox not available", - params.agent_id - )) - })?; - - let child_ref = self.build_child_ref_from_handle(child).await; - log::info!( - "send: message sent to child agent '{}' (subRunId='{}')", - params.agent_id, - child.sub_run_id - ); - self.record_fact_best_effort( - collaboration.runtime(), - collaboration - .fact( - AgentCollaborationActionKind::Send, - AgentCollaborationOutcomeKind::Queued, - ) - .child(child) - .delivery_id(delivery_id.clone()) - .summary("message queued for running child"), - ) - .await; - - Ok(CollaborationResult::Sent { - agent_ref: Some(self.project_child_ref_status(child_ref).await), - delivery_id: Some(delivery_id.into()), - summary: Some(format!( - "子 Agent {} 正在运行;消息已进入 input queue 排队,待当前工作完成后处理。", - params.agent_id - )), - delegation: child.delegation.clone(), - }) - } -} - -/// 将待处理的 inbox 信封与新的 send 输入拼接为 resume 消息。 -/// -/// 如果只有一条消息(无 pending),直接返回该消息; -/// 多条消息时加上"请按顺序处理以下追加要求"前缀并编号。 -fn compose_reusable_child_message( - pending: &[astrcode_core::AgentInboxEnvelope], - params: &astrcode_core::SendToChildParams, -) -> String { - let mut parts = pending - .iter() - .filter(|envelope| { - matches!( - envelope.kind, - astrcode_core::InboxEnvelopeKind::ParentMessage - ) - }) - .map(render_parent_message_envelope) - .collect::<Vec<_>>(); - parts.push(render_parent_message_input( - params.message.as_str(), - params.context.as_deref(), - )); - - if parts.len() == 1 { - return parts.pop().unwrap_or_default(); - } - - let enumerated = parts - .into_iter() - .enumerate() - .map(|(index, part)| format!("{}. {}", index + 1, part)) - .collect::<Vec<_>>() - .join("\n\n"); - format!("请按顺序处理以下追加要求:\n\n{enumerated}") -} - -/// 根据 delivery payload 类型推断 terminal semantics。 -/// -/// Progress 消息是 NonTerminal(不表示结束), -/// Completed / Failed / CloseRequest 是 Terminal(表示结束)。 -fn parent_delivery_terminal_semantics( - payload: &ParentDeliveryPayload, -) -> ParentDeliveryTerminalSemantics { - match payload { - ParentDeliveryPayload::Progress(_) => ParentDeliveryTerminalSemantics::NonTerminal, - ParentDeliveryPayload::Completed(_) - | ParentDeliveryPayload::Failed(_) - | ParentDeliveryPayload::CloseRequest(_) => ParentDeliveryTerminalSemantics::Terminal, - } -} - -fn parent_delivery_notification_kind( - payload: &ParentDeliveryPayload, -) -> ChildSessionNotificationKind { - match payload { - ParentDeliveryPayload::Progress(_) => ChildSessionNotificationKind::ProgressSummary, - ParentDeliveryPayload::Completed(_) => ChildSessionNotificationKind::Delivered, - ParentDeliveryPayload::Failed(_) => ChildSessionNotificationKind::Failed, - ParentDeliveryPayload::CloseRequest(_) => ChildSessionNotificationKind::Closed, - } -} - -fn parent_delivery_label(payload: &ParentDeliveryPayload) -> &'static str { - match payload { - ParentDeliveryPayload::Progress(_) => "progress", - ParentDeliveryPayload::Completed(_) => "completed", - ParentDeliveryPayload::Failed(_) => "failed", - ParentDeliveryPayload::CloseRequest(_) => "close_request", - } -} - -fn explicit_parent_delivery_id( - sub_run_id: &str, - source_turn_id: &str, - source_tool_call_id: Option<&str>, - payload: &ParentDeliveryPayload, -) -> String { - let tool_call_id = source_tool_call_id.unwrap_or("tool-call-missing"); - format!( - "child-send:{sub_run_id}:{source_turn_id}:{tool_call_id}:{}", - parent_delivery_label(payload) - ) -} - -fn render_parent_message_envelope(envelope: &astrcode_core::AgentInboxEnvelope) -> String { - render_parent_message_input(envelope.message.as_str(), envelope.context.as_deref()) -} - -fn render_parent_message_input(message: &str, context: Option<&str>) -> String { - match context { - Some(context) if !context.trim().is_empty() => { - format!("{message}\n\n补充上下文:{context}") - }, - _ => message.to_string(), - } -} - -impl AgentOrchestrationService { - pub(super) async fn append_durable_input_queue( - &self, - child: &SubRunHandle, - envelope: &AgentInboxEnvelope, - ctx: &astrcode_core::ToolContext, - ) -> astrcode_core::Result<()> { - let target_session_id = child - .child_session_id - .clone() - .unwrap_or_else(|| child.session_id.clone()) - .to_string(); - - let sender_agent_id = ctx.agent_context().agent_id.clone().unwrap_or_default(); - let sender_lifecycle_status = if sender_agent_id.is_empty() { - AgentLifecycleStatus::Running - } else { - self.kernel - .get_lifecycle(&sender_agent_id) - .await - .unwrap_or(AgentLifecycleStatus::Running) - }; - let sender_last_turn_outcome = if sender_agent_id.is_empty() { - None - } else { - self.kernel.get_turn_outcome(&sender_agent_id).await - }; - let sender_open_session_id = ctx - .agent_context() - .child_session_id - .clone() - .unwrap_or_else(|| ctx.session_id().to_string().into()); - - let payload = InputQueuedPayload { - envelope: astrcode_core::QueuedInputEnvelope { - delivery_id: envelope.delivery_id.clone().into(), - from_agent_id: envelope.from_agent_id.clone(), - to_agent_id: envelope.to_agent_id.clone(), - message: render_parent_message_input( - &envelope.message, - envelope.context.as_deref(), - ), - queued_at: chrono::Utc::now(), - sender_lifecycle_status, - sender_last_turn_outcome, - sender_open_session_id: sender_open_session_id.to_string(), - }, - }; - - self.session_runtime - .append_agent_input_queued( - &target_session_id, - ctx.turn_id().unwrap_or(child.parent_turn_id.as_str()), - subrun_event_context(child), - payload, - ) - .await?; - Ok(()) - } - - pub(super) async fn append_durable_input_queue_discard_batch( - &self, - handles: &[SubRunHandle], - ctx: &astrcode_core::ToolContext, - ) -> astrcode_core::Result<()> { - for handle in handles { - self.append_durable_input_queue_discard(handle, ctx).await?; - } - Ok(()) - } - - async fn append_durable_input_queue_discard( - &self, - handle: &SubRunHandle, - ctx: &astrcode_core::ToolContext, - ) -> astrcode_core::Result<()> { - let target_session_id = handle - .child_session_id - .clone() - .unwrap_or_else(|| handle.session_id.clone()); - let pending_delivery_ids = self - .session_runtime - .pending_delivery_ids_for_agent(&target_session_id, &handle.agent_id) - .await?; - if pending_delivery_ids.is_empty() { - return Ok(()); - } - - self.session_runtime - .append_agent_input_discarded( - &target_session_id, - ctx.turn_id().unwrap_or(&handle.parent_turn_id), - astrcode_core::AgentEventContext::default(), - InputDiscardedPayload { - target_agent_id: handle.agent_id.to_string(), - delivery_ids: pending_delivery_ids.into_iter().map(Into::into).collect(), - }, - ) - .await?; - Ok(()) - } -} - -/// 将 live 控制面的 lifecycle + outcome 投影回 `ChildAgentRef` 的 lifecycle。 -/// -/// `Idle` + `None` outcome 的含义是:agent 已空闲但还没有完成过一轮 turn, -/// 此时保留调用方传入的 fallback 状态(通常是 handle 上的旧 lifecycle)。 -/// 这避免了把刚 spawn 还没执行过 turn 的 agent 误标为 Idle。 -fn project_collaboration_lifecycle( - lifecycle: AgentLifecycleStatus, - last_turn_outcome: Option<astrcode_core::AgentTurnOutcome>, - fallback: AgentLifecycleStatus, -) -> AgentLifecycleStatus { - match lifecycle { - AgentLifecycleStatus::Pending => AgentLifecycleStatus::Pending, - AgentLifecycleStatus::Running => AgentLifecycleStatus::Running, - AgentLifecycleStatus::Idle => match last_turn_outcome { - Some(_) => AgentLifecycleStatus::Idle, - None => fallback, - }, - AgentLifecycleStatus::Terminated => AgentLifecycleStatus::Terminated, - } } #[cfg(test)] -mod tests { - use std::time::{Duration, Instant}; - - use astrcode_core::{ - AgentCollaborationActionKind, AgentCollaborationOutcomeKind, CancelToken, CloseAgentParams, - CompletedParentDeliveryPayload, ObserveParams, ParentDeliveryPayload, SendAgentParams, - SendToChildParams, SendToParentParams, SessionId, SpawnAgentParams, StorageEventPayload, - ToolContext, - agent::executor::{CollaborationExecutor, SubAgentExecutor}, - }; - use tokio::time::sleep; - - use super::super::{root_execution_event_context, subrun_event_context}; - use crate::{ - AgentKernelPort, AppKernelPort, - agent::test_support::{TestLlmBehavior, build_agent_test_harness}, - lifecycle::governance::ObservabilitySnapshotProvider, - }; - - async fn spawn_direct_child( - harness: &crate::agent::test_support::AgentTestHarness, - parent_session_id: &str, - working_dir: &std::path::Path, - ) -> (String, String) { - harness - .kernel - .agent_control() - .register_root_agent( - "root-agent".to_string(), - parent_session_id.to_string(), - "root-profile".to_string(), - ) - .await - .expect("root agent should be registered"); - let parent_ctx = ToolContext::new( - parent_session_id.to_string().into(), - working_dir.to_path_buf(), - CancelToken::new(), - ) - .with_turn_id("turn-parent") - .with_agent_context(root_execution_event_context("root-agent", "root-profile")); - - let launched = harness - .service - .launch( - SpawnAgentParams { - r#type: Some("reviewer".to_string()), - description: "检查 crates".to_string(), - prompt: "请检查 crates 目录".to_string(), - context: None, - capability_grant: None, - }, - &parent_ctx, - ) - .await - .expect("spawn should succeed"); - let child_agent_id = launched - .handoff() - .and_then(|handoff| { - handoff - .artifacts - .iter() - .find(|artifact| artifact.kind == "agent") - .map(|artifact| artifact.id.clone()) - }) - .expect("child agent artifact should exist"); - for _ in 0..20 { - if harness - .kernel - .get_lifecycle(&child_agent_id) - .await - .is_some_and(|lifecycle| lifecycle == astrcode_core::AgentLifecycleStatus::Idle) - { - break; - } - sleep(Duration::from_millis(20)).await; - } - (child_agent_id, parent_ctx.session_id().to_string()) - } - - #[tokio::test] - async fn collaboration_calls_reject_non_direct_child() { - let harness = build_agent_test_harness(TestLlmBehavior::Succeed { - content: "完成。".to_string(), - }) - .expect("test harness should build"); - let project = tempfile::tempdir().expect("tempdir should be created"); - - let parent_a = harness - .session_runtime - .create_session(project.path().display().to_string()) - .await - .expect("parent session A should be created"); - let (child_agent_id, _) = - spawn_direct_child(&harness, &parent_a.session_id, project.path()).await; - - let parent_b = harness - .session_runtime - .create_session(project.path().display().to_string()) - .await - .expect("parent session B should be created"); - harness - .kernel - .agent_control() - .register_root_agent( - "other-root".to_string(), - parent_b.session_id.clone(), - "root-profile".to_string(), - ) - .await - .expect("other root agent should be registered"); - let other_ctx = ToolContext::new( - parent_b.session_id.clone().into(), - project.path().to_path_buf(), - CancelToken::new(), - ) - .with_turn_id("turn-other") - .with_agent_context(root_execution_event_context("other-root", "root-profile")); - - let send_error = harness - .service - .send( - SendAgentParams::ToChild(SendToChildParams { - agent_id: child_agent_id.clone().into(), - message: "继续".to_string(), - context: None, - }), - &other_ctx, - ) - .await - .expect_err("send should reject non-direct child"); - assert!(send_error.to_string().contains("direct child")); - - let observe_error = harness - .service - .observe( - ObserveParams { - agent_id: child_agent_id.clone(), - }, - &other_ctx, - ) - .await - .expect_err("observe should reject non-direct child"); - assert!(observe_error.to_string().contains("direct child")); - - let close_error = harness - .service - .close( - CloseAgentParams { - agent_id: child_agent_id.into(), - }, - &other_ctx, - ) - .await - .expect_err("close should reject non-direct child"); - assert!(close_error.to_string().contains("direct child")); - - let parent_b_events = harness - .session_runtime - .replay_stored_events(&SessionId::from(parent_b.session_id.clone())) - .await - .expect("other parent events should replay"); - assert!(parent_b_events.iter().any(|stored| matches!( - &stored.event.payload, - StorageEventPayload::AgentCollaborationFact { fact, .. } - if fact.action == AgentCollaborationActionKind::Send - && fact.outcome == AgentCollaborationOutcomeKind::Rejected - && fact.reason_code.as_deref() == Some("ownership_mismatch") - ))); - } - - #[tokio::test] - async fn send_to_idle_child_reports_resume_semantics() { - let harness = build_agent_test_harness(TestLlmBehavior::Succeed { - content: "完成。".to_string(), - }) - .expect("test harness should build"); - let project = tempfile::tempdir().expect("tempdir should be created"); - let parent = harness - .session_runtime - .create_session(project.path().display().to_string()) - .await - .expect("parent session should be created"); - let (child_agent_id, parent_session_id) = - spawn_direct_child(&harness, &parent.session_id, project.path()).await; - let parent_ctx = ToolContext::new( - parent_session_id.into(), - project.path().to_path_buf(), - CancelToken::new(), - ) - .with_turn_id("turn-parent-2") - .with_agent_context(root_execution_event_context("root-agent", "root-profile")); - - let result = harness - .service - .send( - SendAgentParams::ToChild(SendToChildParams { - agent_id: child_agent_id.into(), - message: "请继续整理结论".to_string(), - context: None, - }), - &parent_ctx, - ) - .await - .expect("send should succeed"); - - assert_eq!(result.delivery_id(), None); - assert!( - result - .summary() - .is_some_and(|summary| summary.contains("已恢复")) - ); - assert_eq!( - result - .delegation() - .map(|metadata| metadata.responsibility_summary.as_str()), - Some("检查 crates"), - "resumed child should keep the original responsibility branch metadata" - ); - assert_eq!( - result.agent_ref().map(|child_ref| child_ref.lineage_kind), - Some(astrcode_core::ChildSessionLineageKind::Resume), - "resumed child projection should expose resume lineage instead of masquerading as \ - spawn" - ); - let resumed_child = harness - .kernel - .get_handle( - result - .agent_ref() - .map(|child_ref| child_ref.agent_id().as_str()) - .expect("child ref should exist"), - ) - .await - .expect("resumed child handle should exist"); - assert_eq!(resumed_child.parent_turn_id, "turn-parent-2".into()); - assert_eq!( - resumed_child.lineage_kind, - astrcode_core::ChildSessionLineageKind::Resume - ); - } - - #[tokio::test] - async fn send_to_running_child_reports_input_queue_semantics() { - let harness = build_agent_test_harness(TestLlmBehavior::Succeed { - content: "完成。".to_string(), - }) - .expect("test harness should build"); - let project = tempfile::tempdir().expect("tempdir should be created"); - let parent = harness - .session_runtime - .create_session(project.path().display().to_string()) - .await - .expect("parent session should be created"); - let (child_agent_id, parent_session_id) = - spawn_direct_child(&harness, &parent.session_id, project.path()).await; - let parent_ctx = ToolContext::new( - parent_session_id.into(), - project.path().to_path_buf(), - CancelToken::new(), - ) - .with_turn_id("turn-parent-3") - .with_agent_context(root_execution_event_context("root-agent", "root-profile")); - - let _ = harness - .kernel - .agent_control() - .set_lifecycle( - &child_agent_id, - astrcode_core::AgentLifecycleStatus::Running, - ) - .await; - - let result = harness - .service - .send( - SendAgentParams::ToChild(SendToChildParams { - agent_id: child_agent_id.into(), - message: "继续第二轮".to_string(), - context: Some("只看 CI".to_string()), - }), - &parent_ctx, - ) - .await - .expect("send should succeed"); - - assert!(result.delivery_id().is_some()); - assert!( - result - .summary() - .is_some_and(|summary| summary.contains("input queue 排队")) - ); - } - - #[tokio::test] - async fn send_to_parent_rejects_root_execution_without_direct_parent() { - let harness = build_agent_test_harness(TestLlmBehavior::Succeed { - content: "完成。".to_string(), - }) - .expect("test harness should build"); - let project = tempfile::tempdir().expect("tempdir should be created"); - let parent = harness - .session_runtime - .create_session(project.path().display().to_string()) - .await - .expect("parent session should be created"); - harness - .kernel - .agent_control() - .register_root_agent( - "root-agent".to_string(), - parent.session_id.clone(), - "root-profile".to_string(), - ) - .await - .expect("root agent should be registered"); - - let root_ctx = ToolContext::new( - parent.session_id.clone().into(), - project.path().to_path_buf(), - CancelToken::new(), - ) - .with_turn_id("turn-root") - .with_agent_context(root_execution_event_context("root-agent", "root-profile")); - - let error = harness - .service - .send( - SendAgentParams::ToParent(SendToParentParams { - payload: ParentDeliveryPayload::Completed(CompletedParentDeliveryPayload { - message: "根节点不应该上行".to_string(), - findings: Vec::new(), - artifacts: Vec::new(), - }), - }), - &root_ctx, - ) - .await - .expect_err("root agent should not be able to send upward"); - assert!(error.to_string().contains("no direct parent")); - - let events = harness - .session_runtime - .replay_stored_events(&SessionId::from(parent.session_id.clone())) - .await - .expect("parent events should replay"); - assert!(events.iter().any(|stored| matches!( - &stored.event.payload, - StorageEventPayload::AgentCollaborationFact { fact, .. } - if fact.action == AgentCollaborationActionKind::Delivery - && fact.outcome == AgentCollaborationOutcomeKind::Rejected - && fact.reason_code.as_deref() == Some("missing_direct_parent") - ))); - } - - #[tokio::test] - async fn send_to_parent_from_resumed_child_routes_to_current_parent_turn() { - let harness = build_agent_test_harness(TestLlmBehavior::Succeed { - content: "完成。".to_string(), - }) - .expect("test harness should build"); - let project = tempfile::tempdir().expect("tempdir should be created"); - let parent = harness - .session_runtime - .create_session(project.path().display().to_string()) - .await - .expect("parent session should be created"); - let (child_agent_id, parent_session_id) = - spawn_direct_child(&harness, &parent.session_id, project.path()).await; - let parent_ctx = ToolContext::new( - parent_session_id.into(), - project.path().to_path_buf(), - CancelToken::new(), - ) - .with_turn_id("turn-parent-2") - .with_agent_context(root_execution_event_context("root-agent", "root-profile")); - - harness - .service - .send( - SendAgentParams::ToChild(SendToChildParams { - agent_id: child_agent_id.clone().into(), - message: "继续整理并向我汇报".to_string(), - context: None, - }), - &parent_ctx, - ) - .await - .expect("send should resume idle child"); - - let resumed_child = harness - .kernel - .get_handle(&child_agent_id) - .await - .expect("resumed child handle should exist"); - let child_ctx = ToolContext::new( - resumed_child - .child_session_id - .clone() - .expect("child session id should exist"), - project.path().to_path_buf(), - CancelToken::new(), - ) - .with_turn_id("turn-child-report-2") - .with_agent_context(subrun_event_context(&resumed_child)); - let metrics_before = harness.metrics.snapshot(); - - let result = harness - .service - .send( - SendAgentParams::ToParent(SendToParentParams { - payload: ParentDeliveryPayload::Completed(CompletedParentDeliveryPayload { - message: "继续推进后的显式上报".to_string(), - findings: Vec::new(), - artifacts: Vec::new(), - }), - }), - &child_ctx, - ) - .await - .expect("resumed child should be able to send upward"); - - assert!(result.delivery_id().is_some()); - let deadline = Instant::now() + Duration::from_secs(5); - loop { - let parent_events = harness - .session_runtime - .replay_stored_events(&SessionId::from(parent.session_id.clone())) - .await - .expect("parent events should replay during wake wait"); - if parent_events.iter().any(|stored| { - matches!( - &stored.event.payload, - StorageEventPayload::UserMessage { content, origin, .. } - if *origin == astrcode_core::UserMessageOrigin::QueuedInput - && content.contains("继续推进后的显式上报") - ) - }) { - break; - } - assert!( - Instant::now() < deadline, - "explicit upstream send should trigger parent wake and consume the queued input" - ); - sleep(Duration::from_millis(20)).await; - } - - let parent_events = harness - .session_runtime - .replay_stored_events(&SessionId::from(parent.session_id.clone())) - .await - .expect("parent events should replay"); - assert!(parent_events.iter().any(|stored| matches!( - &stored.event.payload, - StorageEventPayload::ChildSessionNotification { notification, .. } - if stored.event.turn_id.as_deref() == Some("turn-parent-2") - && notification.child_ref.sub_run_id() == &resumed_child.sub_run_id - && notification.child_ref.lineage_kind - == astrcode_core::ChildSessionLineageKind::Resume - && notification.delivery.as_ref().is_some_and(|delivery| { - delivery.origin == astrcode_core::ParentDeliveryOrigin::Explicit - && delivery.payload.message() == "继续推进后的显式上报" - }) - ))); - assert!( - !parent_events.iter().any(|stored| matches!( - &stored.event.payload, - StorageEventPayload::ChildSessionNotification { notification, .. } - if stored.event.turn_id.as_deref() == Some("turn-parent") - && notification.delivery.as_ref().is_some_and(|delivery| { - delivery.payload.message() == "继续推进后的显式上报" - }) - )), - "resumed child delivery must target the current parent turn instead of the stale \ - spawn turn" - ); - assert!( - parent_events.iter().any(|stored| matches!( - &stored.event.payload, - StorageEventPayload::AgentInputQueued { payload } - if payload.envelope.message == "继续推进后的显式上报" - )), - "explicit upstream send should enqueue the same delivery for parent wake consumption" - ); - assert!( - parent_events.iter().any(|stored| matches!( - &stored.event.payload, - StorageEventPayload::UserMessage { content, origin, .. } - if *origin == astrcode_core::UserMessageOrigin::QueuedInput - && content.contains("继续推进后的显式上报") - )), - "parent wake turn should consume the explicit upstream delivery as queued input" - ); - let metrics = harness.metrics.snapshot(); - assert!( - metrics.execution_diagnostics.parent_reactivation_requested - >= metrics_before - .execution_diagnostics - .parent_reactivation_requested - ); - } - - #[tokio::test] - async fn send_to_parent_rejects_when_direct_parent_is_terminated() { - let harness = build_agent_test_harness(TestLlmBehavior::Succeed { - content: "完成。".to_string(), - }) - .expect("test harness should build"); - let project = tempfile::tempdir().expect("tempdir should be created"); - let parent = harness - .session_runtime - .create_session(project.path().display().to_string()) - .await - .expect("parent session should be created"); - let (child_agent_id, _) = - spawn_direct_child(&harness, &parent.session_id, project.path()).await; - let child_handle = harness - .kernel - .get_handle(&child_agent_id) - .await - .expect("child handle should exist"); - - let _ = harness - .kernel - .agent_control() - .set_lifecycle( - "root-agent", - astrcode_core::AgentLifecycleStatus::Terminated, - ) - .await; - - let child_ctx = ToolContext::new( - child_handle - .child_session_id - .clone() - .expect("child session id should exist"), - project.path().to_path_buf(), - CancelToken::new(), - ) - .with_turn_id("turn-child-report") - .with_agent_context(subrun_event_context(&child_handle)); - - let error = harness - .service - .send( - SendAgentParams::ToParent(SendToParentParams { - payload: ParentDeliveryPayload::Completed(CompletedParentDeliveryPayload { - message: "父级已终止".to_string(), - findings: Vec::new(), - artifacts: Vec::new(), - }), - }), - &child_ctx, - ) - .await - .expect_err("terminated parent should reject upward send"); - assert!(error.to_string().contains("terminated")); - - let parent_events = harness - .session_runtime - .replay_stored_events(&SessionId::from(parent.session_id.clone())) - .await - .expect("parent events should replay"); - assert!(parent_events.iter().any(|stored| matches!( - &stored.event.payload, - StorageEventPayload::AgentCollaborationFact { fact, .. } - if fact.action == AgentCollaborationActionKind::Delivery - && fact.outcome == AgentCollaborationOutcomeKind::Rejected - && fact.reason_code.as_deref() == Some("parent_terminated") - ))); - } - - #[tokio::test] - async fn close_reports_cascade_scope_for_descendants() { - let harness = build_agent_test_harness(TestLlmBehavior::Succeed { - content: "完成。".to_string(), - }) - .expect("test harness should build"); - let project = tempfile::tempdir().expect("tempdir should be created"); - let parent = harness - .session_runtime - .create_session(project.path().display().to_string()) - .await - .expect("parent session should be created"); - let (child_agent_id, parent_session_id) = - spawn_direct_child(&harness, &parent.session_id, project.path()).await; - - let child_handle = harness - .kernel - .agent() - .get_handle(&child_agent_id) - .await - .expect("child handle should exist"); - let child_ctx = ToolContext::new( - child_handle - .child_session_id - .clone() - .expect("child session id should exist"), - project.path().to_path_buf(), - CancelToken::new(), - ) - .with_turn_id("turn-child-1") - .with_agent_context(subrun_event_context(&child_handle)); - let _grandchild = harness - .service - .launch( - SpawnAgentParams { - r#type: Some("reviewer".to_string()), - description: "进一步检查".to_string(), - prompt: "请进一步检查测试覆盖".to_string(), - context: None, - capability_grant: None, - }, - &child_ctx, - ) - .await - .expect("grandchild spawn should succeed"); - - let parent_ctx = ToolContext::new( - parent_session_id.into(), - project.path().to_path_buf(), - CancelToken::new(), - ) - .with_turn_id("turn-parent-close") - .with_agent_context(root_execution_event_context("root-agent", "root-profile")); - - let result = harness - .service - .close( - CloseAgentParams { - agent_id: child_agent_id.into(), - }, - &parent_ctx, - ) - .await - .expect("close should succeed"); - - assert_eq!(result.cascade(), Some(true)); - assert!( - result - .summary() - .is_some_and(|summary| summary.contains("1 个后代")) - ); - } -} +mod tests; diff --git a/crates/application/src/agent/routing/child_send.rs b/crates/application/src/agent/routing/child_send.rs new file mode 100644 index 00000000..0da4ed9b --- /dev/null +++ b/crates/application/src/agent/routing/child_send.rs @@ -0,0 +1,323 @@ +use super::*; + +impl AgentOrchestrationService { + /// resume 失败时恢复之前 drain 出的 inbox 信封。 + /// + /// 必须在 resume 前先 drain(否则无法取到 pending 消息来组合 resume prompt), + /// 但如果 resume 本身失败,必须把信封放回去,避免消息丢失。 + async fn restore_pending_inbox(&self, agent_id: &str, pending: Vec<AgentInboxEnvelope>) { + for envelope in pending { + if self.kernel.deliver(agent_id, envelope).await.is_none() { + log::warn!( + "failed to restore drained inbox after resume error: agent='{}'", + agent_id + ); + break; + } + } + } + + async fn restore_pending_inbox_and_fail<T>( + &self, + agent_id: &str, + pending: Vec<AgentInboxEnvelope>, + message: String, + ) -> Result<T, AgentOrchestrationError> { + self.restore_pending_inbox(agent_id, pending).await; + Err(AgentOrchestrationError::Internal(message)) + } + + /// 如果子 agent 处于 Idle 且不占据并发槽位(如 Resume lineage), + /// 则尝试 resume 它以处理新消息,而非排队等待。 + pub(in crate::agent) async fn resume_idle_child_if_needed( + &self, + child: &SubRunHandle, + params: &SendToChildParams, + ctx: &astrcode_core::ToolContext, + collaboration: &ToolCollaborationContext, + lifecycle: Option<AgentLifecycleStatus>, + ) -> Result<Option<CollaborationResult>, AgentOrchestrationError> { + if !matches!(lifecycle, Some(AgentLifecycleStatus::Idle)) || child.lifecycle.occupies_slot() + { + return Ok(None); + } + + let pending = self + .kernel + .drain_inbox(&child.agent_id) + .await + .unwrap_or_default(); + let resume_message = compose_reusable_child_message(&pending, params); + let current_parent_turn_id = ctx.turn_id().unwrap_or(&child.parent_turn_id).to_string(); + + let Some(reused_handle) = self + .kernel + .resume(¶ms.agent_id, ¤t_parent_turn_id) + .await + else { + self.restore_pending_inbox(&child.agent_id, pending).await; + return Ok(None); + }; + + log::info!( + "send: reusable child agent '{}' restarted with new turn (subRunId='{}')", + params.agent_id, + reused_handle.sub_run_id + ); + + let Some(child_session_id) = reused_handle + .child_session_id + .as_ref() + .or(child.child_session_id.as_ref()) + else { + return self + .restore_pending_inbox_and_fail( + &child.agent_id, + pending, + format!( + "agent '{}' resume failed: missing child session id", + params.agent_id + ), + ) + .await; + }; + + let fallback_delegation = build_delegation_metadata( + "", + params.message.as_str(), + &reused_handle.resolved_limits, + false, + ); + let resume_delegation = reused_handle + .delegation + .clone() + .unwrap_or(fallback_delegation); + let runtime = match self + .resolve_runtime_config_for_session(child_session_id) + .await + { + Ok(runtime) => runtime, + Err(error) => { + return self + .restore_pending_inbox_and_fail( + &child.agent_id, + pending, + format!( + "agent '{}' resume runtime resolution failed: {error}", + params.agent_id + ), + ) + .await; + }, + }; + let working_dir = match self + .session_runtime + .get_session_working_dir(child_session_id) + .await + { + Ok(working_dir) => working_dir, + Err(error) => { + return self + .restore_pending_inbox_and_fail( + &child.agent_id, + pending, + format!( + "agent '{}' resume working directory resolution failed: {error}", + params.agent_id + ), + ) + .await; + }, + }; + let resumed_turn_id = format!("turn-{}", chrono::Utc::now().timestamp_millis()); + let resumed_input = ResumedChildGovernanceInput { + session_id: child_session_id.to_string(), + turn_id: resumed_turn_id.clone(), + working_dir, + mode_id: collaboration.mode_id().clone(), + runtime: runtime.clone(), + allowed_tools: effective_allowed_tools_for_limits( + &self.kernel.gateway(), + &reused_handle.resolved_limits, + ), + resolved_limits: reused_handle.resolved_limits.clone(), + delegation: Some(resume_delegation.clone()), + message: params.message.clone(), + context: params.context.clone(), + busy_policy: GovernanceBusyPolicy::RejectOnBusy, + }; + let surface = match self + .governance_surface + .resumed_child_surface(self.kernel.as_ref(), resumed_input) + { + Ok(surface) => surface, + Err(error) => { + return self + .restore_pending_inbox_and_fail( + &child.agent_id, + pending, + format!( + "agent '{}' resume governance failed: {error}", + params.agent_id + ), + ) + .await; + }, + }; + match self + .session_runtime + .try_submit_prompt_for_agent_with_turn_id( + child_session_id, + resumed_turn_id.clone().into(), + resume_message.clone(), + surface.runtime.clone(), + surface.into_submission( + subrun_event_context(&reused_handle), + collaboration.source_tool_call_id(), + ), + ) + .await + { + Ok(Some(accepted)) => accepted, + Ok(None) => { + self.restore_pending_inbox(&child.agent_id, pending).await; + return Ok(None); + }, + Err(error) => { + return self + .restore_pending_inbox_and_fail( + &child.agent_id, + pending, + format!( + "agent '{}' resume prompt submission failed: {error}", + params.agent_id + ), + ) + .await; + }, + }; + + let child_ref = self.build_child_ref_from_handle(&reused_handle).await; + self.record_fact_best_effort( + collaboration.runtime(), + collaboration + .fact( + AgentCollaborationActionKind::Send, + AgentCollaborationOutcomeKind::Delivered, + ) + .child(&reused_handle) + .summary("message delivered by reusing idle child"), + ) + .await; + + Ok(Some(CollaborationResult::Sent { + agent_ref: Some(self.project_child_ref_status(child_ref).await), + delivery_id: None, + summary: Some(format!( + "子 Agent {} 已恢复空闲执行上下文并开始处理新消息。", + params.agent_id + )), + delegation: Some(resume_delegation), + })) + } + + /// 当子 agent 正在运行时,将消息排入 input queue 并持久化 durable InputQueued 事件。 + pub(in crate::agent) async fn queue_message_for_active_child( + &self, + child: &SubRunHandle, + params: &SendToChildParams, + ctx: &astrcode_core::ToolContext, + collaboration: &ToolCollaborationContext, + ) -> Result<CollaborationResult, AgentOrchestrationError> { + let delivery_id = format!( + "send:{}:{}", + ctx.turn_id().unwrap_or("unknown-turn"), + ctx.tool_call_id().unwrap_or("tool-call-missing") + ); + let envelope = AgentInboxEnvelope { + delivery_id: delivery_id.clone(), + from_agent_id: ctx + .agent_context() + .agent_id + .clone() + .map(|id| id.to_string()) + .unwrap_or_default(), + to_agent_id: child.agent_id.to_string(), + kind: InboxEnvelopeKind::ParentMessage, + message: params.message.clone(), + context: params.context.clone(), + is_final: false, + summary: None, + findings: Vec::new(), + artifacts: Vec::new(), + }; + self.kernel + .deliver(&child.agent_id, envelope.clone()) + .await + .ok_or_else(|| { + AgentOrchestrationError::NotFound(format!( + "agent '{}' not found while queueing message", + params.agent_id + )) + })?; + self.append_durable_input_queue(child, &envelope, ctx) + .await + .map_err(AgentOrchestrationError::from)?; + + let child_ref = self.build_child_ref_from_handle(child).await; + self.record_fact_best_effort( + collaboration.runtime(), + collaboration + .fact( + AgentCollaborationActionKind::Send, + AgentCollaborationOutcomeKind::Queued, + ) + .child(child) + .delivery_id(delivery_id.clone()) + .summary("message queued for running child"), + ) + .await; + + Ok(CollaborationResult::Sent { + agent_ref: Some(self.project_child_ref_status(child_ref).await), + delivery_id: Some(delivery_id.into()), + summary: Some(format!( + "子 Agent {} 正在运行;消息已进入 input queue 排队,待当前工作完成后处理。", + params.agent_id + )), + delegation: child.delegation.clone(), + }) + } +} + +fn compose_reusable_child_message( + pending: &[astrcode_core::AgentInboxEnvelope], + params: &astrcode_core::SendToChildParams, +) -> String { + let mut parts = pending + .iter() + .filter(|envelope| { + matches!( + envelope.kind, + astrcode_core::InboxEnvelopeKind::ParentMessage + ) + }) + .map(parent_delivery::render_parent_message_envelope) + .collect::<Vec<_>>(); + parts.push(parent_delivery::render_parent_message_input( + params.message.as_str(), + params.context.as_deref(), + )); + + if parts.len() == 1 { + return parts.pop().unwrap_or_default(); + } + + let enumerated = parts + .into_iter() + .enumerate() + .map(|(index, part)| format!("{}. {}", index + 1, part)) + .collect::<Vec<_>>() + .join("\n\n"); + format!("请按顺序处理以下追加要求:\n\n{enumerated}") +} diff --git a/crates/application/src/agent/routing/parent_delivery.rs b/crates/application/src/agent/routing/parent_delivery.rs new file mode 100644 index 00000000..fef799ed --- /dev/null +++ b/crates/application/src/agent/routing/parent_delivery.rs @@ -0,0 +1,197 @@ +use super::*; + +impl AgentOrchestrationService { + pub(in crate::agent) async fn build_explicit_parent_delivery_notification( + &self, + child: &SubRunHandle, + payload: &ParentDeliveryPayload, + ctx: &astrcode_core::ToolContext, + source_turn_id: &str, + ) -> ChildSessionNotification { + let status = self + .kernel + .get_lifecycle(&child.agent_id) + .await + .unwrap_or(child.lifecycle); + let notification_id = explicit_parent_delivery_id( + &child.sub_run_id, + source_turn_id, + ctx.tool_call_id().map(ToString::to_string).as_deref(), + payload, + ); + + ChildSessionNotification { + notification_id: notification_id.clone().into(), + child_ref: child.child_ref_with_status(status), + kind: parent_delivery_notification_kind(payload), + source_tool_call_id: ctx.tool_call_id().map(ToString::to_string).map(Into::into), + delivery: Some(ParentDelivery { + idempotency_key: notification_id, + origin: ParentDeliveryOrigin::Explicit, + terminal_semantics: parent_delivery_terminal_semantics(payload), + source_turn_id: Some(source_turn_id.to_string()), + payload: payload.clone(), + }), + } + } + + pub(in crate::agent) async fn append_durable_input_queue( + &self, + child: &SubRunHandle, + envelope: &AgentInboxEnvelope, + ctx: &astrcode_core::ToolContext, + ) -> astrcode_core::Result<()> { + let target_session_id = child + .child_session_id + .clone() + .unwrap_or_else(|| child.session_id.clone()) + .to_string(); + + let sender_agent_id = ctx.agent_context().agent_id.clone().unwrap_or_default(); + let sender_lifecycle_status = if sender_agent_id.is_empty() { + AgentLifecycleStatus::Running + } else { + self.kernel + .get_lifecycle(&sender_agent_id) + .await + .unwrap_or(AgentLifecycleStatus::Running) + }; + let sender_last_turn_outcome = if sender_agent_id.is_empty() { + None + } else { + self.kernel.get_turn_outcome(&sender_agent_id).await + }; + let sender_open_session_id = ctx + .agent_context() + .child_session_id + .clone() + .unwrap_or_else(|| ctx.session_id().to_string().into()); + + let payload = InputQueuedPayload { + envelope: astrcode_core::QueuedInputEnvelope { + delivery_id: envelope.delivery_id.clone().into(), + from_agent_id: envelope.from_agent_id.clone(), + to_agent_id: envelope.to_agent_id.clone(), + message: render_parent_message_input( + &envelope.message, + envelope.context.as_deref(), + ), + queued_at: chrono::Utc::now(), + sender_lifecycle_status, + sender_last_turn_outcome, + sender_open_session_id: sender_open_session_id.to_string(), + }, + }; + + self.session_runtime + .append_agent_input_queued( + &target_session_id, + ctx.turn_id().unwrap_or(child.parent_turn_id.as_str()), + subrun_event_context(child), + payload, + ) + .await?; + Ok(()) + } + + pub(in crate::agent) async fn append_durable_input_queue_discard_batch( + &self, + handles: &[SubRunHandle], + ctx: &astrcode_core::ToolContext, + ) -> astrcode_core::Result<()> { + for handle in handles { + self.append_durable_input_queue_discard(handle, ctx).await?; + } + Ok(()) + } + + async fn append_durable_input_queue_discard( + &self, + handle: &SubRunHandle, + ctx: &astrcode_core::ToolContext, + ) -> astrcode_core::Result<()> { + let target_session_id = handle + .child_session_id + .clone() + .unwrap_or_else(|| handle.session_id.clone()); + let pending_delivery_ids = self + .session_runtime + .pending_delivery_ids_for_agent(&target_session_id, &handle.agent_id) + .await?; + if pending_delivery_ids.is_empty() { + return Ok(()); + } + + self.session_runtime + .append_agent_input_discarded( + &target_session_id, + ctx.turn_id().unwrap_or(&handle.parent_turn_id), + astrcode_core::AgentEventContext::default(), + InputDiscardedPayload { + target_agent_id: handle.agent_id.to_string(), + delivery_ids: pending_delivery_ids.into_iter().map(Into::into).collect(), + }, + ) + .await?; + Ok(()) + } +} + +fn parent_delivery_terminal_semantics( + payload: &ParentDeliveryPayload, +) -> ParentDeliveryTerminalSemantics { + match payload { + ParentDeliveryPayload::Progress(_) => ParentDeliveryTerminalSemantics::NonTerminal, + ParentDeliveryPayload::Completed(_) + | ParentDeliveryPayload::Failed(_) + | ParentDeliveryPayload::CloseRequest(_) => ParentDeliveryTerminalSemantics::Terminal, + } +} + +fn parent_delivery_notification_kind( + payload: &ParentDeliveryPayload, +) -> ChildSessionNotificationKind { + match payload { + ParentDeliveryPayload::Progress(_) => ChildSessionNotificationKind::ProgressSummary, + ParentDeliveryPayload::Completed(_) => ChildSessionNotificationKind::Delivered, + ParentDeliveryPayload::Failed(_) => ChildSessionNotificationKind::Failed, + ParentDeliveryPayload::CloseRequest(_) => ChildSessionNotificationKind::Closed, + } +} + +pub(super) fn parent_delivery_label(payload: &ParentDeliveryPayload) -> &'static str { + match payload { + ParentDeliveryPayload::Progress(_) => "progress", + ParentDeliveryPayload::Completed(_) => "completed", + ParentDeliveryPayload::Failed(_) => "failed", + ParentDeliveryPayload::CloseRequest(_) => "close_request", + } +} + +fn explicit_parent_delivery_id( + sub_run_id: &str, + source_turn_id: &str, + source_tool_call_id: Option<&str>, + payload: &ParentDeliveryPayload, +) -> String { + let tool_call_id = source_tool_call_id.unwrap_or("tool-call-missing"); + format!( + "child-send:{sub_run_id}:{source_turn_id}:{tool_call_id}:{}", + parent_delivery_label(payload) + ) +} + +pub(super) fn render_parent_message_envelope( + envelope: &astrcode_core::AgentInboxEnvelope, +) -> String { + render_parent_message_input(envelope.message.as_str(), envelope.context.as_deref()) +} + +pub(super) fn render_parent_message_input(message: &str, context: Option<&str>) -> String { + match context { + Some(context) if !context.trim().is_empty() => { + format!("{message}\n\n补充上下文:{context}") + }, + _ => message.to_string(), + } +} diff --git a/crates/application/src/agent/routing/tests.rs b/crates/application/src/agent/routing/tests.rs new file mode 100644 index 00000000..ff6b039e --- /dev/null +++ b/crates/application/src/agent/routing/tests.rs @@ -0,0 +1,642 @@ +use std::time::{Duration, Instant}; + +use astrcode_core::{ + AgentCollaborationActionKind, AgentCollaborationOutcomeKind, CancelToken, CloseAgentParams, + CompletedParentDeliveryPayload, ObserveParams, ParentDeliveryPayload, SendAgentParams, + SendToChildParams, SendToParentParams, SessionId, SpawnAgentParams, StorageEventPayload, + ToolContext, + agent::executor::{CollaborationExecutor, SubAgentExecutor}, +}; +use tokio::time::sleep; + +use super::super::{root_execution_event_context, subrun_event_context}; +use crate::{ + AgentKernelPort, AppKernelPort, + agent::test_support::{TestLlmBehavior, build_agent_test_harness}, + lifecycle::governance::ObservabilitySnapshotProvider, +}; + +async fn spawn_direct_child( + harness: &crate::agent::test_support::AgentTestHarness, + parent_session_id: &str, + working_dir: &std::path::Path, +) -> (String, String) { + harness + .kernel + .agent_control() + .register_root_agent( + "root-agent".to_string(), + parent_session_id.to_string(), + "root-profile".to_string(), + ) + .await + .expect("root agent should be registered"); + let parent_ctx = ToolContext::new( + parent_session_id.to_string().into(), + working_dir.to_path_buf(), + CancelToken::new(), + ) + .with_turn_id("turn-parent") + .with_agent_context(root_execution_event_context("root-agent", "root-profile")); + + let launched = harness + .service + .launch( + SpawnAgentParams { + r#type: Some("reviewer".to_string()), + description: "检查 crates".to_string(), + prompt: "请检查 crates 目录".to_string(), + context: None, + capability_grant: None, + }, + &parent_ctx, + ) + .await + .expect("spawn should succeed"); + let child_agent_id = launched + .handoff() + .and_then(|handoff| { + handoff + .artifacts + .iter() + .find(|artifact| artifact.kind == "agent") + .map(|artifact| artifact.id.clone()) + }) + .expect("child agent artifact should exist"); + for _ in 0..20 { + if harness + .kernel + .get_lifecycle(&child_agent_id) + .await + .is_some_and(|lifecycle| lifecycle == astrcode_core::AgentLifecycleStatus::Idle) + { + break; + } + sleep(Duration::from_millis(20)).await; + } + (child_agent_id, parent_ctx.session_id().to_string()) +} + +#[tokio::test] +async fn collaboration_calls_reject_non_direct_child() { + let harness = build_agent_test_harness(TestLlmBehavior::Succeed { + content: "完成。".to_string(), + }) + .expect("test harness should build"); + let project = tempfile::tempdir().expect("tempdir should be created"); + + let parent_a = harness + .session_runtime + .create_session(project.path().display().to_string()) + .await + .expect("parent session A should be created"); + let (child_agent_id, _) = + spawn_direct_child(&harness, &parent_a.session_id, project.path()).await; + + let parent_b = harness + .session_runtime + .create_session(project.path().display().to_string()) + .await + .expect("parent session B should be created"); + harness + .kernel + .agent_control() + .register_root_agent( + "other-root".to_string(), + parent_b.session_id.clone(), + "root-profile".to_string(), + ) + .await + .expect("other root agent should be registered"); + let other_ctx = ToolContext::new( + parent_b.session_id.clone().into(), + project.path().to_path_buf(), + CancelToken::new(), + ) + .with_turn_id("turn-other") + .with_agent_context(root_execution_event_context("other-root", "root-profile")); + + let send_error = harness + .service + .send( + SendAgentParams::ToChild(SendToChildParams { + agent_id: child_agent_id.clone().into(), + message: "继续".to_string(), + context: None, + }), + &other_ctx, + ) + .await + .expect_err("send should reject non-direct child"); + assert!(send_error.to_string().contains("direct child")); + + let observe_error = harness + .service + .observe( + ObserveParams { + agent_id: child_agent_id.clone(), + }, + &other_ctx, + ) + .await + .expect_err("observe should reject non-direct child"); + assert!(observe_error.to_string().contains("direct child")); + + let close_error = harness + .service + .close( + CloseAgentParams { + agent_id: child_agent_id.into(), + }, + &other_ctx, + ) + .await + .expect_err("close should reject non-direct child"); + assert!(close_error.to_string().contains("direct child")); + + let parent_b_events = harness + .session_runtime + .replay_stored_events(&SessionId::from(parent_b.session_id.clone())) + .await + .expect("other parent events should replay"); + assert!(parent_b_events.iter().any(|stored| matches!( + &stored.event.payload, + StorageEventPayload::AgentCollaborationFact { fact, .. } + if fact.action == AgentCollaborationActionKind::Send + && fact.outcome == AgentCollaborationOutcomeKind::Rejected + && fact.reason_code.as_deref() == Some("ownership_mismatch") + ))); +} + +#[tokio::test] +async fn send_to_idle_child_reports_resume_semantics() { + let harness = build_agent_test_harness(TestLlmBehavior::Succeed { + content: "完成。".to_string(), + }) + .expect("test harness should build"); + let project = tempfile::tempdir().expect("tempdir should be created"); + let parent = harness + .session_runtime + .create_session(project.path().display().to_string()) + .await + .expect("parent session should be created"); + let (child_agent_id, parent_session_id) = + spawn_direct_child(&harness, &parent.session_id, project.path()).await; + let parent_ctx = ToolContext::new( + parent_session_id.into(), + project.path().to_path_buf(), + CancelToken::new(), + ) + .with_turn_id("turn-parent-2") + .with_agent_context(root_execution_event_context("root-agent", "root-profile")); + + let result = harness + .service + .send( + SendAgentParams::ToChild(SendToChildParams { + agent_id: child_agent_id.into(), + message: "请继续整理结论".to_string(), + context: None, + }), + &parent_ctx, + ) + .await + .expect("send should succeed"); + + assert_eq!(result.delivery_id(), None); + assert!( + result + .summary() + .is_some_and(|summary| summary.contains("已恢复")) + ); + assert_eq!( + result + .delegation() + .map(|metadata| metadata.responsibility_summary.as_str()), + Some("检查 crates"), + "resumed child should keep the original responsibility branch metadata" + ); + assert_eq!( + result.agent_ref().map(|child_ref| child_ref.lineage_kind), + Some(astrcode_core::ChildSessionLineageKind::Resume), + "resumed child projection should expose resume lineage instead of masquerading as spawn" + ); + let resumed_child = harness + .kernel + .get_handle( + result + .agent_ref() + .map(|child_ref| child_ref.agent_id().as_str()) + .expect("child ref should exist"), + ) + .await + .expect("resumed child handle should exist"); + assert_eq!(resumed_child.parent_turn_id, "turn-parent-2".into()); + assert_eq!( + resumed_child.lineage_kind, + astrcode_core::ChildSessionLineageKind::Resume + ); +} + +#[tokio::test] +async fn send_to_running_child_reports_input_queue_semantics() { + let harness = build_agent_test_harness(TestLlmBehavior::Succeed { + content: "完成。".to_string(), + }) + .expect("test harness should build"); + let project = tempfile::tempdir().expect("tempdir should be created"); + let parent = harness + .session_runtime + .create_session(project.path().display().to_string()) + .await + .expect("parent session should be created"); + let (child_agent_id, parent_session_id) = + spawn_direct_child(&harness, &parent.session_id, project.path()).await; + let parent_ctx = ToolContext::new( + parent_session_id.into(), + project.path().to_path_buf(), + CancelToken::new(), + ) + .with_turn_id("turn-parent-3") + .with_agent_context(root_execution_event_context("root-agent", "root-profile")); + + let _ = harness + .kernel + .agent_control() + .set_lifecycle( + &child_agent_id, + astrcode_core::AgentLifecycleStatus::Running, + ) + .await; + + let result = harness + .service + .send( + SendAgentParams::ToChild(SendToChildParams { + agent_id: child_agent_id.into(), + message: "继续第二轮".to_string(), + context: Some("只看 CI".to_string()), + }), + &parent_ctx, + ) + .await + .expect("send should succeed"); + + assert!(result.delivery_id().is_some()); + assert!( + result + .summary() + .is_some_and(|summary| summary.contains("input queue 排队")) + ); +} + +#[tokio::test] +async fn send_to_parent_rejects_root_execution_without_direct_parent() { + let harness = build_agent_test_harness(TestLlmBehavior::Succeed { + content: "完成。".to_string(), + }) + .expect("test harness should build"); + let project = tempfile::tempdir().expect("tempdir should be created"); + let parent = harness + .session_runtime + .create_session(project.path().display().to_string()) + .await + .expect("parent session should be created"); + harness + .kernel + .agent_control() + .register_root_agent( + "root-agent".to_string(), + parent.session_id.clone(), + "root-profile".to_string(), + ) + .await + .expect("root agent should be registered"); + + let root_ctx = ToolContext::new( + parent.session_id.clone().into(), + project.path().to_path_buf(), + CancelToken::new(), + ) + .with_turn_id("turn-root") + .with_agent_context(root_execution_event_context("root-agent", "root-profile")); + + let error = harness + .service + .send( + SendAgentParams::ToParent(SendToParentParams { + payload: ParentDeliveryPayload::Completed(CompletedParentDeliveryPayload { + message: "根节点不应该上行".to_string(), + findings: Vec::new(), + artifacts: Vec::new(), + }), + }), + &root_ctx, + ) + .await + .expect_err("root agent should not be able to send upward"); + assert!(error.to_string().contains("no direct parent")); + + let events = harness + .session_runtime + .replay_stored_events(&SessionId::from(parent.session_id.clone())) + .await + .expect("parent events should replay"); + assert!(events.iter().any(|stored| matches!( + &stored.event.payload, + StorageEventPayload::AgentCollaborationFact { fact, .. } + if fact.action == AgentCollaborationActionKind::Delivery + && fact.outcome == AgentCollaborationOutcomeKind::Rejected + && fact.reason_code.as_deref() == Some("missing_direct_parent") + ))); +} + +#[tokio::test] +async fn send_to_parent_from_resumed_child_routes_to_current_parent_turn() { + let harness = build_agent_test_harness(TestLlmBehavior::Succeed { + content: "完成。".to_string(), + }) + .expect("test harness should build"); + let project = tempfile::tempdir().expect("tempdir should be created"); + let parent = harness + .session_runtime + .create_session(project.path().display().to_string()) + .await + .expect("parent session should be created"); + let (child_agent_id, parent_session_id) = + spawn_direct_child(&harness, &parent.session_id, project.path()).await; + let parent_ctx = ToolContext::new( + parent_session_id.into(), + project.path().to_path_buf(), + CancelToken::new(), + ) + .with_turn_id("turn-parent-2") + .with_agent_context(root_execution_event_context("root-agent", "root-profile")); + + harness + .service + .send( + SendAgentParams::ToChild(SendToChildParams { + agent_id: child_agent_id.clone().into(), + message: "继续整理并向我汇报".to_string(), + context: None, + }), + &parent_ctx, + ) + .await + .expect("send should resume idle child"); + + let resumed_child = harness + .kernel + .get_handle(&child_agent_id) + .await + .expect("resumed child handle should exist"); + let child_ctx = ToolContext::new( + resumed_child + .child_session_id + .clone() + .expect("child session id should exist"), + project.path().to_path_buf(), + CancelToken::new(), + ) + .with_turn_id("turn-child-report-2") + .with_agent_context(subrun_event_context(&resumed_child)); + let metrics_before = harness.metrics.snapshot(); + + let result = harness + .service + .send( + SendAgentParams::ToParent(SendToParentParams { + payload: ParentDeliveryPayload::Completed(CompletedParentDeliveryPayload { + message: "继续推进后的显式上报".to_string(), + findings: Vec::new(), + artifacts: Vec::new(), + }), + }), + &child_ctx, + ) + .await + .expect("resumed child should be able to send upward"); + + assert!(result.delivery_id().is_some()); + let deadline = Instant::now() + Duration::from_secs(5); + loop { + let parent_events = harness + .session_runtime + .replay_stored_events(&SessionId::from(parent.session_id.clone())) + .await + .expect("parent events should replay during wake wait"); + if parent_events.iter().any(|stored| { + matches!( + &stored.event.payload, + StorageEventPayload::UserMessage { content, origin, .. } + if *origin == astrcode_core::UserMessageOrigin::QueuedInput + && content.contains("继续推进后的显式上报") + ) + }) { + break; + } + assert!( + Instant::now() < deadline, + "explicit upstream send should trigger parent wake and consume the queued input" + ); + sleep(Duration::from_millis(20)).await; + } + + let parent_events = harness + .session_runtime + .replay_stored_events(&SessionId::from(parent.session_id.clone())) + .await + .expect("parent events should replay"); + assert!(parent_events.iter().any(|stored| matches!( + &stored.event.payload, + StorageEventPayload::ChildSessionNotification { notification, .. } + if stored.event.turn_id.as_deref() == Some("turn-parent-2") + && notification.child_ref.sub_run_id() == &resumed_child.sub_run_id + && notification.child_ref.lineage_kind + == astrcode_core::ChildSessionLineageKind::Resume + && notification.delivery.as_ref().is_some_and(|delivery| { + delivery.origin == astrcode_core::ParentDeliveryOrigin::Explicit + && delivery.payload.message() == "继续推进后的显式上报" + }) + ))); + assert!( + !parent_events.iter().any(|stored| matches!( + &stored.event.payload, + StorageEventPayload::ChildSessionNotification { notification, .. } + if stored.event.turn_id.as_deref() == Some("turn-parent") + && notification.delivery.as_ref().is_some_and(|delivery| { + delivery.payload.message() == "继续推进后的显式上报" + }) + )), + "resumed child delivery must target the current parent turn instead of the stale spawn \ + turn" + ); + assert!( + parent_events.iter().any(|stored| matches!( + &stored.event.payload, + StorageEventPayload::AgentInputQueued { payload } + if payload.envelope.message == "继续推进后的显式上报" + )), + "explicit upstream send should enqueue the same delivery for parent wake consumption" + ); + assert!( + parent_events.iter().any(|stored| matches!( + &stored.event.payload, + StorageEventPayload::UserMessage { content, origin, .. } + if *origin == astrcode_core::UserMessageOrigin::QueuedInput + && content.contains("继续推进后的显式上报") + )), + "parent wake turn should consume the explicit upstream delivery as queued input" + ); + let metrics = harness.metrics.snapshot(); + assert!( + metrics.execution_diagnostics.parent_reactivation_requested + >= metrics_before + .execution_diagnostics + .parent_reactivation_requested + ); +} + +#[tokio::test] +async fn send_to_parent_rejects_when_direct_parent_is_terminated() { + let harness = build_agent_test_harness(TestLlmBehavior::Succeed { + content: "完成。".to_string(), + }) + .expect("test harness should build"); + let project = tempfile::tempdir().expect("tempdir should be created"); + let parent = harness + .session_runtime + .create_session(project.path().display().to_string()) + .await + .expect("parent session should be created"); + let (child_agent_id, _) = + spawn_direct_child(&harness, &parent.session_id, project.path()).await; + let child_handle = harness + .kernel + .get_handle(&child_agent_id) + .await + .expect("child handle should exist"); + + let _ = harness + .kernel + .agent_control() + .set_lifecycle( + "root-agent", + astrcode_core::AgentLifecycleStatus::Terminated, + ) + .await; + + let child_ctx = ToolContext::new( + child_handle + .child_session_id + .clone() + .expect("child session id should exist"), + project.path().to_path_buf(), + CancelToken::new(), + ) + .with_turn_id("turn-child-report") + .with_agent_context(subrun_event_context(&child_handle)); + + let error = harness + .service + .send( + SendAgentParams::ToParent(SendToParentParams { + payload: ParentDeliveryPayload::Completed(CompletedParentDeliveryPayload { + message: "父级已终止".to_string(), + findings: Vec::new(), + artifacts: Vec::new(), + }), + }), + &child_ctx, + ) + .await + .expect_err("terminated parent should reject upward send"); + assert!(error.to_string().contains("terminated")); + + let parent_events = harness + .session_runtime + .replay_stored_events(&SessionId::from(parent.session_id.clone())) + .await + .expect("parent events should replay"); + assert!(parent_events.iter().any(|stored| matches!( + &stored.event.payload, + StorageEventPayload::AgentCollaborationFact { fact, .. } + if fact.action == AgentCollaborationActionKind::Delivery + && fact.outcome == AgentCollaborationOutcomeKind::Rejected + && fact.reason_code.as_deref() == Some("parent_terminated") + ))); +} + +#[tokio::test] +async fn close_reports_cascade_scope_for_descendants() { + let harness = build_agent_test_harness(TestLlmBehavior::Succeed { + content: "完成。".to_string(), + }) + .expect("test harness should build"); + let project = tempfile::tempdir().expect("tempdir should be created"); + let parent = harness + .session_runtime + .create_session(project.path().display().to_string()) + .await + .expect("parent session should be created"); + let (child_agent_id, parent_session_id) = + spawn_direct_child(&harness, &parent.session_id, project.path()).await; + + let child_handle = harness + .kernel + .agent() + .get_handle(&child_agent_id) + .await + .expect("child handle should exist"); + let child_ctx = ToolContext::new( + child_handle + .child_session_id + .clone() + .expect("child session id should exist"), + project.path().to_path_buf(), + CancelToken::new(), + ) + .with_turn_id("turn-child-1") + .with_agent_context(subrun_event_context(&child_handle)); + let _grandchild = harness + .service + .launch( + SpawnAgentParams { + r#type: Some("reviewer".to_string()), + description: "进一步检查".to_string(), + prompt: "请进一步检查测试覆盖".to_string(), + context: None, + capability_grant: None, + }, + &child_ctx, + ) + .await + .expect("grandchild spawn should succeed"); + + let parent_ctx = ToolContext::new( + parent_session_id.into(), + project.path().to_path_buf(), + CancelToken::new(), + ) + .with_turn_id("turn-parent-close") + .with_agent_context(root_execution_event_context("root-agent", "root-profile")); + + let result = harness + .service + .close( + CloseAgentParams { + agent_id: child_agent_id.into(), + }, + &parent_ctx, + ) + .await + .expect("close should succeed"); + + assert_eq!(result.cascade(), Some(true)); + assert!( + result + .summary() + .is_some_and(|summary| summary.contains("1 个后代")) + ); +} diff --git a/crates/application/src/agent/routing_collaboration_flow.rs b/crates/application/src/agent/routing_collaboration_flow.rs new file mode 100644 index 00000000..b8d77447 --- /dev/null +++ b/crates/application/src/agent/routing_collaboration_flow.rs @@ -0,0 +1,129 @@ +use super::*; + +#[path = "routing/child_send.rs"] +mod child_send; +#[path = "routing/parent_delivery.rs"] +mod parent_delivery; + +impl AgentOrchestrationService { + /// 关闭子 agent 及其整个子树(close 协作工具的业务逻辑)。 + /// + /// 流程: + /// 1. 验证调用者是目标子 agent 的直接父级 + /// 2. 收集子树所有 handle 用于 durable discard + /// 3. 持久化 InputDiscarded 事件,标记待处理消息为已丢弃 + /// 4. 执行 kernel.terminate_subtree() 级联终止 + /// 5. 记录 Close collaboration fact + pub(in crate::agent) async fn close_child( + &self, + params: CloseAgentParams, + ctx: &astrcode_core::ToolContext, + ) -> Result<CollaborationResult, super::AgentOrchestrationError> { + let collaboration = self.tool_collaboration_context(ctx).await?; + params + .validate() + .map_err(super::AgentOrchestrationError::from)?; + + let target = self + .require_direct_child_handle( + ¶ms.agent_id, + AgentCollaborationActionKind::Close, + ctx, + &collaboration, + ) + .await?; + + let subtree_handles = self.kernel.collect_subtree_handles(¶ms.agent_id).await; + let mut discard_targets = Vec::with_capacity(subtree_handles.len() + 1); + discard_targets.push(target.clone()); + discard_targets.extend(subtree_handles.iter().cloned()); + + self.append_durable_input_queue_discard_batch(&discard_targets, ctx) + .await?; + + let cancelled = self + .kernel + .terminate_subtree(¶ms.agent_id) + .await + .ok_or_else(|| { + super::AgentOrchestrationError::NotFound(format!( + "agent '{}' terminate failed (not found or already finalized)", + params.agent_id + )) + })?; + + let subtree_count = subtree_handles.len(); + let summary = if subtree_count > 0 { + format!( + "已级联关闭子 Agent {} 及 {} 个后代。", + params.agent_id, subtree_count + ) + } else { + format!("已关闭子 Agent {}。", params.agent_id) + }; + self.record_fact_best_effort( + collaboration.runtime(), + collaboration + .fact( + AgentCollaborationActionKind::Close, + AgentCollaborationOutcomeKind::Closed, + ) + .child(&target) + .summary(summary.clone()), + ) + .await; + + Ok(CollaborationResult::Closed { + summary: Some(summary), + cascade: true, + closed_root_agent_id: cancelled.agent_id.clone(), + }) + } + + /// 从 SubRunHandle 构造 ChildAgentRef。 + pub(in crate::agent) async fn build_child_ref_from_handle( + &self, + handle: &SubRunHandle, + ) -> ChildAgentRef { + handle.child_ref() + } + + /// 用 live 控制面的最新 lifecycle 投影更新 ChildAgentRef。 + pub(in crate::agent) async fn project_child_ref_status( + &self, + mut child_ref: ChildAgentRef, + ) -> ChildAgentRef { + let lifecycle = self.kernel.get_lifecycle(child_ref.agent_id()).await; + let last_turn_outcome = self.kernel.get_turn_outcome(child_ref.agent_id()).await; + if let Some(lifecycle) = lifecycle { + child_ref.status = + project_collaboration_lifecycle(lifecycle, last_turn_outcome, child_ref.status); + } + child_ref + } +} + +pub(super) fn parent_delivery_label(payload: &ParentDeliveryPayload) -> &'static str { + parent_delivery::parent_delivery_label(payload) +} + +/// 将 live 控制面的 lifecycle + outcome 投影回 `ChildAgentRef` 的 lifecycle。 +/// +/// `Idle` + `None` outcome 的含义是:agent 已空闲但还没有完成过一轮 turn, +/// 此时保留调用方传入的 fallback 状态(通常是 handle 上的旧 lifecycle)。 +/// 这避免了把刚 spawn 还没执行过 turn 的 agent 误标为 Idle。 +fn project_collaboration_lifecycle( + lifecycle: AgentLifecycleStatus, + last_turn_outcome: Option<astrcode_core::AgentTurnOutcome>, + fallback: AgentLifecycleStatus, +) -> AgentLifecycleStatus { + match lifecycle { + AgentLifecycleStatus::Pending => AgentLifecycleStatus::Pending, + AgentLifecycleStatus::Running => AgentLifecycleStatus::Running, + AgentLifecycleStatus::Idle => match last_turn_outcome { + Some(_) => AgentLifecycleStatus::Idle, + None => fallback, + }, + AgentLifecycleStatus::Terminated => AgentLifecycleStatus::Terminated, + } +} diff --git a/crates/application/src/agent/wake.rs b/crates/application/src/agent/wake.rs index 06fcd9f6..e296a042 100644 --- a/crates/application/src/agent/wake.rs +++ b/crates/application/src/agent/wake.rs @@ -15,6 +15,7 @@ use super::{ child_delivery_input_queue_envelope, root_execution_event_context, subrun_event_context, terminal_notification_message, }; +use crate::AppAgentPromptSubmission; const MAX_AUTOMATIC_INPUT_FOLLOW_UPS: u8 = 8; @@ -135,7 +136,7 @@ impl AgentOrchestrationService { queued_inputs, self.resolve_runtime_config_for_session(&parent_session_id) .await?, - astrcode_session_runtime::AgentPromptSubmission { + AppAgentPromptSubmission { agent: wake_agent.clone(), ..Default::default() }, diff --git a/crates/application/src/governance_surface/mod.rs b/crates/application/src/governance_surface/mod.rs index 340fa72b..94828865 100644 --- a/crates/application/src/governance_surface/mod.rs +++ b/crates/application/src/governance_surface/mod.rs @@ -3,7 +3,7 @@ //! 统一管理每次 turn 的治理决策:工具白名单、审批策略、子代理委派策略、协作指导 prompt。 //! //! 核心流程:`*GovernanceInput` → `GovernanceSurfaceAssembler` → `ResolvedGovernanceSurface` → -//! `AgentPromptSubmission` +//! `AppAgentPromptSubmission` //! //! 入口场景: //! - **Session turn**:`session_surface()` — 用户直接发起的 turn @@ -25,7 +25,6 @@ use astrcode_core::{ SpawnCapabilityGrant, }; use astrcode_kernel::CapabilityRouter; -use astrcode_session_runtime::AgentPromptSubmission; pub(crate) use inherited::resolve_inherited_parent_messages; #[cfg(test)] pub(crate) use inherited::{build_inherited_messages, select_inherited_recent_tail}; @@ -38,7 +37,9 @@ pub use prompt::{ build_delegation_metadata, build_fresh_child_contract, build_resumed_child_contract, }; -use crate::{ApplicationError, CompiledModeEnvelope, ExecutionControl}; +use crate::{ + ApplicationError, CompiledModeEnvelope, ExecutionControl, ports::AppAgentPromptSubmission, +}; /// Session busy 时的行为策略。 /// @@ -62,7 +63,7 @@ pub struct GovernanceApprovalPipeline { /// 编译完成的治理面,一次性消费的 turn 级上下文快照。 /// /// 包含工具白名单、审批管线、prompt declarations、注入消息、协作策略等全部治理决策。 -/// 通过 `into_submission()` 转换为 `AgentPromptSubmission` 供 session-runtime 消费。 +/// 通过 `into_submission()` 转换为应用层提交载荷,再交给 session 端口适配到底层 runtime。 #[derive(Clone)] pub struct ResolvedGovernanceSurface { pub mode_id: ModeId, @@ -118,9 +119,9 @@ impl ResolvedGovernanceSurface { self, agent: astrcode_core::AgentEventContext, source_tool_call_id: Option<String>, - ) -> AgentPromptSubmission { + ) -> AppAgentPromptSubmission { let prompt_governance = self.prompt_facts_context(); - AgentPromptSubmission { + AppAgentPromptSubmission { agent, capability_router: self.capability_router, prompt_declarations: self.prompt_declarations, @@ -130,7 +131,7 @@ impl ResolvedGovernanceSurface { source_tool_call_id, policy_context: Some(self.policy_context), governance_revision: Some(self.governance_revision), - approval: self.approval.pending.map(Box::new), + approval: self.approval.pending, prompt_governance: Some(prompt_governance), } } diff --git a/crates/application/src/lib.rs b/crates/application/src/lib.rs index 9e329402..b09b3444 100644 --- a/crates/application/src/lib.rs +++ b/crates/application/src/lib.rs @@ -83,8 +83,8 @@ pub use observability::{ resolve_runtime_status_summary, }; pub use ports::{ - AgentKernelPort, AgentSessionPort, AppKernelPort, AppSessionPort, ComposerResolvedSkill, - ComposerSkillPort, + AgentKernelPort, AgentSessionPort, AppAgentPromptSubmission, AppKernelPort, AppSessionPort, + ComposerResolvedSkill, ComposerSkillPort, }; pub use session_plan::{ProjectPlanArchiveDetail, ProjectPlanArchiveSummary}; pub use session_use_cases::summarize_session_meta; diff --git a/crates/application/src/ports/agent_session.rs b/crates/application/src/ports/agent_session.rs index af5fd387..5363c06d 100644 --- a/crates/application/src/ports/agent_session.rs +++ b/crates/application/src/ports/agent_session.rs @@ -15,12 +15,11 @@ use astrcode_core::{ }; use astrcode_kernel::PendingParentDelivery; use astrcode_session_runtime::{ - AgentObserveSnapshot, AgentPromptSubmission, ProjectedTurnOutcome, SessionRuntime, - TurnTerminalSnapshot, + AgentObserveSnapshot, ProjectedTurnOutcome, SessionRuntime, TurnTerminalSnapshot, }; use async_trait::async_trait; -use super::AppSessionPort; +use super::{AppAgentPromptSubmission, AppSessionPort}; /// Agent 编排子域依赖的 session 稳定端口。 /// @@ -39,7 +38,7 @@ pub trait AgentSessionPort: AppSessionPort { session_id: &str, text: String, runtime: ResolvedRuntimeConfig, - submission: AgentPromptSubmission, + submission: AppAgentPromptSubmission, ) -> astrcode_core::Result<ExecutionAccepted>; async fn try_submit_prompt_for_agent_with_turn_id( &self, @@ -47,7 +46,7 @@ pub trait AgentSessionPort: AppSessionPort { turn_id: TurnId, text: String, runtime: ResolvedRuntimeConfig, - submission: AgentPromptSubmission, + submission: AppAgentPromptSubmission, ) -> astrcode_core::Result<Option<ExecutionAccepted>>; async fn submit_queued_inputs_for_agent_with_turn_id( &self, @@ -55,7 +54,7 @@ pub trait AgentSessionPort: AppSessionPort { turn_id: TurnId, queued_inputs: Vec<String>, runtime: ResolvedRuntimeConfig, - submission: AgentPromptSubmission, + submission: AppAgentPromptSubmission, ) -> astrcode_core::Result<Option<ExecutionAccepted>>; // Durable input queue / collaboration 事件追加。 @@ -149,9 +148,9 @@ impl AgentSessionPort for SessionRuntime { session_id: &str, text: String, runtime: ResolvedRuntimeConfig, - submission: AgentPromptSubmission, + submission: AppAgentPromptSubmission, ) -> astrcode_core::Result<ExecutionAccepted> { - self.submit_prompt_for_agent_with_submission(session_id, text, runtime, submission) + self.submit_prompt_for_agent_with_submission(session_id, text, runtime, submission.into()) .await } @@ -161,10 +160,14 @@ impl AgentSessionPort for SessionRuntime { turn_id: TurnId, text: String, runtime: ResolvedRuntimeConfig, - submission: AgentPromptSubmission, + submission: AppAgentPromptSubmission, ) -> astrcode_core::Result<Option<ExecutionAccepted>> { self.try_submit_prompt_for_agent_with_turn_id( - session_id, turn_id, text, runtime, submission, + session_id, + turn_id, + text, + runtime, + submission.into(), ) .await } @@ -175,14 +178,14 @@ impl AgentSessionPort for SessionRuntime { turn_id: TurnId, queued_inputs: Vec<String>, runtime: ResolvedRuntimeConfig, - submission: AgentPromptSubmission, + submission: AppAgentPromptSubmission, ) -> astrcode_core::Result<Option<ExecutionAccepted>> { self.submit_queued_inputs_for_agent_with_turn_id( session_id, turn_id, queued_inputs, runtime, - submission, + submission.into(), ) .await } diff --git a/crates/application/src/ports/app_session.rs b/crates/application/src/ports/app_session.rs index 24310a6f..de394833 100644 --- a/crates/application/src/ports/app_session.rs +++ b/crates/application/src/ports/app_session.rs @@ -11,13 +11,15 @@ use astrcode_core::{ SessionMeta, StoredEvent, }; use astrcode_session_runtime::{ - AgentPromptSubmission, ConversationSnapshotFacts, ConversationStreamReplayFacts, ForkPoint, - ForkResult, SessionCatalogEvent, SessionControlStateSnapshot, SessionModeSnapshot, - SessionReplay, SessionRuntime, SessionTranscriptSnapshot, + ConversationSnapshotFacts, ConversationStreamReplayFacts, ForkPoint, ForkResult, + SessionCatalogEvent, SessionControlStateSnapshot, SessionModeSnapshot, SessionReplay, + SessionRuntime, SessionTranscriptSnapshot, }; use async_trait::async_trait; use tokio::sync::broadcast; +use super::AppAgentPromptSubmission; + /// `App` 依赖的 session-runtime 稳定端口。 /// /// Why: `App` 只编排 session 用例,不应直接耦合 `SessionRuntime` 的具体结构。 @@ -41,7 +43,7 @@ pub trait AppSessionPort: Send + Sync { session_id: &str, text: String, runtime: ResolvedRuntimeConfig, - submission: AgentPromptSubmission, + submission: AppAgentPromptSubmission, ) -> astrcode_core::Result<ExecutionAccepted>; async fn interrupt_session(&self, session_id: &str) -> astrcode_core::Result<()>; async fn compact_session( @@ -134,9 +136,9 @@ impl AppSessionPort for SessionRuntime { session_id: &str, text: String, runtime: ResolvedRuntimeConfig, - submission: AgentPromptSubmission, + submission: AppAgentPromptSubmission, ) -> astrcode_core::Result<ExecutionAccepted> { - self.submit_prompt_for_agent(session_id, text, runtime, submission) + self.submit_prompt_for_agent(session_id, text, runtime, submission.into()) .await } diff --git a/crates/application/src/ports/mod.rs b/crates/application/src/ports/mod.rs index 7d85e955..2bf88a4f 100644 --- a/crates/application/src/ports/mod.rs +++ b/crates/application/src/ports/mod.rs @@ -12,9 +12,11 @@ mod agent_session; mod app_kernel; mod app_session; mod composer_skill; +mod session_submission; pub use agent_kernel::AgentKernelPort; pub use agent_session::AgentSessionPort; pub use app_kernel::AppKernelPort; pub use app_session::AppSessionPort; pub use composer_skill::{ComposerResolvedSkill, ComposerSkillPort}; +pub use session_submission::AppAgentPromptSubmission; diff --git a/crates/application/src/ports/session_submission.rs b/crates/application/src/ports/session_submission.rs new file mode 100644 index 00000000..c1132c33 --- /dev/null +++ b/crates/application/src/ports/session_submission.rs @@ -0,0 +1,44 @@ +//! 应用层定义的 agent prompt 提交载荷。 +//! +//! Why: application 可以表达“要提交什么治理上下文”, +//! 但不应该直接依赖 session-runtime 的具体提交结构。 + +use astrcode_core::{ + AgentEventContext, CapabilityCall, LlmMessage, PolicyContext, PromptDeclaration, + PromptGovernanceContext, ResolvedExecutionLimitsSnapshot, ResolvedSubagentContextOverrides, +}; +use astrcode_kernel::CapabilityRouter; + +/// 应用层提交给 session 端口的稳定载荷。 +#[derive(Clone, Default)] +pub struct AppAgentPromptSubmission { + pub agent: AgentEventContext, + pub capability_router: Option<CapabilityRouter>, + pub prompt_declarations: Vec<PromptDeclaration>, + pub resolved_limits: Option<ResolvedExecutionLimitsSnapshot>, + pub resolved_overrides: Option<ResolvedSubagentContextOverrides>, + pub injected_messages: Vec<LlmMessage>, + pub source_tool_call_id: Option<String>, + pub policy_context: Option<PolicyContext>, + pub governance_revision: Option<String>, + pub approval: Option<astrcode_core::ApprovalPending<CapabilityCall>>, + pub prompt_governance: Option<PromptGovernanceContext>, +} + +impl From<AppAgentPromptSubmission> for astrcode_session_runtime::AgentPromptSubmission { + fn from(value: AppAgentPromptSubmission) -> Self { + Self { + agent: value.agent, + capability_router: value.capability_router, + prompt_declarations: value.prompt_declarations, + resolved_limits: value.resolved_limits, + resolved_overrides: value.resolved_overrides, + injected_messages: value.injected_messages, + source_tool_call_id: value.source_tool_call_id, + policy_context: value.policy_context, + governance_revision: value.governance_revision, + approval: value.approval.map(Box::new), + prompt_governance: value.prompt_governance, + } + } +} diff --git a/crates/application/src/test_support.rs b/crates/application/src/test_support.rs index fdd29d3e..e533f356 100644 --- a/crates/application/src/test_support.rs +++ b/crates/application/src/test_support.rs @@ -10,15 +10,14 @@ use astrcode_core::{ }; use astrcode_kernel::PendingParentDelivery; use astrcode_session_runtime::{ - AgentObserveSnapshot, AgentPromptSubmission, ConversationSnapshotFacts, - ConversationStreamReplayFacts, ForkPoint, ForkResult, ProjectedTurnOutcome, - SessionCatalogEvent, SessionControlStateSnapshot, SessionModeSnapshot, SessionReplay, - SessionTranscriptSnapshot, TurnTerminalSnapshot, + AgentObserveSnapshot, ConversationSnapshotFacts, ConversationStreamReplayFacts, ForkPoint, + ForkResult, ProjectedTurnOutcome, SessionCatalogEvent, SessionControlStateSnapshot, + SessionModeSnapshot, SessionReplay, SessionTranscriptSnapshot, TurnTerminalSnapshot, }; use async_trait::async_trait; use tokio::sync::broadcast; -use crate::{AgentSessionPort, AppSessionPort}; +use crate::{AgentSessionPort, AppAgentPromptSubmission, AppSessionPort}; fn unimplemented_for_test(area: &str) -> ! { panic!("not used in {area}") @@ -72,7 +71,7 @@ impl AppSessionPort for StubSessionPort { _session_id: &str, _text: String, _runtime: ResolvedRuntimeConfig, - _submission: AgentPromptSubmission, + _submission: AppAgentPromptSubmission, ) -> astrcode_core::Result<ExecutionAccepted> { unimplemented_for_test("application test stub") } @@ -176,7 +175,7 @@ impl AgentSessionPort for StubSessionPort { _session_id: &str, _text: String, _runtime: ResolvedRuntimeConfig, - _submission: AgentPromptSubmission, + _submission: AppAgentPromptSubmission, ) -> astrcode_core::Result<ExecutionAccepted> { unimplemented_for_test("application test stub") } @@ -187,7 +186,7 @@ impl AgentSessionPort for StubSessionPort { _turn_id: TurnId, _text: String, _runtime: ResolvedRuntimeConfig, - _submission: AgentPromptSubmission, + _submission: AppAgentPromptSubmission, ) -> astrcode_core::Result<Option<ExecutionAccepted>> { unimplemented_for_test("application test stub") } @@ -198,7 +197,7 @@ impl AgentSessionPort for StubSessionPort { _turn_id: TurnId, _queued_inputs: Vec<String>, _runtime: ResolvedRuntimeConfig, - _submission: AgentPromptSubmission, + _submission: AppAgentPromptSubmission, ) -> astrcode_core::Result<Option<ExecutionAccepted>> { unimplemented_for_test("application test stub") } diff --git a/crates/core/src/action.rs b/crates/core/src/action.rs index ef208b61..ff4f3580 100644 --- a/crates/core/src/action.rs +++ b/crates/core/src/action.rs @@ -198,6 +198,10 @@ pub enum UserMessageOrigin { ContinuationPrompt, /// 子会话交付后用于唤醒父会话继续决策的内部提示。 ReactivationPrompt, + /// compact 后为最近真实用户消息生成的极短目的摘要。 + RecentUserContextDigest, + /// compact 后重新注入的最近真实用户消息原文。 + RecentUserContext, /// 压缩摘要(上下文压缩后插入的摘要消息) CompactSummary, } diff --git a/crates/core/src/config.rs b/crates/core/src/config.rs index 066288a2..d1acc776 100644 --- a/crates/core/src/config.rs +++ b/crates/core/src/config.rs @@ -36,6 +36,8 @@ pub const DEFAULT_RESERVED_CONTEXT_SIZE: usize = 20_000; pub const DEFAULT_MAX_OUTPUT_CONTINUATION_ATTEMPTS: u8 = 3; pub const DEFAULT_MAX_CONTINUATIONS: u8 = 3; pub const DEFAULT_SUMMARY_RESERVE_TOKENS: usize = 20_000; +pub const DEFAULT_COMPACT_KEEP_RECENT_USER_MESSAGES: u8 = 8; +pub const DEFAULT_COMPACT_MAX_OUTPUT_TOKENS: usize = 20_000; pub const DEFAULT_MAX_TRACKED_FILES: usize = 10; pub const DEFAULT_MAX_RECOVERED_FILES: usize = 5; pub const DEFAULT_RECOVERY_TOKEN_BUDGET: usize = 50_000; @@ -106,6 +108,8 @@ pub struct RuntimeConfig { pub tool_result_max_bytes: Option<usize>, #[serde(skip_serializing_if = "Option::is_none")] pub compact_keep_recent_turns: Option<u8>, + #[serde(skip_serializing_if = "Option::is_none")] + pub compact_keep_recent_user_messages: Option<u8>, #[serde(skip_serializing_if = "Option::is_none")] pub agent: Option<AgentConfig>, @@ -137,6 +141,8 @@ pub struct RuntimeConfig { #[serde(skip_serializing_if = "Option::is_none")] pub summary_reserve_tokens: Option<usize>, #[serde(skip_serializing_if = "Option::is_none")] + pub compact_max_output_tokens: Option<usize>, + #[serde(skip_serializing_if = "Option::is_none")] pub max_tracked_files: Option<usize>, #[serde(skip_serializing_if = "Option::is_none")] pub max_recovered_files: Option<usize>, @@ -209,6 +215,7 @@ pub struct ResolvedRuntimeConfig { pub compact_threshold_percent: u8, pub tool_result_max_bytes: usize, pub compact_keep_recent_turns: u8, + pub compact_keep_recent_user_messages: u8, pub agent: ResolvedAgentConfig, pub max_consecutive_failures: usize, pub recovery_truncate_bytes: usize, @@ -222,6 +229,7 @@ pub struct ResolvedRuntimeConfig { pub max_output_continuation_attempts: u8, pub max_continuations: u8, pub summary_reserve_tokens: usize, + pub compact_max_output_tokens: usize, pub max_tracked_files: usize, pub max_recovered_files: usize, pub recovery_token_budget: usize, @@ -272,6 +280,7 @@ impl Default for ResolvedRuntimeConfig { compact_threshold_percent: DEFAULT_COMPACT_THRESHOLD_PERCENT, tool_result_max_bytes: DEFAULT_TOOL_RESULT_MAX_BYTES, compact_keep_recent_turns: DEFAULT_COMPACT_KEEP_RECENT_TURNS, + compact_keep_recent_user_messages: DEFAULT_COMPACT_KEEP_RECENT_USER_MESSAGES, agent: ResolvedAgentConfig::default(), max_consecutive_failures: DEFAULT_MAX_CONSECUTIVE_FAILURES, recovery_truncate_bytes: DEFAULT_RECOVERY_TRUNCATE_BYTES, @@ -285,6 +294,7 @@ impl Default for ResolvedRuntimeConfig { max_output_continuation_attempts: DEFAULT_MAX_OUTPUT_CONTINUATION_ATTEMPTS, max_continuations: DEFAULT_MAX_CONTINUATIONS, summary_reserve_tokens: DEFAULT_SUMMARY_RESERVE_TOKENS, + compact_max_output_tokens: DEFAULT_COMPACT_MAX_OUTPUT_TOKENS, max_tracked_files: DEFAULT_MAX_TRACKED_FILES, max_recovered_files: DEFAULT_MAX_RECOVERED_FILES, recovery_token_budget: DEFAULT_RECOVERY_TOKEN_BUDGET, @@ -434,6 +444,10 @@ impl fmt::Debug for RuntimeConfig { .field("compact_threshold_percent", &self.compact_threshold_percent) .field("tool_result_max_bytes", &self.tool_result_max_bytes) .field("compact_keep_recent_turns", &self.compact_keep_recent_turns) + .field( + "compact_keep_recent_user_messages", + &self.compact_keep_recent_user_messages, + ) .field("agent", &self.agent) .field("max_consecutive_failures", &self.max_consecutive_failures) .field("recovery_truncate_bytes", &self.recovery_truncate_bytes) @@ -452,6 +466,7 @@ impl fmt::Debug for RuntimeConfig { ) .field("max_continuations", &self.max_continuations) .field("summary_reserve_tokens", &self.summary_reserve_tokens) + .field("compact_max_output_tokens", &self.compact_max_output_tokens) .field("max_tracked_files", &self.max_tracked_files) .field("max_recovered_files", &self.max_recovered_files) .field("recovery_token_budget", &self.recovery_token_budget) @@ -656,6 +671,10 @@ pub fn resolve_runtime_config(runtime: &RuntimeConfig) -> ResolvedRuntimeConfig .compact_keep_recent_turns .unwrap_or(defaults.compact_keep_recent_turns) .max(1), + compact_keep_recent_user_messages: runtime + .compact_keep_recent_user_messages + .unwrap_or(defaults.compact_keep_recent_user_messages) + .max(1), agent: resolve_agent_config(runtime.agent.as_ref()), max_consecutive_failures: runtime .max_consecutive_failures @@ -698,6 +717,10 @@ pub fn resolve_runtime_config(runtime: &RuntimeConfig) -> ResolvedRuntimeConfig summary_reserve_tokens: runtime .summary_reserve_tokens .unwrap_or(defaults.summary_reserve_tokens), + compact_max_output_tokens: runtime + .compact_max_output_tokens + .unwrap_or(defaults.compact_max_output_tokens) + .max(1), max_tracked_files: runtime .max_tracked_files .unwrap_or(defaults.max_tracked_files) @@ -773,6 +796,14 @@ mod tests { resolved.tool_result_inline_limit, DEFAULT_TOOL_RESULT_INLINE_LIMIT ); + assert_eq!( + resolved.compact_keep_recent_user_messages, + DEFAULT_COMPACT_KEEP_RECENT_USER_MESSAGES + ); + assert_eq!( + resolved.compact_max_output_tokens, + DEFAULT_COMPACT_MAX_OUTPUT_TOKENS + ); } #[test] diff --git a/crates/core/src/event/phase.rs b/crates/core/src/event/phase.rs index 0dbf30e6..78de015f 100644 --- a/crates/core/src/event/phase.rs +++ b/crates/core/src/event/phase.rs @@ -72,7 +72,7 @@ pub fn normalize_recovered_phase(phase: Phase) -> Phase { /// /// 关键设计: /// - 内部唤醒消息(AutoContinueNudge / QueuedInput / ContinuationPrompt / ReactivationPrompt / -/// CompactSummary)不触发 phase 变更,避免 UI 闪烁 +/// RecentUserContextDigest / RecentUserContext / CompactSummary)不触发 phase 变更,避免 UI 闪烁 /// - 辅助事件(PromptMetrics / CompactApplied / SubRun 等)也不触发 phase 变更 /// - `force_to` 用于 SessionStart → Idle 和 TurnDone → Idle 这类必须变更的场景 pub struct PhaseTracker { @@ -101,6 +101,8 @@ impl PhaseTracker { | UserMessageOrigin::QueuedInput | UserMessageOrigin::ContinuationPrompt | UserMessageOrigin::ReactivationPrompt + | UserMessageOrigin::RecentUserContextDigest + | UserMessageOrigin::RecentUserContext | UserMessageOrigin::CompactSummary, .. } diff --git a/crates/core/src/event/translate.rs b/crates/core/src/event/translate.rs index 4fe6fab5..fe5c3c29 100644 --- a/crates/core/src/event/translate.rs +++ b/crates/core/src/event/translate.rs @@ -577,6 +577,8 @@ mod tests { UserMessageOrigin::CompactSummary, UserMessageOrigin::AutoContinueNudge, UserMessageOrigin::ContinuationPrompt, + UserMessageOrigin::RecentUserContextDigest, + UserMessageOrigin::RecentUserContext, ] { let records = replay_records( &[StoredEvent { diff --git a/crates/core/src/ports.rs b/crates/core/src/ports.rs index e1f3fe3a..195fd5a9 100644 --- a/crates/core/src/ports.rs +++ b/crates/core/src/ports.rs @@ -80,6 +80,22 @@ pub enum LlmFinishReason { Other(String), } +impl LlmFinishReason { + pub fn is_max_tokens(&self) -> bool { + matches!(self, Self::MaxTokens) + } + + /// 从 OpenAI / Anthropic 返回的 finish reason 字符串解析统一枚举。 + pub fn from_api_value(value: &str) -> Self { + match value { + "stop" | "end_turn" | "stop_sequence" => Self::Stop, + "max_tokens" | "length" => Self::MaxTokens, + "tool_calls" | "tool_use" => Self::ToolCalls, + other => Self::Other(other.to_string()), + } + } +} + /// 流式增量事件。 #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -105,6 +121,7 @@ pub struct LlmRequest { pub cancel: CancelToken, pub system_prompt: Option<String>, pub system_prompt_blocks: Vec<SystemPromptBlock>, + pub max_output_tokens_override: Option<usize>, } impl LlmRequest { @@ -119,6 +136,7 @@ impl LlmRequest { cancel, system_prompt: None, system_prompt_blocks: Vec::new(), + max_output_tokens_override: None, } } @@ -126,6 +144,22 @@ impl LlmRequest { self.system_prompt = Some(prompt.into()); self } + + pub fn with_max_output_tokens_override(mut self, max_output_tokens: usize) -> Self { + self.max_output_tokens_override = Some(max_output_tokens.max(1)); + self + } + + pub fn from_model_request(request: crate::ModelRequest, cancel: CancelToken) -> Self { + Self { + messages: request.messages, + tools: request.tools.into(), + cancel, + system_prompt: request.system_prompt, + system_prompt_blocks: request.system_prompt_blocks, + max_output_tokens_override: None, + } + } } /// 模型调用输出。 diff --git a/crates/server/src/bootstrap/providers.rs b/crates/server/src/bootstrap/providers.rs index d540c178..561c0957 100644 --- a/crates/server/src/bootstrap/providers.rs +++ b/crates/server/src/bootstrap/providers.rs @@ -11,8 +11,7 @@ use std::{ use astrcode_adapter_agents::AgentProfileLoader; use astrcode_adapter_llm::{ - LlmClientConfig, ModelLimits as AdapterModelLimits, anthropic::AnthropicProvider, - openai::OpenAiProvider, + LlmClientConfig, ModelLimits, anthropic::AnthropicProvider, openai::OpenAiProvider, }; use astrcode_adapter_mcp::{core_port::McpResourceProvider, manager::McpConnectionManager}; use astrcode_adapter_prompt::{ @@ -30,8 +29,7 @@ use astrcode_application::{ use super::deps::core::{ AgentProfile, AstrError, LlmEventSink, LlmOutput, LlmProvider, LlmRequest, ModelConfig, - ModelLimits, PromptProvider, ResolvedRuntimeConfig, ResourceProvider, Result, - resolve_runtime_config, + PromptProvider, ResolvedRuntimeConfig, ResourceProvider, Result, resolve_runtime_config, }; pub(crate) fn build_llm_provider( @@ -192,20 +190,14 @@ impl ConfigBackedLlmProvider { spec.endpoint.clone(), spec.api_key.clone(), spec.model.clone(), - AdapterModelLimits { - context_window: spec.limits.context_window, - max_output_tokens: spec.limits.max_output_tokens, - }, + spec.limits, spec.client_config, )?), "anthropic" => Arc::new(AnthropicProvider::new( spec.endpoint.clone(), spec.api_key.clone(), spec.model.clone(), - AdapterModelLimits { - context_window: spec.limits.context_window, - max_output_tokens: spec.limits.max_output_tokens, - }, + spec.limits, spec.client_config, )?), other => { diff --git a/crates/server/src/bootstrap/runtime.rs b/crates/server/src/bootstrap/runtime.rs index 2a460350..e154ef66 100644 --- a/crates/server/src/bootstrap/runtime.rs +++ b/crates/server/src/bootstrap/runtime.rs @@ -8,7 +8,7 @@ use std::{ sync::Arc, }; -use astrcode_adapter_storage::core_port::FsEventStore; +use astrcode_adapter_storage::session::FileSystemSessionRepository; use astrcode_adapter_tools::builtin_tools::tool_search::ToolSearchIndex; use astrcode_application::{ AgentOrchestrationService, App, AppGovernance, GovernanceSurfaceAssembler, @@ -200,9 +200,9 @@ pub async fn bootstrap_server_runtime_with_options( mode_catalog.as_ref().clone(), )); - let event_store: Arc<dyn EventStore> = Arc::new(FsEventStore::new_with_projects_root( - paths.projects_root.clone(), - )); + let event_store: Arc<dyn EventStore> = Arc::new( + FileSystemSessionRepository::new_with_projects_root(paths.projects_root.clone()), + ); let prompt_facts_provider = build_prompt_facts_provider( config_service.clone(), skill_catalog.clone(), diff --git a/crates/session-runtime/src/context_window/compaction.rs b/crates/session-runtime/src/context_window/compaction.rs index 009ea611..db6c0d21 100644 --- a/crates/session-runtime/src/context_window/compaction.rs +++ b/crates/session-runtime/src/context_window/compaction.rs @@ -30,15 +30,23 @@ use super::token_usage::{effective_context_window, estimate_request_tokens}; const BASE_COMPACT_PROMPT_TEMPLATE: &str = include_str!("templates/compact/base.md"); const INCREMENTAL_COMPACT_PROMPT_TEMPLATE: &str = include_str!("templates/compact/incremental.md"); +#[path = "compaction/protocol.rs"] +mod protocol; +use protocol::*; + /// 压缩配置。 #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct CompactConfig { /// 保留最近的用户 turn 数量。 pub keep_recent_turns: usize, + /// 额外保留最近真实用户消息的数量。 + pub keep_recent_user_messages: usize, /// 压缩触发方式。 pub trigger: astrcode_core::CompactTrigger, /// compact 请求自身保留的输出预算。 pub summary_reserve_tokens: usize, + /// compact 请求的最大输出 token 上限。 + pub max_output_tokens: usize, /// compact 允许的最大裁剪重试次数。 pub max_retry_attempts: usize, /// compact 后注入给模型的旧历史 event log 路径提示。 @@ -54,6 +62,10 @@ pub(crate) struct CompactResult { pub messages: Vec<LlmMessage>, /// 压缩生成的摘要文本。 pub summary: String, + /// 最近真实用户消息的极短目的摘要。 + pub recent_user_context_digest: Option<String>, + /// compact 后重新注入的最近真实用户消息原文。 + pub recent_user_context_messages: Vec<String>, /// 保留的最近 turn 数。 pub preserved_recent_turns: usize, /// 压缩前估算 token 数。 @@ -84,13 +96,6 @@ struct CompactionUnit { boundary: CompactionBoundary, } -#[derive(Debug, Clone, PartialEq, Eq)] -struct ParsedCompactOutput { - summary: String, - has_analysis: bool, - used_fallback: bool, -} - #[derive(Debug, Clone, PartialEq, Eq)] enum CompactPromptMode { Fresh, @@ -130,12 +135,21 @@ pub async fn auto_compact( config: CompactConfig, cancel: CancelToken, ) -> Result<Option<CompactResult>> { - let preserved_recent_turns = config.keep_recent_turns.max(1); + let recent_user_context_messages = + collect_recent_user_context_messages(messages, config.keep_recent_user_messages); + let preserved_recent_turns = config + .keep_recent_turns + .max(config.keep_recent_user_messages) + .max(1); let Some(mut split) = split_for_compaction(messages, preserved_recent_turns) else { return Ok(None); }; let pre_tokens = estimate_request_tokens(messages, compact_prompt_context); + let effective_max_output_tokens = config + .max_output_tokens + .min(gateway.model_limits().max_output_tokens) + .max(1); let mut attempts = 0usize; let (parsed_output, prepared_input) = loop { if !trim_prefix_until_compact_request_fits( @@ -143,6 +157,7 @@ pub async fn auto_compact( compact_prompt_context, gateway.model_limits(), &config, + &recent_user_context_messages, ) { return Err(AstrError::Internal( "compact request could not fit within summarization window".to_string(), @@ -158,8 +173,11 @@ pub async fn auto_compact( .with_system(render_compact_system_prompt( compact_prompt_context, prepared_input.prompt_mode.clone(), + effective_max_output_tokens, + &recent_user_context_messages, config.custom_instructions.as_deref(), - )); + )) + .with_max_output_tokens_override(effective_max_output_tokens); match gateway.call_llm(request, None).await { Ok(output) => break (parse_compact_output(&output.content)?, prepared_input), Err(error) if is_prompt_too_long(&error) && attempts < config.max_retry_attempts => { @@ -183,12 +201,28 @@ pub async fn auto_compact( summary } }; + let recent_user_context_digest = parsed_output + .recent_user_context_digest + .as_deref() + .map(sanitize_recent_user_context_digest) + .filter(|value| !value.is_empty()); let output_summary_chars = summary.chars().count().min(u32::MAX as usize) as u32; - let compacted_messages = compacted_messages(&summary, split.suffix); + let compacted_messages = compacted_messages( + &summary, + recent_user_context_digest.as_deref(), + &recent_user_context_messages, + split.keep_start, + split.suffix, + ); let post_tokens_estimate = estimate_request_tokens(&compacted_messages, compact_prompt_context); Ok(Some(CompactResult { messages: compacted_messages, summary, + recent_user_context_digest, + recent_user_context_messages: recent_user_context_messages + .iter() + .map(|message| message.content.clone()) + .collect(), preserved_recent_turns, pre_tokens, post_tokens_estimate, @@ -209,417 +243,12 @@ pub async fn auto_compact( })) } -/// 合并 compact 使用的 prompt 上下文。 -#[cfg(test)] -fn merge_compact_prompt_context( - runtime_system_prompt: Option<&str>, - additional_system_prompt: Option<&str>, -) -> Option<String> { - let runtime_system_prompt = runtime_system_prompt.filter(|v| !v.trim().is_empty()); - let additional_system_prompt = additional_system_prompt.filter(|v| !v.trim().is_empty()); - - match (runtime_system_prompt, additional_system_prompt) { - (None, None) => None, - (Some(base), None) => Some(base.to_string()), - (None, Some(additional)) => Some(additional.to_string()), - (Some(base), Some(additional)) => Some(format!("{base}\n\n{additional}")), - } -} - -/// 判断错误是否为 prompt too long。 -pub fn is_prompt_too_long(error: &astrcode_kernel::KernelError) -> bool { - let message = error.to_string(); - // 检查常见 prompt-too-long 错误模式 - contains_ascii_case_insensitive(&message, "prompt too long") - || contains_ascii_case_insensitive(&message, "context length") - || contains_ascii_case_insensitive(&message, "maximum context") - || contains_ascii_case_insensitive(&message, "too many tokens") -} - -fn render_compact_system_prompt( - compact_prompt_context: Option<&str>, - mode: CompactPromptMode, - custom_instructions: Option<&str>, -) -> String { - let incremental_block = match mode { - CompactPromptMode::Fresh => String::new(), - CompactPromptMode::Incremental { previous_summary } => INCREMENTAL_COMPACT_PROMPT_TEMPLATE - .replace("{{PREVIOUS_SUMMARY}}", previous_summary.trim()), - }; - let runtime_context = compact_prompt_context - .filter(|v| !v.trim().is_empty()) - .map(|v| format!("\nCurrent runtime system prompt for context:\n{v}")) - .unwrap_or_default(); - let custom_instruction_block = custom_instructions - .filter(|value| !value.trim().is_empty()) - .map(|value| { - format!( - "\n## Manual Compact Instructions\nFollow these extra requirements for this \ - compact only:\n{value}" - ) - }) - .unwrap_or_default(); - - BASE_COMPACT_PROMPT_TEMPLATE - .replace("{{INCREMENTAL_MODE}}", incremental_block.trim()) - .replace("{{CUSTOM_INSTRUCTIONS}}", custom_instruction_block.trim()) - .replace("{{RUNTIME_CONTEXT}}", runtime_context.trim_end()) -} - #[derive(Debug, Clone)] struct CompactionSplit { prefix: Vec<LlmMessage>, suffix: Vec<LlmMessage>, keep_start: usize, } - -fn prepare_compact_input(messages: &[LlmMessage]) -> PreparedCompactInput { - let prompt_mode = latest_previous_summary(messages) - .map(|previous_summary| CompactPromptMode::Incremental { previous_summary }) - .unwrap_or(CompactPromptMode::Fresh); - let messages = messages - .iter() - .filter_map(normalize_compaction_message) - .collect::<Vec<_>>(); - let input_units = compaction_units(&messages).len().max(1); - PreparedCompactInput { - messages, - prompt_mode, - input_units, - } -} - -fn latest_previous_summary(messages: &[LlmMessage]) -> Option<String> { - messages.iter().rev().find_map(|message| match message { - LlmMessage::User { - content, - origin: UserMessageOrigin::CompactSummary, - } => parse_compact_summary_message(content) - .map(|envelope| sanitize_compact_summary(&envelope.summary)), - _ => None, - }) -} - -fn normalize_compaction_message(message: &LlmMessage) -> Option<LlmMessage> { - match message { - LlmMessage::User { - content, - origin: UserMessageOrigin::User, - } => Some(LlmMessage::User { - content: content.trim().to_string(), - origin: UserMessageOrigin::User, - }), - LlmMessage::User { .. } => None, - LlmMessage::Assistant { - content, - tool_calls, - .. - } => { - let mut lines = Vec::new(); - let visible = collapse_compaction_whitespace(content); - if !visible.is_empty() { - lines.push(visible); - } - if !tool_calls.is_empty() { - let names = tool_calls - .iter() - .map(|call| call.name.trim()) - .filter(|name| !name.is_empty()) - .collect::<Vec<_>>(); - if !names.is_empty() { - lines.push(format!("Requested tools: {}", names.join(", "))); - } - } - let normalized = lines.join("\n"); - if normalized.trim().is_empty() { - None - } else { - Some(LlmMessage::Assistant { - content: normalized, - tool_calls: Vec::new(), - reasoning: None, - }) - } - }, - LlmMessage::Tool { - tool_call_id, - content, - } => { - let normalized = normalize_compaction_tool_content(content); - if normalized.is_empty() { - None - } else { - Some(LlmMessage::Tool { - tool_call_id: tool_call_id.clone(), - content: normalized, - }) - } - }, - } -} - -fn collapse_compaction_whitespace(content: &str) -> String { - content - .lines() - .map(str::trim) - .collect::<Vec<_>>() - .join("\n") - .split("\n\n\n") - .collect::<Vec<_>>() - .join("\n\n") - .trim() - .to_string() -} - -fn normalize_compaction_tool_content(content: &str) -> String { - let stripped_child_ref = strip_child_agent_reference_hint(content); - let collapsed = collapse_compaction_whitespace(&stripped_child_ref); - if collapsed.is_empty() { - return String::new(); - } - if is_persisted_output(&collapsed) { - return summarize_persisted_tool_output(&collapsed); - } - - const MAX_COMPACTION_TOOL_CHARS: usize = 1_600; - let char_count = collapsed.chars().count(); - if char_count <= MAX_COMPACTION_TOOL_CHARS { - return collapsed; - } - - let preview = collapsed - .chars() - .take(MAX_COMPACTION_TOOL_CHARS) - .collect::<String>(); - format!( - "{preview}\n\n[tool output truncated for compaction; preserve only the conclusion, key \ - errors, important file paths, and referenced IDs]" - ) -} - -fn summarize_persisted_tool_output(content: &str) -> String { - let persisted_path = persisted_output_absolute_path(content) - .unwrap_or_else(|| "unknown persisted path".to_string()); - format!( - "Large tool output was persisted instead of inlined.\nPersisted path: \ - {persisted_path}\nPreserve only the conclusion, referenced path, and any error." - ) -} - -fn sanitize_compact_summary(summary: &str) -> String { - let had_route_sensitive_content = summary_has_route_sensitive_content(summary); - let mut sanitized = summary.trim().to_string(); - sanitized = direct_child_validation_regex() - .replace_all( - &sanitized, - "direct-child validation rejected a stale child reference; use the live direct-child \ - snapshot or the latest live tool result instead.", - ) - .into_owned(); - sanitized = child_agent_reference_block_regex() - .replace_all( - &sanitized, - "Child agent reference metadata existed earlier, but compacted history is not an \ - authoritative routing source.", - ) - .into_owned(); - for (regex, replacement) in [ - ( - route_key_regex("agentId"), - "${key}<latest-direct-child-agentId>", - ), - ( - route_key_regex("childAgentId"), - "${key}<latest-direct-child-agentId>", - ), - (route_key_regex("parentAgentId"), "${key}<parent-agentId>"), - (route_key_regex("subRunId"), "${key}<direct-child-subRunId>"), - (route_key_regex("parentSubRunId"), "${key}<parent-subRunId>"), - (route_key_regex("sessionId"), "${key}<session-id>"), - ( - route_key_regex("childSessionId"), - "${key}<child-session-id>", - ), - (route_key_regex("openSessionId"), "${key}<child-session-id>"), - ] { - sanitized = regex.replace_all(&sanitized, replacement).into_owned(); - } - sanitized = exact_agent_instruction_regex() - .replace_all( - &sanitized, - "Use only the latest live child snapshot or tool result for agent routing.", - ) - .into_owned(); - sanitized = raw_root_agent_id_regex() - .replace_all(&sanitized, "<agent-id>") - .into_owned(); - sanitized = raw_agent_id_regex() - .replace_all(&sanitized, "<agent-id>") - .into_owned(); - sanitized = raw_subrun_id_regex() - .replace_all(&sanitized, "<subrun-id>") - .into_owned(); - sanitized = raw_session_id_regex() - .replace_all(&sanitized, "<session-id>") - .into_owned(); - sanitized = collapse_compaction_whitespace(&sanitized); - if had_route_sensitive_content { - ensure_compact_boundary_section(&sanitized) - } else { - sanitized - } -} - -fn ensure_compact_boundary_section(summary: &str) -> String { - if summary.contains("## Compact Boundary") { - return summary.to_string(); - } - format!( - "## Compact Boundary\n- Historical `agentId`, `subRunId`, and `sessionId` values from \ - compacted history are non-authoritative.\n- Use the live direct-child snapshot or the \ - latest live tool result / child notification for routing.\n\n{}", - summary.trim() - ) -} - -fn summary_has_route_sensitive_content(summary: &str) -> bool { - direct_child_validation_regex().is_match(summary) - || child_agent_reference_block_regex().is_match(summary) - || exact_agent_instruction_regex().is_match(summary) - || raw_root_agent_id_regex().is_match(summary) - || raw_agent_id_regex().is_match(summary) - || raw_subrun_id_regex().is_match(summary) - || raw_session_id_regex().is_match(summary) - || [ - route_key_regex("agentId"), - route_key_regex("childAgentId"), - route_key_regex("parentAgentId"), - route_key_regex("subRunId"), - route_key_regex("parentSubRunId"), - route_key_regex("sessionId"), - route_key_regex("childSessionId"), - route_key_regex("openSessionId"), - ] - .into_iter() - .any(|regex| regex.is_match(summary)) -} - -fn child_agent_reference_block_regex() -> &'static Regex { - static REGEX: OnceLock<Regex> = OnceLock::new(); - REGEX.get_or_init(|| { - Regex::new(r"(?is)Child agent reference:\s*(?:\n- .*)+") - .expect("child agent reference regex should compile") - }) -} - -fn direct_child_validation_regex() -> &'static Regex { - static REGEX: OnceLock<Regex> = OnceLock::new(); - REGEX.get_or_init(|| { - Regex::new(r"(?i)not a direct child of caller[^\n]*") - .expect("direct child validation regex should compile") - }) -} - -fn route_key_regex(key: &str) -> &'static Regex { - static AGENT_ID: OnceLock<Regex> = OnceLock::new(); - static CHILD_AGENT_ID: OnceLock<Regex> = OnceLock::new(); - static PARENT_AGENT_ID: OnceLock<Regex> = OnceLock::new(); - static SUB_RUN_ID: OnceLock<Regex> = OnceLock::new(); - static PARENT_SUB_RUN_ID: OnceLock<Regex> = OnceLock::new(); - static SESSION_ID: OnceLock<Regex> = OnceLock::new(); - static CHILD_SESSION_ID: OnceLock<Regex> = OnceLock::new(); - static OPEN_SESSION_ID: OnceLock<Regex> = OnceLock::new(); - let slot = match key { - "agentId" => &AGENT_ID, - "childAgentId" => &CHILD_AGENT_ID, - "parentAgentId" => &PARENT_AGENT_ID, - "subRunId" => &SUB_RUN_ID, - "parentSubRunId" => &PARENT_SUB_RUN_ID, - "sessionId" => &SESSION_ID, - "childSessionId" => &CHILD_SESSION_ID, - "openSessionId" => &OPEN_SESSION_ID, - other => panic!("unsupported route key regex: {other}"), - }; - slot.get_or_init(|| { - Regex::new(&format!( - r"(?i)(?P<key>`?{key}`?\s*[:=]\s*`?)[^`\s,;\])]+`?" - )) - .expect("route key regex should compile") - }) -} - -fn exact_agent_instruction_regex() -> &'static Regex { - static REGEX: OnceLock<Regex> = OnceLock::new(); - REGEX.get_or_init(|| { - Regex::new( - r"(?i)(use this exact `agentid` value[^\n]*|copy it byte-for-byte[^\n]*|keep `agentid` exact[^\n]*)", - ) - .expect("exact agent instruction regex should compile") - }) -} - -fn raw_root_agent_id_regex() -> &'static Regex { - static REGEX: OnceLock<Regex> = OnceLock::new(); - REGEX.get_or_init(|| { - Regex::new(r"\broot-agent:[A-Za-z0-9._:-]+\b") - .expect("raw root agent id regex should compile") - }) -} - -fn raw_agent_id_regex() -> &'static Regex { - static REGEX: OnceLock<Regex> = OnceLock::new(); - REGEX.get_or_init(|| { - Regex::new(r"\bagent-[A-Za-z0-9._:-]+\b").expect("raw agent id regex should compile") - }) -} - -fn raw_subrun_id_regex() -> &'static Regex { - static REGEX: OnceLock<Regex> = OnceLock::new(); - REGEX.get_or_init(|| { - Regex::new(r"\bsubrun-[A-Za-z0-9._:-]+\b").expect("raw subrun regex should compile") - }) -} - -fn raw_session_id_regex() -> &'static Regex { - static REGEX: OnceLock<Regex> = OnceLock::new(); - REGEX.get_or_init(|| { - Regex::new(r"\bsession-[A-Za-z0-9._:-]+\b").expect("raw session regex should compile") - }) -} - -fn strip_child_agent_reference_hint(content: &str) -> String { - let Some((prefix, child_ref_block)) = content.split_once("\n\nChild agent reference:") else { - return content.to_string(); - }; - let mut has_reference_fields = false; - for line in child_ref_block.lines() { - let trimmed = line.trim(); - if trimmed.starts_with("- agentId:") - || trimmed.starts_with("- subRunId:") - || trimmed.starts_with("- openSessionId:") - || trimmed.starts_with("- status:") - { - has_reference_fields = true; - } - } - let child_ref_summary = if has_reference_fields { - "Child agent reference existed in the original tool result. Do not reuse any agentId, \ - subRunId, or sessionId from compacted history; rely on the latest live tool result or \ - current direct-child snapshot instead." - .to_string() - } else { - "Child agent reference metadata existed in the original tool result, but compacted history \ - is not an authoritative source for later agent routing." - .to_string() - }; - let prefix = prefix.trim(); - if prefix.is_empty() { - child_ref_summary - } else { - format!("{prefix}\n\n{child_ref_summary}") - } -} - /// 检查消息是否可以被压缩。 #[cfg(test)] fn can_compact(messages: &[LlmMessage], keep_recent_turns: usize) -> bool { @@ -726,6 +355,7 @@ fn trim_prefix_until_compact_request_fits( compact_prompt_context: Option<&str>, limits: ModelLimits, config: &CompactConfig, + recent_user_context_messages: &[RecentUserContextMessage], ) -> bool { loop { let prepared_input = prepare_compact_input(prefix); @@ -736,6 +366,11 @@ fn trim_prefix_until_compact_request_fits( let system_prompt = render_compact_system_prompt( compact_prompt_context, prepared_input.prompt_mode, + config + .max_output_tokens + .min(limits.max_output_tokens) + .max(1), + recent_user_context_messages, config.custom_instructions.as_deref(), ); if compact_request_fits_window( @@ -763,584 +398,48 @@ fn compact_request_fits_window( <= effective_context_window(limits, summary_reserve_tokens) } -fn compacted_messages(summary: &str, suffix: Vec<LlmMessage>) -> Vec<LlmMessage> { +fn compacted_messages( + summary: &str, + recent_user_context_digest: Option<&str>, + recent_user_context_messages: &[RecentUserContextMessage], + keep_start: usize, + suffix: Vec<LlmMessage>, +) -> Vec<LlmMessage> { let mut messages = vec![LlmMessage::User { content: format_compact_summary(summary), origin: UserMessageOrigin::CompactSummary, }]; - messages.extend(suffix); + if let Some(digest) = recent_user_context_digest.filter(|value| !value.trim().is_empty()) { + messages.push(LlmMessage::User { + content: digest.trim().to_string(), + origin: UserMessageOrigin::RecentUserContextDigest, + }); + } + for message in recent_user_context_messages { + messages.push(LlmMessage::User { + content: message.content.clone(), + origin: UserMessageOrigin::RecentUserContext, + }); + } + messages.extend( + suffix + .into_iter() + .enumerate() + .filter(|(offset, message)| { + !matches!( + message, + LlmMessage::User { + origin: UserMessageOrigin::User, + .. + } if recent_user_context_messages + .iter() + .any(|recent| recent.index == keep_start + offset) + ) + }) + .map(|(_, message)| message), + ); messages } -fn parse_compact_output(content: &str) -> Result<ParsedCompactOutput> { - let normalized = strip_outer_markdown_code_fence(content); - let has_analysis = extract_xml_block(&normalized, "analysis").is_some(); - if !has_analysis { - log::warn!("compact: missing <analysis> block in LLM response"); - } - - if has_opening_xml_tag(&normalized, "summary") && !has_closing_xml_tag(&normalized, "summary") { - return Err(AstrError::LlmStreamError( - "compact response missing closing </summary> tag".to_string(), - )); - } - - let mut used_fallback = false; - let summary = if let Some(summary) = extract_xml_block(&normalized, "summary") { - summary.to_string() - } else if let Some(structured) = extract_structured_summary_fallback(&normalized) { - used_fallback = true; - structured - } else { - let fallback = strip_xml_block(&normalized, "analysis"); - let fallback = clean_compact_fallback_text(&fallback); - if fallback.is_empty() { - return Err(AstrError::LlmStreamError( - "compact response missing <summary> block".to_string(), - )); - } - log::warn!("compact: missing <summary> block, falling back to raw content"); - used_fallback = true; - fallback - }; - if summary.is_empty() { - return Err(AstrError::LlmStreamError( - "compact summary response was empty".to_string(), - )); - } - - Ok(ParsedCompactOutput { - summary, - has_analysis, - used_fallback, - }) -} - -fn extract_structured_summary_fallback(content: &str) -> Option<String> { - let cleaned = clean_compact_fallback_text(content); - let lower = cleaned.to_ascii_lowercase(); - let candidates = ["## summary", "# summary", "summary:"]; - for marker in candidates { - if let Some(start) = lower.find(marker) { - let body = cleaned[start + marker.len()..].trim(); - if !body.is_empty() { - return Some(body.to_string()); - } - } - } - None -} - -fn extract_xml_block<'a>(content: &'a str, tag: &str) -> Option<&'a str> { - xml_block_regex(tag) - .captures(content) - .and_then(|captures| captures.name("body")) - .map(|body| body.as_str().trim()) -} - -fn strip_xml_block(content: &str, tag: &str) -> String { - xml_block_regex(tag).replace(content, "").into_owned() -} - -fn has_opening_xml_tag(content: &str, tag: &str) -> bool { - xml_opening_tag_regex(tag).is_match(content) -} - -fn has_closing_xml_tag(content: &str, tag: &str) -> bool { - xml_closing_tag_regex(tag).is_match(content) -} - -fn strip_markdown_code_fence(content: &str) -> String { - let trimmed = content.trim(); - if !trimmed.starts_with("```") { - return trimmed.to_string(); - } - - let mut lines = trimmed.lines(); - let Some(first_line) = lines.next() else { - return trimmed.to_string(); - }; - if !first_line.trim_start().starts_with("```") { - return trimmed.to_string(); - } - - let body = lines.collect::<Vec<_>>().join("\n"); - let body = body.trim_end(); - body.strip_suffix("```").unwrap_or(body).trim().to_string() -} - -fn strip_outer_markdown_code_fence(content: &str) -> String { - let mut current = content.trim().to_string(); - loop { - let stripped = strip_markdown_code_fence(¤t); - if stripped == current { - return current; - } - current = stripped; - } -} - -fn clean_compact_fallback_text(content: &str) -> String { - let without_code_fence = strip_outer_markdown_code_fence(content); - let lines = without_code_fence - .lines() - .map(str::trim_end) - .collect::<Vec<_>>(); - let first_meaningful = lines - .iter() - .position(|line| !line.trim().is_empty()) - .unwrap_or(lines.len()); - let cleaned = lines - .into_iter() - .skip(first_meaningful) - .collect::<Vec<_>>() - .join("\n") - .trim() - .to_string(); - strip_leading_summary_preamble(&cleaned) -} - -fn strip_leading_summary_preamble(content: &str) -> String { - let mut lines = content.lines(); - let Some(first_line) = lines.next() else { - return String::new(); - }; - let trimmed_first_line = first_line.trim(); - if is_summary_preamble_line(trimmed_first_line) { - return lines.collect::<Vec<_>>().join("\n").trim().to_string(); - } - content.trim().to_string() -} - -fn is_summary_preamble_line(line: &str) -> bool { - let normalized = line - .trim_matches(|ch: char| matches!(ch, '*' | '#' | '-' | ':' | ' ')) - .trim(); - normalized.eq_ignore_ascii_case("summary") - || normalized.eq_ignore_ascii_case("here is the summary") - || normalized.eq_ignore_ascii_case("compact summary") - || normalized.eq_ignore_ascii_case("here's the summary") -} - -fn xml_block_regex(tag: &str) -> &'static Regex { - static SUMMARY_REGEX: OnceLock<Regex> = OnceLock::new(); - static ANALYSIS_REGEX: OnceLock<Regex> = OnceLock::new(); - - match tag { - "summary" => SUMMARY_REGEX.get_or_init(|| { - Regex::new(r"(?is)<summary(?:\s+[^>]*)?\s*>(?P<body>.*?)</summary\s*>") - .expect("summary regex should compile") - }), - "analysis" => ANALYSIS_REGEX.get_or_init(|| { - Regex::new(r"(?is)<analysis(?:\s+[^>]*)?\s*>(?P<body>.*?)</analysis\s*>") - .expect("analysis regex should compile") - }), - other => panic!("unsupported compact xml tag: {other}"), - } -} - -fn xml_opening_tag_regex(tag: &str) -> &'static Regex { - static SUMMARY_REGEX: OnceLock<Regex> = OnceLock::new(); - static ANALYSIS_REGEX: OnceLock<Regex> = OnceLock::new(); - - match tag { - "summary" => SUMMARY_REGEX.get_or_init(|| { - Regex::new(r"(?i)<summary(?:\s+[^>]*)?\s*>") - .expect("summary opening regex should compile") - }), - "analysis" => ANALYSIS_REGEX.get_or_init(|| { - Regex::new(r"(?i)<analysis(?:\s+[^>]*)?\s*>") - .expect("analysis opening regex should compile") - }), - other => panic!("unsupported compact xml tag: {other}"), - } -} - -fn xml_closing_tag_regex(tag: &str) -> &'static Regex { - static SUMMARY_REGEX: OnceLock<Regex> = OnceLock::new(); - static ANALYSIS_REGEX: OnceLock<Regex> = OnceLock::new(); - - match tag { - "summary" => SUMMARY_REGEX.get_or_init(|| { - Regex::new(r"(?i)</summary\s*>").expect("summary closing regex should compile") - }), - "analysis" => ANALYSIS_REGEX.get_or_init(|| { - Regex::new(r"(?i)</analysis\s*>").expect("analysis closing regex should compile") - }), - other => panic!("unsupported compact xml tag: {other}"), - } -} - -fn contains_ascii_case_insensitive(haystack: &str, needle: &str) -> bool { - let needle = needle.as_bytes(); - haystack - .as_bytes() - .windows(needle.len()) - .any(|window| window.eq_ignore_ascii_case(needle)) -} - #[cfg(test)] -mod tests { - use super::*; - - fn test_compact_config() -> CompactConfig { - CompactConfig { - keep_recent_turns: 1, - trigger: astrcode_core::CompactTrigger::Manual, - summary_reserve_tokens: 20_000, - max_retry_attempts: 3, - history_path: None, - custom_instructions: None, - } - } - - #[test] - fn render_compact_system_prompt_keeps_do_not_continue_instruction_intact() { - let prompt = render_compact_system_prompt(None, CompactPromptMode::Fresh, None); - - assert!( - prompt.contains("**Do NOT continue the conversation.**"), - "compact prompt must explicitly instruct the summarizer not to continue the session" - ); - } - - #[test] - fn render_compact_system_prompt_renders_incremental_block() { - let prompt = render_compact_system_prompt( - None, - CompactPromptMode::Incremental { - previous_summary: "older summary".to_string(), - }, - None, - ); - - assert!(prompt.contains("## Incremental Mode")); - assert!(prompt.contains("<previous-summary>")); - assert!(prompt.contains("older summary")); - } - - #[test] - fn merge_compact_prompt_context_appends_hook_suffix_after_runtime_prompt() { - let merged = merge_compact_prompt_context(Some("base"), Some("hook")) - .expect("merged compact prompt context should exist"); - - assert_eq!(merged, "base\n\nhook"); - } - - #[test] - fn merge_compact_prompt_context_returns_none_when_both_empty() { - assert!(merge_compact_prompt_context(None, None).is_none()); - assert!(merge_compact_prompt_context(Some(" "), Some(" \n\t ")).is_none()); - } - - #[test] - fn parse_compact_output_requires_non_empty_content() { - let error = parse_compact_output(" ").expect_err("empty compact output should fail"); - assert!(error.to_string().contains("missing <summary> block")); - } - - #[test] - fn parse_compact_output_requires_closed_summary_block() { - let error = - parse_compact_output("<summary>open").expect_err("unclosed summary should fail"); - assert!(error.to_string().contains("closing </summary>")); - } - - #[test] - fn parse_compact_output_prefers_summary_block() { - let parsed = - parse_compact_output("<analysis>draft</analysis><summary>\nSection\n</summary>") - .expect("summary should parse"); - - assert_eq!(parsed.summary, "Section"); - assert!(parsed.has_analysis); - } - - #[test] - fn parse_compact_output_accepts_case_insensitive_summary_block() { - let parsed = parse_compact_output("<ANALYSIS>draft</ANALYSIS><SUMMARY>Section</SUMMARY>") - .expect("summary should parse"); - - assert_eq!(parsed.summary, "Section"); - assert!(parsed.has_analysis); - } - - #[test] - fn parse_compact_output_falls_back_to_plain_text_summary() { - let parsed = parse_compact_output("## Goal\n- preserve current task") - .expect("plain text summary should parse"); - - assert_eq!(parsed.summary, "## Goal\n- preserve current task"); - assert!(!parsed.has_analysis); - } - - #[test] - fn parse_compact_output_strips_outer_code_fence_before_parsing() { - let parsed = parse_compact_output( - "```xml\n<analysis>draft</analysis>\n<summary>Section</summary>\n```", - ) - .expect("fenced xml summary should parse"); - - assert_eq!(parsed.summary, "Section"); - assert!(parsed.has_analysis); - } - - #[test] - fn parse_compact_output_strips_common_summary_preamble_in_fallback() { - let parsed = parse_compact_output("Summary:\n## Goal\n- preserve current task") - .expect("summary preamble fallback should parse"); - - assert_eq!(parsed.summary, "## Goal\n- preserve current task"); - } - - #[test] - fn parse_compact_output_accepts_summary_tag_attributes() { - let parsed = parse_compact_output( - "<analysis class=\"draft\">draft</analysis><summary \ - format=\"markdown\">Section</summary>", - ) - .expect("tag attributes should parse"); - - assert_eq!(parsed.summary, "Section"); - assert!(parsed.has_analysis); - } - - #[test] - fn parse_compact_output_does_not_treat_analysis_only_as_summary() { - let error = parse_compact_output("<analysis>draft</analysis>") - .expect_err("analysis-only output should still fail"); - - assert!(error.to_string().contains("missing <summary> block")); - } - - #[test] - fn split_for_compaction_preserves_recent_real_user_turns() { - let messages = vec![ - LlmMessage::User { - content: "older".to_string(), - origin: UserMessageOrigin::User, - }, - LlmMessage::Assistant { - content: "ack".to_string(), - tool_calls: Vec::new(), - reasoning: None, - }, - LlmMessage::User { - content: format_compact_summary("older"), - origin: UserMessageOrigin::CompactSummary, - }, - LlmMessage::User { - content: "newer".to_string(), - origin: UserMessageOrigin::User, - }, - ]; - - let split = split_for_compaction(&messages, 1).expect("split should exist"); - - assert_eq!(split.keep_start, 3); - assert_eq!(split.prefix.len(), 3); - assert_eq!(split.suffix.len(), 1); - } - - #[test] - fn split_for_compaction_falls_back_to_assistant_boundary_for_single_turn() { - let messages = vec![ - LlmMessage::User { - content: "task".to_string(), - origin: UserMessageOrigin::User, - }, - LlmMessage::Assistant { - content: "step 1".to_string(), - tool_calls: Vec::new(), - reasoning: None, - }, - LlmMessage::Assistant { - content: "step 2".to_string(), - tool_calls: Vec::new(), - reasoning: None, - }, - ]; - - let split = split_for_compaction(&messages, 1).expect("single turn should still split"); - assert_eq!(split.keep_start, 2); - } - - #[test] - fn compacted_messages_inserts_summary_as_compact_user_message() { - let compacted = compacted_messages("Older history", Vec::new()); - - assert!(matches!( - &compacted[0], - LlmMessage::User { - origin: UserMessageOrigin::CompactSummary, - .. - } - )); - assert_eq!(compacted.len(), 1); - } - - #[test] - fn prepare_compact_input_strips_history_note_from_previous_summary() { - let filtered = prepare_compact_input(&[LlmMessage::User { - content: CompactSummaryEnvelope::new("older summary") - .with_history_path("~/.astrcode/projects/demo/sessions/abc/session-abc.jsonl") - .render(), - origin: UserMessageOrigin::CompactSummary, - }]); - - assert!(matches!( - filtered.prompt_mode, - CompactPromptMode::Incremental { ref previous_summary } - if previous_summary == "older summary" - )); - } - - #[test] - fn prepare_compact_input_skips_synthetic_user_messages() { - let filtered = prepare_compact_input(&[ - LlmMessage::User { - content: "summary".to_string(), - origin: UserMessageOrigin::CompactSummary, - }, - LlmMessage::User { - content: "wake up".to_string(), - origin: UserMessageOrigin::ReactivationPrompt, - }, - LlmMessage::User { - content: "real user".to_string(), - origin: UserMessageOrigin::User, - }, - ]); - - assert_eq!(filtered.messages.len(), 1); - assert!(matches!( - &filtered.messages[0], - LlmMessage::User { - content, - origin: UserMessageOrigin::User - } if content == "real user" - )); - } - - #[test] - fn normalize_compaction_tool_content_removes_exact_child_identifiers() { - let normalized = normalize_compaction_tool_content( - "spawn 已在后台启动。\n\nChild agent reference:\n- agentId: agent-1\n- subRunId: \ - subrun-1\n- sessionId: session-parent\n- openSessionId: session-child\n- status: \ - running\nUse this exact `agentId` value in later send/observe/close calls.", - ); - - assert!(normalized.contains("spawn 已在后台启动。")); - assert!(normalized.contains("Do not reuse any agentId")); - assert!(!normalized.contains("agent-1")); - assert!(!normalized.contains("subrun-1")); - assert!(!normalized.contains("session-child")); - } - - #[test] - fn sanitize_compact_summary_replaces_stale_route_identifiers_with_boundary_guidance() { - let sanitized = sanitize_compact_summary( - "## Progress\n- Spawned agent-3 and later called observe(agent-2).\n- Error: agent \ - 'agent-2' is not a direct child of caller 'agent-root:session-parent' (actual \ - parent: agent-1); send/observe/close only support direct children.\n- Child ref \ - payload: agentId=agent-2 subRunId=subrun-2 openSessionId=session-child-2", - ); - - assert!(sanitized.contains("## Compact Boundary")); - assert!(sanitized.contains("live direct-child snapshot")); - assert!(sanitized.contains("<agent-id>")); - assert!(sanitized.contains("<subrun-id>") || sanitized.contains("<direct-child-subRunId>")); - assert!(sanitized.contains("<child-session-id>") || sanitized.contains("<session-id>")); - assert!(!sanitized.contains("agent-2")); - assert!(!sanitized.contains("subrun-2")); - assert!(!sanitized.contains("session-child-2")); - assert!(!sanitized.contains("not a direct child of caller")); - } - - #[test] - fn drop_oldest_compaction_unit_is_deterministic() { - let mut prefix = vec![ - LlmMessage::User { - content: "task".to_string(), - origin: UserMessageOrigin::User, - }, - LlmMessage::Assistant { - content: "step-1".to_string(), - tool_calls: Vec::new(), - reasoning: None, - }, - LlmMessage::Assistant { - content: "step-2".to_string(), - tool_calls: Vec::new(), - reasoning: None, - }, - ]; - - assert!(drop_oldest_compaction_unit(&mut prefix)); - assert!(matches!( - &prefix[0], - LlmMessage::Assistant { content, .. } if content == "step-1" - )); - } - - #[test] - fn trim_prefix_until_compact_request_fits_drops_oldest_units_before_calling_llm() { - let mut prefix = vec![ - LlmMessage::User { - content: "very old request ".repeat(1200), - origin: UserMessageOrigin::User, - }, - LlmMessage::Assistant { - content: "first step".repeat(1200), - tool_calls: Vec::new(), - reasoning: None, - }, - LlmMessage::Assistant { - content: "latest step".to_string(), - tool_calls: Vec::new(), - reasoning: None, - }, - ]; - - let trimmed = trim_prefix_until_compact_request_fits( - &mut prefix, - None, - ModelLimits { - context_window: 23_000, - max_output_tokens: 2_000, - }, - &test_compact_config(), - ); - - assert!(trimmed); - assert!(matches!( - prefix.as_slice(), - [LlmMessage::Assistant { content, .. }] if content == "latest step" - )); - } - - #[test] - fn can_compact_returns_false_for_empty_messages() { - assert!(!can_compact(&[], 2)); - } - - #[test] - fn can_compact_returns_true_when_enough_turns() { - let messages = vec![ - LlmMessage::User { - content: "turn-1".to_string(), - origin: UserMessageOrigin::User, - }, - LlmMessage::Assistant { - content: "reply".to_string(), - tool_calls: Vec::new(), - reasoning: None, - }, - LlmMessage::User { - content: "turn-2".to_string(), - origin: UserMessageOrigin::User, - }, - ]; - assert!(can_compact(&messages, 1)); - } -} +mod tests; diff --git a/crates/session-runtime/src/context_window/compaction/protocol.rs b/crates/session-runtime/src/context_window/compaction/protocol.rs new file mode 100644 index 00000000..dd1ede72 --- /dev/null +++ b/crates/session-runtime/src/context_window/compaction/protocol.rs @@ -0,0 +1,275 @@ +use super::*; + +mod sanitize; +mod xml_parsing; +use sanitize as sanitize_impl; +use xml_parsing as xml_parsing_impl; + +/// 合并 compact 使用的 prompt 上下文。 +#[cfg(test)] +pub(super) fn merge_compact_prompt_context( + runtime_system_prompt: Option<&str>, + additional_system_prompt: Option<&str>, +) -> Option<String> { + let runtime_system_prompt = runtime_system_prompt.filter(|v| !v.trim().is_empty()); + let additional_system_prompt = additional_system_prompt.filter(|v| !v.trim().is_empty()); + + match (runtime_system_prompt, additional_system_prompt) { + (None, None) => None, + (Some(base), None) => Some(base.to_string()), + (None, Some(additional)) => Some(additional.to_string()), + (Some(base), Some(additional)) => Some(format!("{base}\n\n{additional}")), + } +} + +/// 判断错误是否为 prompt too long。 +pub(super) fn is_prompt_too_long(error: &astrcode_kernel::KernelError) -> bool { + let message = error.to_string(); + xml_parsing_impl::contains_ascii_case_insensitive(&message, "prompt too long") + || xml_parsing_impl::contains_ascii_case_insensitive(&message, "context length") + || xml_parsing_impl::contains_ascii_case_insensitive(&message, "maximum context") + || xml_parsing_impl::contains_ascii_case_insensitive(&message, "too many tokens") +} + +pub(super) fn render_compact_system_prompt( + compact_prompt_context: Option<&str>, + mode: CompactPromptMode, + effective_max_output_tokens: usize, + recent_user_context_messages: &[RecentUserContextMessage], + custom_instructions: Option<&str>, +) -> String { + let incremental_block = match mode { + CompactPromptMode::Fresh => String::new(), + CompactPromptMode::Incremental { previous_summary } => INCREMENTAL_COMPACT_PROMPT_TEMPLATE + .replace("{{PREVIOUS_SUMMARY}}", previous_summary.trim()), + }; + let runtime_context = compact_prompt_context + .filter(|v| !v.trim().is_empty()) + .map(|v| format!("\nCurrent runtime system prompt for context:\n{v}")) + .unwrap_or_default(); + let custom_instruction_block = custom_instructions + .filter(|value| !value.trim().is_empty()) + .map(|value| { + format!( + "\n## Manual Compact Instructions\nFollow these extra requirements for this \ + compact only:\n{value}" + ) + }) + .unwrap_or_default(); + let recent_user_context_block = + render_recent_user_context_candidates(recent_user_context_messages); + + BASE_COMPACT_PROMPT_TEMPLATE + .replace("{{INCREMENTAL_MODE}}", incremental_block.trim()) + .replace("{{CUSTOM_INSTRUCTIONS}}", custom_instruction_block.trim()) + .replace( + "{{COMPACT_OUTPUT_TOKEN_CAP}}", + &effective_max_output_tokens.to_string(), + ) + .replace( + "{{RECENT_USER_CONTEXT_MESSAGES}}", + recent_user_context_block.trim_end(), + ) + .replace("{{RUNTIME_CONTEXT}}", runtime_context.trim_end()) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) struct RecentUserContextMessage { + pub(super) index: usize, + pub(super) content: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) struct ParsedCompactOutput { + pub(super) summary: String, + pub(super) recent_user_context_digest: Option<String>, + pub(super) has_analysis: bool, + pub(super) used_fallback: bool, +} + +fn render_recent_user_context_candidates(messages: &[RecentUserContextMessage]) -> String { + if messages.is_empty() { + return "(none)".to_string(); + } + + messages + .iter() + .enumerate() + .map(|(position, message)| format!("Message {}:\n{}", position + 1, message.content.trim())) + .collect::<Vec<_>>() + .join("\n\n") +} + +pub(super) fn collect_recent_user_context_messages( + messages: &[LlmMessage], + keep_recent_user_messages: usize, +) -> Vec<RecentUserContextMessage> { + if keep_recent_user_messages == 0 { + return Vec::new(); + } + + let mut collected = messages + .iter() + .enumerate() + .filter_map(|(index, message)| match message { + LlmMessage::User { + content, + origin: UserMessageOrigin::User, + } => Some(RecentUserContextMessage { + index, + content: content.clone(), + }), + _ => None, + }) + .collect::<Vec<_>>(); + let keep_start = collected + .len() + .saturating_sub(keep_recent_user_messages.max(1)); + collected.drain(..keep_start); + collected +} + +pub(super) fn prepare_compact_input(messages: &[LlmMessage]) -> PreparedCompactInput { + let prompt_mode = latest_previous_summary(messages) + .map(|previous_summary| CompactPromptMode::Incremental { previous_summary }) + .unwrap_or(CompactPromptMode::Fresh); + let messages = messages + .iter() + .filter_map(normalize_compaction_message) + .collect::<Vec<_>>(); + let input_units = compaction_units(&messages).len().max(1); + PreparedCompactInput { + messages, + prompt_mode, + input_units, + } +} + +fn latest_previous_summary(messages: &[LlmMessage]) -> Option<String> { + messages.iter().rev().find_map(|message| match message { + LlmMessage::User { + content, + origin: UserMessageOrigin::CompactSummary, + } => parse_compact_summary_message(content) + .map(|envelope| sanitize_impl::sanitize_compact_summary(&envelope.summary)), + _ => None, + }) +} + +fn normalize_compaction_message(message: &LlmMessage) -> Option<LlmMessage> { + match message { + LlmMessage::User { + content, + origin: UserMessageOrigin::User, + } => Some(LlmMessage::User { + content: content.trim().to_string(), + origin: UserMessageOrigin::User, + }), + LlmMessage::User { .. } => None, + LlmMessage::Assistant { + content, + tool_calls, + .. + } => { + let mut lines = Vec::new(); + let visible = collapse_compaction_whitespace(content); + if !visible.is_empty() { + lines.push(visible); + } + if !tool_calls.is_empty() { + let names = tool_calls + .iter() + .map(|call| call.name.trim()) + .filter(|name| !name.is_empty()) + .collect::<Vec<_>>(); + if !names.is_empty() { + lines.push(format!("Requested tools: {}", names.join(", "))); + } + } + let normalized = lines.join("\n"); + if normalized.trim().is_empty() { + None + } else { + Some(LlmMessage::Assistant { + content: normalized, + tool_calls: Vec::new(), + reasoning: None, + }) + } + }, + LlmMessage::Tool { + tool_call_id, + content, + } => { + let normalized = normalize_compaction_tool_content(content); + if normalized.is_empty() { + None + } else { + Some(LlmMessage::Tool { + tool_call_id: tool_call_id.clone(), + content: normalized, + }) + } + }, + } +} + +fn collapse_compaction_whitespace(content: &str) -> String { + content + .lines() + .map(str::trim) + .collect::<Vec<_>>() + .join("\n") + .split("\n\n\n") + .collect::<Vec<_>>() + .join("\n\n") + .trim() + .to_string() +} + +pub(super) fn normalize_compaction_tool_content(content: &str) -> String { + let stripped_child_ref = sanitize_impl::strip_child_agent_reference_hint(content); + let collapsed = collapse_compaction_whitespace(&stripped_child_ref); + if collapsed.is_empty() { + return String::new(); + } + if is_persisted_output(&collapsed) { + return summarize_persisted_tool_output(&collapsed); + } + + const MAX_COMPACTION_TOOL_CHARS: usize = 1_600; + let char_count = collapsed.chars().count(); + if char_count <= MAX_COMPACTION_TOOL_CHARS { + return collapsed; + } + + let preview = collapsed + .chars() + .take(MAX_COMPACTION_TOOL_CHARS) + .collect::<String>(); + format!( + "{preview}\n\n[tool output truncated for compaction; preserve only the conclusion, key \ + errors, important file paths, and referenced IDs]" + ) +} + +pub(super) fn sanitize_compact_summary(summary: &str) -> String { + sanitize_impl::sanitize_compact_summary(summary) +} + +pub(super) fn sanitize_recent_user_context_digest(digest: &str) -> String { + sanitize_impl::sanitize_recent_user_context_digest(digest) +} + +pub(super) fn parse_compact_output(content: &str) -> Result<ParsedCompactOutput> { + xml_parsing_impl::parse_compact_output(content) +} + +fn summarize_persisted_tool_output(content: &str) -> String { + let persisted_path = persisted_output_absolute_path(content) + .unwrap_or_else(|| "unknown persisted path".to_string()); + format!( + "Large tool output was persisted instead of inlined.\nPersisted path: \ + {persisted_path}\nPreserve only the conclusion, referenced path, and any error." + ) +} diff --git a/crates/session-runtime/src/context_window/compaction/sanitize.rs b/crates/session-runtime/src/context_window/compaction/sanitize.rs new file mode 100644 index 00000000..05913b52 --- /dev/null +++ b/crates/session-runtime/src/context_window/compaction/sanitize.rs @@ -0,0 +1,219 @@ +use super::*; + +pub(super) fn sanitize_compact_summary(summary: &str) -> String { + let had_route_sensitive_content = summary_has_route_sensitive_content(summary); + let mut sanitized = summary.trim().to_string(); + sanitized = direct_child_validation_regex() + .replace_all( + &sanitized, + "direct-child validation rejected a stale child reference; use the live direct-child \ + snapshot or the latest live tool result instead.", + ) + .into_owned(); + sanitized = child_agent_reference_block_regex() + .replace_all( + &sanitized, + "Child agent reference metadata existed earlier, but compacted history is not an \ + authoritative routing source.", + ) + .into_owned(); + for (regex, replacement) in [ + ( + route_key_regex("agentId"), + "${key}<latest-direct-child-agentId>", + ), + ( + route_key_regex("childAgentId"), + "${key}<latest-direct-child-agentId>", + ), + (route_key_regex("parentAgentId"), "${key}<parent-agentId>"), + (route_key_regex("subRunId"), "${key}<direct-child-subRunId>"), + (route_key_regex("parentSubRunId"), "${key}<parent-subRunId>"), + (route_key_regex("sessionId"), "${key}<session-id>"), + ( + route_key_regex("childSessionId"), + "${key}<child-session-id>", + ), + (route_key_regex("openSessionId"), "${key}<child-session-id>"), + ] { + sanitized = regex.replace_all(&sanitized, replacement).into_owned(); + } + sanitized = exact_agent_instruction_regex() + .replace_all( + &sanitized, + "Use only the latest live child snapshot or tool result for agent routing.", + ) + .into_owned(); + sanitized = raw_root_agent_id_regex() + .replace_all(&sanitized, "<agent-id>") + .into_owned(); + sanitized = raw_agent_id_regex() + .replace_all(&sanitized, "<agent-id>") + .into_owned(); + sanitized = raw_subrun_id_regex() + .replace_all(&sanitized, "<subrun-id>") + .into_owned(); + sanitized = raw_session_id_regex() + .replace_all(&sanitized, "<session-id>") + .into_owned(); + sanitized = collapse_compaction_whitespace(&sanitized); + if had_route_sensitive_content { + ensure_compact_boundary_section(&sanitized) + } else { + sanitized + } +} + +pub(super) fn sanitize_recent_user_context_digest(digest: &str) -> String { + collapse_compaction_whitespace(digest) +} + +fn ensure_compact_boundary_section(summary: &str) -> String { + if summary.contains("## Compact Boundary") { + return summary.to_string(); + } + format!( + "## Compact Boundary\n- Historical `agentId`, `subRunId`, and `sessionId` values from \ + compacted history are non-authoritative.\n- Use the live direct-child snapshot or the \ + latest live tool result / child notification for routing.\n\n{}", + summary.trim() + ) +} + +fn summary_has_route_sensitive_content(summary: &str) -> bool { + direct_child_validation_regex().is_match(summary) + || child_agent_reference_block_regex().is_match(summary) + || exact_agent_instruction_regex().is_match(summary) + || raw_root_agent_id_regex().is_match(summary) + || raw_agent_id_regex().is_match(summary) + || raw_subrun_id_regex().is_match(summary) + || raw_session_id_regex().is_match(summary) + || [ + route_key_regex("agentId"), + route_key_regex("childAgentId"), + route_key_regex("parentAgentId"), + route_key_regex("subRunId"), + route_key_regex("parentSubRunId"), + route_key_regex("sessionId"), + route_key_regex("childSessionId"), + route_key_regex("openSessionId"), + ] + .into_iter() + .any(|regex| regex.is_match(summary)) +} + +fn child_agent_reference_block_regex() -> &'static Regex { + static REGEX: OnceLock<Regex> = OnceLock::new(); + REGEX.get_or_init(|| { + Regex::new(r"(?is)Child agent reference:\s*(?:\n- .*)+") + .expect("child agent reference regex should compile") + }) +} + +fn direct_child_validation_regex() -> &'static Regex { + static REGEX: OnceLock<Regex> = OnceLock::new(); + REGEX.get_or_init(|| { + Regex::new(r"(?i)not a direct child of caller[^\n]*") + .expect("direct child validation regex should compile") + }) +} + +fn route_key_regex(key: &str) -> &'static Regex { + static AGENT_ID: OnceLock<Regex> = OnceLock::new(); + static CHILD_AGENT_ID: OnceLock<Regex> = OnceLock::new(); + static PARENT_AGENT_ID: OnceLock<Regex> = OnceLock::new(); + static SUB_RUN_ID: OnceLock<Regex> = OnceLock::new(); + static PARENT_SUB_RUN_ID: OnceLock<Regex> = OnceLock::new(); + static SESSION_ID: OnceLock<Regex> = OnceLock::new(); + static CHILD_SESSION_ID: OnceLock<Regex> = OnceLock::new(); + static OPEN_SESSION_ID: OnceLock<Regex> = OnceLock::new(); + let slot = match key { + "agentId" => &AGENT_ID, + "childAgentId" => &CHILD_AGENT_ID, + "parentAgentId" => &PARENT_AGENT_ID, + "subRunId" => &SUB_RUN_ID, + "parentSubRunId" => &PARENT_SUB_RUN_ID, + "sessionId" => &SESSION_ID, + "childSessionId" => &CHILD_SESSION_ID, + "openSessionId" => &OPEN_SESSION_ID, + other => panic!("unsupported route key regex: {other}"), + }; + slot.get_or_init(|| { + Regex::new(&format!( + r"(?i)(?P<key>`?{key}`?\s*[:=]\s*`?)[^`\s,;\])]+`?" + )) + .expect("route key regex should compile") + }) +} + +fn exact_agent_instruction_regex() -> &'static Regex { + static REGEX: OnceLock<Regex> = OnceLock::new(); + REGEX.get_or_init(|| { + Regex::new( + r"(?i)(use this exact `agentid` value[^\n]*|copy it byte-for-byte[^\n]*|keep `agentid` exact[^\n]*)", + ) + .expect("exact agent instruction regex should compile") + }) +} + +fn raw_root_agent_id_regex() -> &'static Regex { + static REGEX: OnceLock<Regex> = OnceLock::new(); + REGEX.get_or_init(|| { + Regex::new(r"\broot-agent:[A-Za-z0-9._:-]+\b") + .expect("raw root agent id regex should compile") + }) +} + +fn raw_agent_id_regex() -> &'static Regex { + static REGEX: OnceLock<Regex> = OnceLock::new(); + REGEX.get_or_init(|| { + Regex::new(r"\bagent-[A-Za-z0-9._:-]+\b").expect("raw agent id regex should compile") + }) +} + +fn raw_subrun_id_regex() -> &'static Regex { + static REGEX: OnceLock<Regex> = OnceLock::new(); + REGEX.get_or_init(|| { + Regex::new(r"\bsubrun-[A-Za-z0-9._:-]+\b").expect("raw subrun regex should compile") + }) +} + +fn raw_session_id_regex() -> &'static Regex { + static REGEX: OnceLock<Regex> = OnceLock::new(); + REGEX.get_or_init(|| { + Regex::new(r"\bsession-[A-Za-z0-9._:-]+\b").expect("raw session regex should compile") + }) +} + +pub(super) fn strip_child_agent_reference_hint(content: &str) -> String { + let Some((prefix, child_ref_block)) = content.split_once("\n\nChild agent reference:") else { + return content.to_string(); + }; + let mut has_reference_fields = false; + for line in child_ref_block.lines() { + let trimmed = line.trim(); + if trimmed.starts_with("- agentId:") + || trimmed.starts_with("- subRunId:") + || trimmed.starts_with("- openSessionId:") + || trimmed.starts_with("- status:") + { + has_reference_fields = true; + } + } + let child_ref_summary = if has_reference_fields { + "Child agent reference existed in the original tool result. Do not reuse any agentId, \ + subRunId, or sessionId from compacted history; rely on the latest live tool result or \ + current direct-child snapshot instead." + .to_string() + } else { + "Child agent reference metadata existed in the original tool result, but compacted history \ + is not an authoritative source for later agent routing." + .to_string() + }; + let prefix = prefix.trim(); + if prefix.is_empty() { + child_ref_summary + } else { + format!("{prefix}\n\n{child_ref_summary}") + } +} diff --git a/crates/session-runtime/src/context_window/compaction/tests.rs b/crates/session-runtime/src/context_window/compaction/tests.rs new file mode 100644 index 00000000..e2cd9e66 --- /dev/null +++ b/crates/session-runtime/src/context_window/compaction/tests.rs @@ -0,0 +1,478 @@ +use super::*; + +fn test_compact_config() -> CompactConfig { + CompactConfig { + keep_recent_turns: 1, + keep_recent_user_messages: 8, + trigger: astrcode_core::CompactTrigger::Manual, + summary_reserve_tokens: 20_000, + max_output_tokens: 20_000, + max_retry_attempts: 3, + history_path: None, + custom_instructions: None, + } +} + +#[test] +fn render_compact_system_prompt_keeps_do_not_continue_instruction_intact() { + let prompt = render_compact_system_prompt(None, CompactPromptMode::Fresh, 20_000, &[], None); + + assert!( + prompt.contains("**Do NOT continue the conversation.**"), + "compact prompt must explicitly instruct the summarizer not to continue the session" + ); +} + +#[test] +fn render_compact_system_prompt_renders_incremental_block() { + let prompt = render_compact_system_prompt( + None, + CompactPromptMode::Incremental { + previous_summary: "older summary".to_string(), + }, + 20_000, + &[], + None, + ); + + assert!(prompt.contains("## Incremental Mode")); + assert!(prompt.contains("<previous-summary>")); + assert!(prompt.contains("older summary")); +} + +#[test] +fn render_compact_system_prompt_includes_output_cap_and_recent_user_context_messages() { + let prompt = render_compact_system_prompt( + None, + CompactPromptMode::Fresh, + 12_345, + &[RecentUserContextMessage { + index: 7, + content: "保留这条约束".to_string(), + }], + None, + ); + + assert!(prompt.contains("12345")); + assert!(prompt.contains("Recently Preserved Real User Messages")); + assert!(prompt.contains("保留这条约束")); + assert!(prompt.contains("<recent_user_context_digest>")); +} + +#[test] +fn merge_compact_prompt_context_appends_hook_suffix_after_runtime_prompt() { + let merged = merge_compact_prompt_context(Some("base"), Some("hook")) + .expect("merged compact prompt context should exist"); + + assert_eq!(merged, "base\n\nhook"); +} + +#[test] +fn merge_compact_prompt_context_returns_none_when_both_empty() { + assert!(merge_compact_prompt_context(None, None).is_none()); + assert!(merge_compact_prompt_context(Some(" "), Some(" \n\t ")).is_none()); +} + +#[test] +fn parse_compact_output_requires_non_empty_content() { + let error = parse_compact_output(" ").expect_err("empty compact output should fail"); + assert!(error.to_string().contains("missing <summary> block")); +} + +#[test] +fn parse_compact_output_requires_closed_summary_block() { + let error = parse_compact_output("<summary>open").expect_err("unclosed summary should fail"); + assert!(error.to_string().contains("closing </summary>")); +} + +#[test] +fn parse_compact_output_prefers_summary_block() { + let parsed = parse_compact_output( + "<analysis>draft</analysis><summary>\nSection\n</\ + summary><recent_user_context_digest>(none)</recent_user_context_digest>", + ) + .expect("summary should parse"); + + assert_eq!(parsed.summary, "Section"); + assert_eq!(parsed.recent_user_context_digest.as_deref(), Some("(none)")); + assert!(parsed.has_analysis); +} + +#[test] +fn parse_compact_output_accepts_case_insensitive_summary_block() { + let parsed = parse_compact_output( + "<ANALYSIS>draft</ANALYSIS><SUMMARY>Section</SUMMARY><RECENT_USER_CONTEXT_DIGEST>digest</\ + RECENT_USER_CONTEXT_DIGEST>", + ) + .expect("summary should parse"); + + assert_eq!(parsed.summary, "Section"); + assert_eq!(parsed.recent_user_context_digest.as_deref(), Some("digest")); + assert!(parsed.has_analysis); +} + +#[test] +fn parse_compact_output_falls_back_to_plain_text_summary() { + let parsed = parse_compact_output("## Goal\n- preserve current task") + .expect("plain text summary should parse"); + + assert_eq!(parsed.summary, "## Goal\n- preserve current task"); + assert!(!parsed.has_analysis); +} + +#[test] +fn parse_compact_output_strips_outer_code_fence_before_parsing() { + let parsed = + parse_compact_output("```xml\n<analysis>draft</analysis>\n<summary>Section</summary>\n```") + .expect("fenced xml summary should parse"); + + assert_eq!(parsed.summary, "Section"); + assert!(parsed.has_analysis); +} + +#[test] +fn parse_compact_output_strips_common_summary_preamble_in_fallback() { + let parsed = parse_compact_output("Summary:\n## Goal\n- preserve current task") + .expect("summary preamble fallback should parse"); + + assert_eq!(parsed.summary, "## Goal\n- preserve current task"); +} + +#[test] +fn parse_compact_output_accepts_summary_tag_attributes() { + let parsed = parse_compact_output( + "<analysis class=\"draft\">draft</analysis><summary format=\"markdown\">Section</summary>", + ) + .expect("tag attributes should parse"); + + assert_eq!(parsed.summary, "Section"); + assert!(parsed.has_analysis); +} + +#[test] +fn parse_compact_output_does_not_treat_analysis_only_as_summary() { + let error = parse_compact_output("<analysis>draft</analysis>") + .expect_err("analysis-only output should still fail"); + + assert!(error.to_string().contains("missing <summary> block")); +} + +#[test] +fn split_for_compaction_preserves_recent_real_user_turns() { + let messages = vec![ + LlmMessage::User { + content: "older".to_string(), + origin: UserMessageOrigin::User, + }, + LlmMessage::Assistant { + content: "ack".to_string(), + tool_calls: Vec::new(), + reasoning: None, + }, + LlmMessage::User { + content: format_compact_summary("older"), + origin: UserMessageOrigin::CompactSummary, + }, + LlmMessage::User { + content: "newer".to_string(), + origin: UserMessageOrigin::User, + }, + ]; + + let split = split_for_compaction(&messages, 1).expect("split should exist"); + + assert_eq!(split.keep_start, 3); + assert_eq!(split.prefix.len(), 3); + assert_eq!(split.suffix.len(), 1); +} + +#[test] +fn split_for_compaction_falls_back_to_assistant_boundary_for_single_turn() { + let messages = vec![ + LlmMessage::User { + content: "task".to_string(), + origin: UserMessageOrigin::User, + }, + LlmMessage::Assistant { + content: "step 1".to_string(), + tool_calls: Vec::new(), + reasoning: None, + }, + LlmMessage::Assistant { + content: "step 2".to_string(), + tool_calls: Vec::new(), + reasoning: None, + }, + ]; + + let split = split_for_compaction(&messages, 1).expect("single turn should still split"); + assert_eq!(split.keep_start, 2); +} + +#[test] +fn compacted_messages_inserts_summary_as_compact_user_message() { + let compacted = compacted_messages("Older history", None, &[], 0, Vec::new()); + + assert!(matches!( + &compacted[0], + LlmMessage::User { + origin: UserMessageOrigin::CompactSummary, + .. + } + )); + assert_eq!(compacted.len(), 1); +} + +#[test] +fn prepare_compact_input_strips_history_note_from_previous_summary() { + let filtered = prepare_compact_input(&[LlmMessage::User { + content: CompactSummaryEnvelope::new("older summary") + .with_history_path("~/.astrcode/projects/demo/sessions/abc/session-abc.jsonl") + .render(), + origin: UserMessageOrigin::CompactSummary, + }]); + + assert!(matches!( + filtered.prompt_mode, + CompactPromptMode::Incremental { ref previous_summary } + if previous_summary == "older summary" + )); +} + +#[test] +fn prepare_compact_input_skips_synthetic_user_messages() { + let filtered = prepare_compact_input(&[ + LlmMessage::User { + content: "summary".to_string(), + origin: UserMessageOrigin::CompactSummary, + }, + LlmMessage::User { + content: "wake up".to_string(), + origin: UserMessageOrigin::ReactivationPrompt, + }, + LlmMessage::User { + content: "digest".to_string(), + origin: UserMessageOrigin::RecentUserContextDigest, + }, + LlmMessage::User { + content: "preserved".to_string(), + origin: UserMessageOrigin::RecentUserContext, + }, + LlmMessage::User { + content: "real user".to_string(), + origin: UserMessageOrigin::User, + }, + ]); + + assert_eq!(filtered.messages.len(), 1); + assert!(matches!( + &filtered.messages[0], + LlmMessage::User { + content, + origin: UserMessageOrigin::User + } if content == "real user" + )); +} + +#[test] +fn normalize_compaction_tool_content_removes_exact_child_identifiers() { + let normalized = normalize_compaction_tool_content( + "spawn 已在后台启动。\n\nChild agent reference:\n- agentId: agent-1\n- subRunId: \ + subrun-1\n- sessionId: session-parent\n- openSessionId: session-child\n- status: \ + running\nUse this exact `agentId` value in later send/observe/close calls.", + ); + + assert!(normalized.contains("spawn 已在后台启动。")); + assert!(normalized.contains("Do not reuse any agentId")); + assert!(!normalized.contains("agent-1")); + assert!(!normalized.contains("subrun-1")); + assert!(!normalized.contains("session-child")); +} + +#[test] +fn sanitize_compact_summary_replaces_stale_route_identifiers_with_boundary_guidance() { + let sanitized = sanitize_compact_summary( + "## Progress\n- Spawned agent-3 and later called observe(agent-2).\n- Error: agent \ + 'agent-2' is not a direct child of caller 'agent-root:session-parent' (actual parent: \ + agent-1); send/observe/close only support direct children.\n- Child ref payload: \ + agentId=agent-2 subRunId=subrun-2 openSessionId=session-child-2", + ); + + assert!(sanitized.contains("## Compact Boundary")); + assert!(sanitized.contains("live direct-child snapshot")); + assert!(sanitized.contains("<agent-id>")); + assert!(sanitized.contains("<subrun-id>") || sanitized.contains("<direct-child-subRunId>")); + assert!(sanitized.contains("<child-session-id>") || sanitized.contains("<session-id>")); + assert!(!sanitized.contains("agent-2")); + assert!(!sanitized.contains("subrun-2")); + assert!(!sanitized.contains("session-child-2")); + assert!(!sanitized.contains("not a direct child of caller")); +} + +#[test] +fn drop_oldest_compaction_unit_is_deterministic() { + let mut prefix = vec![ + LlmMessage::User { + content: "task".to_string(), + origin: UserMessageOrigin::User, + }, + LlmMessage::Assistant { + content: "step-1".to_string(), + tool_calls: Vec::new(), + reasoning: None, + }, + LlmMessage::Assistant { + content: "step-2".to_string(), + tool_calls: Vec::new(), + reasoning: None, + }, + ]; + + assert!(drop_oldest_compaction_unit(&mut prefix)); + assert!(matches!( + &prefix[0], + LlmMessage::Assistant { content, .. } if content == "step-1" + )); +} + +#[test] +fn trim_prefix_until_compact_request_fits_drops_oldest_units_before_calling_llm() { + let mut prefix = vec![ + LlmMessage::User { + content: "very old request ".repeat(1200), + origin: UserMessageOrigin::User, + }, + LlmMessage::Assistant { + content: "first step".repeat(1200), + tool_calls: Vec::new(), + reasoning: None, + }, + LlmMessage::Assistant { + content: "latest step".to_string(), + tool_calls: Vec::new(), + reasoning: None, + }, + ]; + + let trimmed = trim_prefix_until_compact_request_fits( + &mut prefix, + None, + ModelLimits { + context_window: 23_000, + max_output_tokens: 2_000, + }, + &test_compact_config(), + &[], + ); + + assert!(trimmed); + assert!(matches!( + prefix.as_slice(), + [LlmMessage::Assistant { content, .. }] if content == "latest step" + )); +} + +#[test] +fn can_compact_returns_false_for_empty_messages() { + assert!(!can_compact(&[], 2)); +} + +#[test] +fn can_compact_returns_true_when_enough_turns() { + let messages = vec![ + LlmMessage::User { + content: "turn-1".to_string(), + origin: UserMessageOrigin::User, + }, + LlmMessage::Assistant { + content: "reply".to_string(), + tool_calls: Vec::new(), + reasoning: None, + }, + LlmMessage::User { + content: "turn-2".to_string(), + origin: UserMessageOrigin::User, + }, + ]; + assert!(can_compact(&messages, 1)); +} + +#[test] +fn collect_recent_user_context_messages_only_keeps_real_users() { + let recent = collect_recent_user_context_messages( + &[ + LlmMessage::User { + content: "summary".to_string(), + origin: UserMessageOrigin::CompactSummary, + }, + LlmMessage::User { + content: "recent".to_string(), + origin: UserMessageOrigin::User, + }, + LlmMessage::User { + content: "digest".to_string(), + origin: UserMessageOrigin::RecentUserContextDigest, + }, + LlmMessage::User { + content: "latest".to_string(), + origin: UserMessageOrigin::User, + }, + ], + 8, + ); + + assert_eq!(recent.len(), 2); + assert_eq!(recent[0].content, "recent"); + assert_eq!(recent[1].content, "latest"); +} + +#[test] +fn compacted_messages_put_recent_user_context_before_suffix_without_duplicates() { + let messages = compacted_messages( + "Older history", + Some("- keep current objective"), + &[RecentUserContextMessage { + index: 1, + content: "latest user".to_string(), + }], + 1, + vec![ + LlmMessage::User { + content: "latest user".to_string(), + origin: UserMessageOrigin::User, + }, + LlmMessage::Assistant { + content: "latest assistant".to_string(), + tool_calls: Vec::new(), + reasoning: None, + }, + ], + ); + + assert!(matches!( + &messages[0], + LlmMessage::User { + origin: UserMessageOrigin::CompactSummary, + .. + } + )); + assert!(matches!( + &messages[1], + LlmMessage::User { + origin: UserMessageOrigin::RecentUserContextDigest, + .. + } + )); + assert!(matches!( + &messages[2], + LlmMessage::User { + origin: UserMessageOrigin::RecentUserContext, + content, + } if content == "latest user" + )); + assert!(matches!( + &messages[3], + LlmMessage::Assistant { content, .. } if content == "latest assistant" + )); + assert_eq!(messages.len(), 4); +} diff --git a/crates/session-runtime/src/context_window/compaction/xml_parsing.rs b/crates/session-runtime/src/context_window/compaction/xml_parsing.rs new file mode 100644 index 00000000..bb959e3e --- /dev/null +++ b/crates/session-runtime/src/context_window/compaction/xml_parsing.rs @@ -0,0 +1,234 @@ +use super::*; + +pub(super) fn parse_compact_output(content: &str) -> Result<ParsedCompactOutput> { + let normalized = strip_outer_markdown_code_fence(content); + let has_analysis = extract_xml_block(&normalized, "analysis").is_some(); + if !has_analysis { + log::warn!("compact: missing <analysis> block in LLM response"); + } + + if has_opening_xml_tag(&normalized, "summary") && !has_closing_xml_tag(&normalized, "summary") { + return Err(AstrError::LlmStreamError( + "compact response missing closing </summary> tag".to_string(), + )); + } + if has_opening_xml_tag(&normalized, "recent_user_context_digest") + && !has_closing_xml_tag(&normalized, "recent_user_context_digest") + { + return Err(AstrError::LlmStreamError( + "compact response missing closing </recent_user_context_digest> tag".to_string(), + )); + } + + let mut used_fallback = false; + let summary = if let Some(summary) = extract_xml_block(&normalized, "summary") { + summary.to_string() + } else if let Some(structured) = extract_structured_summary_fallback(&normalized) { + used_fallback = true; + structured + } else { + let fallback = strip_xml_block(&normalized, "analysis"); + let fallback = clean_compact_fallback_text(&fallback); + if fallback.is_empty() { + return Err(AstrError::LlmStreamError( + "compact response missing <summary> block".to_string(), + )); + } + log::warn!("compact: missing <summary> block, falling back to raw content"); + used_fallback = true; + fallback + }; + if summary.is_empty() { + return Err(AstrError::LlmStreamError( + "compact summary response was empty".to_string(), + )); + } + + Ok(ParsedCompactOutput { + summary, + recent_user_context_digest: extract_xml_block(&normalized, "recent_user_context_digest") + .map(str::to_string), + has_analysis, + used_fallback, + }) +} + +fn extract_structured_summary_fallback(content: &str) -> Option<String> { + let cleaned = clean_compact_fallback_text(content); + let lower = cleaned.to_ascii_lowercase(); + let candidates = ["## summary", "# summary", "summary:"]; + for marker in candidates { + if let Some(start) = lower.find(marker) { + let body = cleaned[start + marker.len()..].trim(); + if !body.is_empty() { + return Some(body.to_string()); + } + } + } + None +} + +fn extract_xml_block<'a>(content: &'a str, tag: &str) -> Option<&'a str> { + xml_block_regex(tag) + .captures(content) + .and_then(|captures| captures.name("body")) + .map(|body| body.as_str().trim()) +} + +fn strip_xml_block(content: &str, tag: &str) -> String { + xml_block_regex(tag).replace(content, "").into_owned() +} + +fn has_opening_xml_tag(content: &str, tag: &str) -> bool { + xml_opening_tag_regex(tag).is_match(content) +} + +fn has_closing_xml_tag(content: &str, tag: &str) -> bool { + xml_closing_tag_regex(tag).is_match(content) +} + +fn strip_markdown_code_fence(content: &str) -> String { + let trimmed = content.trim(); + if !trimmed.starts_with("```") { + return trimmed.to_string(); + } + + let mut lines = trimmed.lines(); + let Some(first_line) = lines.next() else { + return trimmed.to_string(); + }; + if !first_line.trim_start().starts_with("```") { + return trimmed.to_string(); + } + + let body = lines.collect::<Vec<_>>().join("\n"); + let body = body.trim_end(); + body.strip_suffix("```").unwrap_or(body).trim().to_string() +} + +fn strip_outer_markdown_code_fence(content: &str) -> String { + let mut current = content.trim().to_string(); + loop { + let stripped = strip_markdown_code_fence(¤t); + if stripped == current { + return current; + } + current = stripped; + } +} + +fn clean_compact_fallback_text(content: &str) -> String { + let without_code_fence = strip_outer_markdown_code_fence(content); + let lines = without_code_fence + .lines() + .map(str::trim_end) + .collect::<Vec<_>>(); + let first_meaningful = lines + .iter() + .position(|line| !line.trim().is_empty()) + .unwrap_or(lines.len()); + let cleaned = lines + .into_iter() + .skip(first_meaningful) + .collect::<Vec<_>>() + .join("\n") + .trim() + .to_string(); + strip_leading_summary_preamble(&cleaned) +} + +fn strip_leading_summary_preamble(content: &str) -> String { + let mut lines = content.lines(); + let Some(first_line) = lines.next() else { + return String::new(); + }; + let trimmed_first_line = first_line.trim(); + if is_summary_preamble_line(trimmed_first_line) { + return lines.collect::<Vec<_>>().join("\n").trim().to_string(); + } + content.trim().to_string() +} + +fn is_summary_preamble_line(line: &str) -> bool { + let normalized = line + .trim_matches(|ch: char| matches!(ch, '*' | '#' | '-' | ':' | ' ')) + .trim(); + normalized.eq_ignore_ascii_case("summary") + || normalized.eq_ignore_ascii_case("here is the summary") + || normalized.eq_ignore_ascii_case("compact summary") + || normalized.eq_ignore_ascii_case("here's the summary") +} + +fn xml_block_regex(tag: &str) -> &'static Regex { + static SUMMARY_REGEX: OnceLock<Regex> = OnceLock::new(); + static ANALYSIS_REGEX: OnceLock<Regex> = OnceLock::new(); + static RECENT_USER_CONTEXT_DIGEST_REGEX: OnceLock<Regex> = OnceLock::new(); + + match tag { + "summary" => SUMMARY_REGEX.get_or_init(|| { + Regex::new(r"(?is)<summary(?:\s+[^>]*)?\s*>(?P<body>.*?)</summary\s*>") + .expect("summary regex should compile") + }), + "analysis" => ANALYSIS_REGEX.get_or_init(|| { + Regex::new(r"(?is)<analysis(?:\s+[^>]*)?\s*>(?P<body>.*?)</analysis\s*>") + .expect("analysis regex should compile") + }), + "recent_user_context_digest" => RECENT_USER_CONTEXT_DIGEST_REGEX.get_or_init(|| { + Regex::new( + r"(?is)<recent_user_context_digest(?:\s+[^>]*)?\s*>(?P<body>.*?)</recent_user_context_digest\s*>", + ) + .expect("recent user context digest regex should compile") + }), + other => panic!("unsupported compact xml tag: {other}"), + } +} + +fn xml_opening_tag_regex(tag: &str) -> &'static Regex { + static SUMMARY_REGEX: OnceLock<Regex> = OnceLock::new(); + static ANALYSIS_REGEX: OnceLock<Regex> = OnceLock::new(); + static RECENT_USER_CONTEXT_DIGEST_REGEX: OnceLock<Regex> = OnceLock::new(); + + match tag { + "summary" => SUMMARY_REGEX.get_or_init(|| { + Regex::new(r"(?i)<summary(?:\s+[^>]*)?\s*>") + .expect("summary opening regex should compile") + }), + "analysis" => ANALYSIS_REGEX.get_or_init(|| { + Regex::new(r"(?i)<analysis(?:\s+[^>]*)?\s*>") + .expect("analysis opening regex should compile") + }), + "recent_user_context_digest" => RECENT_USER_CONTEXT_DIGEST_REGEX.get_or_init(|| { + Regex::new(r"(?i)<recent_user_context_digest(?:\s+[^>]*)?\s*>") + .expect("recent user context digest opening regex should compile") + }), + other => panic!("unsupported compact xml tag: {other}"), + } +} + +fn xml_closing_tag_regex(tag: &str) -> &'static Regex { + static SUMMARY_REGEX: OnceLock<Regex> = OnceLock::new(); + static ANALYSIS_REGEX: OnceLock<Regex> = OnceLock::new(); + static RECENT_USER_CONTEXT_DIGEST_REGEX: OnceLock<Regex> = OnceLock::new(); + + match tag { + "summary" => SUMMARY_REGEX.get_or_init(|| { + Regex::new(r"(?i)</summary\s*>").expect("summary closing regex should compile") + }), + "analysis" => ANALYSIS_REGEX.get_or_init(|| { + Regex::new(r"(?i)</analysis\s*>").expect("analysis closing regex should compile") + }), + "recent_user_context_digest" => RECENT_USER_CONTEXT_DIGEST_REGEX.get_or_init(|| { + Regex::new(r"(?i)</recent_user_context_digest\s*>") + .expect("recent user context digest closing regex should compile") + }), + other => panic!("unsupported compact xml tag: {other}"), + } +} + +pub(super) fn contains_ascii_case_insensitive(haystack: &str, needle: &str) -> bool { + let needle = needle.as_bytes(); + haystack + .as_bytes() + .windows(needle.len()) + .any(|window| window.eq_ignore_ascii_case(needle)) +} diff --git a/crates/session-runtime/src/context_window/settings.rs b/crates/session-runtime/src/context_window/settings.rs index 61a653de..13e67d17 100644 --- a/crates/session-runtime/src/context_window/settings.rs +++ b/crates/session-runtime/src/context_window/settings.rs @@ -8,9 +8,11 @@ pub struct ContextWindowSettings { pub compact_threshold_percent: u8, pub reserved_context_size: usize, pub summary_reserve_tokens: usize, + pub compact_max_output_tokens: usize, pub compact_max_retry_attempts: usize, pub tool_result_max_bytes: usize, pub compact_keep_recent_turns: usize, + pub compact_keep_recent_user_messages: usize, pub max_tracked_files: usize, pub max_recovered_files: usize, pub recovery_token_budget: usize, @@ -45,9 +47,13 @@ impl From<&ResolvedRuntimeConfig> for ContextWindowSettings { compact_threshold_percent: config.compact_threshold_percent, reserved_context_size: config.reserved_context_size.max(1), summary_reserve_tokens: config.summary_reserve_tokens.max(1), + compact_max_output_tokens: config.compact_max_output_tokens.max(1), compact_max_retry_attempts: usize::from(config.compact_max_retry_attempts.max(1)), tool_result_max_bytes: config.tool_result_max_bytes, compact_keep_recent_turns: usize::from(config.compact_keep_recent_turns), + compact_keep_recent_user_messages: usize::from( + config.compact_keep_recent_user_messages.max(1), + ), max_tracked_files: config.max_tracked_files, max_recovered_files: config.max_recovered_files.max(1), recovery_token_budget: config.recovery_token_budget.max(1), diff --git a/crates/session-runtime/src/context_window/templates/compact/base.md b/crates/session-runtime/src/context_window/templates/compact/base.md index 5fbd522c..9ef18763 100644 --- a/crates/session-runtime/src/context_window/templates/compact/base.md +++ b/crates/session-runtime/src/context_window/templates/compact/base.md @@ -5,7 +5,8 @@ Your summary will be placed at the start of a continuing session so another agen **DO NOT CALL ANY TOOLS.** This is for summary generation only. **Do NOT continue the conversation.** Only output the structured summary. **Do NOT wrap the answer in Markdown code fences.** -**Even if context is incomplete, still return both `<analysis>` and `<summary>` blocks.** +**Even if context is incomplete, still return `<analysis>`, `<summary>`, and `<recent_user_context_digest>` blocks.** +**The entire output must stay within {{COMPACT_OUTPUT_TOKEN_CAP}} tokens.** ## Compression Priorities (highest -> lowest) 1. Current task state and exact next step @@ -21,13 +22,20 @@ Your summary will be placed at the start of a continuing session so another agen **MERGE:** Similar discussions into single summary points **REMOVE:** Redundant explanations, failed attempts (keep only lessons learned), boilerplate code **CONDENSE:** Long code blocks -> signatures + key logic; long explanations -> bullet points +**FOR RECENT USER CONTEXT DIGEST:** Focus only on current goal, newly added constraints/corrections, and the most recent explicit next step. +**IGNORE AS NOISE:** Tool outputs, tool echoes, file recovery content, internal helper prompts, and repeated restatements of the recent user messages. {{INCREMENTAL_MODE}} {{CUSTOM_INSTRUCTIONS}} +## Recently Preserved Real User Messages +These messages will be preserved verbatim after compaction. Do not restate them in full inside the main summary. + +{{RECENT_USER_CONTEXT_MESSAGES}} + ## Output Format -Return exactly two XML blocks: +Return exactly three XML blocks: <analysis> [Self-check before writing] @@ -82,12 +90,19 @@ Return exactly two XML blocks: </summary> +<recent_user_context_digest> +- [Very short digest of the recent real user messages, ideally 2-4 bullets total] +- [If there are no recent user messages, write "(none)"] +</recent_user_context_digest> + ## Rules -- Output **only** the <analysis> and <summary> blocks - no preamble, no closing remarks. +- Output **only** the <analysis>, <summary>, and <recent_user_context_digest> blocks - no preamble, no closing remarks. - Be concise. Prefer bullet points over paragraphs. - Ignore synthetic compact-summary helper messages. - Write in third-person, factual tone. Do not address the end user. - Preserve exact file paths, function names, error messages - never paraphrase these. +- Keep `<analysis>` extremely short. +- Keep `<recent_user_context_digest>` extremely short and do not quote the preserved messages verbatim unless unavoidable. - Preserve child-agent routing state semantically, but redact exact historical `agentId`, `subRunId`, and `sessionId` values from compacted history. - If child-agent routing matters, say that the next agent must rely on the latest live child snapshot or tool result instead of historical IDs. - If a value is unknown, write a short best-effort placeholder instead of omitting the section. diff --git a/crates/session-runtime/src/context_window/token_usage.rs b/crates/session-runtime/src/context_window/token_usage.rs index d27bd3c8..63b75640 100644 --- a/crates/session-runtime/src/context_window/token_usage.rs +++ b/crates/session-runtime/src/context_window/token_usage.rs @@ -137,6 +137,8 @@ pub fn estimate_message_tokens(message: &LlmMessage) -> usize { UserMessageOrigin::AutoContinueNudge => 6, UserMessageOrigin::ContinuationPrompt => 10, UserMessageOrigin::ReactivationPrompt => 8, + UserMessageOrigin::RecentUserContextDigest => 8, + UserMessageOrigin::RecentUserContext => 8, UserMessageOrigin::CompactSummary => 16, } }, diff --git a/crates/session-runtime/src/query/conversation.rs b/crates/session-runtime/src/query/conversation.rs index b4045419..1dd8155d 100644 --- a/crates/session-runtime/src/query/conversation.rs +++ b/crates/session-runtime/src/query/conversation.rs @@ -12,233 +12,15 @@ use astrcode_core::{ }; use serde_json::Value; -use crate::SessionReplay; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ConversationBlockStatus { - Streaming, - Complete, - Failed, - Cancelled, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ConversationSystemNoteKind { - Compact, - SystemNote, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ConversationChildHandoffKind { - Delegated, - Progress, - Returned, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ConversationTranscriptErrorKind { - ProviderError, - ContextWindowExceeded, - ToolFatal, - RateLimit, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ConversationPlanEventKind { - Saved, - ReviewPending, - Presented, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ConversationPlanReviewKind { - RevisePlan, - FinalReview, -} - -#[derive(Debug, Clone, PartialEq, Eq, Default)] -pub struct ToolCallStreamsFacts { - pub stdout: String, - pub stderr: String, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ConversationUserBlockFacts { - pub id: String, - pub turn_id: Option<String>, - pub markdown: String, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ConversationAssistantBlockFacts { - pub id: String, - pub turn_id: Option<String>, - pub status: ConversationBlockStatus, - pub markdown: String, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ConversationThinkingBlockFacts { - pub id: String, - pub turn_id: Option<String>, - pub status: ConversationBlockStatus, - pub markdown: String, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ConversationPlanReviewFacts { - pub kind: ConversationPlanReviewKind, - pub checklist: Vec<String>, -} - -#[derive(Debug, Clone, PartialEq, Eq, Default)] -pub struct ConversationPlanBlockersFacts { - pub missing_headings: Vec<String>, - pub invalid_sections: Vec<String>, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ConversationPlanBlockFacts { - pub id: String, - pub turn_id: Option<String>, - pub tool_call_id: String, - pub event_kind: ConversationPlanEventKind, - pub title: String, - pub plan_path: String, - pub summary: Option<String>, - pub status: Option<String>, - pub slug: Option<String>, - pub updated_at: Option<String>, - pub content: Option<String>, - pub review: Option<ConversationPlanReviewFacts>, - pub blockers: ConversationPlanBlockersFacts, -} - -#[derive(Debug, Clone, PartialEq)] -pub struct ToolCallBlockFacts { - pub id: String, - pub turn_id: Option<String>, - pub tool_call_id: String, - pub tool_name: String, - pub status: ConversationBlockStatus, - pub input: Option<Value>, - pub summary: Option<String>, - pub error: Option<String>, - pub duration_ms: Option<u64>, - pub truncated: bool, - pub metadata: Option<Value>, - pub child_ref: Option<ChildAgentRef>, - pub streams: ToolCallStreamsFacts, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ConversationErrorBlockFacts { - pub id: String, - pub turn_id: Option<String>, - pub code: ConversationTranscriptErrorKind, - pub message: String, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ConversationSystemNoteBlockFacts { - pub id: String, - pub note_kind: ConversationSystemNoteKind, - pub markdown: String, - pub compact_trigger: Option<CompactTrigger>, - pub compact_meta: Option<CompactAppliedMeta>, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ConversationChildHandoffBlockFacts { - pub id: String, - pub handoff_kind: ConversationChildHandoffKind, - pub child_ref: ChildAgentRef, - pub message: Option<String>, -} - -#[derive(Debug, Clone, PartialEq)] -pub enum ConversationBlockFacts { - User(ConversationUserBlockFacts), - Assistant(ConversationAssistantBlockFacts), - Thinking(ConversationThinkingBlockFacts), - Plan(Box<ConversationPlanBlockFacts>), - ToolCall(Box<ToolCallBlockFacts>), - Error(ConversationErrorBlockFacts), - SystemNote(ConversationSystemNoteBlockFacts), - ChildHandoff(ConversationChildHandoffBlockFacts), -} - -#[derive(Debug, Clone, PartialEq)] -pub enum ConversationBlockPatchFacts { - AppendMarkdown { - markdown: String, - }, - ReplaceMarkdown { - markdown: String, - }, - AppendToolStream { - stream: ToolOutputStream, - chunk: String, - }, - ReplaceSummary { - summary: String, - }, - ReplaceMetadata { - metadata: Value, - }, - ReplaceError { - error: Option<String>, - }, - ReplaceDuration { - duration_ms: u64, - }, - ReplaceChildRef { - child_ref: ChildAgentRef, - }, - SetTruncated { - truncated: bool, - }, - SetStatus { - status: ConversationBlockStatus, - }, -} - -#[derive(Debug, Clone, PartialEq)] -pub enum ConversationDeltaFacts { - AppendBlock { - block: Box<ConversationBlockFacts>, - }, - PatchBlock { - block_id: String, - patch: ConversationBlockPatchFacts, - }, - CompleteBlock { - block_id: String, - status: ConversationBlockStatus, - }, -} - -#[derive(Debug, Clone, PartialEq)] -pub struct ConversationDeltaFrameFacts { - pub cursor: String, - pub delta: ConversationDeltaFacts, -} - -#[derive(Debug, Clone, PartialEq)] -pub struct ConversationSnapshotFacts { - pub cursor: Option<String>, - pub phase: Phase, - pub blocks: Vec<ConversationBlockFacts>, -} - -#[derive(Debug)] -pub struct ConversationStreamReplayFacts { - pub cursor: Option<String>, - pub phase: Phase, - pub seed_records: Vec<SessionEventRecord>, - pub replay_frames: Vec<ConversationDeltaFrameFacts>, - pub replay: SessionReplay, -} +mod facts; +#[path = "conversation/projection_support.rs"] +mod projection_support; + +pub use facts::*; +use projection_support::*; +pub(crate) use projection_support::{ + build_conversation_replay_frames, project_conversation_snapshot, +}; #[derive(Default)] pub struct ConversationDeltaProjector { @@ -1172,959 +954,5 @@ impl ConversationDeltaProjector { } } -impl ConversationStreamProjector { - pub fn new(last_sent_cursor: Option<String>, facts: &ConversationStreamReplayFacts) -> Self { - let mut projector = ConversationDeltaProjector::new(); - projector.seed(&facts.seed_records); - Self { - projector, - last_sent_cursor, - fallback_live_cursor: fallback_live_cursor(facts), - } - } - - pub fn last_sent_cursor(&self) -> Option<&str> { - self.last_sent_cursor.as_deref() - } - - pub fn seed_initial_replay( - &mut self, - facts: &ConversationStreamReplayFacts, - ) -> Vec<ConversationDeltaFrameFacts> { - let frames = facts.replay_frames.clone(); - self.observe_durable_frames(&frames); - frames - } - - pub fn project_durable_record( - &mut self, - record: &SessionEventRecord, - ) -> Vec<ConversationDeltaFrameFacts> { - let deltas = self.projector.project_record(record); - self.wrap_durable_deltas(record.event_id.as_str(), deltas) - } - - pub fn project_live_event(&mut self, event: &AgentEvent) -> Vec<ConversationDeltaFrameFacts> { - let cursor = self.live_cursor(); - self.projector - .project_live_event(event) - .into_iter() - .map(|delta| ConversationDeltaFrameFacts { - cursor: cursor.clone(), - delta, - }) - .collect() - } - - pub fn recover_from( - &mut self, - recovered: &ConversationStreamReplayFacts, - ) -> Vec<ConversationDeltaFrameFacts> { - self.fallback_live_cursor = fallback_live_cursor(recovered); - let mut frames = Vec::new(); - for record in &recovered.replay.history { - frames.extend(self.project_durable_record(record)); - } - frames - } - - fn wrap_durable_deltas( - &mut self, - cursor: &str, - deltas: Vec<ConversationDeltaFacts>, - ) -> Vec<ConversationDeltaFrameFacts> { - if deltas.is_empty() { - return Vec::new(); - } - let cursor_owned = cursor.to_string(); - self.last_sent_cursor = Some(cursor_owned.clone()); - deltas - .into_iter() - .map(|delta| ConversationDeltaFrameFacts { - cursor: cursor_owned.clone(), - delta, - }) - .collect() - } - - fn observe_durable_frames(&mut self, frames: &[ConversationDeltaFrameFacts]) { - if let Some(cursor) = frames.last().map(|frame| frame.cursor.clone()) { - self.last_sent_cursor = Some(cursor); - } - } - - fn live_cursor(&self) -> String { - self.last_sent_cursor - .clone() - .or_else(|| self.fallback_live_cursor.clone()) - .unwrap_or_else(|| "0.0".to_string()) - } -} - -pub(crate) fn project_conversation_snapshot( - records: &[SessionEventRecord], - phase: Phase, -) -> ConversationSnapshotFacts { - let mut projector = ConversationDeltaProjector::new(); - projector.seed(records); - ConversationSnapshotFacts { - cursor: records.last().map(|record| record.event_id.clone()), - phase, - blocks: projector.into_blocks(), - } -} - -pub(crate) fn build_conversation_replay_frames( - seed_records: &[SessionEventRecord], - history: &[SessionEventRecord], -) -> Vec<ConversationDeltaFrameFacts> { - let mut projector = ConversationDeltaProjector::new(); - projector.seed(seed_records); - let mut frames = Vec::new(); - for record in history { - for delta in projector.project_record(record) { - frames.push(ConversationDeltaFrameFacts { - cursor: record.event_id.clone(), - delta, - }); - } - } - frames -} - -pub(crate) fn fallback_live_cursor(facts: &ConversationStreamReplayFacts) -> Option<String> { - facts - .seed_records - .last() - .map(|record| record.event_id.clone()) - .or_else(|| { - facts - .replay - .history - .last() - .map(|record| record.event_id.clone()) - }) -} - -fn block_id(block: &ConversationBlockFacts) -> &str { - match block { - ConversationBlockFacts::User(block) => &block.id, - ConversationBlockFacts::Assistant(block) => &block.id, - ConversationBlockFacts::Thinking(block) => &block.id, - ConversationBlockFacts::Plan(block) => &block.id, - ConversationBlockFacts::ToolCall(block) => &block.id, - ConversationBlockFacts::Error(block) => &block.id, - ConversationBlockFacts::SystemNote(block) => &block.id, - ConversationBlockFacts::ChildHandoff(block) => &block.id, - } -} - -fn should_suppress_tool_call_block(tool_name: &str, _input: Option<&Value>) -> bool { - matches!(tool_name, "upsertSessionPlan" | "exitPlanMode") -} - -fn plan_block_from_tool_result( - turn_id: &str, - result: &ToolExecutionResult, -) -> Option<ConversationPlanBlockFacts> { - if !result.ok { - return None; - } - - let metadata = result.metadata.as_ref()?.as_object()?; - match result.tool_name.as_str() { - "upsertSessionPlan" => { - let title = json_string(metadata, "title")?; - let plan_path = json_string(metadata, "planPath")?; - Some(ConversationPlanBlockFacts { - id: format!("plan:{}:saved", result.tool_call_id), - turn_id: Some(turn_id.to_string()), - tool_call_id: result.tool_call_id.clone(), - event_kind: ConversationPlanEventKind::Saved, - title, - plan_path, - summary: Some(tool_result_summary(result)), - status: json_string(metadata, "status"), - slug: json_string(metadata, "slug"), - updated_at: json_string(metadata, "updatedAt"), - content: None, - review: None, - blockers: ConversationPlanBlockersFacts::default(), - }) - }, - "exitPlanMode" => match json_string(metadata, "schema").as_deref() { - Some("sessionPlanExit") => plan_presented_block(turn_id, result, metadata), - Some("sessionPlanExitReviewPending") | Some("sessionPlanExitBlocked") => { - plan_review_pending_block(turn_id, result, metadata) - }, - _ => None, - }, - _ => None, - } -} - -fn plan_presented_block( - turn_id: &str, - result: &ToolExecutionResult, - metadata: &serde_json::Map<String, Value>, -) -> Option<ConversationPlanBlockFacts> { - let plan = metadata.get("plan")?.as_object()?; - Some(ConversationPlanBlockFacts { - id: format!("plan:{}:presented", result.tool_call_id), - turn_id: Some(turn_id.to_string()), - tool_call_id: result.tool_call_id.clone(), - event_kind: ConversationPlanEventKind::Presented, - title: json_string(plan, "title")?, - plan_path: json_string(plan, "planPath")?, - summary: Some("计划已呈递".to_string()), - status: json_string(plan, "status"), - slug: json_string(plan, "slug"), - updated_at: json_string(plan, "updatedAt"), - content: json_string(plan, "content"), - review: None, - blockers: ConversationPlanBlockersFacts::default(), - }) -} - -fn plan_review_pending_block( - turn_id: &str, - result: &ToolExecutionResult, - metadata: &serde_json::Map<String, Value>, -) -> Option<ConversationPlanBlockFacts> { - let plan = metadata.get("plan")?.as_object()?; - let review = metadata - .get("review") - .and_then(Value::as_object) - .and_then(|review| { - let kind = match json_string(review, "kind").as_deref() { - Some("revise_plan") => ConversationPlanReviewKind::RevisePlan, - Some("final_review") => ConversationPlanReviewKind::FinalReview, - _ => return None, - }; - Some(ConversationPlanReviewFacts { - kind, - checklist: json_string_array(review, "checklist"), - }) - }); - let blockers = metadata - .get("blockers") - .and_then(Value::as_object) - .map(|blockers| ConversationPlanBlockersFacts { - missing_headings: json_string_array(blockers, "missingHeadings"), - invalid_sections: json_string_array(blockers, "invalidSections"), - }) - .unwrap_or_default(); - - Some(ConversationPlanBlockFacts { - id: format!("plan:{}:review-pending", result.tool_call_id), - turn_id: Some(turn_id.to_string()), - tool_call_id: result.tool_call_id.clone(), - event_kind: ConversationPlanEventKind::ReviewPending, - title: json_string(plan, "title")?, - plan_path: json_string(plan, "planPath")?, - summary: Some(match review.as_ref().map(|review| review.kind) { - Some(ConversationPlanReviewKind::RevisePlan) => "正在修计划".to_string(), - Some(ConversationPlanReviewKind::FinalReview) => "正在做退出前自审".to_string(), - None => "继续完善中".to_string(), - }), - status: None, - slug: None, - updated_at: None, - content: None, - review, - blockers, - }) -} - -fn json_string(container: &serde_json::Map<String, Value>, key: &str) -> Option<String> { - container - .get(key) - .and_then(Value::as_str) - .map(ToString::to_string) -} - -fn json_string_array(container: &serde_json::Map<String, Value>, key: &str) -> Vec<String> { - container - .get(key) - .and_then(Value::as_array) - .map(|items| { - items - .iter() - .filter_map(Value::as_str) - .map(ToString::to_string) - .collect() - }) - .unwrap_or_default() -} - -fn tool_result_summary(result: &ToolExecutionResult) -> String { - const MAX_SUMMARY_CHARS: usize = 120; - - if result.ok { - if !result.output.trim().is_empty() { - crate::query::text::summarize_inline_text(&result.output, MAX_SUMMARY_CHARS) - .unwrap_or_else(|| format!("{} completed", result.tool_name)) - } else { - format!("{} completed", result.tool_name) - } - } else if let Some(error) = &result.error { - crate::query::text::summarize_inline_text(error, MAX_SUMMARY_CHARS) - .unwrap_or_else(|| format!("{} failed", result.tool_name)) - } else if !result.output.trim().is_empty() { - crate::query::text::summarize_inline_text(&result.output, MAX_SUMMARY_CHARS) - .unwrap_or_else(|| format!("{} failed", result.tool_name)) - } else { - format!("{} failed", result.tool_name) - } -} - -fn classify_transcript_error(message: &str) -> ConversationTranscriptErrorKind { - let lower = message.to_lowercase(); - if lower.contains("context window") || lower.contains("token limit") { - ConversationTranscriptErrorKind::ContextWindowExceeded - } else if lower.contains("rate limit") { - ConversationTranscriptErrorKind::RateLimit - } else if lower.contains("tool") { - ConversationTranscriptErrorKind::ToolFatal - } else { - ConversationTranscriptErrorKind::ProviderError - } -} - #[cfg(test)] -mod tests { - use std::{path::Path, sync::Arc}; - - use astrcode_core::{ - AgentEvent, AgentEventContext, AgentLifecycleStatus, ChildAgentRef, ChildExecutionIdentity, - ChildSessionLineageKind, ChildSessionNotification, ChildSessionNotificationKind, - DeleteProjectResult, EventStore, ParentDelivery, ParentDeliveryOrigin, - ParentDeliveryPayload, ParentDeliveryTerminalSemantics, ParentExecutionRef, Phase, - SessionEventRecord, SessionId, SessionMeta, SessionTurnAcquireResult, StorageEvent, - StorageEventPayload, StoredEvent, ToolExecutionResult, ToolOutputStream, UserMessageOrigin, - }; - use async_trait::async_trait; - use chrono::Utc; - use serde_json::json; - use tokio::sync::broadcast; - - use super::{ - ConversationBlockFacts, ConversationBlockPatchFacts, ConversationBlockStatus, - ConversationChildHandoffKind, ConversationDeltaFacts, ConversationDeltaProjector, - ConversationPlanEventKind, ConversationStreamProjector, ConversationStreamReplayFacts, - build_conversation_replay_frames, fallback_live_cursor, project_conversation_snapshot, - }; - use crate::{ - SessionReplay, SessionRuntime, - turn::test_support::{NoopMetrics, NoopPromptFactsProvider, test_kernel}, - }; - - #[test] - fn snapshot_projects_tool_call_block_with_streams_and_terminal_fields() { - let records = vec![ - record( - "1.1", - AgentEvent::ToolCallStart { - turn_id: "turn-1".to_string(), - agent: sample_agent_context(), - tool_call_id: "call-1".to_string(), - tool_name: "shell_command".to_string(), - input: json!({ "command": "pwd" }), - }, - ), - record( - "1.2", - AgentEvent::ToolCallDelta { - turn_id: "turn-1".to_string(), - agent: sample_agent_context(), - tool_call_id: "call-1".to_string(), - tool_name: "shell_command".to_string(), - stream: ToolOutputStream::Stdout, - delta: "line-1\n".to_string(), - }, - ), - record( - "1.3", - AgentEvent::ToolCallDelta { - turn_id: "turn-1".to_string(), - agent: sample_agent_context(), - tool_call_id: "call-1".to_string(), - tool_name: "shell_command".to_string(), - stream: ToolOutputStream::Stderr, - delta: "warn\n".to_string(), - }, - ), - record( - "1.4", - AgentEvent::ToolCallResult { - turn_id: "turn-1".to_string(), - agent: sample_agent_context(), - result: ToolExecutionResult { - tool_call_id: "call-1".to_string(), - tool_name: "shell_command".to_string(), - ok: false, - output: "line-1\n".to_string(), - error: Some("permission denied".to_string()), - metadata: Some(json!({ "path": "/tmp", "truncated": true })), - child_ref: None, - duration_ms: 42, - truncated: true, - }, - }, - ), - ]; - - let snapshot = project_conversation_snapshot(&records, Phase::CallingTool); - let tool = snapshot - .blocks - .iter() - .find_map(|block| match block { - ConversationBlockFacts::ToolCall(block) => Some(block), - _ => None, - }) - .expect("tool block should exist"); - - assert_eq!(tool.tool_call_id, "call-1"); - assert_eq!(tool.status, ConversationBlockStatus::Failed); - assert_eq!(tool.streams.stdout, "line-1\n"); - assert_eq!(tool.streams.stderr, "warn\n"); - assert_eq!(tool.error.as_deref(), Some("permission denied")); - assert_eq!(tool.duration_ms, Some(42)); - assert!(tool.truncated); - } - - #[test] - fn snapshot_preserves_failed_tool_status_after_turn_done() { - let records = vec![ - record( - "1.1", - AgentEvent::ToolCallStart { - turn_id: "turn-1".to_string(), - agent: sample_agent_context(), - tool_call_id: "call-1".to_string(), - tool_name: "shell_command".to_string(), - input: json!({ "command": "missing-command" }), - }, - ), - record( - "1.2", - AgentEvent::ToolCallResult { - turn_id: "turn-1".to_string(), - agent: sample_agent_context(), - result: ToolExecutionResult { - tool_call_id: "call-1".to_string(), - tool_name: "shell_command".to_string(), - ok: false, - output: String::new(), - error: Some("command not found".to_string()), - metadata: None, - child_ref: None, - duration_ms: 127, - truncated: false, - }, - }, - ), - record( - "1.3", - AgentEvent::TurnDone { - turn_id: "turn-1".to_string(), - agent: sample_agent_context(), - }, - ), - ]; - - let snapshot = project_conversation_snapshot(&records, Phase::Idle); - let tool = snapshot - .blocks - .iter() - .find_map(|block| match block { - ConversationBlockFacts::ToolCall(block) => Some(block), - _ => None, - }) - .expect("tool block should exist"); - - assert_eq!(tool.status, ConversationBlockStatus::Failed); - assert_eq!(tool.error.as_deref(), Some("command not found")); - assert_eq!(tool.duration_ms, Some(127)); - } - - #[test] - fn snapshot_projects_plan_blocks_in_durable_event_order() { - let records = vec![ - record( - "1.1", - AgentEvent::ToolCallStart { - turn_id: "turn-1".to_string(), - agent: sample_agent_context(), - tool_call_id: "call-plan-save".to_string(), - tool_name: "upsertSessionPlan".to_string(), - input: json!({ - "title": "Cleanup crates", - "content": "# Plan: Cleanup crates" - }), - }, - ), - record( - "1.2", - AgentEvent::ToolCallResult { - turn_id: "turn-1".to_string(), - agent: sample_agent_context(), - result: ToolExecutionResult { - tool_call_id: "call-plan-save".to_string(), - tool_name: "upsertSessionPlan".to_string(), - ok: true, - output: "updated session plan".to_string(), - error: None, - metadata: Some(json!({ - "planPath": "C:/Users/demo/.astrcode/projects/demo/sessions/session-1/plan/cleanup-crates.md", - "slug": "cleanup-crates", - "status": "draft", - "title": "Cleanup crates", - "updatedAt": "2026-04-19T09:00:00Z" - })), - child_ref: None, - duration_ms: 7, - truncated: false, - }, - }, - ), - record( - "1.3", - AgentEvent::ToolCallStart { - turn_id: "turn-1".to_string(), - agent: sample_agent_context(), - tool_call_id: "call-shell".to_string(), - tool_name: "shell_command".to_string(), - input: json!({ "command": "pwd" }), - }, - ), - record( - "1.4", - AgentEvent::ToolCallResult { - turn_id: "turn-1".to_string(), - agent: sample_agent_context(), - result: ToolExecutionResult { - tool_call_id: "call-shell".to_string(), - tool_name: "shell_command".to_string(), - ok: true, - output: "D:/GitObjectsOwn/Astrcode".to_string(), - error: None, - metadata: None, - child_ref: None, - duration_ms: 9, - truncated: false, - }, - }, - ), - record( - "1.5", - AgentEvent::ToolCallStart { - turn_id: "turn-1".to_string(), - agent: sample_agent_context(), - tool_call_id: "call-plan-exit".to_string(), - tool_name: "exitPlanMode".to_string(), - input: json!({}), - }, - ), - record( - "1.6", - AgentEvent::ToolCallResult { - turn_id: "turn-1".to_string(), - agent: sample_agent_context(), - result: ToolExecutionResult { - tool_call_id: "call-plan-exit".to_string(), - tool_name: "exitPlanMode".to_string(), - ok: true, - output: "Before exiting plan mode, do one final self-review.".to_string(), - error: None, - metadata: Some(json!({ - "schema": "sessionPlanExitReviewPending", - "plan": { - "title": "Cleanup crates", - "planPath": "C:/Users/demo/.astrcode/projects/demo/sessions/session-1/plan/cleanup-crates.md" - }, - "review": { - "kind": "final_review", - "checklist": [ - "Re-check assumptions against the code you already inspected." - ] - }, - "blockers": { - "missingHeadings": ["## Verification"], - "invalidSections": [] - } - })), - child_ref: None, - duration_ms: 5, - truncated: false, - }, - }, - ), - ]; - - let snapshot = project_conversation_snapshot(&records, Phase::Idle); - assert_eq!(snapshot.blocks.len(), 3); - assert!(matches!( - &snapshot.blocks[0], - ConversationBlockFacts::Plan(block) - if block.tool_call_id == "call-plan-save" - && block.event_kind == ConversationPlanEventKind::Saved - )); - assert!(matches!( - &snapshot.blocks[1], - ConversationBlockFacts::ToolCall(block) if block.tool_call_id == "call-shell" - )); - assert!(matches!( - &snapshot.blocks[2], - ConversationBlockFacts::Plan(block) - if block.tool_call_id == "call-plan-exit" - && block.event_kind == ConversationPlanEventKind::ReviewPending - )); - } - - #[test] - fn live_then_durable_tool_delta_dedupes_chunk_on_same_tool_block() { - let facts = sample_stream_replay_facts( - vec![record( - "1.1", - AgentEvent::ToolCallStart { - turn_id: "turn-1".to_string(), - agent: sample_agent_context(), - tool_call_id: "call-1".to_string(), - tool_name: "shell_command".to_string(), - input: json!({ "command": "pwd" }), - }, - )], - vec![record( - "1.2", - AgentEvent::ToolCallDelta { - turn_id: "turn-1".to_string(), - agent: sample_agent_context(), - tool_call_id: "call-1".to_string(), - tool_name: "shell_command".to_string(), - stream: ToolOutputStream::Stdout, - delta: "line-1\n".to_string(), - }, - )], - ); - let mut stream = ConversationStreamProjector::new(Some("1.1".to_string()), &facts); - - let live_frames = stream.project_live_event(&AgentEvent::ToolCallDelta { - turn_id: "turn-1".to_string(), - agent: sample_agent_context(), - tool_call_id: "call-1".to_string(), - tool_name: "shell_command".to_string(), - stream: ToolOutputStream::Stdout, - delta: "line-1\n".to_string(), - }); - assert_eq!(live_frames.len(), 1); - - let replayed = stream.recover_from(&facts); - assert!( - replayed.is_empty(), - "durable replay should not duplicate the live-emitted chunk" - ); - } - - #[test] - fn child_notification_patches_tool_block_and_appends_handoff_block() { - let mut projector = ConversationDeltaProjector::new(); - projector.seed(&[record( - "1.1", - AgentEvent::ToolCallStart { - turn_id: "turn-1".to_string(), - agent: sample_agent_context(), - tool_call_id: "call-spawn".to_string(), - tool_name: "spawn_agent".to_string(), - input: json!({ "task": "inspect" }), - }, - )]); - - let deltas = projector.project_record(&record( - "1.2", - AgentEvent::ChildSessionNotification { - turn_id: Some("turn-1".to_string()), - agent: sample_agent_context(), - notification: sample_child_notification(), - }, - )); - - assert!(deltas.iter().any(|delta| matches!( - delta, - ConversationDeltaFacts::PatchBlock { - block_id, - patch: ConversationBlockPatchFacts::ReplaceChildRef { .. }, - } if block_id == "tool:call-spawn:call" - ))); - assert!(deltas.iter().any(|delta| matches!( - delta, - ConversationDeltaFacts::AppendBlock { - block, - } if matches!( - block.as_ref(), - ConversationBlockFacts::ChildHandoff(block) - if block.handoff_kind == ConversationChildHandoffKind::Returned - ) - ))); - } - - #[tokio::test] - async fn runtime_query_builds_snapshot_and_stream_replay_facts() { - let event_store = Arc::new(ReplayOnlyEventStore::new(vec![ - stored( - 1, - storage_event( - Some("turn-1"), - StorageEventPayload::UserMessage { - content: "inspect repo".to_string(), - origin: UserMessageOrigin::User, - timestamp: Utc::now(), - }, - ), - ), - stored( - 2, - storage_event( - Some("turn-1"), - StorageEventPayload::ToolCall { - tool_call_id: "call-1".to_string(), - tool_name: "shell_command".to_string(), - args: json!({ "command": "pwd" }), - }, - ), - ), - stored( - 3, - storage_event( - Some("turn-1"), - StorageEventPayload::ToolCallDelta { - tool_call_id: "call-1".to_string(), - tool_name: "shell_command".to_string(), - stream: ToolOutputStream::Stdout, - delta: "D:/GitObjectsOwn/Astrcode\n".to_string(), - }, - ), - ), - stored( - 4, - storage_event( - Some("turn-1"), - StorageEventPayload::ToolResult { - tool_call_id: "call-1".to_string(), - tool_name: "shell_command".to_string(), - output: "D:/GitObjectsOwn/Astrcode\n".to_string(), - success: true, - error: None, - metadata: None, - child_ref: None, - duration_ms: 7, - }, - ), - ), - stored( - 5, - storage_event( - Some("turn-1"), - StorageEventPayload::AssistantFinal { - content: "done".to_string(), - reasoning_content: Some("think".to_string()), - reasoning_signature: None, - timestamp: None, - }, - ), - ), - ])); - let runtime = SessionRuntime::new( - Arc::new(test_kernel(8192)), - Arc::new(NoopPromptFactsProvider), - event_store, - Arc::new(NoopMetrics), - ); - - let snapshot = runtime - .conversation_snapshot("session-1") - .await - .expect("snapshot should build"); - assert!(snapshot.blocks.iter().any(|block| matches!( - block, - ConversationBlockFacts::ToolCall(block) - if block.tool_call_id == "call-1" - ))); - - let transcript = runtime - .session_transcript_snapshot("session-1") - .await - .expect("transcript snapshot should build"); - assert!(transcript.records.len() > 4); - let cursor = transcript.records[3].event_id.clone(); - - let replay = runtime - .conversation_stream_replay("session-1", Some(cursor.as_str())) - .await - .expect("replay facts should build"); - assert_eq!( - replay - .seed_records - .last() - .map(|record| record.event_id.as_str()), - Some(cursor.as_str()) - ); - assert!(!replay.replay_frames.is_empty()); - assert_eq!( - fallback_live_cursor(&replay).as_deref(), - Some(cursor.as_str()) - ); - } - - fn sample_stream_replay_facts( - seed_records: Vec<SessionEventRecord>, - history: Vec<SessionEventRecord>, - ) -> ConversationStreamReplayFacts { - let (_, receiver) = broadcast::channel(8); - let (_, live_receiver) = broadcast::channel(8); - ConversationStreamReplayFacts { - cursor: history.last().map(|record| record.event_id.clone()), - phase: Phase::CallingTool, - seed_records: seed_records.clone(), - replay_frames: build_conversation_replay_frames(&seed_records, &history), - replay: SessionReplay { - history, - receiver, - live_receiver, - }, - } - } - - fn sample_agent_context() -> AgentEventContext { - AgentEventContext::root_execution("agent-root", "default") - } - - fn sample_child_notification() -> ChildSessionNotification { - ChildSessionNotification { - notification_id: "child-note-1".to_string().into(), - child_ref: ChildAgentRef { - identity: ChildExecutionIdentity { - agent_id: "agent-child-1".to_string().into(), - session_id: "session-root".to_string().into(), - sub_run_id: "subrun-child-1".to_string().into(), - }, - parent: ParentExecutionRef { - parent_agent_id: Some("agent-root".to_string().into()), - parent_sub_run_id: Some("subrun-root".to_string().into()), - }, - lineage_kind: ChildSessionLineageKind::Spawn, - status: AgentLifecycleStatus::Running, - open_session_id: "session-child-1".to_string().into(), - }, - kind: ChildSessionNotificationKind::Delivered, - source_tool_call_id: Some("call-spawn".to_string().into()), - delivery: Some(ParentDelivery { - idempotency_key: "delivery-1".to_string(), - origin: ParentDeliveryOrigin::Explicit, - terminal_semantics: ParentDeliveryTerminalSemantics::Terminal, - source_turn_id: Some("turn-1".to_string()), - payload: ParentDeliveryPayload::Progress( - astrcode_core::ProgressParentDeliveryPayload { - message: "child finished".to_string(), - }, - ), - }), - } - } - - fn record(event_id: &str, event: AgentEvent) -> SessionEventRecord { - SessionEventRecord { - event_id: event_id.to_string(), - event, - } - } - - fn stored(storage_seq: u64, event: StorageEvent) -> StoredEvent { - StoredEvent { storage_seq, event } - } - - fn storage_event(turn_id: Option<&str>, payload: StorageEventPayload) -> StorageEvent { - StorageEvent { - turn_id: turn_id.map(ToString::to_string), - agent: sample_agent_context(), - payload, - } - } - - struct ReplayOnlyEventStore { - events: Vec<StoredEvent>, - } - - impl ReplayOnlyEventStore { - fn new(events: Vec<StoredEvent>) -> Self { - Self { events } - } - } - - struct StubTurnLease; - - impl astrcode_core::SessionTurnLease for StubTurnLease {} - - #[async_trait] - impl EventStore for ReplayOnlyEventStore { - async fn ensure_session( - &self, - _session_id: &SessionId, - _working_dir: &Path, - ) -> astrcode_core::Result<()> { - Ok(()) - } - - async fn append( - &self, - _session_id: &SessionId, - _event: &astrcode_core::StorageEvent, - ) -> astrcode_core::Result<StoredEvent> { - panic!("append should not be called in replay-only test store"); - } - - async fn replay(&self, _session_id: &SessionId) -> astrcode_core::Result<Vec<StoredEvent>> { - Ok(self.events.clone()) - } - - async fn try_acquire_turn( - &self, - _session_id: &SessionId, - _turn_id: &str, - ) -> astrcode_core::Result<SessionTurnAcquireResult> { - Ok(SessionTurnAcquireResult::Acquired(Box::new(StubTurnLease))) - } - - async fn list_sessions(&self) -> astrcode_core::Result<Vec<SessionId>> { - Ok(vec![SessionId::from("session-1".to_string())]) - } - - async fn list_session_metas(&self) -> astrcode_core::Result<Vec<SessionMeta>> { - Ok(vec![SessionMeta { - session_id: "session-1".to_string(), - working_dir: ".".to_string(), - display_name: "session-1".to_string(), - title: "session-1".to_string(), - created_at: Utc::now(), - updated_at: Utc::now(), - parent_session_id: None, - parent_storage_seq: None, - phase: Phase::Done, - }]) - } - - async fn delete_session(&self, _session_id: &SessionId) -> astrcode_core::Result<()> { - Ok(()) - } - - async fn delete_sessions_by_working_dir( - &self, - _working_dir: &str, - ) -> astrcode_core::Result<DeleteProjectResult> { - Ok(DeleteProjectResult { - success_count: 0, - failed_session_ids: Vec::new(), - }) - } - } -} +mod tests; diff --git a/crates/session-runtime/src/query/conversation/facts.rs b/crates/session-runtime/src/query/conversation/facts.rs new file mode 100644 index 00000000..572a2060 --- /dev/null +++ b/crates/session-runtime/src/query/conversation/facts.rs @@ -0,0 +1,231 @@ +use astrcode_core::{ + ChildAgentRef, CompactAppliedMeta, CompactTrigger, Phase, SessionEventRecord, ToolOutputStream, +}; +use serde_json::Value; + +use crate::SessionReplay; +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ConversationBlockStatus { + Streaming, + Complete, + Failed, + Cancelled, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ConversationSystemNoteKind { + Compact, + SystemNote, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ConversationChildHandoffKind { + Delegated, + Progress, + Returned, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ConversationTranscriptErrorKind { + ProviderError, + ContextWindowExceeded, + ToolFatal, + RateLimit, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ConversationPlanEventKind { + Saved, + ReviewPending, + Presented, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ConversationPlanReviewKind { + RevisePlan, + FinalReview, +} + +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct ToolCallStreamsFacts { + pub stdout: String, + pub stderr: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ConversationUserBlockFacts { + pub id: String, + pub turn_id: Option<String>, + pub markdown: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ConversationAssistantBlockFacts { + pub id: String, + pub turn_id: Option<String>, + pub status: ConversationBlockStatus, + pub markdown: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ConversationThinkingBlockFacts { + pub id: String, + pub turn_id: Option<String>, + pub status: ConversationBlockStatus, + pub markdown: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ConversationPlanReviewFacts { + pub kind: ConversationPlanReviewKind, + pub checklist: Vec<String>, +} + +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct ConversationPlanBlockersFacts { + pub missing_headings: Vec<String>, + pub invalid_sections: Vec<String>, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ConversationPlanBlockFacts { + pub id: String, + pub turn_id: Option<String>, + pub tool_call_id: String, + pub event_kind: ConversationPlanEventKind, + pub title: String, + pub plan_path: String, + pub summary: Option<String>, + pub status: Option<String>, + pub slug: Option<String>, + pub updated_at: Option<String>, + pub content: Option<String>, + pub review: Option<ConversationPlanReviewFacts>, + pub blockers: ConversationPlanBlockersFacts, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct ToolCallBlockFacts { + pub id: String, + pub turn_id: Option<String>, + pub tool_call_id: String, + pub tool_name: String, + pub status: ConversationBlockStatus, + pub input: Option<Value>, + pub summary: Option<String>, + pub error: Option<String>, + pub duration_ms: Option<u64>, + pub truncated: bool, + pub metadata: Option<Value>, + pub child_ref: Option<ChildAgentRef>, + pub streams: ToolCallStreamsFacts, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ConversationErrorBlockFacts { + pub id: String, + pub turn_id: Option<String>, + pub code: ConversationTranscriptErrorKind, + pub message: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ConversationSystemNoteBlockFacts { + pub id: String, + pub note_kind: ConversationSystemNoteKind, + pub markdown: String, + pub compact_trigger: Option<CompactTrigger>, + pub compact_meta: Option<CompactAppliedMeta>, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ConversationChildHandoffBlockFacts { + pub id: String, + pub handoff_kind: ConversationChildHandoffKind, + pub child_ref: ChildAgentRef, + pub message: Option<String>, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum ConversationBlockFacts { + User(ConversationUserBlockFacts), + Assistant(ConversationAssistantBlockFacts), + Thinking(ConversationThinkingBlockFacts), + Plan(Box<ConversationPlanBlockFacts>), + ToolCall(Box<ToolCallBlockFacts>), + Error(ConversationErrorBlockFacts), + SystemNote(ConversationSystemNoteBlockFacts), + ChildHandoff(ConversationChildHandoffBlockFacts), +} + +#[derive(Debug, Clone, PartialEq)] +pub enum ConversationBlockPatchFacts { + AppendMarkdown { + markdown: String, + }, + ReplaceMarkdown { + markdown: String, + }, + AppendToolStream { + stream: ToolOutputStream, + chunk: String, + }, + ReplaceSummary { + summary: String, + }, + ReplaceMetadata { + metadata: Value, + }, + ReplaceError { + error: Option<String>, + }, + ReplaceDuration { + duration_ms: u64, + }, + ReplaceChildRef { + child_ref: ChildAgentRef, + }, + SetTruncated { + truncated: bool, + }, + SetStatus { + status: ConversationBlockStatus, + }, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum ConversationDeltaFacts { + AppendBlock { + block: Box<ConversationBlockFacts>, + }, + PatchBlock { + block_id: String, + patch: ConversationBlockPatchFacts, + }, + CompleteBlock { + block_id: String, + status: ConversationBlockStatus, + }, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct ConversationDeltaFrameFacts { + pub cursor: String, + pub delta: ConversationDeltaFacts, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct ConversationSnapshotFacts { + pub cursor: Option<String>, + pub phase: Phase, + pub blocks: Vec<ConversationBlockFacts>, +} + +#[derive(Debug)] +pub struct ConversationStreamReplayFacts { + pub cursor: Option<String>, + pub phase: Phase, + pub seed_records: Vec<SessionEventRecord>, + pub replay_frames: Vec<ConversationDeltaFrameFacts>, + pub replay: SessionReplay, +} diff --git a/crates/session-runtime/src/query/conversation/plan_projection.rs b/crates/session-runtime/src/query/conversation/plan_projection.rs new file mode 100644 index 00000000..acdfe28a --- /dev/null +++ b/crates/session-runtime/src/query/conversation/plan_projection.rs @@ -0,0 +1,135 @@ +use super::*; + +pub(super) fn plan_block_from_tool_result( + turn_id: &str, + result: &ToolExecutionResult, +) -> Option<ConversationPlanBlockFacts> { + if !result.ok { + return None; + } + + let metadata = result.metadata.as_ref()?.as_object()?; + match result.tool_name.as_str() { + "upsertSessionPlan" => { + let title = json_string(metadata, "title")?; + let plan_path = json_string(metadata, "planPath")?; + Some(ConversationPlanBlockFacts { + id: format!("plan:{}:saved", result.tool_call_id), + turn_id: Some(turn_id.to_string()), + tool_call_id: result.tool_call_id.clone(), + event_kind: ConversationPlanEventKind::Saved, + title, + plan_path, + summary: Some(super::tool_result_summary(result)), + status: json_string(metadata, "status"), + slug: json_string(metadata, "slug"), + updated_at: json_string(metadata, "updatedAt"), + content: None, + review: None, + blockers: ConversationPlanBlockersFacts::default(), + }) + }, + "exitPlanMode" => match json_string(metadata, "schema").as_deref() { + Some("sessionPlanExit") => plan_presented_block(turn_id, result, metadata), + Some("sessionPlanExitReviewPending") | Some("sessionPlanExitBlocked") => { + plan_review_pending_block(turn_id, result, metadata) + }, + _ => None, + }, + _ => None, + } +} + +fn plan_presented_block( + turn_id: &str, + result: &ToolExecutionResult, + metadata: &serde_json::Map<String, Value>, +) -> Option<ConversationPlanBlockFacts> { + let plan = metadata.get("plan")?.as_object()?; + Some(ConversationPlanBlockFacts { + id: format!("plan:{}:presented", result.tool_call_id), + turn_id: Some(turn_id.to_string()), + tool_call_id: result.tool_call_id.clone(), + event_kind: ConversationPlanEventKind::Presented, + title: json_string(plan, "title")?, + plan_path: json_string(plan, "planPath")?, + summary: Some("计划已呈递".to_string()), + status: json_string(plan, "status"), + slug: json_string(plan, "slug"), + updated_at: json_string(plan, "updatedAt"), + content: json_string(plan, "content"), + review: None, + blockers: ConversationPlanBlockersFacts::default(), + }) +} + +fn plan_review_pending_block( + turn_id: &str, + result: &ToolExecutionResult, + metadata: &serde_json::Map<String, Value>, +) -> Option<ConversationPlanBlockFacts> { + let plan = metadata.get("plan")?.as_object()?; + let review = metadata + .get("review") + .and_then(Value::as_object) + .and_then(|review| { + let kind = match json_string(review, "kind").as_deref() { + Some("revise_plan") => ConversationPlanReviewKind::RevisePlan, + Some("final_review") => ConversationPlanReviewKind::FinalReview, + _ => return None, + }; + Some(ConversationPlanReviewFacts { + kind, + checklist: json_string_array(review, "checklist"), + }) + }); + let blockers = metadata + .get("blockers") + .and_then(Value::as_object) + .map(|blockers| ConversationPlanBlockersFacts { + missing_headings: json_string_array(blockers, "missingHeadings"), + invalid_sections: json_string_array(blockers, "invalidSections"), + }) + .unwrap_or_default(); + + Some(ConversationPlanBlockFacts { + id: format!("plan:{}:review-pending", result.tool_call_id), + turn_id: Some(turn_id.to_string()), + tool_call_id: result.tool_call_id.clone(), + event_kind: ConversationPlanEventKind::ReviewPending, + title: json_string(plan, "title")?, + plan_path: json_string(plan, "planPath")?, + summary: Some(match review.as_ref().map(|review| review.kind) { + Some(ConversationPlanReviewKind::RevisePlan) => "正在修计划".to_string(), + Some(ConversationPlanReviewKind::FinalReview) => "正在做退出前自审".to_string(), + None => "继续完善中".to_string(), + }), + status: None, + slug: None, + updated_at: None, + content: None, + review, + blockers, + }) +} + +fn json_string(container: &serde_json::Map<String, Value>, key: &str) -> Option<String> { + container + .get(key) + .and_then(Value::as_str) + .map(ToString::to_string) +} + +fn json_string_array(container: &serde_json::Map<String, Value>, key: &str) -> Vec<String> { + container + .get(key) + .and_then(Value::as_array) + .map(|items| { + items + .iter() + .filter_map(Value::as_str) + .map(ToString::to_string) + .collect() + }) + .unwrap_or_default() +} diff --git a/crates/session-runtime/src/query/conversation/projection_support.rs b/crates/session-runtime/src/query/conversation/projection_support.rs new file mode 100644 index 00000000..f2d422f7 --- /dev/null +++ b/crates/session-runtime/src/query/conversation/projection_support.rs @@ -0,0 +1,195 @@ +use super::*; + +mod plan_projection; + +impl ConversationStreamProjector { + pub fn new(last_sent_cursor: Option<String>, facts: &ConversationStreamReplayFacts) -> Self { + let mut projector = ConversationDeltaProjector::new(); + projector.seed(&facts.seed_records); + Self { + projector, + last_sent_cursor, + fallback_live_cursor: fallback_live_cursor(facts), + } + } + + pub fn last_sent_cursor(&self) -> Option<&str> { + self.last_sent_cursor.as_deref() + } + + pub fn seed_initial_replay( + &mut self, + facts: &ConversationStreamReplayFacts, + ) -> Vec<ConversationDeltaFrameFacts> { + let frames = facts.replay_frames.clone(); + self.observe_durable_frames(&frames); + frames + } + + pub fn project_durable_record( + &mut self, + record: &SessionEventRecord, + ) -> Vec<ConversationDeltaFrameFacts> { + let deltas = self.projector.project_record(record); + self.wrap_durable_deltas(record.event_id.as_str(), deltas) + } + + pub fn project_live_event(&mut self, event: &AgentEvent) -> Vec<ConversationDeltaFrameFacts> { + let cursor = self.live_cursor(); + self.projector + .project_live_event(event) + .into_iter() + .map(|delta| ConversationDeltaFrameFacts { + cursor: cursor.clone(), + delta, + }) + .collect() + } + + pub fn recover_from( + &mut self, + recovered: &ConversationStreamReplayFacts, + ) -> Vec<ConversationDeltaFrameFacts> { + self.fallback_live_cursor = fallback_live_cursor(recovered); + let mut frames = Vec::new(); + for record in &recovered.replay.history { + frames.extend(self.project_durable_record(record)); + } + frames + } + + fn wrap_durable_deltas( + &mut self, + cursor: &str, + deltas: Vec<ConversationDeltaFacts>, + ) -> Vec<ConversationDeltaFrameFacts> { + if deltas.is_empty() { + return Vec::new(); + } + let cursor_owned = cursor.to_string(); + self.last_sent_cursor = Some(cursor_owned.clone()); + deltas + .into_iter() + .map(|delta| ConversationDeltaFrameFacts { + cursor: cursor_owned.clone(), + delta, + }) + .collect() + } + + fn observe_durable_frames(&mut self, frames: &[ConversationDeltaFrameFacts]) { + if let Some(cursor) = frames.last().map(|frame| frame.cursor.clone()) { + self.last_sent_cursor = Some(cursor); + } + } + + fn live_cursor(&self) -> String { + self.last_sent_cursor + .clone() + .or_else(|| self.fallback_live_cursor.clone()) + .unwrap_or_else(|| "0.0".to_string()) + } +} + +pub(crate) fn project_conversation_snapshot( + records: &[SessionEventRecord], + phase: Phase, +) -> ConversationSnapshotFacts { + let mut projector = ConversationDeltaProjector::new(); + projector.seed(records); + ConversationSnapshotFacts { + cursor: records.last().map(|record| record.event_id.clone()), + phase, + blocks: projector.into_blocks(), + } +} + +pub(crate) fn build_conversation_replay_frames( + seed_records: &[SessionEventRecord], + history: &[SessionEventRecord], +) -> Vec<ConversationDeltaFrameFacts> { + let mut projector = ConversationDeltaProjector::new(); + projector.seed(seed_records); + let mut frames = Vec::new(); + for record in history { + for delta in projector.project_record(record) { + frames.push(ConversationDeltaFrameFacts { + cursor: record.event_id.clone(), + delta, + }); + } + } + frames +} + +pub(crate) fn fallback_live_cursor(facts: &ConversationStreamReplayFacts) -> Option<String> { + facts + .seed_records + .last() + .map(|record| record.event_id.clone()) + .or_else(|| { + facts + .replay + .history + .last() + .map(|record| record.event_id.clone()) + }) +} + +pub(super) fn block_id(block: &ConversationBlockFacts) -> &str { + match block { + ConversationBlockFacts::User(block) => &block.id, + ConversationBlockFacts::Assistant(block) => &block.id, + ConversationBlockFacts::Thinking(block) => &block.id, + ConversationBlockFacts::Plan(block) => &block.id, + ConversationBlockFacts::ToolCall(block) => &block.id, + ConversationBlockFacts::Error(block) => &block.id, + ConversationBlockFacts::SystemNote(block) => &block.id, + ConversationBlockFacts::ChildHandoff(block) => &block.id, + } +} + +pub(super) fn should_suppress_tool_call_block(tool_name: &str, _input: Option<&Value>) -> bool { + matches!(tool_name, "upsertSessionPlan" | "exitPlanMode") +} + +pub(super) fn plan_block_from_tool_result( + turn_id: &str, + result: &ToolExecutionResult, +) -> Option<ConversationPlanBlockFacts> { + plan_projection::plan_block_from_tool_result(turn_id, result) +} + +pub(super) fn tool_result_summary(result: &ToolExecutionResult) -> String { + const MAX_SUMMARY_CHARS: usize = 120; + + if result.ok { + if !result.output.trim().is_empty() { + crate::query::text::summarize_inline_text(&result.output, MAX_SUMMARY_CHARS) + .unwrap_or_else(|| format!("{} completed", result.tool_name)) + } else { + format!("{} completed", result.tool_name) + } + } else if let Some(error) = &result.error { + crate::query::text::summarize_inline_text(error, MAX_SUMMARY_CHARS) + .unwrap_or_else(|| format!("{} failed", result.tool_name)) + } else if !result.output.trim().is_empty() { + crate::query::text::summarize_inline_text(&result.output, MAX_SUMMARY_CHARS) + .unwrap_or_else(|| format!("{} failed", result.tool_name)) + } else { + format!("{} failed", result.tool_name) + } +} + +pub(super) fn classify_transcript_error(message: &str) -> ConversationTranscriptErrorKind { + let lower = message.to_lowercase(); + if lower.contains("context window") || lower.contains("token limit") { + ConversationTranscriptErrorKind::ContextWindowExceeded + } else if lower.contains("rate limit") { + ConversationTranscriptErrorKind::RateLimit + } else if lower.contains("tool") { + ConversationTranscriptErrorKind::ToolFatal + } else { + ConversationTranscriptErrorKind::ProviderError + } +} diff --git a/crates/session-runtime/src/query/conversation/tests.rs b/crates/session-runtime/src/query/conversation/tests.rs new file mode 100644 index 00000000..aae4afb5 --- /dev/null +++ b/crates/session-runtime/src/query/conversation/tests.rs @@ -0,0 +1,634 @@ +use std::{path::Path, sync::Arc}; + +use astrcode_core::{ + AgentEvent, AgentEventContext, AgentLifecycleStatus, ChildAgentRef, ChildExecutionIdentity, + ChildSessionLineageKind, ChildSessionNotification, ChildSessionNotificationKind, + DeleteProjectResult, EventStore, ParentDelivery, ParentDeliveryOrigin, ParentDeliveryPayload, + ParentDeliveryTerminalSemantics, ParentExecutionRef, Phase, SessionEventRecord, SessionId, + SessionMeta, SessionTurnAcquireResult, StorageEvent, StorageEventPayload, StoredEvent, + ToolExecutionResult, ToolOutputStream, UserMessageOrigin, +}; +use async_trait::async_trait; +use chrono::Utc; +use serde_json::json; +use tokio::sync::broadcast; + +use super::{ + ConversationBlockFacts, ConversationBlockPatchFacts, ConversationBlockStatus, + ConversationChildHandoffKind, ConversationDeltaFacts, ConversationDeltaProjector, + ConversationPlanEventKind, ConversationStreamProjector, ConversationStreamReplayFacts, + build_conversation_replay_frames, fallback_live_cursor, project_conversation_snapshot, +}; +use crate::{ + SessionReplay, SessionRuntime, + turn::test_support::{NoopMetrics, NoopPromptFactsProvider, test_kernel}, +}; + +#[test] +fn snapshot_projects_tool_call_block_with_streams_and_terminal_fields() { + let records = vec![ + record( + "1.1", + AgentEvent::ToolCallStart { + turn_id: "turn-1".to_string(), + agent: sample_agent_context(), + tool_call_id: "call-1".to_string(), + tool_name: "shell_command".to_string(), + input: json!({ "command": "pwd" }), + }, + ), + record( + "1.2", + AgentEvent::ToolCallDelta { + turn_id: "turn-1".to_string(), + agent: sample_agent_context(), + tool_call_id: "call-1".to_string(), + tool_name: "shell_command".to_string(), + stream: ToolOutputStream::Stdout, + delta: "line-1\n".to_string(), + }, + ), + record( + "1.3", + AgentEvent::ToolCallDelta { + turn_id: "turn-1".to_string(), + agent: sample_agent_context(), + tool_call_id: "call-1".to_string(), + tool_name: "shell_command".to_string(), + stream: ToolOutputStream::Stderr, + delta: "warn\n".to_string(), + }, + ), + record( + "1.4", + AgentEvent::ToolCallResult { + turn_id: "turn-1".to_string(), + agent: sample_agent_context(), + result: ToolExecutionResult { + tool_call_id: "call-1".to_string(), + tool_name: "shell_command".to_string(), + ok: false, + output: "line-1\n".to_string(), + error: Some("permission denied".to_string()), + metadata: Some(json!({ "path": "/tmp", "truncated": true })), + child_ref: None, + duration_ms: 42, + truncated: true, + }, + }, + ), + ]; + + let snapshot = project_conversation_snapshot(&records, Phase::CallingTool); + let tool = snapshot + .blocks + .iter() + .find_map(|block| match block { + ConversationBlockFacts::ToolCall(block) => Some(block), + _ => None, + }) + .expect("tool block should exist"); + + assert_eq!(tool.tool_call_id, "call-1"); + assert_eq!(tool.status, ConversationBlockStatus::Failed); + assert_eq!(tool.streams.stdout, "line-1\n"); + assert_eq!(tool.streams.stderr, "warn\n"); + assert_eq!(tool.error.as_deref(), Some("permission denied")); + assert_eq!(tool.duration_ms, Some(42)); + assert!(tool.truncated); +} + +#[test] +fn snapshot_preserves_failed_tool_status_after_turn_done() { + let records = vec![ + record( + "1.1", + AgentEvent::ToolCallStart { + turn_id: "turn-1".to_string(), + agent: sample_agent_context(), + tool_call_id: "call-1".to_string(), + tool_name: "shell_command".to_string(), + input: json!({ "command": "missing-command" }), + }, + ), + record( + "1.2", + AgentEvent::ToolCallResult { + turn_id: "turn-1".to_string(), + agent: sample_agent_context(), + result: ToolExecutionResult { + tool_call_id: "call-1".to_string(), + tool_name: "shell_command".to_string(), + ok: false, + output: String::new(), + error: Some("command not found".to_string()), + metadata: None, + child_ref: None, + duration_ms: 127, + truncated: false, + }, + }, + ), + record( + "1.3", + AgentEvent::TurnDone { + turn_id: "turn-1".to_string(), + agent: sample_agent_context(), + }, + ), + ]; + + let snapshot = project_conversation_snapshot(&records, Phase::Idle); + let tool = snapshot + .blocks + .iter() + .find_map(|block| match block { + ConversationBlockFacts::ToolCall(block) => Some(block), + _ => None, + }) + .expect("tool block should exist"); + + assert_eq!(tool.status, ConversationBlockStatus::Failed); + assert_eq!(tool.error.as_deref(), Some("command not found")); + assert_eq!(tool.duration_ms, Some(127)); +} + +#[test] +fn snapshot_projects_plan_blocks_in_durable_event_order() { + let records = vec![ + record( + "1.1", + AgentEvent::ToolCallStart { + turn_id: "turn-1".to_string(), + agent: sample_agent_context(), + tool_call_id: "call-plan-save".to_string(), + tool_name: "upsertSessionPlan".to_string(), + input: json!({ + "title": "Cleanup crates", + "content": "# Plan: Cleanup crates" + }), + }, + ), + record( + "1.2", + AgentEvent::ToolCallResult { + turn_id: "turn-1".to_string(), + agent: sample_agent_context(), + result: ToolExecutionResult { + tool_call_id: "call-plan-save".to_string(), + tool_name: "upsertSessionPlan".to_string(), + ok: true, + output: "updated session plan".to_string(), + error: None, + metadata: Some(json!({ + "planPath": "C:/Users/demo/.astrcode/projects/demo/sessions/session-1/plan/cleanup-crates.md", + "slug": "cleanup-crates", + "status": "draft", + "title": "Cleanup crates", + "updatedAt": "2026-04-19T09:00:00Z" + })), + child_ref: None, + duration_ms: 7, + truncated: false, + }, + }, + ), + record( + "1.3", + AgentEvent::ToolCallStart { + turn_id: "turn-1".to_string(), + agent: sample_agent_context(), + tool_call_id: "call-shell".to_string(), + tool_name: "shell_command".to_string(), + input: json!({ "command": "pwd" }), + }, + ), + record( + "1.4", + AgentEvent::ToolCallResult { + turn_id: "turn-1".to_string(), + agent: sample_agent_context(), + result: ToolExecutionResult { + tool_call_id: "call-shell".to_string(), + tool_name: "shell_command".to_string(), + ok: true, + output: "D:/GitObjectsOwn/Astrcode".to_string(), + error: None, + metadata: None, + child_ref: None, + duration_ms: 9, + truncated: false, + }, + }, + ), + record( + "1.5", + AgentEvent::ToolCallStart { + turn_id: "turn-1".to_string(), + agent: sample_agent_context(), + tool_call_id: "call-plan-exit".to_string(), + tool_name: "exitPlanMode".to_string(), + input: json!({}), + }, + ), + record( + "1.6", + AgentEvent::ToolCallResult { + turn_id: "turn-1".to_string(), + agent: sample_agent_context(), + result: ToolExecutionResult { + tool_call_id: "call-plan-exit".to_string(), + tool_name: "exitPlanMode".to_string(), + ok: true, + output: "Before exiting plan mode, do one final self-review.".to_string(), + error: None, + metadata: Some(json!({ + "schema": "sessionPlanExitReviewPending", + "plan": { + "title": "Cleanup crates", + "planPath": "C:/Users/demo/.astrcode/projects/demo/sessions/session-1/plan/cleanup-crates.md" + }, + "review": { + "kind": "final_review", + "checklist": [ + "Re-check assumptions against the code you already inspected." + ] + }, + "blockers": { + "missingHeadings": ["## Verification"], + "invalidSections": [] + } + })), + child_ref: None, + duration_ms: 5, + truncated: false, + }, + }, + ), + ]; + + let snapshot = project_conversation_snapshot(&records, Phase::Idle); + assert_eq!(snapshot.blocks.len(), 3); + assert!(matches!( + &snapshot.blocks[0], + ConversationBlockFacts::Plan(block) + if block.tool_call_id == "call-plan-save" + && block.event_kind == ConversationPlanEventKind::Saved + )); + assert!(matches!( + &snapshot.blocks[1], + ConversationBlockFacts::ToolCall(block) if block.tool_call_id == "call-shell" + )); + assert!(matches!( + &snapshot.blocks[2], + ConversationBlockFacts::Plan(block) + if block.tool_call_id == "call-plan-exit" + && block.event_kind == ConversationPlanEventKind::ReviewPending + )); +} + +#[test] +fn live_then_durable_tool_delta_dedupes_chunk_on_same_tool_block() { + let facts = sample_stream_replay_facts( + vec![record( + "1.1", + AgentEvent::ToolCallStart { + turn_id: "turn-1".to_string(), + agent: sample_agent_context(), + tool_call_id: "call-1".to_string(), + tool_name: "shell_command".to_string(), + input: json!({ "command": "pwd" }), + }, + )], + vec![record( + "1.2", + AgentEvent::ToolCallDelta { + turn_id: "turn-1".to_string(), + agent: sample_agent_context(), + tool_call_id: "call-1".to_string(), + tool_name: "shell_command".to_string(), + stream: ToolOutputStream::Stdout, + delta: "line-1\n".to_string(), + }, + )], + ); + let mut stream = ConversationStreamProjector::new(Some("1.1".to_string()), &facts); + + let live_frames = stream.project_live_event(&AgentEvent::ToolCallDelta { + turn_id: "turn-1".to_string(), + agent: sample_agent_context(), + tool_call_id: "call-1".to_string(), + tool_name: "shell_command".to_string(), + stream: ToolOutputStream::Stdout, + delta: "line-1\n".to_string(), + }); + assert_eq!(live_frames.len(), 1); + + let replayed = stream.recover_from(&facts); + assert!( + replayed.is_empty(), + "durable replay should not duplicate the live-emitted chunk" + ); +} + +#[test] +fn child_notification_patches_tool_block_and_appends_handoff_block() { + let mut projector = ConversationDeltaProjector::new(); + projector.seed(&[record( + "1.1", + AgentEvent::ToolCallStart { + turn_id: "turn-1".to_string(), + agent: sample_agent_context(), + tool_call_id: "call-spawn".to_string(), + tool_name: "spawn_agent".to_string(), + input: json!({ "task": "inspect" }), + }, + )]); + + let deltas = projector.project_record(&record( + "1.2", + AgentEvent::ChildSessionNotification { + turn_id: Some("turn-1".to_string()), + agent: sample_agent_context(), + notification: sample_child_notification(), + }, + )); + + assert!(deltas.iter().any(|delta| matches!( + delta, + ConversationDeltaFacts::PatchBlock { + block_id, + patch: ConversationBlockPatchFacts::ReplaceChildRef { .. }, + } if block_id == "tool:call-spawn:call" + ))); + assert!(deltas.iter().any(|delta| matches!( + delta, + ConversationDeltaFacts::AppendBlock { + block, + } if matches!( + block.as_ref(), + ConversationBlockFacts::ChildHandoff(block) + if block.handoff_kind == ConversationChildHandoffKind::Returned + ) + ))); +} + +#[tokio::test] +async fn runtime_query_builds_snapshot_and_stream_replay_facts() { + let event_store = Arc::new(ReplayOnlyEventStore::new(vec![ + stored( + 1, + storage_event( + Some("turn-1"), + StorageEventPayload::UserMessage { + content: "inspect repo".to_string(), + origin: UserMessageOrigin::User, + timestamp: Utc::now(), + }, + ), + ), + stored( + 2, + storage_event( + Some("turn-1"), + StorageEventPayload::ToolCall { + tool_call_id: "call-1".to_string(), + tool_name: "shell_command".to_string(), + args: json!({ "command": "pwd" }), + }, + ), + ), + stored( + 3, + storage_event( + Some("turn-1"), + StorageEventPayload::ToolCallDelta { + tool_call_id: "call-1".to_string(), + tool_name: "shell_command".to_string(), + stream: ToolOutputStream::Stdout, + delta: "D:/GitObjectsOwn/Astrcode\n".to_string(), + }, + ), + ), + stored( + 4, + storage_event( + Some("turn-1"), + StorageEventPayload::ToolResult { + tool_call_id: "call-1".to_string(), + tool_name: "shell_command".to_string(), + output: "D:/GitObjectsOwn/Astrcode\n".to_string(), + success: true, + error: None, + metadata: None, + child_ref: None, + duration_ms: 7, + }, + ), + ), + stored( + 5, + storage_event( + Some("turn-1"), + StorageEventPayload::AssistantFinal { + content: "done".to_string(), + reasoning_content: Some("think".to_string()), + reasoning_signature: None, + timestamp: None, + }, + ), + ), + ])); + let runtime = SessionRuntime::new( + Arc::new(test_kernel(8192)), + Arc::new(NoopPromptFactsProvider), + event_store, + Arc::new(NoopMetrics), + ); + + let snapshot = runtime + .conversation_snapshot("session-1") + .await + .expect("snapshot should build"); + assert!(snapshot.blocks.iter().any(|block| matches!( + block, + ConversationBlockFacts::ToolCall(block) + if block.tool_call_id == "call-1" + ))); + + let transcript = runtime + .session_transcript_snapshot("session-1") + .await + .expect("transcript snapshot should build"); + assert!(transcript.records.len() > 4); + let cursor = transcript.records[3].event_id.clone(); + + let replay = runtime + .conversation_stream_replay("session-1", Some(cursor.as_str())) + .await + .expect("replay facts should build"); + assert_eq!( + replay + .seed_records + .last() + .map(|record| record.event_id.as_str()), + Some(cursor.as_str()) + ); + assert!(!replay.replay_frames.is_empty()); + assert_eq!( + fallback_live_cursor(&replay).as_deref(), + Some(cursor.as_str()) + ); +} + +fn sample_stream_replay_facts( + seed_records: Vec<SessionEventRecord>, + history: Vec<SessionEventRecord>, +) -> ConversationStreamReplayFacts { + let (_, receiver) = broadcast::channel(8); + let (_, live_receiver) = broadcast::channel(8); + ConversationStreamReplayFacts { + cursor: history.last().map(|record| record.event_id.clone()), + phase: Phase::CallingTool, + seed_records: seed_records.clone(), + replay_frames: build_conversation_replay_frames(&seed_records, &history), + replay: SessionReplay { + history, + receiver, + live_receiver, + }, + } +} + +fn sample_agent_context() -> AgentEventContext { + AgentEventContext::root_execution("agent-root", "default") +} + +fn sample_child_notification() -> ChildSessionNotification { + ChildSessionNotification { + notification_id: "child-note-1".to_string().into(), + child_ref: ChildAgentRef { + identity: ChildExecutionIdentity { + agent_id: "agent-child-1".to_string().into(), + session_id: "session-root".to_string().into(), + sub_run_id: "subrun-child-1".to_string().into(), + }, + parent: ParentExecutionRef { + parent_agent_id: Some("agent-root".to_string().into()), + parent_sub_run_id: Some("subrun-root".to_string().into()), + }, + lineage_kind: ChildSessionLineageKind::Spawn, + status: AgentLifecycleStatus::Running, + open_session_id: "session-child-1".to_string().into(), + }, + kind: ChildSessionNotificationKind::Delivered, + source_tool_call_id: Some("call-spawn".to_string().into()), + delivery: Some(ParentDelivery { + idempotency_key: "delivery-1".to_string(), + origin: ParentDeliveryOrigin::Explicit, + terminal_semantics: ParentDeliveryTerminalSemantics::Terminal, + source_turn_id: Some("turn-1".to_string()), + payload: ParentDeliveryPayload::Progress( + astrcode_core::ProgressParentDeliveryPayload { + message: "child finished".to_string(), + }, + ), + }), + } +} + +fn record(event_id: &str, event: AgentEvent) -> SessionEventRecord { + SessionEventRecord { + event_id: event_id.to_string(), + event, + } +} + +fn stored(storage_seq: u64, event: StorageEvent) -> StoredEvent { + StoredEvent { storage_seq, event } +} + +fn storage_event(turn_id: Option<&str>, payload: StorageEventPayload) -> StorageEvent { + StorageEvent { + turn_id: turn_id.map(ToString::to_string), + agent: sample_agent_context(), + payload, + } +} + +struct ReplayOnlyEventStore { + events: Vec<StoredEvent>, +} + +impl ReplayOnlyEventStore { + fn new(events: Vec<StoredEvent>) -> Self { + Self { events } + } +} + +struct StubTurnLease; + +impl astrcode_core::SessionTurnLease for StubTurnLease {} + +#[async_trait] +impl EventStore for ReplayOnlyEventStore { + async fn ensure_session( + &self, + _session_id: &SessionId, + _working_dir: &Path, + ) -> astrcode_core::Result<()> { + Ok(()) + } + + async fn append( + &self, + _session_id: &SessionId, + _event: &astrcode_core::StorageEvent, + ) -> astrcode_core::Result<StoredEvent> { + panic!("append should not be called in replay-only test store"); + } + + async fn replay(&self, _session_id: &SessionId) -> astrcode_core::Result<Vec<StoredEvent>> { + Ok(self.events.clone()) + } + + async fn try_acquire_turn( + &self, + _session_id: &SessionId, + _turn_id: &str, + ) -> astrcode_core::Result<SessionTurnAcquireResult> { + Ok(SessionTurnAcquireResult::Acquired(Box::new(StubTurnLease))) + } + + async fn list_sessions(&self) -> astrcode_core::Result<Vec<SessionId>> { + Ok(vec![SessionId::from("session-1".to_string())]) + } + + async fn list_session_metas(&self) -> astrcode_core::Result<Vec<SessionMeta>> { + Ok(vec![SessionMeta { + session_id: "session-1".to_string(), + working_dir: ".".to_string(), + display_name: "session-1".to_string(), + title: "session-1".to_string(), + created_at: Utc::now(), + updated_at: Utc::now(), + parent_session_id: None, + parent_storage_seq: None, + phase: Phase::Done, + }]) + } + + async fn delete_session(&self, _session_id: &SessionId) -> astrcode_core::Result<()> { + Ok(()) + } + + async fn delete_sessions_by_working_dir( + &self, + _working_dir: &str, + ) -> astrcode_core::Result<DeleteProjectResult> { + Ok(DeleteProjectResult { + success_count: 0, + failed_session_ids: Vec::new(), + }) + } +} diff --git a/crates/session-runtime/src/turn/compaction_cycle.rs b/crates/session-runtime/src/turn/compaction_cycle.rs index 6b4b0b91..5a0d8234 100644 --- a/crates/session-runtime/src/turn/compaction_cycle.rs +++ b/crates/session-runtime/src/turn/compaction_cycle.rs @@ -13,7 +13,7 @@ use astrcode_core::{ AgentEventContext, CancelToken, CompactTrigger, LlmMessage, PromptFactsProvider, Result, - StorageEvent, + StorageEvent, UserMessageOrigin, }; use astrcode_kernel::KernelGateway; @@ -25,7 +25,7 @@ use crate::{ }, state::compact_history_event_log_path, turn::{ - events::{CompactAppliedStats, compact_applied_event}, + events::{CompactAppliedStats, compact_applied_event, user_message_event}, request::{PromptOutputRequest, build_prompt_output}, }, }; @@ -66,7 +66,7 @@ fn recovery_result_from_compaction( Some(turn_id), agent, CompactTrigger::Auto, - compaction.summary, + compaction.summary.clone(), CompactAppliedStats { meta: compaction.meta, preserved_recent_turns: compaction.preserved_recent_turns, @@ -77,6 +77,25 @@ fn recovery_result_from_compaction( }, compaction.timestamp, )]; + let mut events = events; + if let Some(digest) = compaction.recent_user_context_digest.clone() { + events.push(user_message_event( + turn_id, + agent, + digest, + UserMessageOrigin::RecentUserContextDigest, + compaction.timestamp, + )); + } + for content in &compaction.recent_user_context_messages { + events.push(user_message_event( + turn_id, + agent, + content.clone(), + UserMessageOrigin::RecentUserContext, + compaction.timestamp, + )); + } let mut messages = compaction.messages; messages.extend(file_access_tracker.build_recovery_messages(settings.file_recovery_config())); @@ -112,8 +131,10 @@ pub async fn try_reactive_compact( Some(&prompt_output.system_prompt), CompactConfig { keep_recent_turns: ctx.settings.compact_keep_recent_turns, + keep_recent_user_messages: ctx.settings.compact_keep_recent_user_messages, trigger: CompactTrigger::Auto, summary_reserve_tokens: ctx.settings.summary_reserve_tokens, + max_output_tokens: ctx.settings.compact_max_output_tokens, max_retry_attempts: ctx.settings.compact_max_retry_attempts, history_path: Some(compact_history_event_log_path( ctx.session_id, @@ -157,9 +178,11 @@ mod tests { compact_threshold_percent: 80, reserved_context_size: 20_000, summary_reserve_tokens: 20_000, + compact_max_output_tokens: 20_000, compact_max_retry_attempts: 3, tool_result_max_bytes: 16_384, compact_keep_recent_turns: 1, + compact_keep_recent_user_messages: 8, max_tracked_files: 8, max_recovered_files: 2, recovery_token_budget: 512, @@ -210,6 +233,8 @@ mod tests { CompactResult { messages: vec![compacted_message.clone()], summary: "older context summary".to_string(), + recent_user_context_digest: None, + recent_user_context_messages: Vec::new(), meta: CompactAppliedMeta { mode: CompactMode::RetrySalvage, instructions_present: false, diff --git a/crates/session-runtime/src/turn/manual_compact.rs b/crates/session-runtime/src/turn/manual_compact.rs index a3d2fd58..550ca0c5 100644 --- a/crates/session-runtime/src/turn/manual_compact.rs +++ b/crates/session-runtime/src/turn/manual_compact.rs @@ -63,8 +63,10 @@ pub(crate) async fn build_manual_compact_events( Some(&prompt_output.system_prompt), CompactConfig { keep_recent_turns: settings.compact_keep_recent_turns, + keep_recent_user_messages: settings.compact_keep_recent_user_messages, trigger: request.trigger, summary_reserve_tokens: settings.summary_reserve_tokens, + max_output_tokens: settings.compact_max_output_tokens, max_retry_attempts: settings.compact_max_retry_attempts, history_path: Some(compact_history_event_log_path( request.session_id, @@ -83,7 +85,7 @@ pub(crate) async fn build_manual_compact_events( None, &AgentEventContext::default(), request.trigger, - compaction.summary, + compaction.summary.clone(), CompactAppliedStats { meta: compaction.meta, preserved_recent_turns: compaction.preserved_recent_turns, @@ -95,6 +97,29 @@ pub(crate) async fn build_manual_compact_events( compaction.timestamp, )]; + if let Some(digest) = compaction.recent_user_context_digest { + events.push(StorageEvent { + turn_id: None, + agent: AgentEventContext::default(), + payload: StorageEventPayload::UserMessage { + content: digest, + origin: astrcode_core::UserMessageOrigin::RecentUserContextDigest, + timestamp: compaction.timestamp, + }, + }); + } + for content in compaction.recent_user_context_messages { + events.push(StorageEvent { + turn_id: None, + agent: AgentEventContext::default(), + payload: StorageEventPayload::UserMessage { + content, + origin: astrcode_core::UserMessageOrigin::RecentUserContext, + timestamp: compaction.timestamp, + }, + }); + } + for message in file_access_tracker.build_recovery_messages(settings.file_recovery_config()) { let astrcode_core::LlmMessage::User { content, origin } = message else { continue; diff --git a/crates/session-runtime/src/turn/request.rs b/crates/session-runtime/src/turn/request.rs index 17e87768..d127a5cb 100644 --- a/crates/session-runtime/src/turn/request.rs +++ b/crates/session-runtime/src/turn/request.rs @@ -23,7 +23,9 @@ use crate::{ }, state::compact_history_event_log_path, turn::{ - events::{CompactAppliedStats, compact_applied_event, prompt_metrics_event}, + events::{ + CompactAppliedStats, compact_applied_event, prompt_metrics_event, user_message_event, + }, tool_result_budget::{ ApplyToolResultBudgetRequest, ToolResultBudgetOutcome, ToolResultBudgetStats, ToolResultReplacementState, apply_tool_result_budget, @@ -148,8 +150,10 @@ pub async fn assemble_prompt_request( Some(&prompt_output.system_prompt), CompactConfig { keep_recent_turns: request.settings.compact_keep_recent_turns, + keep_recent_user_messages: request.settings.compact_keep_recent_user_messages, trigger: CompactTrigger::Auto, summary_reserve_tokens: request.settings.summary_reserve_tokens, + max_output_tokens: request.settings.compact_max_output_tokens, max_retry_attempts: request.settings.compact_max_retry_attempts, history_path: Some(compact_history_event_log_path( request.session_id, @@ -175,7 +179,7 @@ pub async fn assemble_prompt_request( Some(request.turn_id), request.agent, CompactTrigger::Auto, - compaction.summary, + compaction.summary.clone(), CompactAppliedStats { meta: compaction.meta, preserved_recent_turns: compaction.preserved_recent_turns, @@ -186,6 +190,24 @@ pub async fn assemble_prompt_request( }, compaction.timestamp, )); + if let Some(digest) = compaction.recent_user_context_digest.clone() { + events.push(user_message_event( + request.turn_id, + request.agent, + digest, + UserMessageOrigin::RecentUserContextDigest, + compaction.timestamp, + )); + } + for content in &compaction.recent_user_context_messages { + events.push(user_message_event( + request.turn_id, + request.agent, + content.clone(), + UserMessageOrigin::RecentUserContext, + compaction.timestamp, + )); + } prompt_output = build_prompt_output(PromptOutputRequest { gateway: request.gateway, diff --git a/openspec/changes/async-shell-terminal-sessions/.openspec.yaml b/openspec/changes/async-shell-terminal-sessions/.openspec.yaml new file mode 100644 index 00000000..c8af3f5f --- /dev/null +++ b/openspec/changes/async-shell-terminal-sessions/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-04-19 diff --git a/openspec/changes/async-shell-terminal-sessions/design.md b/openspec/changes/async-shell-terminal-sessions/design.md new file mode 100644 index 00000000..151eac1c --- /dev/null +++ b/openspec/changes/async-shell-terminal-sessions/design.md @@ -0,0 +1,186 @@ +## Context + +当前实现中,`crates/session-runtime/src/turn/tool_cycle.rs` 会直接等待工具返回最终 `ToolExecutionResult`,因此任何长时间运行的工具都会阻塞同一 turn 的推进。现有 `crates/adapter-tools/src/builtin_tools/shell.rs` 虽已支持 stdout / stderr 流式输出,但仍是“一次性、非交互式、完成即返回”的命令工具;`crates/core/src/action.rs`、`crates/core/src/event/domain.rs` 与 `crates/session-runtime/src/query/conversation.rs` 也只覆盖完成态工具结果,没有正式的后台任务通知或终端会话实体。 + +与此同时,仓库已有两个重要基础: + +- `session-runtime` 已经是单会话真相面,适合承接 waitpoint、恢复和 authoritative read model。 +- `ToolContext` 已提供 stdout / stderr 增量输出发射能力,说明长任务与终端输出的 live/durable 双写链路已经具备雏形。 + +本次设计是一个跨 `core / session-runtime / application / adapter-tools / frontend` 的架构演进。它不与 `PROJECT_ARCHITECTURE.md` 的现有原则冲突,反而落实了其中关于事件日志优先、query/command 分离的方向;但实现完成后仍应补充文档,显式记录后台进程监管与终端会话的长期归属。 + +## Goals / Non-Goals + +**Goals:** + +- 让长耗时工具不再阻塞当前 turn,而是立即转为后台任务并在完成时发送正式通知。 +- 让 `shell` 与持久终端会话共享同一套进程监管基础设施,而不是各自维护私有生命周期。 +- 为终端会话建立正式工具合同:启动、写入 stdin、显式读取 stdout/stderr、关闭、失败/丢失反馈。 +- 为 conversation authoritative read model 增加后台任务通知与 terminal session 的稳定 block 语义。 +- 保证恢复与失败语义明确:Astrcode 进程重启、会话中断、任务取消都必须可观测、可回放、可投影。 + +**Non-Goals:** + +- 本次不实现“跨 Astrcode 进程重启继续附着同一底层 OS 进程”的强恢复能力。 +- 本次不做后台任务面板、任务搜索或模型轮询式 `task_get/task_list` 产品面。 +- 本次不把所有工具都改造成可后台化;第一阶段只要求 `shell` 和新的终端会话工具接入。 +- 本次不新增第二套组合根或让前端直接管理进程真相。 + +## Decisions + +### 1. 不引入 `WaitingTool`,而是采用 Claude Code 风格的后台任务句柄 + +决策: + +- 保持现有 session phase 集合,不新增 `WaitingTool`。 +- `shell(background)` 调用立即返回普通 `ToolExecutionResult`,其中 metadata 包含 `backgroundTaskId`、`outputPath`、`notificationMode`、`startedAt` 等纯数据字段。 +- 后台执行真相不通过“挂起中的 tool block”表达,而通过独立的后台任务事件与通知块表达。 + +原因: + +- Claude Code 的后台 shell 不是 runtime phase,而是“立即返回 + 后台任务 ID + 完成通知”。 +- 这让主 turn 始终保持短生命周期,不必把“等待外部进程”硬塞进 turn 状态机。 +- 对 Astrcode 来说,这比引入 suspended turn / waitpoint 更接近你希望的产品体验。 + +备选方案与否决: + +- 方案 A:引入 `WaitingTool` 和 waitpoint。否决,因为这更像 runtime-first 的恢复设计,不像 Claude Code。 +- 方案 B:扩展 `InvocationMode`。否决,因为 `unary/streaming` 描述的是传输形态,不是后台任务语义。 + +### 2. 后台任务与终端会话由 `application` 侧 `ProcessSupervisor` 统一监管 + +决策: + +- 在 `crates/application` 增加 `ProcessSupervisor`,作为全局用例层基础设施。 +- 它下辖两个子域: + - `AsyncTaskRegistry`:一次性后台命令 + - `TerminalSessionRegistry`:持久终端会话 +- `server` 只在组合根装配;`session-runtime` 通过稳定端口与 supervisor 通信,不直接持有底层 PTY/进程实现。 + +原因: + +- 进程监管是跨 session 的 live control 基础设施,适合位于 `application`,不应把 PTY/进程实现泄漏到 `session-runtime`。 +- `session-runtime` 仍然只负责单会话真相、等待点与恢复,不负责平台进程细节。 + +备选方案与否决: + +- 方案 A:把后台任务 registry 放进 `session-runtime`。否决,因为这会让 `session-runtime` 直接承担跨平台进程实现和全局 live handle 管理。 +- 方案 B:把进程真相放到前端或 server handler。否决,因为这违反“Server is the truth”和组合根边界。 + +### 3. 保留 `shell` 为一次性命令工具,新增显式读写式终端会话工具族 + +决策: + +- 现有 `shell` 保持“一次性命令”语义,只增加 `executionMode: auto|foreground|background`。 +- 新增工具族: + - `terminal_start` + - `terminal_write` + - `terminal_read` + - `terminal_close` + - `terminal_resize`(可选但建议一期包含) + +原因: + +- 一次性命令与持久终端会话的生命周期不同。 +- 终端会话如果不采用显式 `read/write`,最终还是会回到“等待某段输出何时结束”的隐藏状态机。 +- 用 `terminal_write + terminal_read(cursor)` 更接近 Codex 的 session handle,也更符合“不引入 WaitingTool”的约束。 + +备选方案与否决: + +- 方案 A:扩展 `shell`,让同一工具同时承担一次性命令和终端会话。否决,因为 tool call 级语义无法干净表达跨多次交互的终端 session。 + +### 4. 后台任务与终端会话都使用独立 durable 事件,而不是复用单个 ToolCallDelta + +决策: + +- 后台 `shell` 的原始 tool call 仍然只记录“任务已启动”这一即时结果。 +- 新增独立 durable 事件: + - `BackgroundTaskStarted` + - `BackgroundTaskProgressed` + - `BackgroundTaskCompleted` + - `BackgroundTaskFailed` +- 终端会话新增独立 durable 事件: + - `TerminalSessionStarted` + - `TerminalSessionInputAccepted` + - `TerminalSessionOutputDelta` + - `TerminalSessionStateChanged` + - `TerminalSessionClosed` +- conversation read model 增加 `BackgroundTaskNotificationBlockFacts`、`TerminalSessionBlockFacts` 与对应 patch。 + +原因: + +- `ToolCallDelta` 绑定 `tool_call_id`,适合“一次调用一次结果”。 +- 背景任务完成通知和终端会话都跨越单次 tool call,必须拥有自己的主键和事件流。 + +备选方案与否决: + +- 方案 A:所有终端输出都继续挂在 `terminal_start` 那个 tool call 上。否决,因为后续 `terminal_input`、close、lost 等更新无法自然归并。 + +### 5. 后台任务与终端会话采用“事件日志 + live handle + 通知输入”模型 + +决策: + +- `session-runtime` 不持久化 suspended turn。 +- `application` 持有后台任务/终端会话 live handle。 +- `core/session-runtime` 持久化的是任务/会话事实和完成通知。 +- 若后台任务完成且配置为通知模型继续决策,runtime 将其转成一条内部 queued input 或 system note,而不是恢复旧 turn。 +- `ProcessSupervisor` 维护 live handle,不写入 DTO。 +- 会话 query 读取后台任务/终端会话事实时,优先基于 durable 事件投影;live handle 仅补充当前可取消、可写入等运行态控制信息。 + +原因: + +- durable 事件是恢复与回放真相。 +- live handle 不能直接序列化,也不应污染协议层。 +- “继续下一步”由新的输入触发,而不是依赖旧 turn 暂停后复活。 + +失败与恢复语义: + +- 同进程存活时:后台任务完成后发出完成通知;若配置允许,再注入一条内部输入唤醒新 turn。 +- Astrcode 进程重启后: + - 后台任务和终端会话仍可被投影为 running / completed / failed / lost。 + - 若底层进程已丢失,则系统必须追加明确失败或 lost 事件,不能静默消失。 + +### 6. 前端与 conversation surface 直接消费 authoritative background task / terminal blocks + +决策: + +- `terminal-chat-read-model` delta/snapshot 增加: + - `backgroundTask` notification block + - `terminalSession` block type + - `terminalSession` output / state patch +- 前端不得继续用 metadata 或相邻 block regroup 猜测后台任务完成与终端会话语义。 + +原因: + +- 工具与终端展示真相必须后端聚合。 +- 这与现有 `conversation` 作为 authoritative read surface 的方向一致。 + +## Risks / Trade-offs + +- [风险] 后台任务 live handle 与 durable 状态不一致 → 缓解:durable 事件定义 started/completed/failed/lost 真相,live handle 只补充可操作运行态。 +- [风险] 终端 prompt 检测在不同 shell 上不稳定 → 缓解:一期不依赖 prompt 检测,只支持 `wait_until = none|silence|exit`;后续再加 shell-specific prompt heuristic。 +- [风险] 新增 PTY 依赖带来跨平台复杂度 → 缓解:将 PTY 封装限制在 adapter/application 交界,核心与协议只看纯数据结构。 +- [风险] 工具事件、后台任务事件与终端事件并存导致 read model 更复杂 → 缓解:明确“一次性工具”“后台任务通知”“持久终端会话”是三类 block,不混用主键和状态机。 +- [风险] 重启后无法继续控制旧终端进程会让用户预期落差 → 缓解:在 spec 中明确该场景为 lost / failed,而不是承诺透明恢复。 + +## Migration Plan + +1. 在 `core`、`protocol`、`session-runtime` 中增加后台任务通知 / terminal session 纯数据结构。 +2. 在 `application` 组装 `ProcessSupervisor`,先接入一次性后台 shell。 +3. 改造 `shell` 为可选择 foreground/background,并打通任务输出落盘与完成通知。 +4. 新增终端会话工具族与 PTY/pipe 实现,接入 supervisor。 +5. 扩展 conversation/query/frontend 渲染 background task 与 terminal session。 +6. 同步更新 `PROJECT_ARCHITECTURE.md`,记录新的职责边界。 + +回滚策略: + +- 若后台 shell 路径不稳定,可暂时关闭 `executionMode=background/auto`,保留 foreground shell。 +- 若终端会话实现不稳定,可整体关闭 `terminal_*` 工具暴露,不影响一次性 shell 与既有聊天流程。 +- 新事件类型保持前后端同批发布;若回滚,先停止暴露新工具,再回滚前端块渲染。 + +## Open Questions + +- 一期是否需要 `terminal_resize` 正式对外暴露,还是先内部保留? +- 后台 shell 的 `auto` 判定阈值应基于超时、命令类型,还是工具显式标记? +- 后台任务完成后默认只通知用户,还是同时生成一条内部输入唤醒模型继续决策? +- `ProcessSupervisor` 的可观测性指标是否需要单独纳入 `runtime-observability-pipeline` 的 spec 更新? diff --git a/openspec/changes/async-shell-terminal-sessions/proposal.md b/openspec/changes/async-shell-terminal-sessions/proposal.md new file mode 100644 index 00000000..ad6deea0 --- /dev/null +++ b/openspec/changes/async-shell-terminal-sessions/proposal.md @@ -0,0 +1,41 @@ +## Why + +当前 `Astrcode` 的工具调用仍以同步 turn 执行为主:长时间运行的 shell / tool 会直接阻塞 turn loop,而现有运行时又没有一套像 Claude Code 那样的后台任务注册、输出落盘与完成通知机制。同时,现有 `shell` 只覆盖一次性非交互命令,无法提供持久终端会话、stdin 回写与显式读取输出语义,导致 LLM 无法像现代 coding agent 那样稳定操控终端。 + +现在引入该能力的时机已经成熟:`session-runtime` 已经是单会话真相面,`ToolContext` 也已有 stdout / stderr 流式输出通道,继续沿同步一次性工具模型堆补丁,只会让事件模型、conversation 投影与前端展示继续分裂。 + +## What Changes + +- 引入 Claude Code 风格的后台任务执行语义:长耗时工具不再阻塞当前 turn,而是立即返回 `backgroundTaskId`、输出路径和状态摘要,后续通过完成通知与显式读取获取结果。 +- 为长任务建立统一的进程监管面,区分“一次性后台命令”和“持久终端会话”,统一处理生命周期、取消、失败、输出落盘与通知。 +- 保留现有 `shell` 作为一次性命令工具,并增加 `background` 执行模式;同时新增持久终端会话工具族,使 LLM 可以启动终端、写入 stdin、显式读取新输出并关闭会话。 +- 扩展事件模型、conversation authoritative read model 与前端展示,使“后台任务已启动/已完成/已失败”和“终端会话输出/状态变化”成为正式合同,而不是前端本地猜测。 +- 明确后台任务和终端会话的恢复/失败语义:Astrcode 重启后不得静默丢失状态,必须向用户和模型暴露明确的 lost / failed 结果。 + +## Capabilities + +### New Capabilities +- `async-tool-execution`: 定义工具如何立即返回后台任务句柄、持续产出输出、发送完成通知并暴露显式读取语义。 +- `terminal-tool-sessions`: 定义持久终端会话的创建、输入输出、生命周期控制与失败恢复语义。 + +### Modified Capabilities +- `session-runtime`: 增加后台任务通知、终端会话状态投影、内部完成唤醒输入等会话真相要求。 +- `terminal-chat-read-model`: 增加后台任务通知块、终端会话块及其 hydration / delta 合同。 + +## Impact + +- 受影响模块: + - `crates/core`:工具结果类型、后台任务/终端事件与端口契约 + - `crates/session-runtime`:完成通知输入、conversation/query 投影、终端会话读取路径 + - `crates/application`:后台任务/终端会话监管与用例编排 + - `crates/adapter-tools`:`shell` 扩展与新终端工具族 + - `crates/protocol` 与 `frontend`:waiting / terminal session DTO 与渲染 +- 用户可见影响: + - 长工具调用不再卡死会话,而是变成后台任务并在完成时收到通知 + - LLM 可以通过正式工具直接操控持久终端会话 +- 开发者可见影响: + - 工具执行从“同步完成即返回”演进为“foreground / background / terminal-session”多模式合同 + - 事件模型需要新增后台任务通知与终端会话语义,避免继续把跨多次交互的输出硬塞进单个 `tool_call_id` +- 依赖与系统影响: + - 可能需要引入跨平台 PTY 支撑(例如 `portable-pty` 或等价方案) + - 需要同步更新 `PROJECT_ARCHITECTURE.md`,明确后台进程监管与终端会话在 `application` / `session-runtime` / read model 之间的职责边界 diff --git a/openspec/changes/async-shell-terminal-sessions/specs/async-tool-execution/spec.md b/openspec/changes/async-shell-terminal-sessions/specs/async-tool-execution/spec.md new file mode 100644 index 00000000..92b53293 --- /dev/null +++ b/openspec/changes/async-shell-terminal-sessions/specs/async-tool-execution/spec.md @@ -0,0 +1,43 @@ +## ADDED Requirements + +### Requirement: 长耗时工具调用 SHALL 支持立即转为后台任务 +当工具执行满足后台化条件时,系统 MUST 允许该工具调用立即返回后台任务信息,而不是持续阻塞同一个 turn loop 直到工具返回最终结果。 + +#### Scenario: shell 命令显式请求后台执行 +- **WHEN** 模型调用 `shell` 且参数声明 `executionMode=background` +- **THEN** 系统 MUST 为该调用创建后台任务 +- **AND** 当前 tool result MUST 返回 `backgroundTaskId` +- **AND** 当前 turn MUST 正常结束,而不是进入挂起状态 + +#### Scenario: 自动策略判定转入后台执行 +- **WHEN** 工具执行达到后台化策略阈值,且该工具声明允许 deferred 执行 +- **THEN** 系统 MUST 将该调用转换为后台任务 +- **AND** MUST 返回可读取输出的稳定路径或等价句柄 + +### Requirement: 后台工具输出 SHALL 进入稳定输出存储并可被显式读取 +后台工具在执行期间产生的 stdout / stderr MUST 持续写入稳定输出存储,并允许后续通过输出路径或正式读取能力获取。 + +#### Scenario: 后台 shell 持续写入输出文件 +- **WHEN** 已进入后台的 shell 任务继续产生 stdout 或 stderr +- **THEN** 系统 MUST 将这些输出持续写入与 `backgroundTaskId` 关联的稳定输出存储 +- **AND** 该存储路径或读取句柄 MUST 对模型可见 + +#### Scenario: 后台工具最终完成 +- **WHEN** 后台工具成功完成 +- **THEN** 系统 MUST 产出独立的 completed 通知事件 +- **AND** 通知中 MUST 包含 `backgroundTaskId`、总结信息与输出存储引用 +- **AND** 系统 MAY 额外注入一条内部输入以唤醒后续新 turn + +### Requirement: 后台工具失败、取消与丢失 SHALL 有显式终态 +后台工具执行不得以“没有后续输出”来表达失败或丢失;系统 MUST 产出明确的失败、取消或 lost 终态,并结束对应后台任务。 + +#### Scenario: 用户取消等待中的后台工具 +- **WHEN** 用户或系统取消一个运行中的后台工具 +- **THEN** 系统 MUST 终止底层任务或标记其为已取消 +- **AND** 对应后台任务 MUST 进入 cancelled 或 failed 终态 +- **AND** 系统 MUST 发送明确的取消通知 + +#### Scenario: Astrcode 进程重启后丢失后台任务句柄 +- **WHEN** 系统重启后发现某个后台任务的 live handle 已不可恢复 +- **THEN** 系统 MUST 将该后台任务显式标记为 lost 或 failed +- **AND** MUST NOT 无限保留一个看似仍可完成的 running 状态 diff --git a/openspec/changes/async-shell-terminal-sessions/specs/session-runtime/spec.md b/openspec/changes/async-shell-terminal-sessions/specs/session-runtime/spec.md new file mode 100644 index 00000000..3086d725 --- /dev/null +++ b/openspec/changes/async-shell-terminal-sessions/specs/session-runtime/spec.md @@ -0,0 +1,42 @@ +## ADDED Requirements + +### Requirement: turn loop SHALL 不因后台任务或终端会话而进入挂起状态 +`session-runtime` MUST 保持 turn 的短生命周期语义;后台任务与终端会话不得通过 suspended turn 表达,而应通过独立事件、通知和后续新输入推进。 + +#### Scenario: 背景工具启动后 turn 正常结束 +- **WHEN** `shell(background)` 成功创建后台任务 +- **THEN** `session-runtime` MUST 将当前 tool call 视为已完成的即时结果 +- **AND** 当前 turn MUST 可正常结束 +- **AND** session phase MUST 不新增 waiting_tool + +#### Scenario: 后台任务完成后触发新输入 +- **WHEN** 后台任务完成或失败 +- **THEN** `session-runtime` MUST 记录该完成事实 +- **AND** 若策略要求继续让模型处理结果,系统 MUST 通过内部 queued input 或等价消息触发新的 turn + +### Requirement: 后台任务与终端会话 SHALL 以 durable 事件和查询投影为真相 +后台任务状态、终端会话状态与完成通知 MUST 进入 durable 事件流,并由 query / replay 投影恢复,而不是只存在于内存态。 + +#### Scenario: 重放后台任务历史 +- **WHEN** 系统回放一个包含后台任务历史的 session +- **THEN** query 层 MUST 能恢复出任务 started / completed / failed / lost 的事实 +- **AND** 客户端 hydration MUST 能看到对应后台任务通知块或任务摘要 + +#### Scenario: 丢失 live handle 后投影失败终态 +- **WHEN** durable 历史显示某个后台任务或终端会话仍在运行,但 live 侧已确认底层进程不可恢复 +- **THEN** `session-runtime` MUST 追加显式失败或 lost 事实 +- **AND** query MUST 不再无限投影其为仍可继续运行 + +### Requirement: session-runtime SHALL 区分工具块、后台任务通知块与终端会话块 +`session-runtime` 的 authoritative conversation read model MUST 把“一次性工具调用”“后台任务通知”和“持久终端会话”建模为不同 block 类型,并分别使用稳定主键。 + +#### Scenario: 背景 shell 的启动结果属于 tool call,完成结果属于后台任务通知 +- **WHEN** `shell` 以后台模式执行 +- **THEN** 原始 tool call block 只表达“任务已启动”的即时结果 +- **AND** 后续完成或失败信息 MUST 进入后台任务通知块 +- **AND** MUST NOT 被投影成 terminal session block + +#### Scenario: 持久终端会话拥有独立 block +- **WHEN** 模型创建一个持久终端会话并多次向其输入 +- **THEN** `session-runtime` MUST 为该会话维护独立 terminal session block +- **AND** 后续输出与状态变化 MUST 持续 patch 该 block diff --git a/openspec/changes/async-shell-terminal-sessions/specs/terminal-chat-read-model/spec.md b/openspec/changes/async-shell-terminal-sessions/specs/terminal-chat-read-model/spec.md new file mode 100644 index 00000000..cd1ab1fd --- /dev/null +++ b/openspec/changes/async-shell-terminal-sessions/specs/terminal-chat-read-model/spec.md @@ -0,0 +1,32 @@ +## ADDED Requirements + +### Requirement: conversation read model SHALL 暴露后台任务通知块 +authoritative conversation read model MUST 为后台任务提供正式通知块,使客户端能够区分“任务已启动”“任务已完成”“任务已失败”“任务已丢失”,而不是通过工具 metadata 或文本摘要猜测。 + +#### Scenario: hydration 返回后台任务历史 +- **WHEN** 客户端为一个包含后台任务历史的 session 请求 hydration snapshot +- **THEN** 服务端 MUST 返回后台任务启动与终态通知块 +- **AND** 客户端 MUST 无需回放原始事件即可识别任务是否已经完成 + +#### Scenario: 后台任务完成生成独立通知 +- **WHEN** 某个后台任务最终完成、失败或取消 +- **THEN** 增量流 MUST 追加一条独立的后台任务终态通知 +- **AND** 该通知 MUST 包含 `backgroundTaskId`、终态和输出引用 + +### Requirement: conversation read model SHALL 暴露 terminal session block +authoritative conversation read model MUST 为持久终端会话提供独立 block 类型,至少包含 `terminal_session_id`、状态、stdout/stderr 聚合结果、cursor 以及增量 patch 语义。 + +#### Scenario: terminal session 首次启动进入 transcript +- **WHEN** 系统创建一个新的终端会话 +- **THEN** hydration / delta 流 MUST 追加一个新的 terminal session block +- **AND** 该 block MUST 暴露稳定的 `terminal_session_id` + +#### Scenario: terminal session 输出持续 patch 同一 block +- **WHEN** 终端会话持续产出 stdout 或 stderr +- **THEN** conversation delta MUST 以 append patch 更新同一 terminal session block +- **AND** 客户端 MUST NOT 自行把多次 tool result 重新拼装成终端视图 + +#### Scenario: terminal session 状态变化进入同一 block +- **WHEN** 终端会话进入 running、waiting-input、closed、failed、lost 或 exited +- **THEN** conversation delta MUST 在同一 terminal session block 上更新状态 +- **AND** 客户端 MUST 能在不重建 transcript 的情况下显示最新状态 diff --git a/openspec/changes/async-shell-terminal-sessions/specs/terminal-tool-sessions/spec.md b/openspec/changes/async-shell-terminal-sessions/specs/terminal-tool-sessions/spec.md new file mode 100644 index 00000000..e4fd070e --- /dev/null +++ b/openspec/changes/async-shell-terminal-sessions/specs/terminal-tool-sessions/spec.md @@ -0,0 +1,60 @@ +## ADDED Requirements + +### Requirement: 系统 SHALL 提供持久终端会话工具合同 +系统 MUST 提供与一次性 `shell` 分离的持久终端会话能力,使模型可以创建一个终端会话并获得稳定的 `terminal_session_id`,后续所有输入输出与状态更新 MUST 归属于该标识。 + +#### Scenario: 创建新的终端会话 +- **WHEN** 模型调用 `terminal_start` +- **THEN** 系统 MUST 启动一个新的终端会话并返回稳定的 `terminal_session_id` +- **AND** transcript / conversation read model MUST 追加对应的 terminal session block + +#### Scenario: 同一会话跨多次输入保持同一身份 +- **WHEN** 模型随后多次调用 `terminal_write` +- **THEN** 每次输入 MUST 绑定到已有的 `terminal_session_id` +- **AND** 系统 MUST NOT 为每次输入新建一个独立终端会话实体 + +### Requirement: 终端会话 SHALL 支持显式 stdin 写入与显式输出读取 +终端会话 MUST 支持向底层 shell / PTY 写入 stdin,并通过正式读取接口暴露自某个 cursor 之后的新 stdout / stderr 输出。 + +#### Scenario: 模型写入终端输入 +- **WHEN** 模型调用 `terminal_write` 并携带文本输入 +- **THEN** 系统 MUST 将该文本写入对应终端会话的 stdin +- **AND** 该调用本身 MUST 返回明确的接受结果,而不是假定输入已经执行完毕 + +#### Scenario: 读取自某个 cursor 之后的新输出 +- **WHEN** 模型调用 `terminal_read` 并携带某个 `cursor` +- **THEN** 系统 MUST 返回该 cursor 之后产生的新 stdout / stderr 输出片段 +- **AND** MUST 返回新的 cursor 供后续继续读取 +- **AND** 客户端或模型 MUST 无需重新读取整个终端历史 + +### Requirement: 终端会话读取 SHALL 支持有限等待策略 +终端读取工具 MAY 支持受限的长轮询读取,但系统 MUST 不以 suspended turn 或隐藏等待状态表达该能力。 + +#### Scenario: 仅发送输入立即返回 +- **WHEN** 模型调用 `terminal_write` +- **THEN** 系统 MUST 在写入 stdin 后立即返回 +- **AND** 当前 turn MUST NOT 进入挂起状态 + +#### Scenario: 读取时使用短暂长轮询 +- **WHEN** 模型调用 `terminal_read` 且声明短暂等待窗口 +- **THEN** 系统 MAY 在该窗口内等待新输出到达 +- **AND** 超时后 MUST 返回当前已知输出和最新 cursor +- **AND** MUST NOT 把该读取升级为长期挂起的 turn 状态 + +### Requirement: 终端会话 SHALL 支持显式关闭与明确失败语义 +系统 MUST 允许模型显式关闭终端会话;若终端进程崩溃、会话丢失或系统重启导致无法继续控制,也 MUST 产生明确的失败或 lost 终态。 + +#### Scenario: 模型关闭终端会话 +- **WHEN** 模型调用 `terminal_close` +- **THEN** 系统 MUST 关闭对应终端会话并追加 closed 终态 +- **AND** 后续对该 `terminal_session_id` 的输入 MUST 被拒绝 + +#### Scenario: 终端进程异常退出 +- **WHEN** 底层终端进程非预期退出 +- **THEN** 系统 MUST 将 terminal session 标记为 failed 或 exited +- **AND** 会话读模型 MUST 暴露退出码或失败原因 + +#### Scenario: 系统重启后会话句柄丢失 +- **WHEN** Astrcode 重启且无法重新附着到某个终端会话的底层进程 +- **THEN** 系统 MUST 将该终端会话标记为 lost 或 failed +- **AND** MUST NOT 继续宣称其仍可输入 diff --git a/openspec/changes/async-shell-terminal-sessions/tasks.md b/openspec/changes/async-shell-terminal-sessions/tasks.md new file mode 100644 index 00000000..e069a295 --- /dev/null +++ b/openspec/changes/async-shell-terminal-sessions/tasks.md @@ -0,0 +1,23 @@ +## 1. 核心类型与事件合同 + +- [ ] 1.1 在 `crates/core/src/action.rs`、`crates/core/src/event/domain.rs`、`crates/core/src/event/types.rs`、`crates/protocol/src/http/conversation/v1.rs` 引入后台任务通知、terminal session 纯数据结构与事件定义;验证:`cargo check --workspace` +- [ ] 1.2 扩展 `crates/session-runtime/src/query/conversation.rs` 与前端类型 `frontend/src/types.ts`,支持 background task notification block 和 terminal session block/patch;验证:`cargo test -p astrcode-session-runtime query::conversation` 与 `cd frontend && npm run typecheck` +- [ ] 1.3 更新 `PROJECT_ARCHITECTURE.md`,补充后台进程监管与 terminal session 的职责边界;验证:人工检查文档与本变更 design 一致 + +## 2. Claude 风格后台 shell + +- [ ] 2.1 在 `crates/session-runtime` 中接入后台任务 started/completed/failed durable 事件与内部完成通知输入,不引入 suspended turn;验证:新增 `session-runtime` 单测覆盖后台任务完成通知 -> 新 turn 唤醒主路径 +- [ ] 2.2 在 `crates/application/src/lifecycle/` 或相邻新模块实现 `ProcessSupervisor`/`AsyncTaskRegistry`,提供后台命令注册、完成通知、取消与 lost 终态上报;验证:`cargo test -p astrcode-application` +- [ ] 2.3 改造 `crates/adapter-tools/src/builtin_tools/shell.rs`,支持 `executionMode=auto|foreground|background`,返回 `backgroundTaskId` 与输出路径,并接入后台 shell 主路径;验证:新增 shell 工具集成测试,覆盖 foreground、background、cancel 三条路径 + +## 3. 持久终端会话工具族 + +- [ ] 3.1 新增终端会话工具模块,例如 `crates/adapter-tools/src/builtin_tools/terminal_start.rs`、`terminal_write.rs`、`terminal_read.rs`、`terminal_close.rs`、`terminal_resize.rs`,定义参数与返回合同;验证:`cargo test -p astrcode-adapter-tools terminal_` +- [ ] 3.2 在 `crates/application` 与对应 adapter 层实现 PTY/pipe 驱动的 `TerminalSessionRegistry`,支持 stdin 写入、stdout/stderr 流、退出码、关闭与 lost 语义;验证:新增跨平台可运行的单元/集成测试,至少覆盖启动、输入、退出、关闭 +- [ ] 3.3 在 `crates/session-runtime` 接入 terminal session durable 事件、hydration 投影与 `cursor` 读取语义;验证:新增 query/replay 测试覆盖 terminal session block 和增量读取主路径 + +## 4. 前端展示与验收 + +- [ ] 4.1 更新 `frontend/src/lib/toolDisplay.ts`、`frontend/src/components/Chat/ToolCallBlock.tsx` 及相关组件,为 background task notification 与 terminal session 提供稳定展示;验证:`cd frontend && npm run typecheck && npm run lint` +- [ ] 4.2 为 conversation surface 增加 background task / terminal session 的 API 与 SSE 验收样例,并补齐 `frontend` / `session-runtime` 快照测试;验证:`cargo test -p astrcode-session-runtime` 与 `cd frontend && npm run test -- --runInBand` +- [ ] 4.3 执行实现前总体验证清单:`cargo fmt --all`、`cargo clippy --all-targets --all-features -- -D warnings`、`cargo test --workspace --exclude astrcode`、`node scripts/check-crate-boundaries.mjs`;验证:上述命令全部通过 From 895809c0052fa1b6e30f1b13ea46279c39518c17 Mon Sep 17 00:00:00 2001 From: whatevertogo <whatevertogo@users.noreply.github.com> Date: Sun, 19 Apr 2026 23:54:47 +0800 Subject: [PATCH 44/53] =?UTF-8?q?=E2=9C=A8=20feat(async-shell-terminal-ses?= =?UTF-8?q?sions):=20=E5=BC=95=E5=85=A5=E6=8C=81=E4=B9=85=E7=BB=88?= =?UTF-8?q?=E7=AB=AF=E4=BC=9A=E8=AF=9D=E5=B7=A5=E5=85=B7=E6=97=8F=EF=BC=8C?= =?UTF-8?q?=E6=94=AF=E6=8C=81=E5=9F=BA=E4=BA=8E=20process=5Fid=20=E7=9A=84?= =?UTF-8?q?=E4=BA=A4=E4=BA=92=E4=B8=8E=E8=BE=93=E5=87=BA=E7=AE=A1=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../async-shell-terminal-sessions/design.md | 44 ++++----- .../async-shell-terminal-sessions/proposal.md | 10 +-- .../specs/session-runtime/spec.md | 2 +- .../specs/terminal-chat-read-model/spec.md | 9 +- .../specs/terminal-tool-sessions/spec.md | 89 ++++++++++++------- .../async-shell-terminal-sessions/tasks.md | 6 +- 6 files changed, 96 insertions(+), 64 deletions(-) diff --git a/openspec/changes/async-shell-terminal-sessions/design.md b/openspec/changes/async-shell-terminal-sessions/design.md index 151eac1c..a3b4f7bc 100644 --- a/openspec/changes/async-shell-terminal-sessions/design.md +++ b/openspec/changes/async-shell-terminal-sessions/design.md @@ -4,7 +4,7 @@ 与此同时,仓库已有两个重要基础: -- `session-runtime` 已经是单会话真相面,适合承接 waitpoint、恢复和 authoritative read model。 +- `session-runtime` 已经是单会话真相面,适合承接 durable 事件、通知与 authoritative read model。 - `ToolContext` 已提供 stdout / stderr 增量输出发射能力,说明长任务与终端输出的 live/durable 双写链路已经具备雏形。 本次设计是一个跨 `core / session-runtime / application / adapter-tools / frontend` 的架构演进。它不与 `PROJECT_ARCHITECTURE.md` 的现有原则冲突,反而落实了其中关于事件日志优先、query/command 分离的方向;但实现完成后仍应补充文档,显式记录后台进程监管与终端会话的长期归属。 @@ -15,7 +15,7 @@ - 让长耗时工具不再阻塞当前 turn,而是立即转为后台任务并在完成时发送正式通知。 - 让 `shell` 与持久终端会话共享同一套进程监管基础设施,而不是各自维护私有生命周期。 -- 为终端会话建立正式工具合同:启动、写入 stdin、显式读取 stdout/stderr、关闭、失败/丢失反馈。 +- 为终端会话建立正式工具合同:启动、写入 stdin、在有限等待窗口内返回输出、关闭、失败/丢失反馈。 - 为 conversation authoritative read model 增加后台任务通知与 terminal session 的稳定 block 语义。 - 保证恢复与失败语义明确:Astrcode 进程重启、会话中断、任务取消都必须可观测、可回放、可投影。 @@ -67,27 +67,29 @@ - 方案 A:把后台任务 registry 放进 `session-runtime`。否决,因为这会让 `session-runtime` 直接承担跨平台进程实现和全局 live handle 管理。 - 方案 B:把进程真相放到前端或 server handler。否决,因为这违反“Server is the truth”和组合根边界。 -### 3. 保留 `shell` 为一次性命令工具,新增显式读写式终端会话工具族 +### 3. 保留 `shell` 为一次性命令工具,新增 Codex 风格的持久执行工具族 决策: - 现有 `shell` 保持“一次性命令”语义,只增加 `executionMode: auto|foreground|background`。 - 新增工具族: - - `terminal_start` - - `terminal_write` - - `terminal_read` - - `terminal_close` - - `terminal_resize`(可选但建议一期包含) + - `exec_command` + - `write_stdin` + - `resize_terminal` + - `terminate_terminal` + - `close_stdin`(可选但建议一期包含) 原因: - 一次性命令与持久终端会话的生命周期不同。 -- 终端会话如果不采用显式 `read/write`,最终还是会回到“等待某段输出何时结束”的隐藏状态机。 -- 用 `terminal_write + terminal_read(cursor)` 更接近 Codex 的 session handle,也更符合“不引入 WaitingTool”的约束。 +- Codex 的成熟方案不是 `terminal_read(cursor)`,而是 `exec_command + process_id + write_stdin + 流式事件`。 +- 输出主通道应由 durable/live 事件流持续推送,工具返回只负责本次等待窗口内的输出快照与 `process_id`。 +- 这样可以避免额外造一套 cursor 读取协议,同时保留持久 session 和 stdin 控制能力。 备选方案与否决: - 方案 A:扩展 `shell`,让同一工具同时承担一次性命令和终端会话。否决,因为 tool call 级语义无法干净表达跨多次交互的终端 session。 +- 方案 B:采用 `terminal_start / terminal_write / terminal_read`。否决,因为这会复制一套读取协议,而 Codex 已证明 `write_stdin + 输出事件 + process_id` 更自然。 ### 4. 后台任务与终端会话都使用独立 durable 事件,而不是复用单个 ToolCallDelta @@ -99,12 +101,12 @@ - `BackgroundTaskProgressed` - `BackgroundTaskCompleted` - `BackgroundTaskFailed` -- 终端会话新增独立 durable 事件: - - `TerminalSessionStarted` - - `TerminalSessionInputAccepted` - - `TerminalSessionOutputDelta` - - `TerminalSessionStateChanged` - - `TerminalSessionClosed` +- 持久执行会话新增独立 durable 事件: + - `ExecSessionStarted` + - `ExecSessionOutputDelta` + - `TerminalInteractionRecorded` + - `ExecSessionStateChanged` + - `ExecSessionEnded` - conversation read model 增加 `BackgroundTaskNotificationBlockFacts`、`TerminalSessionBlockFacts` 与对应 patch。 原因: @@ -114,7 +116,7 @@ 备选方案与否决: -- 方案 A:所有终端输出都继续挂在 `terminal_start` 那个 tool call 上。否决,因为后续 `terminal_input`、close、lost 等更新无法自然归并。 +- 方案 A:所有终端输出都继续挂在 `exec_command` 首次启动那个 tool call 的 result 里。否决,因为后续 `write_stdin`、terminate、close、lost 等更新无法自然归并。 ### 5. 后台任务与终端会话采用“事件日志 + live handle + 通知输入”模型 @@ -158,7 +160,7 @@ ## Risks / Trade-offs - [风险] 后台任务 live handle 与 durable 状态不一致 → 缓解:durable 事件定义 started/completed/failed/lost 真相,live handle 只补充可操作运行态。 -- [风险] 终端 prompt 检测在不同 shell 上不稳定 → 缓解:一期不依赖 prompt 检测,只支持 `wait_until = none|silence|exit`;后续再加 shell-specific prompt heuristic。 +- [风险] 交互式终端如果没有明确等待窗口,容易让模型误用成长期阻塞读取 → 缓解:沿用 Codex 风格 `yield_time_ms`,让每次 `exec_command` / `write_stdin` 只等待有限窗口并返回当前输出快照。 - [风险] 新增 PTY 依赖带来跨平台复杂度 → 缓解:将 PTY 封装限制在 adapter/application 交界,核心与协议只看纯数据结构。 - [风险] 工具事件、后台任务事件与终端事件并存导致 read model 更复杂 → 缓解:明确“一次性工具”“后台任务通知”“持久终端会话”是三类 block,不混用主键和状态机。 - [风险] 重启后无法继续控制旧终端进程会让用户预期落差 → 缓解:在 spec 中明确该场景为 lost / failed,而不是承诺透明恢复。 @@ -168,19 +170,19 @@ 1. 在 `core`、`protocol`、`session-runtime` 中增加后台任务通知 / terminal session 纯数据结构。 2. 在 `application` 组装 `ProcessSupervisor`,先接入一次性后台 shell。 3. 改造 `shell` 为可选择 foreground/background,并打通任务输出落盘与完成通知。 -4. 新增终端会话工具族与 PTY/pipe 实现,接入 supervisor。 +4. 新增 `exec_command` / `write_stdin` 工具族与 PTY/pipe 实现,接入 supervisor。 5. 扩展 conversation/query/frontend 渲染 background task 与 terminal session。 6. 同步更新 `PROJECT_ARCHITECTURE.md`,记录新的职责边界。 回滚策略: - 若后台 shell 路径不稳定,可暂时关闭 `executionMode=background/auto`,保留 foreground shell。 -- 若终端会话实现不稳定,可整体关闭 `terminal_*` 工具暴露,不影响一次性 shell 与既有聊天流程。 +- 若终端会话实现不稳定,可整体关闭 `exec_command` / `write_stdin` 等持久执行工具暴露,不影响一次性 shell 与既有聊天流程。 - 新事件类型保持前后端同批发布;若回滚,先停止暴露新工具,再回滚前端块渲染。 ## Open Questions -- 一期是否需要 `terminal_resize` 正式对外暴露,还是先内部保留? +- 一期是否需要同时暴露 `resize_terminal` 与 `close_stdin`,还是先只开放 `write_stdin` / `terminate_terminal`? - 后台 shell 的 `auto` 判定阈值应基于超时、命令类型,还是工具显式标记? - 后台任务完成后默认只通知用户,还是同时生成一条内部输入唤醒模型继续决策? - `ProcessSupervisor` 的可观测性指标是否需要单独纳入 `runtime-observability-pipeline` 的 spec 更新? diff --git a/openspec/changes/async-shell-terminal-sessions/proposal.md b/openspec/changes/async-shell-terminal-sessions/proposal.md index ad6deea0..afbd538e 100644 --- a/openspec/changes/async-shell-terminal-sessions/proposal.md +++ b/openspec/changes/async-shell-terminal-sessions/proposal.md @@ -1,6 +1,6 @@ ## Why -当前 `Astrcode` 的工具调用仍以同步 turn 执行为主:长时间运行的 shell / tool 会直接阻塞 turn loop,而现有运行时又没有一套像 Claude Code 那样的后台任务注册、输出落盘与完成通知机制。同时,现有 `shell` 只覆盖一次性非交互命令,无法提供持久终端会话、stdin 回写与显式读取输出语义,导致 LLM 无法像现代 coding agent 那样稳定操控终端。 +当前 `Astrcode` 的工具调用仍以同步 turn 执行为主:长时间运行的 shell / tool 会直接阻塞 turn loop,而现有运行时又没有一套像 Claude Code 那样的后台任务注册、输出落盘与完成通知机制。同时,现有 `shell` 只覆盖一次性非交互命令,无法提供持久终端会话、stdin 回写与有限等待窗口输出语义,导致 LLM 无法像现代 coding agent 那样稳定操控终端。 现在引入该能力的时机已经成熟:`session-runtime` 已经是单会话真相面,`ToolContext` 也已有 stdout / stderr 流式输出通道,继续沿同步一次性工具模型堆补丁,只会让事件模型、conversation 投影与前端展示继续分裂。 @@ -8,14 +8,14 @@ - 引入 Claude Code 风格的后台任务执行语义:长耗时工具不再阻塞当前 turn,而是立即返回 `backgroundTaskId`、输出路径和状态摘要,后续通过完成通知与显式读取获取结果。 - 为长任务建立统一的进程监管面,区分“一次性后台命令”和“持久终端会话”,统一处理生命周期、取消、失败、输出落盘与通知。 -- 保留现有 `shell` 作为一次性命令工具,并增加 `background` 执行模式;同时新增持久终端会话工具族,使 LLM 可以启动终端、写入 stdin、显式读取新输出并关闭会话。 +- 保留现有 `shell` 作为一次性命令工具,并增加 `background` 执行模式;同时新增 Codex 风格的持久执行工具族,使 LLM 可以启动带 `process_id` 的终端会话、写入 stdin、在有限等待窗口内拿到新输出并终止或关闭会话。 - 扩展事件模型、conversation authoritative read model 与前端展示,使“后台任务已启动/已完成/已失败”和“终端会话输出/状态变化”成为正式合同,而不是前端本地猜测。 - 明确后台任务和终端会话的恢复/失败语义:Astrcode 重启后不得静默丢失状态,必须向用户和模型暴露明确的 lost / failed 结果。 ## Capabilities ### New Capabilities -- `async-tool-execution`: 定义工具如何立即返回后台任务句柄、持续产出输出、发送完成通知并暴露显式读取语义。 +- `async-tool-execution`: 定义工具如何立即返回后台任务句柄、持续产出输出、发送完成通知并暴露稳定输出引用。 - `terminal-tool-sessions`: 定义持久终端会话的创建、输入输出、生命周期控制与失败恢复语义。 ### Modified Capabilities @@ -32,9 +32,9 @@ - `crates/protocol` 与 `frontend`:waiting / terminal session DTO 与渲染 - 用户可见影响: - 长工具调用不再卡死会话,而是变成后台任务并在完成时收到通知 - - LLM 可以通过正式工具直接操控持久终端会话 + - LLM 可以通过正式工具直接操控持久终端会话,并基于 `process_id` 持续交互 - 开发者可见影响: - - 工具执行从“同步完成即返回”演进为“foreground / background / terminal-session”多模式合同 + - 工具执行从“同步完成即返回”演进为“foreground / background / persistent-exec-session”多模式合同 - 事件模型需要新增后台任务通知与终端会话语义,避免继续把跨多次交互的输出硬塞进单个 `tool_call_id` - 依赖与系统影响: - 可能需要引入跨平台 PTY 支撑(例如 `portable-pty` 或等价方案) diff --git a/openspec/changes/async-shell-terminal-sessions/specs/session-runtime/spec.md b/openspec/changes/async-shell-terminal-sessions/specs/session-runtime/spec.md index 3086d725..09829b52 100644 --- a/openspec/changes/async-shell-terminal-sessions/specs/session-runtime/spec.md +++ b/openspec/changes/async-shell-terminal-sessions/specs/session-runtime/spec.md @@ -39,4 +39,4 @@ #### Scenario: 持久终端会话拥有独立 block - **WHEN** 模型创建一个持久终端会话并多次向其输入 - **THEN** `session-runtime` MUST 为该会话维护独立 terminal session block -- **AND** 后续输出与状态变化 MUST 持续 patch 该 block +- **AND** 后续输出、stdin 交互记录与状态变化 MUST 持续 patch 该 block diff --git a/openspec/changes/async-shell-terminal-sessions/specs/terminal-chat-read-model/spec.md b/openspec/changes/async-shell-terminal-sessions/specs/terminal-chat-read-model/spec.md index cd1ab1fd..91fe1eff 100644 --- a/openspec/changes/async-shell-terminal-sessions/specs/terminal-chat-read-model/spec.md +++ b/openspec/changes/async-shell-terminal-sessions/specs/terminal-chat-read-model/spec.md @@ -14,18 +14,23 @@ authoritative conversation read model MUST 为后台任务提供正式通知块 - **AND** 该通知 MUST 包含 `backgroundTaskId`、终态和输出引用 ### Requirement: conversation read model SHALL 暴露 terminal session block -authoritative conversation read model MUST 为持久终端会话提供独立 block 类型,至少包含 `terminal_session_id`、状态、stdout/stderr 聚合结果、cursor 以及增量 patch 语义。 +authoritative conversation read model MUST 为持久终端会话提供独立 block 类型,至少包含 `process_id`、状态、stdout/stderr 聚合结果、最近交互记录以及增量 patch 语义。 #### Scenario: terminal session 首次启动进入 transcript - **WHEN** 系统创建一个新的终端会话 - **THEN** hydration / delta 流 MUST 追加一个新的 terminal session block -- **AND** 该 block MUST 暴露稳定的 `terminal_session_id` +- **AND** 该 block MUST 暴露稳定的 `process_id` #### Scenario: terminal session 输出持续 patch 同一 block - **WHEN** 终端会话持续产出 stdout 或 stderr - **THEN** conversation delta MUST 以 append patch 更新同一 terminal session block - **AND** 客户端 MUST NOT 自行把多次 tool result 重新拼装成终端视图 +#### Scenario: terminal interaction 进入同一 block 的交互流 +- **WHEN** 模型对某个持久终端会话调用 `write_stdin` +- **THEN** conversation delta MUST 为同一 terminal session block 追加对应的 stdin 交互记录 +- **AND** 客户端 MUST 能把输入与随后输出关联到同一会话 + #### Scenario: terminal session 状态变化进入同一 block - **WHEN** 终端会话进入 running、waiting-input、closed、failed、lost 或 exited - **THEN** conversation delta MUST 在同一 terminal session block 上更新状态 diff --git a/openspec/changes/async-shell-terminal-sessions/specs/terminal-tool-sessions/spec.md b/openspec/changes/async-shell-terminal-sessions/specs/terminal-tool-sessions/spec.md index e4fd070e..54ee35a1 100644 --- a/openspec/changes/async-shell-terminal-sessions/specs/terminal-tool-sessions/spec.md +++ b/openspec/changes/async-shell-terminal-sessions/specs/terminal-tool-sessions/spec.md @@ -1,53 +1,65 @@ ## ADDED Requirements -### Requirement: 系统 SHALL 提供持久终端会话工具合同 -系统 MUST 提供与一次性 `shell` 分离的持久终端会话能力,使模型可以创建一个终端会话并获得稳定的 `terminal_session_id`,后续所有输入输出与状态更新 MUST 归属于该标识。 +### Requirement: 系统 SHALL 提供基于 `process_id` 的持久终端会话工具合同 +系统 MUST 提供与一次性 `shell` 分离的持久终端会话能力,使模型可以通过 `exec_command` 创建一个终端会话并获得稳定的 `process_id`,后续所有输入输出与状态更新 MUST 归属于该标识。 #### Scenario: 创建新的终端会话 -- **WHEN** 模型调用 `terminal_start` -- **THEN** 系统 MUST 启动一个新的终端会话并返回稳定的 `terminal_session_id` +- **WHEN** 模型调用 `exec_command` +- **THEN** 系统 MUST 启动一个新的终端会话并返回稳定的 `process_id` - **AND** transcript / conversation read model MUST 追加对应的 terminal session block #### Scenario: 同一会话跨多次输入保持同一身份 -- **WHEN** 模型随后多次调用 `terminal_write` -- **THEN** 每次输入 MUST 绑定到已有的 `terminal_session_id` +- **WHEN** 模型随后多次调用 `write_stdin` +- **THEN** 每次输入 MUST 绑定到已有的 `process_id` - **AND** 系统 MUST NOT 为每次输入新建一个独立终端会话实体 -### Requirement: 终端会话 SHALL 支持显式 stdin 写入与显式输出读取 -终端会话 MUST 支持向底层 shell / PTY 写入 stdin,并通过正式读取接口暴露自某个 cursor 之后的新 stdout / stderr 输出。 +### Requirement: 终端会话 SHALL 支持显式 stdin 写入与有限窗口输出返回 +终端会话 MUST 支持向底层 shell / PTY 写入 stdin,并在每次 `exec_command` / `write_stdin` 调用中返回该等待窗口内的新 stdout / stderr 输出快照。 + +#### Scenario: 启动终端时返回初始等待窗口内输出 +- **WHEN** 模型调用 `exec_command` +- **THEN** 系统 MUST 在 `yield_time_ms` 指定的有限等待窗口内收集新输出 +- **AND** MUST 返回该窗口内的 stdout / stderr 输出快照 +- **AND** 若进程仍在运行,响应 MUST 保留 `process_id` #### Scenario: 模型写入终端输入 -- **WHEN** 模型调用 `terminal_write` 并携带文本输入 +- **WHEN** 模型调用 `write_stdin` 并携带文本输入 - **THEN** 系统 MUST 将该文本写入对应终端会话的 stdin -- **AND** 该调用本身 MUST 返回明确的接受结果,而不是假定输入已经执行完毕 +- **AND** 该调用 MUST 返回本次等待窗口内的新输出、当前 `process_id` 和已知退出状态 +- **AND** 该调用 MUST NOT 假定输入已经执行完毕 + +### Requirement: 终端会话交互 SHALL 支持有限等待策略 +终端会话工具 MAY 支持受限的短暂等待,但系统 MUST 不以 suspended turn 或隐藏等待状态表达该能力。 -#### Scenario: 读取自某个 cursor 之后的新输出 -- **WHEN** 模型调用 `terminal_read` 并携带某个 `cursor` -- **THEN** 系统 MUST 返回该 cursor 之后产生的新 stdout / stderr 输出片段 -- **AND** MUST 返回新的 cursor 供后续继续读取 -- **AND** 客户端或模型 MUST 无需重新读取整个终端历史 +#### Scenario: `write_stdin` 使用短暂等待窗口 +- **WHEN** 模型调用 `write_stdin` 且声明 `yield_time_ms` +- **THEN** 系统 MAY 在该窗口内等待新输出到达 +- **AND** 超时后 MUST 返回当前已知输出与最新进程状态 +- **AND** MUST NOT 把该交互升级为长期挂起的 turn 状态 -### Requirement: 终端会话读取 SHALL 支持有限等待策略 -终端读取工具 MAY 支持受限的长轮询读取,但系统 MUST 不以 suspended turn 或隐藏等待状态表达该能力。 +#### Scenario: 空输入用于轮询后台终端输出 +- **WHEN** 模型调用 `write_stdin` 且 `chars` 为空字符串 +- **THEN** 系统 MAY 将该调用视为一次不写入 stdin 的输出轮询 +- **AND** MUST 仍然复用同一 `process_id` +- **AND** MUST 返回等待窗口内观察到的新输出或空输出 -#### Scenario: 仅发送输入立即返回 -- **WHEN** 模型调用 `terminal_write` -- **THEN** 系统 MUST 在写入 stdin 后立即返回 -- **AND** 当前 turn MUST NOT 进入挂起状态 +### Requirement: 终端会话 SHALL 支持显式控制与明确失败语义 +系统 MUST 允许模型显式终止、关闭 stdin 或调整终端尺寸;若终端进程崩溃、会话丢失或系统重启导致无法继续控制,也 MUST 产生明确的失败或 lost 终态。 -#### Scenario: 读取时使用短暂长轮询 -- **WHEN** 模型调用 `terminal_read` 且声明短暂等待窗口 -- **THEN** 系统 MAY 在该窗口内等待新输出到达 -- **AND** 超时后 MUST 返回当前已知输出和最新 cursor -- **AND** MUST NOT 把该读取升级为长期挂起的 turn 状态 +#### Scenario: 模型终止终端会话 +- **WHEN** 模型调用 `terminate_terminal` +- **THEN** 系统 MUST 终止对应终端会话并追加 closed、failed 或 exited 终态 +- **AND** 后续对该 `process_id` 的输入 MUST 被拒绝 -### Requirement: 终端会话 SHALL 支持显式关闭与明确失败语义 -系统 MUST 允许模型显式关闭终端会话;若终端进程崩溃、会话丢失或系统重启导致无法继续控制,也 MUST 产生明确的失败或 lost 终态。 +#### Scenario: 模型关闭 stdin +- **WHEN** 模型调用 `close_stdin` +- **THEN** 系统 MUST 关闭对应终端会话的 stdin +- **AND** 如进程仍存活,系统 MUST 继续保留该会话直到其自然退出或被显式终止 -#### Scenario: 模型关闭终端会话 -- **WHEN** 模型调用 `terminal_close` -- **THEN** 系统 MUST 关闭对应终端会话并追加 closed 终态 -- **AND** 后续对该 `terminal_session_id` 的输入 MUST 被拒绝 +#### Scenario: 模型调整终端尺寸 +- **WHEN** 模型调用 `resize_terminal` +- **THEN** 系统 MUST 尝试调整对应 PTY 的终端尺寸 +- **AND** 若该会话不是 PTY,会话工具 MUST 返回明确拒绝 #### Scenario: 终端进程异常退出 - **WHEN** 底层终端进程非预期退出 @@ -58,3 +70,16 @@ - **WHEN** Astrcode 重启且无法重新附着到某个终端会话的底层进程 - **THEN** 系统 MUST 将该终端会话标记为 lost 或 failed - **AND** MUST NOT 继续宣称其仍可输入 + +### Requirement: 会话输出主路径 SHALL 通过 durable/live 事件持续投影 +终端会话的 stdout / stderr 主通道 MUST 通过 begin/delta/end 事件和终端交互记录持续进入 conversation/read model,而不是要求模型依赖单独的读取游标协议。 + +#### Scenario: 运行中会话持续发出输出增量 +- **WHEN** 持久终端会话持续产出 stdout 或 stderr +- **THEN** 系统 MUST 持续发出与该 `process_id` 关联的输出增量事件 +- **AND** conversation read model MUST 把这些输出 patch 到同一 terminal session block + +#### Scenario: stdin 写入被记录为交互事件 +- **WHEN** 模型对会话调用 `write_stdin` +- **THEN** 系统 MUST 记录对应的 terminal interaction 事件 +- **AND** 该交互记录 MUST 能与同一 `process_id` 关联 diff --git a/openspec/changes/async-shell-terminal-sessions/tasks.md b/openspec/changes/async-shell-terminal-sessions/tasks.md index e069a295..216de10d 100644 --- a/openspec/changes/async-shell-terminal-sessions/tasks.md +++ b/openspec/changes/async-shell-terminal-sessions/tasks.md @@ -12,9 +12,9 @@ ## 3. 持久终端会话工具族 -- [ ] 3.1 新增终端会话工具模块,例如 `crates/adapter-tools/src/builtin_tools/terminal_start.rs`、`terminal_write.rs`、`terminal_read.rs`、`terminal_close.rs`、`terminal_resize.rs`,定义参数与返回合同;验证:`cargo test -p astrcode-adapter-tools terminal_` -- [ ] 3.2 在 `crates/application` 与对应 adapter 层实现 PTY/pipe 驱动的 `TerminalSessionRegistry`,支持 stdin 写入、stdout/stderr 流、退出码、关闭与 lost 语义;验证:新增跨平台可运行的单元/集成测试,至少覆盖启动、输入、退出、关闭 -- [ ] 3.3 在 `crates/session-runtime` 接入 terminal session durable 事件、hydration 投影与 `cursor` 读取语义;验证:新增 query/replay 测试覆盖 terminal session block 和增量读取主路径 +- [ ] 3.1 新增持久执行工具模块,例如 `crates/adapter-tools/src/builtin_tools/exec_command.rs`、`write_stdin.rs`、`resize_terminal.rs`、`terminate_terminal.rs`、`close_stdin.rs`,定义参数与返回合同;验证:`cargo test -p astrcode-adapter-tools exec_command` +- [ ] 3.2 在 `crates/application` 与对应 adapter 层实现 PTY/pipe 驱动的 `TerminalSessionRegistry`,采用 `process_id` 持有活跃会话,支持 stdin 写入、stdout/stderr 流、退出码、关闭与 lost 语义;验证:新增跨平台可运行的单元/集成测试,至少覆盖启动、输入、退出、关闭 +- [ ] 3.3 在 `crates/session-runtime` 接入 terminal session durable 事件、hydration 投影与 `process_id` 关联语义,输出主路径走 begin/delta/end 事件与 terminal interaction 记录;验证:新增 query/replay 测试覆盖 terminal session block、交互记录和长期运行会话主路径 ## 4. 前端展示与验收 From 9b1a02152f311fafc4eb86ed913543a2012e1c99 Mon Sep 17 00:00:00 2001 From: whatevertogo <whatevertogo@users.noreply.github.com> Date: Mon, 20 Apr 2026 01:01:17 +0800 Subject: [PATCH 45/53] docs --- .../add-task-write-system/.openspec.yaml | 2 + .../changes/add-task-write-system/design.md | 304 ++++++++++++++++++ .../changes/add-task-write-system/proposal.md | 46 +++ .../specs/application-use-cases/spec.md | 23 ++ .../specs/execution-task-tracking/spec.md | 112 +++++++ .../specs/terminal-chat-read-model/spec.md | 30 ++ .../changes/add-task-write-system/tasks.md | 24 ++ .../design.md | 0 .../proposal.md | 0 .../specs/agent-delegation-surface/spec.md | 0 .../specs/agent-tool-governance/spec.md | 0 .../specs/governance-mode-system/spec.md | 0 .../specs/mode-capability-compilation/spec.md | 0 .../specs/mode-command-surface/spec.md | 0 .../specs/mode-execution-policy/spec.md | 0 .../specs/mode-policy-engine/spec.md | 0 .../specs/mode-prompt-program/spec.md | 0 .../tasks.md | 0 .../.openspec.yaml | 0 .../design.md | 0 .../proposal.md | 0 .../specs/agent-delegation-surface/spec.md | 0 .../specs/agent-tool-governance/spec.md | 0 .../specs/capability-router-assembly/spec.md | 0 .../specs/delegation-policy-surface/spec.md | 0 .../specs/execution-limits-control/spec.md | 0 .../specs/governance-surface-assembly/spec.md | 0 .../specs/policy-engine-integration/spec.md | 0 .../prompt-facts-governance-linkage/spec.md | 0 .../tasks.md | 0 .../specs/agent-delegation-surface/spec.md | 132 ++++++++ openspec/specs/agent-tool-governance/spec.md | 80 +++++ .../specs/capability-router-assembly/spec.md | 43 +++ .../specs/delegation-policy-surface/spec.md | 61 ++++ .../specs/execution-limits-control/spec.md | 63 ++++ openspec/specs/governance-mode-system/spec.md | 212 ++++++++++++ .../specs/governance-surface-assembly/spec.md | 94 ++++++ .../specs/mode-capability-compilation/spec.md | 90 ++++++ openspec/specs/mode-command-surface/spec.md | 103 ++++++ openspec/specs/mode-execution-policy/spec.md | 87 +++++ openspec/specs/mode-policy-engine/spec.md | 80 +++++ openspec/specs/mode-prompt-program/spec.md | 103 ++++++ .../specs/policy-engine-integration/spec.md | 63 ++++ .../prompt-facts-governance-linkage/spec.md | 63 ++++ 44 files changed, 1815 insertions(+) create mode 100644 openspec/changes/add-task-write-system/.openspec.yaml create mode 100644 openspec/changes/add-task-write-system/design.md create mode 100644 openspec/changes/add-task-write-system/proposal.md create mode 100644 openspec/changes/add-task-write-system/specs/application-use-cases/spec.md create mode 100644 openspec/changes/add-task-write-system/specs/execution-task-tracking/spec.md create mode 100644 openspec/changes/add-task-write-system/specs/terminal-chat-read-model/spec.md create mode 100644 openspec/changes/add-task-write-system/tasks.md rename openspec/changes/{collaboration-mode-system => archive/2026-04-20-collaboration-mode-system}/design.md (100%) rename openspec/changes/{collaboration-mode-system => archive/2026-04-20-collaboration-mode-system}/proposal.md (100%) rename openspec/changes/{collaboration-mode-system => archive/2026-04-20-collaboration-mode-system}/specs/agent-delegation-surface/spec.md (100%) rename openspec/changes/{collaboration-mode-system => archive/2026-04-20-collaboration-mode-system}/specs/agent-tool-governance/spec.md (100%) rename openspec/changes/{collaboration-mode-system => archive/2026-04-20-collaboration-mode-system}/specs/governance-mode-system/spec.md (100%) rename openspec/changes/{collaboration-mode-system => archive/2026-04-20-collaboration-mode-system}/specs/mode-capability-compilation/spec.md (100%) rename openspec/changes/{collaboration-mode-system => archive/2026-04-20-collaboration-mode-system}/specs/mode-command-surface/spec.md (100%) rename openspec/changes/{collaboration-mode-system => archive/2026-04-20-collaboration-mode-system}/specs/mode-execution-policy/spec.md (100%) rename openspec/changes/{collaboration-mode-system => archive/2026-04-20-collaboration-mode-system}/specs/mode-policy-engine/spec.md (100%) rename openspec/changes/{collaboration-mode-system => archive/2026-04-20-collaboration-mode-system}/specs/mode-prompt-program/spec.md (100%) rename openspec/changes/{collaboration-mode-system => archive/2026-04-20-collaboration-mode-system}/tasks.md (100%) rename openspec/changes/{governance-surface-cleanup => archive/2026-04-20-governance-surface-cleanup}/.openspec.yaml (100%) rename openspec/changes/{governance-surface-cleanup => archive/2026-04-20-governance-surface-cleanup}/design.md (100%) rename openspec/changes/{governance-surface-cleanup => archive/2026-04-20-governance-surface-cleanup}/proposal.md (100%) rename openspec/changes/{governance-surface-cleanup => archive/2026-04-20-governance-surface-cleanup}/specs/agent-delegation-surface/spec.md (100%) rename openspec/changes/{governance-surface-cleanup => archive/2026-04-20-governance-surface-cleanup}/specs/agent-tool-governance/spec.md (100%) rename openspec/changes/{governance-surface-cleanup => archive/2026-04-20-governance-surface-cleanup}/specs/capability-router-assembly/spec.md (100%) rename openspec/changes/{governance-surface-cleanup => archive/2026-04-20-governance-surface-cleanup}/specs/delegation-policy-surface/spec.md (100%) rename openspec/changes/{governance-surface-cleanup => archive/2026-04-20-governance-surface-cleanup}/specs/execution-limits-control/spec.md (100%) rename openspec/changes/{governance-surface-cleanup => archive/2026-04-20-governance-surface-cleanup}/specs/governance-surface-assembly/spec.md (100%) rename openspec/changes/{governance-surface-cleanup => archive/2026-04-20-governance-surface-cleanup}/specs/policy-engine-integration/spec.md (100%) rename openspec/changes/{governance-surface-cleanup => archive/2026-04-20-governance-surface-cleanup}/specs/prompt-facts-governance-linkage/spec.md (100%) rename openspec/changes/{governance-surface-cleanup => archive/2026-04-20-governance-surface-cleanup}/tasks.md (100%) create mode 100644 openspec/specs/capability-router-assembly/spec.md create mode 100644 openspec/specs/delegation-policy-surface/spec.md create mode 100644 openspec/specs/execution-limits-control/spec.md create mode 100644 openspec/specs/governance-mode-system/spec.md create mode 100644 openspec/specs/governance-surface-assembly/spec.md create mode 100644 openspec/specs/mode-capability-compilation/spec.md create mode 100644 openspec/specs/mode-command-surface/spec.md create mode 100644 openspec/specs/mode-execution-policy/spec.md create mode 100644 openspec/specs/mode-policy-engine/spec.md create mode 100644 openspec/specs/mode-prompt-program/spec.md create mode 100644 openspec/specs/policy-engine-integration/spec.md create mode 100644 openspec/specs/prompt-facts-governance-linkage/spec.md diff --git a/openspec/changes/add-task-write-system/.openspec.yaml b/openspec/changes/add-task-write-system/.openspec.yaml new file mode 100644 index 00000000..c8af3f5f --- /dev/null +++ b/openspec/changes/add-task-write-system/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-04-19 diff --git a/openspec/changes/add-task-write-system/design.md b/openspec/changes/add-task-write-system/design.md new file mode 100644 index 00000000..187dd1bd --- /dev/null +++ b/openspec/changes/add-task-write-system/design.md @@ -0,0 +1,304 @@ +## Context + +Astrcode 当前已经有一套明确的正式计划系统: + +- `application::session_plan` 维护 session 下唯一 canonical plan artifact、审批、归档和 prompt 注入。 +- `upsertSessionPlan` / `exitPlanMode` 是正式 plan 的唯一写入和呈递路径。 +- `session-runtime` conversation projection 会把 plan 工具结果专门投影成 `Plan` block。 +- 前端已经有 `PlanMessage`、`PlanSurface` 和 `activePlan` 控制态。 + +这说明 Astrcode 现有的 `plan` 语义不是“给模型记待办”,而是“正式治理工件”。因此,本次设计不能把 Todo/Task 简化成 `session_plan` 的轻量版。 + +对比现有三个参照系统,可以得到更清晰的定位: + +| 系统 | 核心对象 | 主要回答的问题 | 生命周期 | 真相形态 | +| --- | --- | --- | --- | --- | +| Codex | `update_plan` / turn todo | 这一轮接下来做哪几步 | `per-turn` | thread item / turn 级投影 | +| Claude Code V1 | `TodoWrite` | 当前 agent 正在做什么 | `per-session` | AppState + 日志恢复 | +| Claude Code V2 | `TaskCreate/Get/Update/List` | 团队 / 多 agent 如何协作推进任务 | `跨会话` | 文件任务库 | +| Astrcode 当前 | `session_plan` | 这个 session 正式批准要做什么 | `session 级` | canonical artifact + 审批归档 | + +Astrcode 缺的不是“更重的 plan”,而是“与 formal plan 分离的 execution task layer”。 + +另外,当前 turn request 装配有一个很关键的事实:`build_prompt_output()` 会在每一步重新调用 `PromptFactsProvider.resolve_prompt_facts()`,并在 `turn/request` 里追加 live declaration。这意味着如果 task 真要帮助模型“同一 turn 里也不忘事”,它必须走动态读事实链路,而不是只像 `session_plan` 那样在 submit 时塞一次固定声明。 + +本次变更与 `PROJECT_ARCHITECTURE.md` 不冲突,不需要先改架构总文档。原因是: + +- `adapter-tools` 只负责工具定义与 capability 桥接; +- `session-runtime` 继续持有单 session task 真相与投影; +- `application` 继续只做稳定用例和治理编排; +- `frontend` 只消费 authoritative read model,不自己组 transcript 推断任务面板。 + +## Goals / Non-Goals + +**Goals:** + +- 引入与 `session_plan` 明确解耦的执行期 task 系统。 +- 让模型能通过 `taskWrite` 外化当前执行清单,并在同一 turn 后续步骤与下一轮 turn 继续读取最新 task 状态。 +- 保持 durable truth 优先来自已有事件日志 / tool result,而不是前端本地状态或新的平行存储。 +- 提供单独 task panel 读模型,不污染 `PlanMessage` / `PlanSurface` 语义。 +- 首版尽量轻量:只实现最小闭环 `taskWrite + projection + prompt 注入 + panel`。 + +**Non-Goals:** + +- 不实现 Claude Code V2 风格的 task CRUD 工具族、任务依赖图和共享任务库。 +- 不把 task 系统做成新的治理模式,也不在 `plan` / `review` mode 中开放写能力。 +- 不为 task 单独新增一套 HTTP/SSE 专属 surface;优先复用现有 conversation/control read model 扩展。 +- 不把 task 当作 transcript 主体消息类型;首版不要求历史回放里显示每次 task 变更记录。 +- 不要求多 owner 并行面板、任务提醒器、快捷键等产品增强在首版一起落地。 + +## Decisions + +### Decision 1:Task 系统与 Session Plan 严格分层 + +**选择:** + +- `session_plan` 继续表示“正式计划、审批和归档”。 +- 新增 `taskWrite` 表示“执行期清单和当前工作记忆”。 +- `taskWrite` 不读写 `sessions/<id>/plan/**`,也不复用 `PlanMessage` / `activePlan`。 + +**理由:** + +- Astrcode 已经把 plan 语义做成治理工件,如果再承载执行期清单,会把 canonical artifact 与临时执行状态混成一层。 +- task 的更新频率、恢复语义和 UI 呈现都与 plan 完全不同。 + +**替代方案:** + +- 复用 `upsertSessionPlan`:拒绝。语义错误,且会破坏 plan mode 的审查/审批边界。 + +### Decision 2:首版只做一个 `taskWrite`,采用全量快照模型 + +**选择:** + +- 新增单一 builtin tool:`taskWrite`。 +- 输入为全量 task 快照: + - `content: string` + - `status: pending | in_progress | completed` + - `activeForm?: string` +- 首版要求最多一个 `in_progress` 任务。 +- `in_progress` 项若存在,必须提供 `activeForm`,用于 prompt 和 UI 表达当前进行中的动作。 +- 单次快照最多 20 条 item,超出时工具返回错误。防止大列表导致 token 膨胀。 +- `taskWrite` 的 capability metadata 声明 `side_effect: Local`,这样 plan mode(排除 `SideEffect(Local)`)和 review mode(只允许 `SideEffect(None)`)自然不暴露该工具,code mode(`AllTools`)可见。无需在 mode catalog 中按名字添加白名单或黑名单。 + +**理由:** + +- 全量快照最容易让模型理解当前完整状态,避免增量更新带来的 task id 管理和部分失败合并问题。 +- Astrcode 现在的主要诉求是增强执行记忆,不是做团队任务板。 +- `SideEffect::Local` 复用现有 mode selector 的集合代数,不需要为 `taskWrite` 特殊修改 mode catalog。 + +**替代方案:** + +- 直接上 `taskCreate` / `taskUpdate` / `taskList`:拒绝。复杂度和多 agent 约束管理都过早。 +- 只接受自由文本 checklist:拒绝。难以验证、难以投影、难以做稳定 prompt 注入。 +- 使用 `SideEffect::None`:拒绝。会导致 taskWrite 在 plan 和 review mode 也可见,必须额外在 mode catalog 中按名字排除。 + +### Decision 3:Task 所有权采用 `agent_id || session_id`,但 UI 只显示当前 session 的 active snapshot + +**选择:** + +- task snapshot 写入时绑定 owner: + - 优先使用 `ToolContext.agent_context().agent_id` + - 若 agent id 缺失,则回退到当前 `session_id` +- `session-runtime` 为每个 owner 维护最新 snapshot。 +- conversation/control read model 首版只暴露“当前 session 当前 owner 的 active tasks”。 + +**理由:** + +- 这与 Claude Code V1 的“agentId ?? sessionId”隔离原则一致,同时兼容 Astrcode 现有 child session / subrun 体系。 +- 当前 Astrcode 的 child 执行主要是独立 session,但预留 owner 维度可避免未来共享 session 分支时返工。 + +**替代方案:** + +- 整个 session 只有一份任务表:接受度低。未来一旦出现共享 session 多 owner,就会污染任务真相。 + +### Decision 4:首版不新增专门事件类型,复用 durable tool result metadata + +**选择:** + +- `taskWrite` 正常走现有 `ToolCall` / `ToolResult` durable 事件链路。 +- `ToolExecutionResult.metadata` 写入结构化 payload,例如: + - `schema: "executionTaskSnapshot"` + - `owner` + - `items` + - `cleared` +- `session-runtime` 新增 task projector,从 durable tool result 中提取最新 snapshot。 +- `SessionState` 新增 `active_tasks: StdMutex<HashMap<String, TaskSnapshot>>` 字段(key = owner),遵循现有 `child_nodes: StdMutex<HashMap<String, ChildSessionNode>>` 和 `input_queue_projection_index` 模式。 +- `SessionState::translate_store_and_cache()` 在处理 `StorageEventPayload::ToolResult` 时,若 `tool_name == "taskWrite"`,从 `metadata` 提取 snapshot 并更新对应 owner 的 `active_tasks` 条目。空列表或全部 completed 时移除该 owner 的条目。 +- 冷启动(`SessionActor::from_replay()`)通过完整事件回放自动恢复所有 owner 的最新 task snapshot,不需要额外的持久化文件。 +- `SessionState` 暴露查询方法 `active_tasks_for(owner: &str) -> Option<TaskSnapshot>`,供 prompt 注入和读模型使用。 + +**理由:** + +- 这与当前系统的工具模型完全一致,接入成本低。 +- 在需求尚未扩展到共享任务板前,没有必要提前引入新的 `StorageEventPayload::TaskUpdated`。 +- `StdMutex<HashMap<String, TaskSnapshot>>` 与现有 `child_nodes` / `input_queue_projection_index` 保持一致的缓存模式。 + +**替代方案:** + +- 新增专门 durable 事件:暂不采用。可以作为后续演进点,但首版没有必要扩大底层事件面。 +- 前端内存保存任务状态:拒绝。恢复、回放和 prompt 注入都会失真。 +- 独立持久化文件(类似 session plan state.json):拒绝。task 真相已经由 durable tool result 保证,额外文件是冗余写入且增加一致性问题。 + +### Decision 5:动态 prompt 注入放在 `session-runtime/turn/request`,而不是 submit-time declaration + +**选择:** + +- 不沿用 `session_plan` 的 submit-time extra prompt declaration 方式。 +- 在 `session-runtime::turn::request::build_prompt_output()` 中,参照 `live_direct_child_snapshot_declaration(...)`(当前位于 lines 313-317),新增 `live_task_snapshot_declaration(...)`。 +- 每一步 request assembly 都从 `SessionState.active_tasks_for(current_owner)` 读取最新 snapshot 并生成精简 task 声明。 +- 声明只包含活跃任务摘要(`in_progress` + `pending`),已完成项不注入,空列表不生成声明。 +- 注入格式参照 `live_direct_child_snapshot_declaration` 的 block_id / layer / priority 模式,使用独立的 block_id(如 `”task.active_snapshot”`),避免与其他声明冲突。 + +**理由:** + +- `build_prompt_output()` 每一步都会重新执行,因此这是让 task 更新在”同一 turn 后续步骤”生效的唯一自然挂点。 +- 如果只在 submit 时注入,`taskWrite` 只能帮助下一轮,不足以支撑真正的执行记忆。 +- 只注入活跃项避免 token 浪费,与 design 的”全部 completed 即清除”语义一致。 + +**替代方案:** + +- 扩展 `RuntimePromptFactsProvider` 去读 session task:不优先采用。当前 provider 不依赖 session-runtime,把 session 级 live truth 拉进 provider 会破坏装配边界。 + +### Decision 6:Task 读模型挂在 conversation/control surface,不进入 transcript 主体 + +**选择:** + +- 首版 task panel 通过 conversation snapshot/stream 的 control facts 暴露,在 `ConversationControlStateDto` 中新增 `activeTasks` 字段(结构类似 `activePlan`,为 `Option<Vec<TaskItemDto>>`)。 +- task 更新不生成新的 transcript 主消息类型,也不占用 `PlanMessage`。 +- 当前无 active tasks 时,`activeTasks` 为 `None`,前端隐藏 task 区域。 +- **taskWrite 工具调用本身保留为正常 `ToolCallBlock`**,不做抑制(与 plan 工具被 `should_suppress_tool_call_block()` 抑制并转为 `Plan` block 的行为不同)。这样用户可以在 transcript 中看到模型何时调用了 taskWrite。 +- 前端 task 区域首版渲染为**对话区顶部的折叠卡片**(位于消息列表上方、TopBar 下方),展示当前 in_progress 任务标题 + pending/completed 计数。不采用独立侧边栏或 TopBar pill,因为 task 信息需要比 badge 更大的展示面积,但又不需要打断对话布局。 + +**理由:** + +- 用户要的是”始终可见的执行记忆面板”,不是”在 transcript 里堆一串 task 更新消息”。 +- 当前前端已有 `activePlan` 这类 control state 模式(TopBar pill),task 也适合走持续控制态。 +- 保留 taskWrite 作为正常 ToolCallBlock 让 transcript 保持完整的操作审计链。 +- 对话区顶部卡片是 TopBar badge(太小)和侧边栏(架构改动大)之间的折中方案。 + +**替代方案:** + +- 把 task 作为普通 ToolCallBlock 展示并依赖用户滚动查看:拒绝。持续可见性差。 +- 抑制 taskWrite 工具调用(像 plan 那样转为专属 block):拒绝。task 更新频率远高于 plan,抑制后 transcript 会丢失重要的执行审计记录。 +- 新建独立 HTTP/SSE surface:暂不采用。首版先复用现有 conversation surface 扩展。 +- TopBar pill badge:拒绝。面积不足以展示任务列表内容。 +- 独立侧边栏:首版范围过大,可作为后续演进。 + +### Decision 7:`taskWrite` 只在 `code` mode 可用 + +**选择:** + +- `taskWrite` 注册为稳定 builtin tool,通过 `SideEffect::Local`(见 Decision 2)实现 mode 自动过滤: + - Code mode(`AllTools`)→ 可见。 + - Plan mode(排除 `SideEffect(Local)`)→ 不可见。 + - Review mode(只允许 `SideEffect(None)`)→ 不可见。 +- 无需在 mode catalog 中按名字添加白名单或黑名单,复用现有集合代数。 + +**理由:** + +- task 是执行期控制面,不是规划工件。 +- 若在 plan mode 允许 task 写入,会让模型用执行 checklist 替代正式 plan 产出。 +- 利用 SideEffect 而非按名字排除,保持 mode catalog 的声明式纯净度。 + +**替代方案:** + +- 所有 mode 都暴露 taskWrite:拒绝。会冲淡治理模式边界。 +- 使用 `SideEffect::None` 并在 mode catalog 按名字排除:可行但增加了 mode catalog 维护成本,不如直接用 `Local` 自然排除。 + +### Decision 8:首版不做提醒器、依赖关系和多工具协作 + +**选择:** + +- 首版不实现自动 reminder、task 依赖关系、任务 claim、owner/blocking、跨 session 文件任务库。 +- 仅通过 tool prompt metadata 和 task panel 提升模型使用率。 + +**理由:** + +- 这些能力都建立在 task 闭环已经稳定的前提上。 +- 当前最有价值的是让模型有”外化的、持久的、动态可见的执行记忆”,不是引入新的协调系统。 + +**替代方案:** + +- 同步实现 Claude Code V1 + V2 的全量能力:拒绝。范围过大。 + +### Decision 9:`taskWrite` 的 Prompt Guidance + +**选择:** + +- `taskWrite` 的 `ToolPromptMetadata` 提供详细的使用指导,包含: + - **何时使用**:任务需要 3+ 步骤、用户给出多个任务、非平凡多步操作、用户明确要求跟踪进度。 + - **何时不用**:单步简单任务、纯对话/信息查询、可在 3 步内完成的琐碎操作。 + - **状态管理规则**:同一时刻最多 1 个 `in_progress`;开始工作前必须先标为 `in_progress`;完成后立即标为 `completed`(不批量);不再需要的任务直接从列表移除(不保留 completed)。 + - **双形式要求**:每项必须同时提供 `content`(祈使句)和 `activeForm`(进行时),用于 prompt 注入和 UI spinner。 + - **完成标准**:只在真正完成时标 `completed`;测试失败、实现部分、未解决错误时保持 `in_progress`。 +- 参照 Claude Code `TodoWriteTool/prompt.ts` 的模式,但精简到 Astrcode 首版的 3 状态模型。 + +**理由:** + +- 没有详细 prompt guidance 的工具通常被模型忽略或误用。Claude Code 的 TodoWrite 之所以有效,很大程度上归功于 180+ 行的精确使用指导。 +- 双形式(content + activeForm)让 prompt 注入和 UI 各取所需,不需要系统猜测进行时态。 + +**替代方案:** + +- 只提供简短 description,依赖模型自行推断用法:拒绝。实测表明模型会过度使用(给单步任务建 list)或误用(批量标 completed)。 +- 不要求 activeForm:可行但会降低 UI spinner 和 prompt 注入的表达力。 + +## Risks / Trade-offs + +- **[Risk] 全量快照在 task 很多时会增加 token 和工具输入成本** + → Mitigation:首版通过 20 条上限约束 + tool guidance 鼓励精简列表;prompt 注入只保留 in_progress + pending 摘要。 + +- **[Risk] 使用 tool result metadata 而非专门事件类型,后续扩展可能遇到 schema 演进压力** + → Mitigation:从第一版开始定义稳定 `schema` 字段(`"executionTaskSnapshot"`)和版本化 payload;当需求升级到多工具协作时再考虑提升为一等事件。 + +- **[Risk] task 不进入 transcript 主体,会降低历史可审计性** + → Mitigation:taskWrite 工具调用本身保留为正常 ToolCallBlock,提供操作审计;durable tool result 完整保留 snapshot 数据。 + +- **[Risk] owner 维度在当前实现里不够直观** + → Mitigation:UI 首版只显示当前 session 当前 owner 的 active snapshot,把多 owner 展示留到后续迭代。 + +- **[Risk] 动态 prompt 注入如果无长度控制,可能和其他 prompt declaration 竞争预算** + → Mitigation:限制注入只含活跃项(in_progress + pending),已完成项不注入,空列表不生成声明。 + +- **[Trade-off] 首版不做 `taskRead` 工具** + → 接受。对模型来说,动态 prompt 注入比显式读取工具更稳定,避免多一个会被误用的工具。 + +### Decision 10:Application 层 task facts 读取路径 + +**选择:** + +- `session-runtime` 的 `SessionQueries` 新增 `active_task_snapshot(session_id, owner) -> Option<TaskSnapshot>` 查询方法,直接读取 `SessionState.active_tasks` 缓存。 +- `application` 层在 `terminal_control_facts()` 中调用该方法(遵循现有 `activePlan` 通过 `session_plan_control_summary()` 读取的模式),将结果映射为 `TerminalControlFacts.active_tasks` 字段。 +- `server` 层在 `to_conversation_control_state_dto()` 映射中将 `active_tasks` 转为 `ConversationControlStateDto.activeTasks`。 +- 整条链路为 `SessionState → SessionQueries → terminal_control_facts → ConversationControlStateDto`,与 `activePlan` 的三级读取模式完全对齐。 + +**理由:** + +- 与现有 `activePlan` 读取路径保持架构一致性。 +- `application` 不直接扫描 tool result 事件,而是通过 `SessionRuntime` 的稳定 query 接口读取。 + +**替代方案:** + +- 在 `SessionControlStateSnapshot` 中直接携带 task 数据:可行但会增加 session-runtime 与 application 的耦合面,不如走 query 方法灵活。 + +## Migration Plan + +1. 在 `crates/core` 引入 task 稳定类型(`ExecutionTaskItem`、`ExecutionTaskStatus`、`TaskSnapshot`)与 metadata schema 结构,但不改现有 durable 事件定义。 +2. 在 `crates/adapter-tools` 新增 `taskWrite` 及其输入校验(含 20 条上限)、prompt guidance(`ToolPromptMetadata`)、capability metadata(`SideEffect::Local`)和 tool result metadata 写出。 +3. 在 `crates/server/src/bootstrap/capabilities.rs` 注册 `taskWrite`。 +4. 在 `crates/session-runtime` 的 `SessionState` 新增 `active_tasks: StdMutex<HashMap<String, TaskSnapshot>>` 字段,在 `translate_store_and_cache()` 中拦截 `taskWrite` 的 `ToolResult` 事件并更新投影。 +5. 在 `crates/session-runtime/src/turn/request.rs` 的 `build_prompt_output()` 中新增 `live_task_snapshot_declaration(...)`,参照 `live_direct_child_snapshot_declaration` 模式,只注入 in_progress + pending 项。 +6. 在 `crates/session-runtime` 的 `SessionQueries` 新增 `active_task_snapshot()` 查询方法。 +7. 在 `crates/application` 的 `terminal_control_facts()` 中调用 `active_task_snapshot()`,将结果映射到 `TerminalControlFacts.active_tasks`。 +8. 在 `crates/protocol` 的 `ConversationControlStateDto` 新增 `activeTasks` 字段;在 `crates/server` 的 `to_conversation_control_state_dto()` 映射中填充该字段。 +9. 在前端 `ConversationControlState` 类型新增 `activeTasks`,新增 task 卡片组件(对话区顶部折叠卡片),接入 conversation control state 的 hydration 和 delta 更新。 + +**回滚策略:** + +- 如果实现质量不满足预期,可先从 capability surface 中移除 `taskWrite`,前端隐藏 task panel。 +- 已存在的 durable tool result metadata 继续留在事件日志中,但旧数据会被新读模型忽略,不影响现有 Plan / transcript 行为。 + +## Open Questions + +- 首版是否需要在 transcript 里保留一条极简 “task updated” system note 作为调试辅助?当前设计默认不需要,这不是 apply-ready 的阻塞项。 +- 若后续需要让用户直接编辑任务面板,是否复用 `taskWrite` 作为唯一写入口,还是引入 UI 专属 command?当前设计先保留 `taskWrite` 为唯一写入口。 diff --git a/openspec/changes/add-task-write-system/proposal.md b/openspec/changes/add-task-write-system/proposal.md new file mode 100644 index 00000000..46f0d697 --- /dev/null +++ b/openspec/changes/add-task-write-system/proposal.md @@ -0,0 +1,46 @@ +## Why + +Astrcode 现在已经有 `session_plan` 和 `plan mode`,但它们承担的是“正式计划工件”和“治理审批流程”的职责,不适合承载执行期的高频工作记忆。长任务里,模型只能从 transcript、tool 输出和历史思路里逆向推断“当前正在做什么”,容易出现偏题、重复劳动、遗漏未完成项和恢复后失忆。 + +现在补上独立的 task 系统,是因为 Plan 语义已经在 `application`、`session-runtime` 和前端读模型里稳定落地,边界足够清晰。此时再增加一个与 Plan 解耦、专门服务执行阶段的 task tool / projection / prompt 注入链路,可以直接提升长任务稳定性,而不会污染 canonical session plan 的治理语义。 + +## What Changes + +- 新增内置执行期工具 `taskWrite`,用于让模型以全量快照方式维护当前执行清单,而不是改写 `session_plan`。 +- 新增执行期 task 数据模型,首版采用 `content`、`status`、`activeForm` 的轻量结构,优先支持单个活跃任务和少量任务列表。 +- 新增基于 durable tool result metadata 的 task 投影与恢复机制,让当前 task 状态可跨 turn、重连和 session 恢复。 +- 在 `session-runtime` 的 turn/request 装配链路中动态注入当前 task 摘要,让同一 turn 后续步骤和下一轮执行都能读取最新执行清单。 +- 扩展终端 / 前端 conversation read model,暴露独立 task panel 所需的 authoritative facts,但不把 task 与 Plan Surface 混成同一种 UI 语义。 +- 限制 `taskWrite` 只在执行态可用;`plan` / `review` mode 继续只使用正式 plan 机制。 +- 为未来演进预留空间,但本次不引入 Claude Code V2 风格的 `taskCreate` / `taskUpdate` / `taskList` / owner-blocking 文件任务板。 + +## Capabilities + +### New Capabilities +- `execution-task-tracking`: 定义执行期 task tool、task snapshot 数据模型、作用域、持久化 / 恢复语义、prompt 注入语义,以及与 `session_plan` 的边界。 + +### Modified Capabilities +- `terminal-chat-read-model`: conversation snapshot / stream 需要暴露 active task panel 所需的 authoritative facts,前端不得通过本地重放 tool history 自行拼任务面板。 +- `application-use-cases`: `application` 需要通过 `SessionRuntime` 的稳定 query/command 入口暴露 task display facts,而不是直接扫描底层 tool 事件或让上层自行组装。 + +## Impact + +- `crates/core`:新增 task 相关稳定类型(`ExecutionTaskItem`、`ExecutionTaskStatus`、`TaskSnapshot`)与 metadata schema 结构,供 tool、runtime 和前端映射复用。 +- `crates/adapter-tools`:新增 `taskWrite` builtin tool(`SideEffect::Local`),并提供 prompt guidance(`ToolPromptMetadata`)、输入校验(含 20 条上限)和 metadata 写出(`schema: “executionTaskSnapshot”`)。 +- `crates/server`:在 bootstrap 能力装配中注册 `taskWrite`。 +- `crates/session-runtime`:在 `SessionState` 新增 `active_tasks` 缓存字段,在 `translate_store_and_cache()` 中拦截 `taskWrite` 的 `ToolResult` 事件更新投影;在 `SessionQueries` 新增 `active_task_snapshot()` 查询方法;在 `turn/request` 的 `build_prompt_output()` 中新增 `live_task_snapshot_declaration`。 +- `crates/application`:在 `terminal_control_facts()` 中通过 `SessionQueries::active_task_snapshot()` 读取 task facts,映射到 `TerminalControlFacts.active_tasks`。 +- `crates/protocol`:在 `ConversationControlStateDto` 新增 `activeTasks` 字段。 +- `frontend`:新增 task 卡片组件(对话区顶部折叠卡片),接入 conversation control state 的 hydration 和 `UpdateControlState` delta。 +- 用户可见影响:执行阶段会出现独立 task 卡片(对话区顶部),模型会更稳定地维持当前工作清单。 +- 开发者可见影响:需要遵守”task 是执行期状态,不是 session plan””durable truth 优先来自事件日志/工具结果投影”的边界。 +- 依赖影响:本次不新增核心第三方依赖。 + +## Non-Goals + +- 不把 `taskWrite` 与 `upsertSessionPlan` / `exitPlanMode` 复用同一状态文件或同一前端 surface。 +- 不在首版引入 Claude Code V2 风格的任务 ID、owner、blockedBy、blocks、共享任务板或跨 session 任务目录。 +- 不在首版新增专门的 `StorageEventPayload::TaskUpdated`;优先复用已有 tool result durable 事件与投影链路。 +- 不要求客户端通过 transcript block 回放出 task 真相;task 卡片以 authoritative read model 为准。 +- `taskWrite` 工具调用本身保留为正常 `ToolCallBlock`(不做抑制),与 plan 工具的抑制行为不同。 +- 不要求同步修改 `PROJECT_ARCHITECTURE.md`;本次方案与现有 `application` / `session-runtime` / `adapter-*` 分层保持一致。 diff --git a/openspec/changes/add-task-write-system/specs/application-use-cases/spec.md b/openspec/changes/add-task-write-system/specs/application-use-cases/spec.md new file mode 100644 index 00000000..c3d4ad6a --- /dev/null +++ b/openspec/changes/add-task-write-system/specs/application-use-cases/spec.md @@ -0,0 +1,23 @@ +## ADDED Requirements + +### Requirement: application SHALL expose task display facts through stable session-runtime contracts + +在 conversation snapshot、stream catch-up 或等价的 task display 场景中,`application` MUST 通过 `SessionRuntime` 的 `SessionQueries::active_task_snapshot()` 稳定 query 方法读取 authoritative task facts,并在 `terminal_control_facts()` 中将结果映射为 `TerminalControlFacts.active_tasks` 字段。`application` MUST NOT 直接扫描原始 `taskWrite` tool 事件、手写 replay 逻辑或把待上层再拼装的底层事实当成正式合同向上传递。 + +#### Scenario: server requests conversation facts with active tasks + +- **WHEN** `server` 请求某个 session 的 conversation snapshot 或 stream catch-up,且该 session 当前存在 active tasks +- **THEN** `application` SHALL 通过 `terminal_control_facts()` 返回已收敛的 task display facts +- **AND** `server` 只负责 DTO 映射(`to_conversation_control_state_dto()` 将 `active_tasks` 映射为 `ConversationControlStateDto.activeTasks`)、HTTP 状态码与 SSE framing + +#### Scenario: application does not reconstruct tasks from raw tool history + +- **WHEN** `application` 需要返回某个 session 的 active-task panel facts +- **THEN** 它 SHALL 统一通过 `SessionQueries::active_task_snapshot()` 读取结果 +- **AND** SHALL NOT 自行遍历原始 tool result 或重写底层 projection 规则 + +#### Scenario: no active tasks yields None + +- **WHEN** `application` 查询 task facts,但当前 session 无 active tasks(空列表或全部 completed) +- **THEN** `TerminalControlFacts.active_tasks` SHALL 为 `None` +- **AND** `ConversationControlStateDto.activeTasks` SHALL 为 `None` diff --git a/openspec/changes/add-task-write-system/specs/execution-task-tracking/spec.md b/openspec/changes/add-task-write-system/specs/execution-task-tracking/spec.md new file mode 100644 index 00000000..51fc38af --- /dev/null +++ b/openspec/changes/add-task-write-system/specs/execution-task-tracking/spec.md @@ -0,0 +1,112 @@ +## ADDED Requirements + +### Requirement: execution tasks SHALL remain independent from the canonical session plan + +系统 MUST 将执行期 task 与 canonical `session_plan` 视为两套不同真相。`taskWrite` 只用于维护当前执行清单,MUST NOT 读写 `sessions/<id>/plan/**`,也 MUST NOT 改变当前 `activePlan`、plan 审批状态或 Plan Surface 语义。 + +#### Scenario: taskWrite updates execution state without mutating session plan + +- **WHEN** 模型在 `code` mode 调用 `taskWrite` +- **THEN** 系统 SHALL 仅更新执行期 task snapshot +- **AND** SHALL NOT 修改 session plan markdown、plan state 或 plan review 状态 + +#### Scenario: plan workflow remains the only formal planning path + +- **WHEN** 模型需要产出、更新或呈递正式计划 +- **THEN** 系统 SHALL 继续要求使用 `upsertSessionPlan` / `exitPlanMode` +- **AND** SHALL NOT 接受 `taskWrite` 作为 formal plan 的替代写入口 + +### Requirement: taskWrite SHALL accept a full execution-task snapshot + +`taskWrite` MUST 接受当前 owner 的完整任务快照,而不是增量 patch。每个 task item MUST 至少包含 `content` 与 `status`,其中 `status` 只能是 `pending`、`in_progress` 或 `completed`。若某项为 `in_progress`,则该项 MUST 提供 `activeForm`。同一个 snapshot 中 MUST NOT 存在多个 `in_progress` 项。单次快照 MUST NOT 超过 20 条 item。 + +#### Scenario: valid snapshot is accepted + +- **WHEN** `taskWrite` 收到一个合法的 task 列表,且至多只有一个 `in_progress` 项 +- **THEN** 系统 SHALL 接受该调用 +- **AND** SHALL 将该列表视为当前 owner 的最新完整 task snapshot + +#### Scenario: invalid snapshot is rejected + +- **WHEN** `taskWrite` 输入包含未知状态、多个 `in_progress` 项,或某个 `in_progress` 项缺少 `activeForm` +- **THEN** 系统 SHALL 拒绝该调用并返回明确错误 +- **AND** SHALL NOT 落盘部分 task 状态 + +#### Scenario: oversized snapshot is rejected + +- **WHEN** `taskWrite` 输入包含超过 20 条 item +- **THEN** 系统 SHALL 拒绝该调用并返回明确错误 +- **AND** SHALL NOT 落盘部分 task 状态 + +### Requirement: task ownership SHALL be scoped to the current execution owner + +系统 MUST 为 task snapshot 绑定稳定 owner。owner MUST 优先取当前工具上下文中的 `agent_id`;若该字段缺失,则 MUST 回退到当前 `session_id`。不同 owner 的 task snapshot MUST 相互隔离。 + +#### Scenario: root execution falls back to session ownership + +- **WHEN** 一次 `taskWrite` 调用发生在没有 `agent_id` 的根执行上下文 +- **THEN** 系统 SHALL 使用当前 `session_id` 作为 owner +- **AND** 后续读取时 SHALL 返回该 session 的最新 task snapshot + +#### Scenario: child or agent-scoped task snapshots do not overwrite each other + +- **WHEN** 两个不同 owner 分别写入 task snapshot +- **THEN** 系统 SHALL 为它们分别保留最新 snapshot +- **AND** 一个 owner 的写入 SHALL NOT 覆盖另一个 owner 的 task 状态 + +### Requirement: active task state SHALL be recoverable from durable tool results + +执行期 task 真相 MUST 可通过 durable tool result 回放恢复。`taskWrite` 的最终结果 MUST 以结构化 metadata(`schema: “executionTaskSnapshot”`)形式持久化最新 snapshot。`SessionState` MUST 维护 `active_tasks: HashMap<String, TaskSnapshot>` 缓存(key = owner),在 `translate_store_and_cache()` 中拦截 `taskWrite` 的 `ToolResult` 事件并更新投影。session reload、replay 或重连后,系统 SHALL 通过完整事件回放恢复每个 owner 的最新 task 状态。空列表或”全部 completed”列表 MUST 使该 owner 的条目被移除。系统 MUST NOT 为 task 维护独立的持久化文件(task 真相完全来自 durable tool result)。 + +#### Scenario: latest task snapshot survives replay + +- **WHEN** 某个 session 已经成功记录过一个 `taskWrite` tool result +- **THEN** 会话重载或回放后 SHALL 恢复该 owner 的最新 task snapshot +- **AND** 前端 hydration 与后续 prompt 注入 SHALL 看到相同的 active tasks + +#### Scenario: completed or empty snapshot clears active tasks + +- **WHEN** `taskWrite` 写入空列表,或写入一个全部为 `completed` 的列表 +- **THEN** 系统 SHALL 将该 owner 视为没有 active tasks +- **AND** 后续 prompt 注入与 task panel SHALL 隐藏该 owner 的 task 状态 + +### Requirement: active tasks SHALL be injected into subsequent turn steps + +系统 MUST 在 turn request assembly 的 `build_prompt_output()` 中将当前 owner 的 active tasks 作为动态 prompt facts 注入,让同一 turn 的后续步骤也能读取最新执行清单。注入内容 MUST 只包含 `in_progress` 和 `pending` 状态的项,MUST NOT 回放 `completed` 项或历史快照。当前 owner 无 active tasks 时,MUST NOT 生成 task prompt 声明。注入 MUST 使用独立 block_id(如 `"task.active_snapshot"`),不影响其他 prompt declaration。 + +#### Scenario: a taskWrite call influences later steps in the same turn + +- **WHEN** 模型在某个 turn 中调用 `taskWrite`,随后同一 turn 还会继续请求模型 +- **THEN** 后续 step 的 prompt SHALL 包含该 owner 最新的 active task 摘要 +- **AND** 模型 SHALL 不需要等到下一轮 turn 才能看到刚写入的 task 状态 + +#### Scenario: cleared tasks are removed from later prompts + +- **WHEN** 当前 owner 的最新 task snapshot 为空或全部 completed +- **THEN** 后续 step 的 prompt SHALL 不再包含 active task 声明 +- **AND** 系统 SHALL 不继续向模型暗示已完成的执行清单 + +### Requirement: taskWrite SHALL only be available in execution-oriented modes + +`taskWrite` MUST 声明 `SideEffect::Local`,通过现有 mode selector 集合代数自动限制可见性。`plan` mode 和 `review` mode MUST NOT 向模型暴露该工具。Code mode(`AllTools`)MUST 包含 `taskWrite`。 + +#### Scenario: code mode exposes taskWrite + +- **WHEN** 当前 session 处于 builtin `code` mode 或等价执行态 mode +- **THEN** capability surface SHALL 包含 `taskWrite` +- **AND** 模型可通过该工具维护执行期 task snapshot + +#### Scenario: plan and review modes hide taskWrite + +- **WHEN** 当前 session 处于 `plan` 或 `review` mode +- **THEN** capability surface SHALL 不包含 `taskWrite`(因为 `SideEffect::Local` 被 plan mode 排除、review mode 只允许 `SideEffect::None`) +- **AND** 模型 MUST 继续分别使用 formal plan 工具或只读审查工具 + +### Requirement: taskWrite SHALL carry detailed prompt guidance + +`taskWrite` 的 capability metadata MUST 包含 `ToolPromptMetadata`,提供以下使用指导: +- 何时主动使用:3+ 步骤任务、用户提供多个任务、非平凡多步操作。 +- 何时不用:单步简单任务、纯对话查询、3 步内可完成的琐碎操作。 +- 状态管理规则:同一时刻最多 1 个 `in_progress`;开始工作前先标 `in_progress`;完成后立即标 `completed`。 +- 双形式要求:每项必须同时提供 `content`(祈使句)和 `activeForm`(进行时)。 +- 完成标准:只在真正完成时标 `completed`;测试失败或实现部分时保持 `in_progress`。 diff --git a/openspec/changes/add-task-write-system/specs/terminal-chat-read-model/spec.md b/openspec/changes/add-task-write-system/specs/terminal-chat-read-model/spec.md new file mode 100644 index 00000000..68831044 --- /dev/null +++ b/openspec/changes/add-task-write-system/specs/terminal-chat-read-model/spec.md @@ -0,0 +1,30 @@ +## ADDED Requirements + +### Requirement: conversation surface SHALL expose authoritative active-task panel facts + +`conversation` surface 的 hydration snapshot 与增量 stream MUST 直接暴露当前 session 的 active-task panel facts,通过 `ConversationControlStateDto.activeTasks: Option<Vec<TaskItemDto>>` 字段传递。该事实源 MUST 来自服务端 authoritative projection(`SessionState.active_tasks` → `terminal_control_facts()` → DTO 映射),客户端 MUST NOT 通过扫描 `taskWrite` tool history、metadata fallback 或本地 reducer 自行重建任务面板。 + +#### Scenario: hydration snapshot includes current active tasks + +- **WHEN** 终端或前端首次打开一个 session,并且该 session 当前存在 active tasks +- **THEN** 服务端 SHALL 在 conversation hydration 结果的 `activeTasks` 字段中返回当前 active-task panel facts +- **AND** 客户端 MUST 能在不回放历史 tool result 的前提下直接渲染任务卡片 + +#### Scenario: stream delta updates the task panel after taskWrite + +- **WHEN** 当前 session 成功写入新的 `taskWrite` snapshot +- **THEN** conversation 增量流 SHALL 通过 `UpdateControlState` delta 推送更新后的 `activeTasks` +- **AND** 客户端 MUST 能仅凭该 authoritative delta 更新任务卡片 + +#### Scenario: task panel hides after tasks are cleared + +- **WHEN** 当前 session 的最新 task snapshot 为空或全部 completed +- **THEN** `activeTasks` SHALL 为 `None` +- **AND** 客户端 SHALL 隐藏 task 卡片 + +#### Scenario: taskWrite tool calls appear as normal ToolCallBlocks + +- **WHEN** 模型在 transcript 中调用 `taskWrite` +- **THEN** 该调用 SHALL 作为正常 `ToolCallBlock` 出现在消息列表中 +- **AND** 系统 SHALL NOT 抑制该工具调用(与 plan 工具被 `should_suppress_tool_call_block()` 抑制的行为不同) +- **AND** `activeTasks` control state 与 transcript 中的 ToolCallBlock 是两个独立的 UI 表面 diff --git a/openspec/changes/add-task-write-system/tasks.md b/openspec/changes/add-task-write-system/tasks.md new file mode 100644 index 00000000..ce78c51c --- /dev/null +++ b/openspec/changes/add-task-write-system/tasks.md @@ -0,0 +1,24 @@ +## 1. 核心模型与工具契约 + +- [x] 1.1 在 `crates/core/src/` 新增 task 稳定类型与 metadata schema 结构(`ExecutionTaskItem`、`ExecutionTaskStatus`、`TaskSnapshot`、owner 标识),并在 `crates/core/src/lib.rs` 导出;验证:`cargo test -p astrcode-core --lib` +- [x] 1.2 在 `crates/adapter-tools/src/builtin_tools/task_write.rs` 实现 `taskWrite` 输入 schema、校验规则(含 20 条上限、单 in_progress 约束)、prompt guidance(`ToolPromptMetadata`)、capability metadata(`SideEffect::Local`)和 `ToolExecutionResult.metadata` 写出逻辑(`schema: "executionTaskSnapshot"`);验证:为合法/非法/超限 snapshot 增加工具单测并运行 `cargo test -p astrcode-adapter-tools --lib task_write` +- [x] 1.3 在 `crates/server/src/bootstrap/capabilities.rs` 注册 `taskWrite`,确认 `SideEffect::Local` 使其在 code mode 可见、plan/review mode 不可见;验证:补充 capability surface / mode 相关测试并运行对应 Rust 单测 + +## 2. Task 投影、恢复与 prompt 注入 + +- [x] 2.1 在 `crates/session-runtime/src/state/mod.rs` 的 `SessionState` 新增 `active_tasks: StdMutex<HashMap<String, TaskSnapshot>>` 字段,在 `translate_store_and_cache()` 中拦截 `tool_name == "taskWrite"` 的 `ToolResult` 事件,从 `metadata` 提取 snapshot 并更新对应 owner 的缓存条目;验证:补充 replay / clear / owner 隔离测试并运行 `cargo test -p astrcode-session-runtime --lib` +- [x] 2.2 在 `crates/session-runtime/src/query/service.rs` 的 `SessionQueries` 新增 `active_task_snapshot(session_id, owner)` 查询方法;验证:增加 query 方法单测 +- [x] 2.3 在 `crates/session-runtime/src/turn/request.rs` 的 `build_prompt_output()` 中新增 `live_task_snapshot_declaration(...)`,参照 `live_direct_child_snapshot_declaration` 模式,只注入 `in_progress` + `pending` 项;验证:新增 turn/request 测试,确认 `taskWrite` 后下一步 prompt 已包含活跃 task 摘要、空列表时不生成声明 + +## 3. 应用层读模型与前端展示 + +- [x] 3.1 在 `crates/application` 的 `terminal_control_facts()` 中调用 `SessionQueries::active_task_snapshot()`,将结果映射到 `TerminalControlFacts.active_tasks` 字段;验证:补充应用层用例测试 +- [x] 3.2 在 `crates/protocol/src/http/conversation/v1.rs` 的 `ConversationControlStateDto` 新增 `activeTasks: Option<Vec<TaskItemDto>>` 字段;在 `crates/server/src/http/terminal_projection.rs` 的 `to_conversation_control_state_dto()` 映射中填充该字段;验证:`cargo test -p astrcode-protocol` +- [x] 3.3 扩展 `frontend/src/types.ts` 的 `ConversationControlState` 新增 `activeTasks` 字段,更新 `frontend/src/lib/api/conversation.ts` 的 DTO 映射;验证:`cd frontend && npm run typecheck` +- [x] 3.4 新增前端 task 卡片组件(对话区顶部折叠卡片),展示当前 in_progress 任务标题 + pending/completed 计数,在 `activeTasks` 为 `None` 时自动隐藏,接入 conversation control state 的 hydration 和 `UpdateControlState` delta;验证:`cd frontend && npm run lint` + +## 4. 回归验证与收尾 + +- [x] 4.1 为 `taskWrite` 与 `session_plan` 的边界增加回归测试,确认调用 `taskWrite` 不会修改 canonical plan 文件、plan 状态或 plan surface;确认 `taskWrite` 调用在 transcript 中作为正常 ToolCallBlock 出现(不被抑制);验证:运行相关 Rust 单测 +- [x] 4.2 为 conversation hydration / delta 的 task panel 行为增加端到端读模型测试,确认客户端不需要扫描 transcript 即可恢复 task 状态;验证:运行 `cargo test -p astrcode-session-runtime --lib` 和前端测试 +- [x] 4.3 运行仓库级格式与边界检查,确保新增 task 系统未破坏分层约束;验证:`cargo fmt --all`、`cargo clippy --all-targets --all-features -- -D warnings`、`node scripts/check-crate-boundaries.mjs` diff --git a/openspec/changes/collaboration-mode-system/design.md b/openspec/changes/archive/2026-04-20-collaboration-mode-system/design.md similarity index 100% rename from openspec/changes/collaboration-mode-system/design.md rename to openspec/changes/archive/2026-04-20-collaboration-mode-system/design.md diff --git a/openspec/changes/collaboration-mode-system/proposal.md b/openspec/changes/archive/2026-04-20-collaboration-mode-system/proposal.md similarity index 100% rename from openspec/changes/collaboration-mode-system/proposal.md rename to openspec/changes/archive/2026-04-20-collaboration-mode-system/proposal.md diff --git a/openspec/changes/collaboration-mode-system/specs/agent-delegation-surface/spec.md b/openspec/changes/archive/2026-04-20-collaboration-mode-system/specs/agent-delegation-surface/spec.md similarity index 100% rename from openspec/changes/collaboration-mode-system/specs/agent-delegation-surface/spec.md rename to openspec/changes/archive/2026-04-20-collaboration-mode-system/specs/agent-delegation-surface/spec.md diff --git a/openspec/changes/collaboration-mode-system/specs/agent-tool-governance/spec.md b/openspec/changes/archive/2026-04-20-collaboration-mode-system/specs/agent-tool-governance/spec.md similarity index 100% rename from openspec/changes/collaboration-mode-system/specs/agent-tool-governance/spec.md rename to openspec/changes/archive/2026-04-20-collaboration-mode-system/specs/agent-tool-governance/spec.md diff --git a/openspec/changes/collaboration-mode-system/specs/governance-mode-system/spec.md b/openspec/changes/archive/2026-04-20-collaboration-mode-system/specs/governance-mode-system/spec.md similarity index 100% rename from openspec/changes/collaboration-mode-system/specs/governance-mode-system/spec.md rename to openspec/changes/archive/2026-04-20-collaboration-mode-system/specs/governance-mode-system/spec.md diff --git a/openspec/changes/collaboration-mode-system/specs/mode-capability-compilation/spec.md b/openspec/changes/archive/2026-04-20-collaboration-mode-system/specs/mode-capability-compilation/spec.md similarity index 100% rename from openspec/changes/collaboration-mode-system/specs/mode-capability-compilation/spec.md rename to openspec/changes/archive/2026-04-20-collaboration-mode-system/specs/mode-capability-compilation/spec.md diff --git a/openspec/changes/collaboration-mode-system/specs/mode-command-surface/spec.md b/openspec/changes/archive/2026-04-20-collaboration-mode-system/specs/mode-command-surface/spec.md similarity index 100% rename from openspec/changes/collaboration-mode-system/specs/mode-command-surface/spec.md rename to openspec/changes/archive/2026-04-20-collaboration-mode-system/specs/mode-command-surface/spec.md diff --git a/openspec/changes/collaboration-mode-system/specs/mode-execution-policy/spec.md b/openspec/changes/archive/2026-04-20-collaboration-mode-system/specs/mode-execution-policy/spec.md similarity index 100% rename from openspec/changes/collaboration-mode-system/specs/mode-execution-policy/spec.md rename to openspec/changes/archive/2026-04-20-collaboration-mode-system/specs/mode-execution-policy/spec.md diff --git a/openspec/changes/collaboration-mode-system/specs/mode-policy-engine/spec.md b/openspec/changes/archive/2026-04-20-collaboration-mode-system/specs/mode-policy-engine/spec.md similarity index 100% rename from openspec/changes/collaboration-mode-system/specs/mode-policy-engine/spec.md rename to openspec/changes/archive/2026-04-20-collaboration-mode-system/specs/mode-policy-engine/spec.md diff --git a/openspec/changes/collaboration-mode-system/specs/mode-prompt-program/spec.md b/openspec/changes/archive/2026-04-20-collaboration-mode-system/specs/mode-prompt-program/spec.md similarity index 100% rename from openspec/changes/collaboration-mode-system/specs/mode-prompt-program/spec.md rename to openspec/changes/archive/2026-04-20-collaboration-mode-system/specs/mode-prompt-program/spec.md diff --git a/openspec/changes/collaboration-mode-system/tasks.md b/openspec/changes/archive/2026-04-20-collaboration-mode-system/tasks.md similarity index 100% rename from openspec/changes/collaboration-mode-system/tasks.md rename to openspec/changes/archive/2026-04-20-collaboration-mode-system/tasks.md diff --git a/openspec/changes/governance-surface-cleanup/.openspec.yaml b/openspec/changes/archive/2026-04-20-governance-surface-cleanup/.openspec.yaml similarity index 100% rename from openspec/changes/governance-surface-cleanup/.openspec.yaml rename to openspec/changes/archive/2026-04-20-governance-surface-cleanup/.openspec.yaml diff --git a/openspec/changes/governance-surface-cleanup/design.md b/openspec/changes/archive/2026-04-20-governance-surface-cleanup/design.md similarity index 100% rename from openspec/changes/governance-surface-cleanup/design.md rename to openspec/changes/archive/2026-04-20-governance-surface-cleanup/design.md diff --git a/openspec/changes/governance-surface-cleanup/proposal.md b/openspec/changes/archive/2026-04-20-governance-surface-cleanup/proposal.md similarity index 100% rename from openspec/changes/governance-surface-cleanup/proposal.md rename to openspec/changes/archive/2026-04-20-governance-surface-cleanup/proposal.md diff --git a/openspec/changes/governance-surface-cleanup/specs/agent-delegation-surface/spec.md b/openspec/changes/archive/2026-04-20-governance-surface-cleanup/specs/agent-delegation-surface/spec.md similarity index 100% rename from openspec/changes/governance-surface-cleanup/specs/agent-delegation-surface/spec.md rename to openspec/changes/archive/2026-04-20-governance-surface-cleanup/specs/agent-delegation-surface/spec.md diff --git a/openspec/changes/governance-surface-cleanup/specs/agent-tool-governance/spec.md b/openspec/changes/archive/2026-04-20-governance-surface-cleanup/specs/agent-tool-governance/spec.md similarity index 100% rename from openspec/changes/governance-surface-cleanup/specs/agent-tool-governance/spec.md rename to openspec/changes/archive/2026-04-20-governance-surface-cleanup/specs/agent-tool-governance/spec.md diff --git a/openspec/changes/governance-surface-cleanup/specs/capability-router-assembly/spec.md b/openspec/changes/archive/2026-04-20-governance-surface-cleanup/specs/capability-router-assembly/spec.md similarity index 100% rename from openspec/changes/governance-surface-cleanup/specs/capability-router-assembly/spec.md rename to openspec/changes/archive/2026-04-20-governance-surface-cleanup/specs/capability-router-assembly/spec.md diff --git a/openspec/changes/governance-surface-cleanup/specs/delegation-policy-surface/spec.md b/openspec/changes/archive/2026-04-20-governance-surface-cleanup/specs/delegation-policy-surface/spec.md similarity index 100% rename from openspec/changes/governance-surface-cleanup/specs/delegation-policy-surface/spec.md rename to openspec/changes/archive/2026-04-20-governance-surface-cleanup/specs/delegation-policy-surface/spec.md diff --git a/openspec/changes/governance-surface-cleanup/specs/execution-limits-control/spec.md b/openspec/changes/archive/2026-04-20-governance-surface-cleanup/specs/execution-limits-control/spec.md similarity index 100% rename from openspec/changes/governance-surface-cleanup/specs/execution-limits-control/spec.md rename to openspec/changes/archive/2026-04-20-governance-surface-cleanup/specs/execution-limits-control/spec.md diff --git a/openspec/changes/governance-surface-cleanup/specs/governance-surface-assembly/spec.md b/openspec/changes/archive/2026-04-20-governance-surface-cleanup/specs/governance-surface-assembly/spec.md similarity index 100% rename from openspec/changes/governance-surface-cleanup/specs/governance-surface-assembly/spec.md rename to openspec/changes/archive/2026-04-20-governance-surface-cleanup/specs/governance-surface-assembly/spec.md diff --git a/openspec/changes/governance-surface-cleanup/specs/policy-engine-integration/spec.md b/openspec/changes/archive/2026-04-20-governance-surface-cleanup/specs/policy-engine-integration/spec.md similarity index 100% rename from openspec/changes/governance-surface-cleanup/specs/policy-engine-integration/spec.md rename to openspec/changes/archive/2026-04-20-governance-surface-cleanup/specs/policy-engine-integration/spec.md diff --git a/openspec/changes/governance-surface-cleanup/specs/prompt-facts-governance-linkage/spec.md b/openspec/changes/archive/2026-04-20-governance-surface-cleanup/specs/prompt-facts-governance-linkage/spec.md similarity index 100% rename from openspec/changes/governance-surface-cleanup/specs/prompt-facts-governance-linkage/spec.md rename to openspec/changes/archive/2026-04-20-governance-surface-cleanup/specs/prompt-facts-governance-linkage/spec.md diff --git a/openspec/changes/governance-surface-cleanup/tasks.md b/openspec/changes/archive/2026-04-20-governance-surface-cleanup/tasks.md similarity index 100% rename from openspec/changes/governance-surface-cleanup/tasks.md rename to openspec/changes/archive/2026-04-20-governance-surface-cleanup/tasks.md diff --git a/openspec/specs/agent-delegation-surface/spec.md b/openspec/specs/agent-delegation-surface/spec.md index 38fa4c99..c61ebfcc 100644 --- a/openspec/specs/agent-delegation-surface/spec.md +++ b/openspec/specs/agent-delegation-surface/spec.md @@ -26,6 +26,138 @@ - **THEN** catalog MUST NOT 把某个 behavior template 表达成一组静态工具权限 - **AND** MUST 保持“profile 是行为模板,capability truth 在 launch 时求解”的边界 +### Requirement: delegation surface SHALL reflect the resolved governance envelope + +模型可见的 child delegation catalog 与 child-scoped execution contract MUST 受当前 turn 的 resolved governance envelope 约束,而不是只根据静态 profile 列表或全局默认行为生成。 + +#### Scenario: delegation catalog is omitted when current mode forbids child delegation + +- **WHEN** 当前 turn 的 governance envelope 禁止创建新的 child 分支 +- **THEN** 系统 SHALL 不渲染可供选择的 child delegation catalog +- **AND** SHALL NOT 让模型先看到不可用条目再依赖 runtime 事后拒绝 + +#### Scenario: governance envelope narrows visible child templates + +- **WHEN** 当前 mode 只允许一部分 behavior template 用于 child delegation +- **THEN** delegation catalog SHALL 仅展示这些允许的 template +- **AND** SHALL 继续保持"profile 是行为模板,而非权限目录"的表达边界 + +### Requirement: child execution contract SHALL include governance-derived branch constraints + +child execution contract MUST 体现启动该 child 时生效的 governance child policy,包括 child 初始 mode、capability-aware 约束与是否允许继续委派。 + +#### Scenario: fresh child contract includes initial mode summary + +- **WHEN** 系统首次启动一个新的 child session +- **THEN** child execution contract SHALL 明确该 child 当前使用的治理模式或等价治理摘要 +- **AND** SHALL 说明该分支的责任边界与允许动作 + +#### Scenario: restricted child contract includes delegation boundary + +- **WHEN** child 由当前 governance mode 以受限 delegation policy 启动 +- **THEN** child execution contract SHALL 明确该 child 不应承担超出当前治理边界的工作 +- **AND** SHALL 在需要更宽能力面或更宽 delegation 权限时要求回退到父级重新决策 + +### Requirement: DelegationMetadata SHALL reflect mode-compiled child policy + +`DelegationMetadata`(responsibility_summary、reuse_scope_summary、restricted、capability_limit_summary)MUST 由 mode 编译的 child policy 驱动生成,而不是由局部 helper 独立构建。 + +#### Scenario: restricted flag comes from mode child policy + +- **WHEN** 当前 mode 的 child policy 指定 child 为 restricted delegation +- **THEN** `DelegationMetadata.restricted` SHALL 为 true +- **AND** responsibility_summary 和 capability_limit_summary SHALL 反映 child policy 的约束 + +#### Scenario: reuse scope aligns with mode delegation constraints + +- **WHEN** mode 限制 child reuse 的条件 +- **THEN** `DelegationMetadata.reuse_scope_summary` SHALL 体现 mode 定义的复用边界 +- **AND** SHALL NOT 使用与 mode 无关的默认复用策略 + +### Requirement: SpawnCapabilityGrant SHALL be derived from mode capability selector and child policy + +child 的 `SpawnCapabilityGrant.allowed_tools` MUST 由 mode 的 capability selector 与 child policy 联合计算,而不是从 spawn 参数直接构造。 + +#### Scenario: grant is intersection of mode selector and spawn parameters + +- **WHEN** mode 的 child policy 指定了 capability selector,同时 spawn 调用传入了 allowed_tools +- **THEN** 最终 `SpawnCapabilityGrant.allowed_tools` SHALL 为两者交集 +- **AND** 空交集 SHALL 导致 spawn 被拒绝并返回明确错误 + +#### Scenario: mode with no child policy uses spawn parameters directly + +- **WHEN** mode 未指定 child policy 的 capability selector +- **THEN** `SpawnCapabilityGrant` SHALL 使用 spawn 调用传入的 allowed_tools +- **AND** 行为与当前默认等价 + +### Requirement: delegation catalog SHALL be filtered by mode child policy + +`AgentProfileSummaryContributor` 渲染的 child profile 列表 MUST 受 mode child policy 约束。mode 可以限制可用于 delegation 的 profile 范围。 + +#### Scenario: mode limits available profiles + +- **WHEN** mode 的 child policy 仅允许部分 profile 用于 delegation +- **THEN** delegation catalog SHALL 仅展示这些允许的 profile +- **AND** 不可用 profile SHALL 不出现在列表中 + +#### Scenario: mode forbids delegation entirely + +- **WHEN** mode 的 child policy 禁止所有 delegation +- **THEN** spawn 工具 SHALL 不在可见能力面中 +- **AND** `AgentProfileSummaryContributor` SHALL 因 spawn 不可用而不渲染(通过现有守卫条件自动生效) + +### Requirement: child execution contracts SHALL be emitted from the shared governance assembly path + +fresh child 与 resumed child 的 execution contract MUST 由统一治理装配路径生成,而不是由不同调用路径分别手工拼接。 + +#### Scenario: fresh child contract uses the shared assembly path + +- **WHEN** 系统首次启动一个承担新责任分支的 child +- **THEN** child execution contract SHALL 通过共享治理装配器生成 +- **AND** SHALL 与同一次提交中的其他治理声明保持同一事实源 + +#### Scenario: resumed child contract uses the same authoritative source + +- **WHEN** 父级复用已有 child 并发送 delta instruction +- **THEN** resumed child contract SHALL 由同一治理装配路径生成 +- **AND** SHALL NOT 退回到独立 helper 拼接的平行实现 + +### Requirement: delegation catalog and child contracts SHALL stay consistent under the same governance surface + +delegation catalog 可见的 behavior template、child execution contract 中的责任边界与 capability-aware 限制 MUST 来源于同一治理包络。 + +#### Scenario: catalog and contract agree on branch constraints + +- **WHEN** 某个 child template 在当前提交中可见且被用于启动 child +- **THEN** delegation catalog 与最终 child execution contract SHALL 体现一致的责任边界和限制摘要 +- **AND** SHALL NOT 让 catalog 与 contract 分别读取不同来源的治理事实 + +### Requirement: collaboration facts SHALL be recordable with governance envelope context + +`AgentCollaborationFact`(core/agent/mod.rs:1129-1155)记录 spawn/send/observe/close/delivery 等协作动作的审计事件。这些事实 MUST 能关联到生成该动作时的治理包络上下文,使审计链路可追溯。 + +#### Scenario: collaboration fact includes governance context + +- **WHEN** 系统记录一个 `AgentCollaborationFact`(如 spawn 或 send) +- **THEN** 该事实 SHALL 能关联到当前 turn 的治理包络标识或摘要 +- **AND** SHALL NOT 丢失治理上下文导致无法追溯决策依据 + +#### Scenario: policy revision aligns with governance envelope + +- **WHEN** `AGENT_COLLABORATION_POLICY_REVISION` 用于标记协作策略版本 +- **THEN** 该版本标识 SHALL 与治理包络中的策略版本一致 +- **AND** SHALL NOT 出现审计事实的策略版本与实际治理策略不同步 + +### Requirement: CollaborationFactRecord SHALL derive its parameters from the governance envelope + +`CollaborationFactRecord`(agent/mod.rs:96-166)跟踪每个协作动作的结果、原因码和延迟。其构建参数 MUST 来自治理包络,而不是各调用点独立组装。 + +#### Scenario: fact record uses governance-resolved child identity and limits + +- **WHEN** 系统为一个 spawn 或 send 动作构建 `CollaborationFactRecord` +- **THEN** child identity、capability limits 等字段 SHALL 从治理包络中获取 +- **AND** SHALL NOT 从不同参数源独立读取导致与治理包络不一致 + ### Requirement: child execution contract SHALL be rendered through a child-scoped prompt surface 系统 MUST 为 child agent 渲染独立的 execution contract prompt surface,用来明确责任边界、交付方式与限制条件,而不是要求调用方仅靠自然语言 prompt 自行约定这些信息。 diff --git a/openspec/specs/agent-tool-governance/spec.md b/openspec/specs/agent-tool-governance/spec.md index e3bcbfbd..34c4c370 100644 --- a/openspec/specs/agent-tool-governance/spec.md +++ b/openspec/specs/agent-tool-governance/spec.md @@ -146,6 +146,86 @@ - **THEN** 每个 description MUST 聚焦于该工具的一步动作与边界 - **AND** MUST NOT 重新解释整个 child delegation 心智模型 +### Requirement: collaboration guidance SHALL be generated from the current governance mode + +当当前 session 可使用协作工具时,系统渲染给模型的协作 guidance MUST 来自当前 governance mode 编译得到的 action policy 与 prompt program,而不是固定的全局静态文本。 + +#### Scenario: execute mode renders the default collaboration protocol + +- **WHEN** 当前 session 处于 builtin `execute` mode +- **THEN** 系统 SHALL 继续渲染默认的四工具协作协议 +- **AND** 其行为语义 SHALL 与当前默认 guidance 保持等价 + +#### Scenario: restricted mode hides forbidden collaboration actions + +- **WHEN** 当前 governance mode 禁止某类协作动作,例如新的 child delegation +- **THEN** 系统 SHALL 不向模型渲染鼓励该动作的 guidance +- **AND** SHALL 只保留当前 mode 允许的协作决策协议 + +### Requirement: collaboration guidance SHALL reflect mode-specific delegation constraints + +协作 guidance MUST 体现当前 governance mode 对委派行为的额外约束,例如 child policy、reuse-first 限制与 capability mismatch 处置规则。 + +#### Scenario: mode narrows child reuse conditions + +- **WHEN** 当前 mode 对 child reuse 设置了更严格的责任边界或能力前提 +- **THEN** guidance SHALL 明确这些更严格的继续复用条件 +- **AND** SHALL NOT 继续沿用更宽松的默认文案 + +#### Scenario: mode disables recursive delegation + +- **WHEN** 当前 mode 的 child policy 禁止 child 再向下继续委派 +- **THEN** guidance SHALL 明确当前分支的 delegation boundary +- **AND** SHALL NOT 鼓励模型继续 fan-out 新的 child 层级 + +### Requirement: CapabilityPromptContributor SHALL automatically reflect mode capability surface + +`CapabilityPromptContributor` 通过 `PromptContext.tool_names` 和 `capability_specs` 渲染工具摘要和详细指南。mode 对工具面的约束 SHALL 自动反映在 contributor 的输出中,无需 contributor 自身感知 mode。 + +#### Scenario: mode removes collaboration tools from tool summary + +- **WHEN** mode 编译的 capability router 移除了 spawn/send/close/observe 工具 +- **THEN** `build_tool_summary_block` 的 "Agent Collaboration Tools" 分组 SHALL 为空 +- **AND** 详细指南 SHALL 不包含被移除工具的条目 + +#### Scenario: mode restricts external tools + +- **WHEN** mode 的 capability selector 排除了 source:mcp 或 source:plugin 工具 +- **THEN** "External MCP / Plugin Tools" 分组 SHALL 仅包含未被排除的工具 +- **AND** SHALL NOT 显示已被 mode 限制的工具 + +### Requirement: workflow_examples contributor SHALL delegate governance content to mode prompt program + +`WorkflowExamplesContributor` 中与治理强相关的内容(协作协议、delegation modes、spawn 限制等)MUST 由 mode prompt program 生成的 PromptDeclarations 替代。contributor SHALL 仅保留非治理的 few-shot 教学内容。 + +#### Scenario: execute mode guidance is served from mode prompt program + +- **WHEN** 当前 mode 为 `code` +- **THEN** 协作协议 guidance SHALL 来自 mode 编译的 PromptDeclarations +- **AND** `WorkflowExamplesContributor` 的 `child-collaboration-guidance` block SHALL 不再包含治理真相 + +#### Scenario: plan mode provides different collaboration guidance + +- **WHEN** 当前 mode 为 `plan` 且允许有限 delegation +- **THEN** 协作 guidance SHALL 来自 plan mode 的 prompt program +- **AND** SHALL 包含 plan-specific 的委派策略说明 + +### Requirement: authoritative collaboration guidance SHALL be assembled outside adapter-owned static prompt code + +协作 guidance 的 authoritative 来源 MUST 来自统一治理装配路径,而不是继续散落在 adapter 层的静态 builtin prompt 代码中。 + +#### Scenario: adapter renders but does not own collaboration truth + +- **WHEN** 模型 prompt 中出现协作 guidance +- **THEN** `adapter-prompt` SHALL 只负责渲染该 guidance 对应的 `PromptDeclaration` +- **AND** SHALL NOT 继续把协作治理真相直接硬编码在 contributor 内作为唯一事实源 + +#### Scenario: multiple entrypoints receive consistent collaboration guidance + +- **WHEN** root execution、普通 session submit 与 child execution 都需要协作 guidance +- **THEN** 它们 SHALL 从同一治理装配路径获得一致的协作声明 +- **AND** SHALL NOT 因入口不同而依赖不同的硬编码文本来源 + ### Requirement: spawn guidance SHALL distinguish fresh, resumed, and restricted delegation modes 协作 guidance MUST 正式区分 fresh child、resumed child 与 restricted child 三种 delegation mode,并为每种 mode 提供不同的 briefing 规则。 diff --git a/openspec/specs/capability-router-assembly/spec.md b/openspec/specs/capability-router-assembly/spec.md new file mode 100644 index 00000000..c8f6e2e7 --- /dev/null +++ b/openspec/specs/capability-router-assembly/spec.md @@ -0,0 +1,43 @@ +## Purpose + +定义 capability router 的统一装配路径,确保 root execution、subagent launch 与 resumed child 三条路径通过同一治理装配器构建能力面。 + +## Requirements + +### Requirement: capability router assembly SHALL follow a unified path across all turn entrypoints + +root execution、subagent launch 与 resumed child 三条路径构建 capability router 的逻辑 MUST 统一经过治理装配器,而不是各自独立从不同来源计算能力面。 + +#### Scenario: root execution resolves capability surface through the governance assembler + +- **WHEN** 系统发起一次 root agent execution +- **THEN** root 路径 SHALL 通过统一治理装配器解析当前 turn 的 capability router +- **AND** SHALL NOT 直接在 `execution/root.rs` 中从 `kernel.gateway().capabilities().tool_names()` 独立计算工具面 + +#### Scenario: subagent launch resolves child-scoped router through the same assembler + +- **WHEN** 系统启动一个 fresh child session +- **THEN** subagent 路径 SHALL 通过统一治理装配器生成 child-scoped capability router +- **AND** SHALL NOT 独立在 `execution/subagent.rs:141-172` 中做 `parent_allowed_tools ∩ SpawnCapabilityGrant.allowed_tools` 交集计算 + +#### Scenario: resumed child resolves scoped router through the shared path + +- **WHEN** 父级通过 `send` 恢复一个 idle child +- **THEN** resume 路径 SHALL 通过统一治理装配器生成与 fresh child 一致的 scoped router +- **AND** SHALL NOT 在 `agent/routing.rs:571-722` 中独立构建 capability 子集 + +### Requirement: capability subset computation SHALL be parameterized by governance envelope, not hardcoded per call site + +能力子集的计算参数(parent_allowed_tools、SpawnCapabilityGrant、可见能力面)MUST 从治理包络中统一解析,而不是作为独立参数散落在各调用点。 + +#### Scenario: child capability grant comes from the governance envelope + +- **WHEN** 治理装配器为一个 child turn 生成 capability router +- **THEN** child 的 `SpawnCapabilityGrant` 与 parent 的 allowed_tools SHALL 从治理包络中统一读取 +- **AND** SHALL NOT 分别由 `subagent.rs` 和 `routing.rs` 各自从不同参数源构造 + +#### Scenario: kernel gateway capabilities feed into the governance assembler + +- **WHEN** 治理装配器需要当前全局能力面作为输入 +- **THEN** 它 SHALL 从 `kernel.gateway().capabilities()` 获取权威来源 +- **AND** root/child/resume 路径 SHALL NOT 各自直接调用 kernel gateway 获取能力列表 diff --git a/openspec/specs/delegation-policy-surface/spec.md b/openspec/specs/delegation-policy-surface/spec.md new file mode 100644 index 00000000..ff7aeaab --- /dev/null +++ b/openspec/specs/delegation-policy-surface/spec.md @@ -0,0 +1,61 @@ +## Purpose + +定义 delegation 策略相关参数(DelegationMetadata、SpawnCapabilityGrant、AgentCollaborationPolicyContext、spawn budget)如何从治理包络统一获取,消除分散读取。 + +## Requirements + +### Requirement: delegation metadata SHALL be generated from the unified governance assembly path + +`DelegationMetadata`(responsibility_summary、reuse_scope_summary、restricted、capability_limit_summary)MUST 由统一治理装配器生成,而不是在 `agent/mod.rs:287-312` 的 `build_delegation_metadata` helper 中独立拼装。 + +#### Scenario: delegation metadata comes from the governance assembler + +- **WHEN** 系统启动或恢复一个 child session +- **THEN** `DelegationMetadata` SHALL 由治理装配器根据治理包络中的 child policy 统一生成 +- **AND** SHALL NOT 由 `build_delegation_metadata` helper 从局部参数独立构建 + +#### Scenario: delegation metadata is consistent across fresh and resumed child + +- **WHEN** fresh child 和 resumed child 各自生成 delegation metadata +- **THEN** 两者的 metadata 字段含义和来源 SHALL 一致 +- **AND** SHALL NOT 因 fresh/resumed 路径不同而使用不同的 metadata 生成逻辑 + +### Requirement: SpawnCapabilityGrant SHALL be resolved from the governance envelope, not passed as ad-hoc spawn parameters + +`SpawnCapabilityGrant` 当前作为 `SpawnAgentParams` 的字段由调用方直接构造。它 MUST 从治理包络中解析,使 child 的能力授权受统一治理决策约束。 + +#### Scenario: capability grant comes from governance-resolved child policy + +- **WHEN** 系统确定一个 child 允许使用的工具集合 +- **THEN** `SpawnCapabilityGrant.allowed_tools` SHALL 由治理装配器根据 child policy 与 parent capability surface 计算得出 +- **AND** SHALL NOT 由 spawn 调用方从模型参数中直接构造 + +### Requirement: AgentCollaborationPolicyContext SHALL be built from the governance envelope + +`AgentCollaborationPolicyContext`(policy_revision + max_subrun_depth + max_spawn_per_turn)MUST 从治理包络中获取参数,而不是在 `agent/mod.rs:741-749` 中独立从 runtime config 读取。 + +#### Scenario: policy context uses governance-resolved parameters + +- **WHEN** 系统构建 `AgentCollaborationPolicyContext` 用于协作事实记录 +- **THEN** `max_subrun_depth` 和 `max_spawn_per_turn` SHALL 来自治理包络 +- **AND** SHALL NOT 从 `ResolvedAgentConfig` 独立读取 + +### Requirement: spawn budget enforcement SHALL consume governance-resolved limits + +`enforce_spawn_budget_for_turn`(agent/mod.rs:992-1012)当前直接从 runtime config 读取 `max_spawn_per_turn`。它 MUST 使用治理包络中已解析的限制参数。 + +#### Scenario: spawn budget check uses envelope parameters + +- **WHEN** 系统 spawn 一个新 child 前检查 turn 内 spawn 预算 +- **THEN** 预算上限 SHALL 来自治理包络 +- **AND** SHALL NOT 从 runtime config 独立读取 `max_spawn_per_turn` + +### Requirement: delegation metadata persistence SHALL stay consistent with governance envelope + +`persist_delegation_for_handle`(agent/mod.rs:394-436)将 delegation metadata 持久化到 kernel 控制面。持久化的数据 MUST 与治理包络中的 delegation 信息保持一致。 + +#### Scenario: persisted delegation matches envelope + +- **WHEN** 系统持久化 child 的 delegation metadata +- **THEN** 持久化的数据 SHALL 与治理包络中生成的 delegation 信息一致 +- **AND** SHALL NOT 出现持久化数据与治理包络不同步的情况 diff --git a/openspec/specs/execution-limits-control/spec.md b/openspec/specs/execution-limits-control/spec.md new file mode 100644 index 00000000..af03ae7d --- /dev/null +++ b/openspec/specs/execution-limits-control/spec.md @@ -0,0 +1,63 @@ +## Purpose + +定义执行限制与控制参数(ResolvedExecutionLimitsSnapshot、ExecutionControl、ForkMode、SubmitBusyPolicy)如何通过治理装配路径统一解析,消除分散计算。 + +## Requirements + +### Requirement: execution limits SHALL be resolved as part of the unified governance envelope + +`ResolvedExecutionLimitsSnapshot`、`ExecutionControl`、`ForkMode` 与 `SubmitBusyPolicy` 等执行限制与控制输入 MUST 在治理装配阶段统一解析为治理包络的一部分,而不是在提交路径中各自独立计算。 + +#### Scenario: root execution limits come from the governance assembler + +- **WHEN** 系统发起一次 root agent execution +- **THEN** `ResolvedExecutionLimitsSnapshot`(allowed_tools + max_steps)SHALL 由治理装配器生成 +- **AND** SHALL NOT 在 `execution/root.rs:71-85` 中独立从 kernel gateway 和 `ExecutionControl.max_steps` 计算 + +#### Scenario: child execution limits come from the governance assembler + +- **WHEN** 系统启动或恢复一个 child session +- **THEN** child 的 `ResolvedExecutionLimitsSnapshot` SHALL 由治理装配器根据 child policy 与 parent limits 统一计算 +- **AND** SHALL NOT 在 `execution/subagent.rs:141-172` 中独立做 allowed_tools 交集运算 + +#### Scenario: ExecutionControl feeds into the governance assembler, not directly into submission + +- **WHEN** 用户通过 `submit_prompt_with_control` 提交一个带 `ExecutionControl` 的请求 +- **THEN** `ExecutionControl` 的 max_steps 与 manual_compact SHALL 作为治理装配器的输入参数 +- **AND** SHALL NOT 直接在 `session_use_cases.rs:125-134` 中覆写 runtime config + +### Requirement: AgentConfig governance parameters SHALL flow through the governance assembly path + +`max_subrun_depth`、`max_spawn_per_turn`、`max_concurrent_agents` 等 `AgentConfig` 治理参数 MUST 通过治理装配路径统一传递到消费方,而不是通过 runtime config 在各消费点分散读取。 + +#### Scenario: spawn budget enforcement uses governance-resolved parameters + +- **WHEN** `enforce_spawn_budget_for_turn` 检查当前 turn 的 spawn 预算 +- **THEN** 它 SHALL 使用治理包络中已解析的 spawn 限制参数 +- **AND** SHALL NOT 直接从 `ResolvedAgentConfig` 中分散读取 `max_spawn_per_turn` + +#### Scenario: collaboration policy context is built from the governance envelope + +- **WHEN** `AgentCollaborationPolicyContext` 需要构建用于协作事实记录 +- **THEN** `max_subrun_depth` 和 `max_spawn_per_turn` SHALL 来自治理包络 +- **AND** SHALL NOT 在 `agent/mod.rs:741-749` 中独立从 runtime config 读取 + +### Requirement: ForkMode and context inheritance SHALL be governed by the unified assembly path + +`ForkMode`(FullHistory/LastNTurns)决定的上下文继承策略 MUST 作为治理包络的一部分,而不是在 `subagent.rs:247-297` 中作为独立逻辑处理。 + +#### Scenario: child context inheritance strategy comes from the governance envelope + +- **WHEN** 系统为 child 选择继承的父级上下文范围 +- **THEN** ForkMode 的选择与 recent tail 裁剪逻辑 SHALL 由治理装配器驱动 +- **AND** SHALL NOT 在 `select_inherited_recent_tail` 中独立实现 + +### Requirement: SubmitBusyPolicy SHALL be derivable from the governance envelope + +`SubmitBusyPolicy`(BranchOnBusy/RejectOnBusy)当前硬编码在 session-runtime,但其语义是 turn 级并发治理策略,MUST 可以被治理包络覆盖。 + +#### Scenario: default busy policy is derived from governance configuration + +- **WHEN** 系统 submit 一个 prompt 且已有 turn 在执行 +- **THEN** busy policy SHALL 可从治理包络中读取,而不是固定为 `BranchOnBusy` +- **AND** 不同入口类型(root vs subagent vs resumed)SHALL 可以有不同的默认 busy policy diff --git a/openspec/specs/governance-mode-system/spec.md b/openspec/specs/governance-mode-system/spec.md new file mode 100644 index 00000000..93114ce2 --- /dev/null +++ b/openspec/specs/governance-mode-system/spec.md @@ -0,0 +1,212 @@ +## Purpose + +定义 governance mode 的核心系统架构,包括 mode catalog 管理、envelope 编译、事件持久化、mode transition 校验、child policy 推导与观测性。 + +## Requirements + +### Requirement: governance mode catalog SHALL support builtin and plugin-defined modes through stable IDs + +系统 SHALL 通过开放式 mode catalog 管理执行治理模式,而不是依赖封闭枚举。每个 mode MUST 由稳定 `mode id` 标识,并可由 builtin 或插件注册。 + +#### Scenario: builtin execute mode is available by default + +- **WHEN** 系统创建一个新 session,且没有显式 mode 事件 +- **THEN** 系统 SHALL 解析到 builtin `code` mode +- **AND** 该默认 mode 的治理行为 SHALL 与当前默认执行行为保持等价 + +#### Scenario: plugin-defined mode joins the same catalog + +- **WHEN** 一个插件在 bootstrap 或 reload 成功注册自定义 mode +- **THEN** 该 mode SHALL 出现在统一的 mode catalog 中 +- **AND** 系统 SHALL 继续用与 builtin mode 相同的解析与编译流程消费它 + +### Requirement: governance mode SHALL compile to a turn-scoped execution envelope + +系统 SHALL 在 turn 边界把当前 mode 编译为 `ResolvedTurnEnvelope`。该 envelope MUST 至少包含当前 turn 的 capability surface、prompt declarations、execution limits、action policies 与 child policy。 + +#### Scenario: plan mode compiles a restricted capability surface + +- **WHEN** 当前 session 的 mode 为一个只读规划型 mode +- **THEN** 系统 SHALL 为该 turn 编译出收缩后的 capability router +- **AND** 当前 turn 模型可见的工具集合 SHALL 与该 router 保持一致 + +#### Scenario: execute mode compiles the full default envelope + +- **WHEN** 当前 session 的 mode 为 builtin `code` +- **THEN** 系统 SHALL 编译出与当前默认执行行为等价的 envelope +- **AND** SHALL NOT 因引入 mode 而额外改变 turn loop 语义 + +### Requirement: mode capability selection SHALL be resolved against the current capability semantic model + +mode 的能力选择 MUST 建立在当前 `CapabilitySpec` / capability router 之上,而不是维护平行工具注册表。mode selector SHALL 至少支持基于名称、kind、side effect 或 tag 的投影。 + +#### Scenario: selector filters against current capability surface + +- **WHEN** 某个 mode 使用 tag 或 side-effect selector 选择能力 +- **THEN** 系统 SHALL 基于当前 capability surface 中的 `CapabilitySpec` 解析可见能力 +- **AND** SHALL NOT 通过独立 mode 工具目录重建另一份真相 + +#### Scenario: plugin capability is governed by the same selectors + +- **WHEN** 当前 capability surface 同时包含 builtin 与插件工具 +- **THEN** mode selector SHALL 对它们一视同仁地解析 +- **AND** SHALL NOT 因来源不同而走不同治理路径 + +### Requirement: session SHALL persist the current mode as an event-driven projection + +系统 SHALL 通过 durable 事件记录 session 当前 mode 的变更,并在 `session-runtime` 内维护当前 mode 的投影缓存。 + +#### Scenario: new session replays without mode events + +- **WHEN** 一个旧 session 的事件流中不存在 mode 变更事件 +- **THEN** replay 结果 SHALL 回退到 builtin `execute` mode + +#### Scenario: mode change survives replay + +- **WHEN** session 已经追加过一次有效的 mode 变更事件 +- **THEN** 会话重载或回放后 SHALL 恢复为该最新 mode +- **AND** 后续 turn SHALL 继续使用该 mode 的 envelope 编译结果 + +### Requirement: mode transition SHALL be validated through a unified governance entrypoint + +所有 mode 切换请求 MUST 经过统一治理入口校验 target mode、transition policy 与 entry policy,然后再由 `session-runtime` 应用 durable 变更。 + +#### Scenario: invalid transition is rejected before runtime mutation + +- **WHEN** 一个切换请求的目标 mode 不满足当前 mode 的 transition policy +- **THEN** 系统 SHALL 在追加任何 durable 事件前拒绝该请求 + +#### Scenario: valid transition applies on the next turn + +- **WHEN** 当前 turn 中途发生一次合法 mode 切换 +- **THEN** 当前 turn 的执行 envelope SHALL 保持不变 +- **AND** 新 mode SHALL 从下一次 turn 开始生效 + +### Requirement: governance mode SHALL constrain orchestration inputs without replacing the runtime engine + +governance mode 可以约束 prompt program、能力面、委派策略与行为入口,但 MUST NOT 直接替换 `run_turn`、tool cycle、streaming path 或 compaction 算法。 + +#### Scenario: plugin mode customizes prompt without replacing loop + +- **WHEN** 一个插件 mode 定义了自定义 prompt program +- **THEN** 系统 SHALL 只把它编译为 envelope 中的 prompt declarations +- **AND** SHALL 继续使用同一套通用 turn loop 执行该 turn + +#### Scenario: mode-specific loop implementations are forbidden + +- **WHEN** 实现一个新的 governance mode +- **THEN** 系统 SHALL NOT 要求新增独立的 `run_<mode>_turn` 或等价 loop 实现 + +### Requirement: child sessions SHALL derive their initial governance mode from child policy + +子 session 的初始治理模式 MUST 由父 turn 的 resolved child policy 推导,而不是简单继承父 session 的 mode 标签。 + +#### Scenario: parent plan mode launches an execute-capable child by policy + +- **WHEN** 父 session 当前处于规划型 mode,且其 child policy 允许子分支使用执行型 mode +- **THEN** 新 child session SHALL 按 child policy 初始化为对应 child mode +- **AND** SHALL NOT 因父 mode 是 plan 而被强制继承为同名 mode + +#### Scenario: child delegation is disabled by current mode + +- **WHEN** 当前 mode 的 child policy 禁止创建新的 child 分支 +- **THEN** 当前 turn SHALL 不向模型暴露新的 child delegation 行为入口 +- **AND** SHALL 在 delegation surface 中反映该约束 + +### Requirement: mode catalog SHALL be assembled during bootstrap and updated on reload + +builtin mode catalog MUST 在 server bootstrap 阶段通过 `GovernanceBuildInput` 装配,插件 mode 在 bootstrap 或 reload 时注册到同一 catalog。reload 时,mode catalog 的替换 SHALL 与能力面替换保持原子性。 + +#### Scenario: builtin modes are available after bootstrap + +- **WHEN** server bootstrap 完成 +- **THEN** `execute`、`plan`、`review` 等 builtin mode SHALL 已在 catalog 中注册 +- **AND** 无需任何额外配置即可使用 + +#### Scenario: plugin mode joins catalog during bootstrap + +- **WHEN** 一个插件在 bootstrap 握手阶段声明了自定义 mode +- **THEN** 该 mode SHALL 出现在统一 catalog 中 +- **AND** 如果 mode spec 校验失败,SHALL 整批拒绝该插件的所有 mode + +#### Scenario: reload atomically swaps mode catalog and capability surface + +- **WHEN** runtime reload 触发 +- **THEN** mode catalog 替换 SHALL 与 capability surface 替换在同一原子操作中完成 +- **AND** reload 失败时 SHALL 继续使用旧的 mode catalog(与当前能力面回滚策略一致) + +#### Scenario: running sessions are unaffected by catalog reload + +- **WHEN** reload 发生时有 session 正在执行 +- **THEN** 已在执行的 turn SHALL 使用 reload 前的 envelope +- **AND** 仅在下一 turn 开始时使用新的 catalog 编译 envelope + +### Requirement: mode change SHALL be recorded as a durable event in session event log + +mode 变更 MUST 通过 durable 事件记录到 session 事件流。`StorageEventPayload` SHALL 增加对应的变体(如 `ModeChanged`),确保 mode 变更可回放、可审计。 + +#### Scenario: mode change appends a ModeChanged event + +- **WHEN** session 的 mode 成功切换 +- **THEN** 系统 SHALL 追加一个 `ModeChanged { from: ModeId, to: ModeId }` 事件到 session event log +- **AND** 该事件 SHALL 包含足够信息用于回放和审计 + +#### Scenario: old session replay falls back to default mode + +- **WHEN** 一个旧 session 的事件流中不包含 `ModeChanged` 事件 +- **THEN** replay 结果 SHALL 回退到 builtin `execute` mode +- **AND** 行为 SHALL 与当前默认行为等价 + +#### Scenario: mode change survives replay + +- **WHEN** session 事件流包含一个或多个 `ModeChanged` 事件 +- **THEN** replay 后 SHALL 恢复为最新事件指定的 mode +- **AND** 后续 turn SHALL 使用该 mode 编译 envelope + +### Requirement: session state SHALL maintain current mode as a projection from event log + +`SessionState` SHALL 维护当前 mode 的投影缓存,该投影从事件流中 `ModeChanged` 事件增量计算得出。 + +#### Scenario: SessionState exposes current mode projection + +- **WHEN** session state 需要知道当前 mode +- **THEN** 它 SHALL 从投影缓存中读取当前 `ModeId` +- **AND** 投影更新 SHALL 在 `translate_store_and_cache` 中通过事件驱动完成 + +#### Scenario: AgentState projector handles ModeChanged events + +- **WHEN** `AgentStateProjector.apply()` 接收到 `ModeChanged` 事件 +- **THEN** `AgentState` SHALL 更新其 mode 字段 +- **AND** 后续 `project()` 调用 SHALL 反映最新 mode + +### Requirement: collaboration audit facts SHALL include mode context + +`AgentCollaborationFact` 记录的协作审计事件 MUST 包含当前 mode 上下文,使审计链路能追溯到 mode 治理决策。 + +#### Scenario: collaboration fact records active mode at action time + +- **WHEN** 系统记录一个 `AgentCollaborationFact`(如 spawn 或 send) +- **THEN** 该事实 SHALL 包含当前 session 的 `mode_id` +- **AND** 审计查询 SHALL 能按 mode 过滤协作事实 + +#### Scenario: mode transition during turn does not affect audit context + +- **WHEN** turn 执行中途发生 mode 变更(下一 turn 生效) +- **THEN** 当前 turn 内的协作事实 SHALL 使用 turn 开始时的 mode +- **AND** SHALL NOT 因 mode 变更导致同一 turn 内的审计上下文不一致 + +### Requirement: mode observability SHALL support monitoring and diagnostics + +mode 系统的运行状态 SHALL 可观测,包括当前活跃 mode、mode 变更历史、以及 mode 编译的 envelope 摘要。 + +#### Scenario: observability snapshot includes current mode + +- **WHEN** `ObservabilitySnapshotProvider` 采集快照 +- **THEN** 快照 SHALL 包含 session 当前 mode ID +- **AND** SHALL 包含最近 mode 变更的时间戳 + +#### Scenario: envelope compilation diagnostics are available + +- **WHEN** envelope 编译产生异常结果(如空能力面) +- **THEN** 系统 SHALL 记录诊断信息 +- **AND** 该信息 SHALL 可通过 observability 接口查询 diff --git a/openspec/specs/governance-surface-assembly/spec.md b/openspec/specs/governance-surface-assembly/spec.md new file mode 100644 index 00000000..55d1bd27 --- /dev/null +++ b/openspec/specs/governance-surface-assembly/spec.md @@ -0,0 +1,94 @@ +## Purpose + +定义统一治理装配的顶层架构,确保所有 turn 入口共享同一治理面解析路径,治理包络是 turn-scoped 治理输入的 authoritative 来源。 + +## Requirements + +### Requirement: all turn entrypoints SHALL resolve a shared governance surface before session-runtime submission + +系统 MUST 在 root execution、普通 session prompt 提交、fresh child launch 与 resumed child submit 等所有 turn 入口上,先解析一个统一的治理包络,再把它交给 `session-runtime`。 + +#### Scenario: root execution uses the shared governance assembly path + +- **WHEN** 系统发起一次 root agent execution +- **THEN** 它 SHALL 先解析统一治理包络 +- **AND** SHALL NOT 直接在调用点手工拼接 scoped router、prompt declarations 与其他治理输入 + +#### Scenario: subagent launch uses the same governance surface shape + +- **WHEN** 系统启动一个 fresh 或 resumed child session +- **THEN** 它 SHALL 通过相同的治理装配入口生成治理包络 +- **AND** 输出形状 SHALL 与其他 turn 入口一致 + +### Requirement: governance surface SHALL be the authoritative source for turn-scoped governance inputs + +治理包络 MUST 成为 turn-scoped capability router、prompt declarations、resolved limits、context inheritance 与 child contract 等治理输入的 authoritative 来源。 + +#### Scenario: session-runtime consumes a resolved governance envelope + +- **WHEN** `session-runtime` 接收一次 turn 提交 +- **THEN** 它 SHALL 读取已解析的治理包络作为治理输入 +- **AND** SHALL NOT 在底层重新推导业务级治理决策 + +#### Scenario: prompt declarations come from the governance surface + +- **WHEN** 当前 turn 需要内置协作 guidance、child contract 或其他治理声明 +- **THEN** 这些声明 SHALL 来源于治理包络 +- **AND** SHALL 通过统一的 `PromptDeclaration` 链路进入 prompt 组装 + +### Requirement: governance surface cleanup SHALL preserve current default behavior while removing duplicated assembly paths + +本轮治理收口重构 MUST 以行为等价为默认目标;在没有显式新治理配置的前提下,root/session/subagent 入口的默认执行行为 SHALL 与当前保持等价。 + +#### Scenario: default execute path remains behaviorally equivalent + +- **WHEN** 系统在未启用额外治理配置的情况下提交普通执行任务 +- **THEN** 模型可见工具、默认协作 guidance 与 child contract 语义 SHALL 与当前默认行为保持等价 + +#### Scenario: duplicate assembly logic is removed without changing runtime engine + +- **WHEN** 完成本轮 cleanup +- **THEN** turn 相关治理输入 SHALL 由统一装配路径生成 +- **AND** `run_turn`、tool cycle、streaming path 与 compaction engine SHALL 保持单一实现 + +### Requirement: bootstrap governance assembly SHALL provide a clear entrypoint for mode system integration + +`build_app_governance`(server/bootstrap/governance.rs:43-80)和 `GovernanceBuildInput` 是服务器级治理组合根。它们 MUST 为后续 mode system 提供明确的接入点,使 mode catalog 能在 bootstrap/reload 阶段被装配。 + +#### Scenario: GovernanceBuildInput exposes mode-catalog-ready assembly hooks + +- **WHEN** 后续 governance mode system 需要在 bootstrap 阶段注册 mode catalog +- **THEN** `GovernanceBuildInput` SHALL 已具备接入 mode catalog 的参数或接口 +- **AND** SHALL NOT 要求修改 bootstrap 编排流程的核心结构 + +#### Scenario: AppGovernance reload path supports mode catalog swap + +- **WHEN** 运行时 reload 触发能力面和配置的原子替换 +- **THEN** reload 编排 SHALL 能同时替换 mode catalog(如果存在) +- **AND** SHALL NOT 因缺少接入点而要求在 mode system 实现时重新编排 reload 流程 + +### Requirement: runtime governance lifecycle SHALL keep clear boundaries between governance assembly and runtime execution + +`AppGovernance`(application/lifecycle/governance.rs)负责 reload/shutdown 生命周期管理。治理装配与运行时执行的边界 MUST 保持清晰,治理装配层不吞并 runtime engine 的执行控制逻辑。 + +#### Scenario: reload governance check remains in application layer + +- **WHEN** `AppGovernance.reload()` 检查是否有 running session +- **THEN** 该检查 SHALL 继续在 application 层完成 +- **AND** SHALL NOT 下沉到 session-runtime 或 kernel 层 + +#### Scenario: capability surface replacement uses the governance assembly path + +- **WHEN** `RuntimeCoordinator.replace_runtime_surface` 执行原子化能力面替换 +- **THEN** 新能力面 SHALL 通过治理装配路径传递到后续 turn 提交 +- **AND** SHALL NOT 出现替换后的能力面与正在执行的 turn 使用的能力面不一致的竞态 + +### Requirement: CapabilitySurfaceSync and runtime coordinator SHALL be governance-surface-aware + +`CapabilitySurfaceSync`(server/bootstrap/capabilities.rs:108-156)管理 stable local + dynamic external 能力面的同步。`RuntimeCoordinator`(core/runtime/coordinator.rs)负责原子化运行时表面替换。两者 MUST 在治理面变更后能通知治理装配器刷新缓存。 + +#### Scenario: capability surface change triggers governance envelope refresh + +- **WHEN** MCP 连接变更或插件 reload 导致能力面发生改变 +- **THEN** 后续 turn 的治理包络 SHALL 使用更新后的能力面 +- **AND** SHALL NOT 使用 stale 的缓存能力面继续生成治理包络 diff --git a/openspec/specs/mode-capability-compilation/spec.md b/openspec/specs/mode-capability-compilation/spec.md new file mode 100644 index 00000000..e5d32d2c --- /dev/null +++ b/openspec/specs/mode-capability-compilation/spec.md @@ -0,0 +1,90 @@ +## Purpose + +定义 governance mode 如何通过 CapabilitySelector 从当前 CapabilitySpec / capability router 投影出 mode-specific 的能力子集,编译为 scoped CapabilityRouter。 + +## Requirements + +### Requirement: mode SHALL compile to a scoped CapabilityRouter through CapabilitySelector resolution + +每个 governance mode MUST 通过 `CapabilitySelector` 从当前 `CapabilitySpec` / capability router 投影出 mode-specific 的能力子集,编译为 scoped `CapabilityRouter`,并在 turn 开始时通过 `scoped_gateway()` 固定工具面。 + +#### Scenario: execute mode compiles the full capability surface + +- **WHEN** 当前 session 的 mode 为 builtin `code` +- **THEN** envelope 编译 SHALL 产生包含当前全部可见工具的 capability router +- **AND** `scoped_gateway()` (runner.rs:339) SHALL 传入该 router,结果与当前默认行为等价 + +#### Scenario: plan mode compiles a read-only capability subset + +- **WHEN** 当前 session 的 mode 为 builtin `plan` +- **THEN** envelope 编译 SHALL 基于 CapabilitySelector 筛除具有 `SideEffect::Workspace` 或 `SideEffect::External` 的工具 +- **AND** 保留 `SideEffect::None` 和 `SideEffect::Local` 的工具 +- **AND** 模型在该 turn 中 SHALL NOT 看到或调用被筛除的工具 + +#### Scenario: review mode compiles an observation-only subset + +- **WHEN** 当前 session 的 mode 为 builtin `review` +- **THEN** envelope 编译 SHALL 仅保留无副作用的工具(可能包括 observe、read-only 工具) +- **AND** SHALL 移除 spawn、send、close 等协作工具 + +### Requirement: CapabilitySelector SHALL resolve against current CapabilitySpec metadata + +CapabilitySelector 的投影 MUST 严格基于当前 `CapabilitySpec` 的字段(name、kind、side_effect、tags),MUST NOT 维护平行的工具注册表。 + +#### Scenario: NameSelector matches exact capability name + +- **WHEN** mode 使用 `Name("shell")` selector +- **THEN** 编译结果 SHALL 包含名称为 "shell" 的 capability(如果它在当前 surface 中存在) +- **AND** SHALL NOT 匹配名称不包含 "shell" 的 capability + +#### Scenario: KindSelector matches capability kind + +- **WHEN** mode 使用 `Kind(Tool)` selector +- **THEN** 编译结果 SHALL 包含所有 `CapabilityKind::Tool` 类型的 capability + +#### Scenario: SideEffectSelector matches side effect level + +- **WHEN** mode 使用 `SideEffect(None)` selector +- **THEN** 编译结果 SHALL 仅包含 `side_effect == SideEffect::None` 的 capability +- **AND** SHALL NOT 包含 `SideEffect::Local` 或更高级别的 capability + +#### Scenario: TagSelector matches capability tags + +- **WHEN** mode 使用 `Tag("source:mcp")` selector +- **THEN** 编译结果 SHALL 包含 tags 中含有 `"source:mcp"` 的 capability + +#### Scenario: selector operates uniformly on builtin and plugin capabilities + +- **WHEN** 当前 capability surface 同时包含 builtin 与插件工具 +- **THEN** CapabilitySelector SHALL 对它们一视同仁地解析 +- **AND** SHALL NOT 因来源不同而走不同选择路径 + +### Requirement: mode SHALL support compositional capability selectors + +mode 的能力选择 SHALL 支持组合操作(并集、交集、差集),使 mode spec 能表达复杂的能力面约束。 + +#### Scenario: mode uses intersection of selectors + +- **WHEN** mode 定义能力选择为 `Kind(Tool) ∩ NotSideEffect(External)` +- **THEN** 编译结果 SHALL 包含所有 Tool 类型且不具有 External 副作用的 capability + +#### Scenario: mode uses exclusion selector + +- **WHEN** mode 定义能力选择为 `AllTools - Name("spawn")` +- **THEN** 编译结果 SHALL 包含除 "spawn" 外的所有工具 + +### Requirement: child capability router SHALL be derived from parent mode's child policy + +child session 的能力路由 MUST 由父 mode 的 child policy 推导,而不是简单继承父 session 的完整能力面。推导过程 SHALL 复用 CapabilitySelector 机制。 + +#### Scenario: child policy specifies narrower capability selector + +- **WHEN** 父 mode 的 child policy 定义了 `capability_selector` 限制 child 可用工具 +- **THEN** child 的 capability router SHALL 先按 child policy 的 selector 从父能力面中筛选 +- **AND** SHALL NOT 直接继承父的完整能力面 + +#### Scenario: child policy intersects with SpawnCapabilityGrant + +- **WHEN** 父 mode 的 child policy 有 capability selector,同时 spawn 调用指定了 `SpawnCapabilityGrant` +- **THEN** child 的最终能力面 SHALL 为两者交集 +- **AND** 空交集 SHALL 导致 spawn 被拒绝 diff --git a/openspec/specs/mode-command-surface/spec.md b/openspec/specs/mode-command-surface/spec.md new file mode 100644 index 00000000..0c24368d --- /dev/null +++ b/openspec/specs/mode-command-surface/spec.md @@ -0,0 +1,103 @@ +## Purpose + +定义用户如何通过 /mode 命令与 governance mode 系统交互,包括 mode 切换、tab 补全、命令面板集成与状态显示。 + +## Requirements + +### Requirement: mode switching SHALL be accessible through a /mode slash command + +用户 MUST 能通过 `/mode` slash 命令切换当前 session 的 governance mode。该命令 SHALL 集成到现有 `Command` enum(cli/src/command/mod.rs)中。 + +#### Scenario: /mode with no argument shows current mode and available modes + +- **WHEN** 用户输入 `/mode`(无参数) +- **THEN** 系统 SHALL 显示当前 session 的 mode 和 catalog 中可用的 mode 列表 +- **AND** 显示内容 SHALL 包括每个 mode 的名称和简短描述 + +#### Scenario: /mode with mode ID switches to target mode + +- **WHEN** 用户输入 `/mode plan` +- **THEN** 系统 SHALL 校验 `plan` 是有效的 mode ID +- **AND** 校验通过后 SHALL 在当前 turn 追加 mode 变更事件 +- **AND** 新 mode SHALL 从下一次 turn 开始生效 + +#### Scenario: /mode with invalid mode ID is rejected + +- **WHEN** 用户输入 `/mode nonexistent` +- **THEN** 系统 SHALL 返回错误提示,列出可用的 mode ID +- **AND** SHALL NOT 改变当前 mode + +### Requirement: /mode SHALL support tab completion from the mode catalog + +`/mode` 命令 SHALL 支持 tab 补全,补全候选来自当前 mode catalog 中可用的 mode ID。 + +#### Scenario: tab completion lists available modes + +- **WHEN** 用户在 `/mode ` 后按 tab +- **THEN** 系统 SHALL 显示当前 catalog 中所有可用 mode ID 作为候选 +- **AND** 候选列表 SHALL 排除当前已处于的 mode + +#### Scenario: tab completion filters by prefix + +- **WHEN** 用户输入 `/mode pl` 后按 tab +- **THEN** 系统 SHALL 过滤并显示以 "pl" 开头的 mode ID(如 "plan") +- **AND** 若无匹配 SHALL 不显示候选 + +### Requirement: /mode SHALL integrate with the existing slash command palette + +`/mode` 命令 SHALL 出现在 slash command palette 中,与 `/model`、`/compact` 等命令并列。 + +#### Scenario: /mode appears in slash candidates + +- **WHEN** 用户输入 `/` 触发 slash palette +- **THEN** `/mode` SHALL 出现在候选列表中 +- **AND** SHALL 附带描述文本(如 "Switch execution governance mode") + +### Requirement: mode command SHALL route through unified application governance entrypoint + +`/mode` 命令的解析和执行 MUST 走统一的 application 治理入口,而不是在 `session-runtime` 中解析命令语法。这与项目架构中 "slash command 只是输入壳,语义映射到稳定 server/application contract" 的要求一致。 + +#### Scenario: CLI sends mode transition request to application layer + +- **WHEN** CLI 收到 `/mode plan` 命令 +- **THEN** 它 SHALL 将 mode transition 请求发送到 application 的统一治理入口 +- **AND** application 层 SHALL 校验 target mode、entry policy 和 transition policy +- **AND** session-runtime SHALL 只接收已验证的 transition command + +#### Scenario: mode transition from tool call uses the same governance path + +- **WHEN** 模型通过工具调用请求 mode 切换 +- **THEN** 该请求 SHALL 走与 `/mode` 命令相同的治理入口 +- **AND** 校验逻辑 SHALL 完全一致 + +### Requirement: mode status SHALL be visible to the user + +用户 MUST 能在 UI/CLI 中看到当前 session 的 active mode。 + +#### Scenario: session status shows current mode + +- **WHEN** session 处于活跃状态 +- **THEN** CLI/UI SHALL 显示当前 session 的 mode ID(如 `[plan]` 或 `[code]`) +- **AND** mode 变更后 SHALL 即时更新显示 + +#### Scenario: mode change is reported to the user + +- **WHEN** mode 切换成功 +- **THEN** 系统 SHALL 向用户确认 mode 已变更 +- **AND** SHALL 提示新 mode 在下一 turn 生效 + +### Requirement: mode transition rejection SHALL provide actionable feedback + +当 mode 切换被拒绝时,系统 MUST 提供清晰的错误信息和可操作的建议。 + +#### Scenario: transition policy violation is explained + +- **WHEN** 当前 mode 的 transition policy 禁止切换到目标 mode +- **THEN** 系统 SHALL 解释拒绝原因(如 "Cannot switch from review to plan: transition not allowed") +- **AND** SHALL 列出从当前 mode 可以切换到的 mode 列表 + +#### Scenario: running session blocks certain mode transitions + +- **WHEN** 某些 mode 要求在无 running turn 时才能切换 +- **THEN** 系统 SHALL 提示用户等待当前 turn 完成后再切换 +- **AND** SHALL NOT 静默忽略切换请求 diff --git a/openspec/specs/mode-execution-policy/spec.md b/openspec/specs/mode-execution-policy/spec.md new file mode 100644 index 00000000..559f9041 --- /dev/null +++ b/openspec/specs/mode-execution-policy/spec.md @@ -0,0 +1,87 @@ +## Purpose + +定义 governance mode 如何影响 turn 级执行限制,包括 max_steps、ForkMode 策略、SubmitBusyPolicy 以及与用户 ExecutionControl 的组合规则。 + +## Requirements + +### Requirement: mode SHALL resolve mode-specific execution limits into the turn envelope + +每个 governance mode MUST 在编译 envelope 时解析 mode-specific 的执行限制参数,包括 max_steps、ForkMode 策略、以及 turn 级并发策略(SubmitBusyPolicy),作为 `ResolvedTurnEnvelope` 的一部分。 + +#### Scenario: execute mode uses default execution limits + +- **WHEN** 当前 session 的 mode 为 builtin `code` +- **THEN** envelope 的 execution limits SHALL 与当前默认行为等价 +- **AND** max_steps SHALL 来自 runtime config 或用户 `ExecutionControl` + +#### Scenario: plan mode uses reduced max_steps + +- **WHEN** 当前 session 的 mode 为 builtin `plan` +- **THEN** envelope 的 max_steps SHALL 可由 mode spec 指定上限 +- **AND** 用户通过 `ExecutionControl.max_steps` 指定的值 SHALL NOT 超过 mode spec 的上限 + +#### Scenario: review mode uses minimal execution limits + +- **WHEN** 当前 session 的 mode 为 builtin `review` +- **THEN** envelope 的 max_steps SHALL 可限制为 1(仅观察,不执行多步) +- **AND** SHALL NOT 允许 tool chain 执行 + +### Requirement: mode SHALL determine ForkMode policy for child context inheritance + +ForkMode(FullHistory/LastNTurns)决定的上下文继承策略 MUST 受当前 mode 约束。mode spec 可以限制 child 可继承的上下文范围。 + +#### Scenario: execute mode allows default ForkMode + +- **WHEN** 当前 mode 对 child context inheritance 无特殊限制 +- **THEN** ForkMode SHALL 按 SpawnAgentParams 的调用参数决定(与当前行为等价) + +#### Scenario: restricted mode limits child context to LastNTurns + +- **WHEN** 当前 mode 的 child policy 规定 child 只能继承最近 N 个 turn 的上下文 +- **THEN** ForkMode SHALL 强制使用 `LastNTurns(N)` 而非 `FullHistory` +- **AND** 即使调用方指定 `FullHistory`,SHALL 被降级为 mode 允许的最大范围 + +### Requirement: mode SHALL influence SubmitBusyPolicy for turn concurrency + +不同 mode 可以有不同的 turn 并发治理策略。mode spec SHALL 能指定当已有 turn 执行时,新 submit 应使用 `BranchOnBusy` 还是 `RejectOnBusy`。 + +#### Scenario: execute mode uses BranchOnBusy + +- **WHEN** 当前 mode 对 turn 并发无特殊限制 +- **THEN** SubmitBusyPolicy SHALL 默认为 `BranchOnBusy`(与当前行为等价) + +#### Scenario: plan mode uses RejectOnBusy + +- **WHEN** 当前 mode 要求 turn 串行执行 +- **THEN** SubmitBusyPolicy SHALL 为 `RejectOnBusy` +- **AND** 已有 turn 在执行时的新 submit SHALL 被拒绝而非 branching + +### Requirement: mode execution limits SHALL compose with user-specified ExecutionControl + +mode 的执行限制 MUST 与用户通过 `ExecutionControl` 指定的限制取交集(更严格者生效),而不是简单覆盖。 + +#### Scenario: user max_steps is lower than mode limit + +- **WHEN** mode spec 允许 max_steps = 50,但用户指定 `ExecutionControl.max_steps = 10` +- **THEN** 实际 max_steps SHALL 为 10(用户限制更严格) + +#### Scenario: user max_steps exceeds mode limit + +- **WHEN** mode spec 限制 max_steps = 20,但用户指定 `ExecutionControl.max_steps = 50` +- **THEN** 实际 max_steps SHALL 为 20(mode 限制更严格) +- **AND** 系统 SHALL NOT 静默截断,可选择在 submit 响应中提示限制已生效 + +### Requirement: mode SHALL resolve AgentConfig governance parameters for the current turn + +`AgentConfig` 中的治理参数(max_subrun_depth、max_spawn_per_turn 等)MUST 可被 mode spec 覆盖或限制,使不同 mode 能表达不同的协作深度策略。 + +#### Scenario: plan mode reduces max_spawn_per_turn + +- **WHEN** 当前 mode 的 spec 指定 `max_spawn_per_turn = 0` +- **THEN** 该 turn SHALL 不允许 spawn 任何 child +- **AND** spawn 工具 SHALL 不在可见能力面中 + +#### Scenario: mode does not override AgentConfig by default + +- **WHEN** mode spec 未指定覆盖参数 +- **THEN** 这些参数 SHALL 使用 runtime config 中的值(与当前行为等价) diff --git a/openspec/specs/mode-policy-engine/spec.md b/openspec/specs/mode-policy-engine/spec.md new file mode 100644 index 00000000..a1737a0e --- /dev/null +++ b/openspec/specs/mode-policy-engine/spec.md @@ -0,0 +1,80 @@ +## Purpose + +定义 governance mode 如何编译为 action policies 供 PolicyEngine 消费,以及 mode 如何影响上下文策略决策。 + +## Requirements + +### Requirement: mode SHALL compile to action policies that the PolicyEngine enforces + +每个 governance mode MUST 编译为 action policies,作为 `ResolvedTurnEnvelope` 的一部分。`PolicyEngine` 的三个检查点 SHALL 在 turn 执行链路中消费这些 action policies。 + +#### Scenario: execute mode compiles permissive action policies + +- **WHEN** 当前 session 的 mode 为 builtin `code` +- **THEN** action policies SHALL 编译为默认允许所有能力调用 +- **AND** `check_capability_call` SHALL 返回 `PolicyVerdict::Allow`(与当前 `AllowAllPolicyEngine` 行为等价) + +#### Scenario: plan mode compiles restrictive action policies + +- **WHEN** 当前 session 的 mode 为 builtin `plan` +- **THEN** action policies SHALL 禁止具有 `SideEffect::Workspace` 或 `SideEffect::External` 的能力调用 +- **AND** `check_capability_call` SHALL 对这些调用返回 `PolicyVerdict::Deny` + +#### Scenario: custom mode compiles ask-on-high-risk policies + +- **WHEN** 一个插件 mode 定义了"高风险操作需审批"的 action policy +- **THEN** 对高风险能力调用 `check_capability_call` SHALL 返回 `PolicyVerdict::Ask` +- **AND** 系统 SHALL 发起审批流(通过治理包络建立的管线) + +### Requirement: PolicyContext SHALL be populated from the mode-compiled envelope + +`PolicyContext`(core/policy/engine.rs:108-124)的构建 MUST 从 mode 编译后的治理包络派生,确保策略引擎与 turn 执行链路使用同一事实源。 + +#### Scenario: PolicyContext session/turn identifiers come from envelope + +- **WHEN** PolicyEngine 需要构建 `PolicyContext` 用于裁决 +- **THEN** session_id、turn_id、step_index、working_dir、profile SHALL 从治理包络中获取 +- **AND** SHALL NOT 在调用点独立组装 + +#### Scenario: PolicyContext profile aligns with envelope capability surface + +- **WHEN** mode 编译后的 envelope 指定了特定的 capability surface +- **THEN** PolicyContext 可用的能力信息 SHALL 与 envelope 一致 +- **AND** SHALL NOT 出现 PolicyContext 认为某工具可用但 envelope 已移除的不一致 + +### Requirement: mode SHALL influence context strategy decisions + +`decide_context_strategy`(PolicyEngine 的上下文策略检查点)SHALL 能参考当前 mode 的上下文治理偏好,使不同 mode 可以有不同的 context pressure 处理策略。 + +#### Scenario: execute mode uses default context strategy + +- **WHEN** context pressure 触发策略裁决且当前 mode 为 `code` +- **THEN** 策略 SHALL 使用默认的 `ContextStrategy::Compact`(与当前行为等价) + +#### Scenario: review mode prefers truncate over compact + +- **WHEN** context pressure 触发策略裁决且当前 mode 为 `review` +- **THEN** 策略 MAY 优先使用 `ContextStrategy::Truncate` 而非 Compact +- **AND** SHALL NOT 丢失 review 对象的内容 + +#### Scenario: mode does not specify context strategy + +- **WHEN** mode spec 未定义上下文策略偏好 +- **THEN** 系统 SHALL 使用 runtime config 的默认策略 +- **AND** SHALL NOT 因缺少 mode 配置而无法裁决 + +### Requirement: mode-specific policy engine SHALL be swappable without modifying turn loop + +mode 变更后,后续 turn 的策略行为 SHALL 通过替换治理包络中的 action policies 实现,MUST NOT 要求修改 `run_turn`、tool cycle 或 streaming path。 + +#### Scenario: mode transition changes policy behavior at turn boundary + +- **WHEN** session 从 `code` mode 切换到 `plan` mode +- **THEN** 下一 turn 的 PolicyEngine 行为 SHALL 基于 plan mode 的 action policies +- **AND** 当前 turn 的执行 SHALL 不受影响(next-turn 生效语义) + +#### Scenario: plugin mode provides custom policy implementation + +- **WHEN** 一个插件 mode 定义了自定义的策略裁决逻辑 +- **THEN** 系统 SHALL 通过治理包络中的 action policies 传递该逻辑 +- **AND** SHALL NOT 要求插件直接实现 `PolicyEngine` trait 或修改 turn runner diff --git a/openspec/specs/mode-prompt-program/spec.md b/openspec/specs/mode-prompt-program/spec.md new file mode 100644 index 00000000..b51ef01a --- /dev/null +++ b/openspec/specs/mode-prompt-program/spec.md @@ -0,0 +1,103 @@ +## Purpose + +定义 governance mode 如何编译为 prompt program 生成 PromptDeclarations,以及 mode 如何控制 builtin prompt contributor 的行为。 + +## Requirements + +### Requirement: mode SHALL compile to a prompt program that generates PromptDeclarations + +每个 governance mode MUST 编译为一个 prompt program,该 program 在 turn 边界生成一组 `PromptDeclaration`,作为 `ResolvedTurnEnvelope` 的一部分注入 prompt 组装管线。 + +#### Scenario: execute mode compiles the default prompt program + +- **WHEN** 当前 session 的 mode 为 builtin `code` +- **THEN** prompt program SHALL 生成与当前默认协作 guidance 等价的 PromptDeclarations +- **AND** 渲染结果 SHALL 与现有 `WorkflowExamplesContributor` 的 `child-collaboration-guidance` block 行为等价 + +#### Scenario: plan mode compiles a planning-oriented prompt program + +- **WHEN** 当前 session 的 mode 为 builtin `plan` +- **THEN** prompt program SHALL 生成规划导向的 PromptDeclarations +- **AND** SHALL 包含规划方法论 guidance、输出格式约束、以及不允许直接执行的声明 + +#### Scenario: review mode compiles an observation-oriented prompt program + +- **WHEN** 当前 session 的 mode 为 builtin `review` +- **THEN** prompt program SHALL 生成审查导向的 PromptDeclarations +- **AND** SHALL 不包含 spawn/send 协作协议 guidance + +### Requirement: mode prompt program SHALL integrate through the existing PromptDeclaration injection path + +mode 生成的 PromptDeclarations MUST 通过现有注入路径进入 prompt 组装,即 `TurnRunRequest.prompt_declarations` -> `TurnExecutionResources` -> `AssemblePromptRequest` -> `PromptOutputRequest.submission_prompt_declarations` -> `build_prompt_output()`,MUST NOT 开辟新的 prompt 注入旁路。 + +#### Scenario: mode declarations travel the standard path + +- **WHEN** mode 编译生成 PromptDeclarations +- **THEN** 它们 SHALL 被放入 `AgentPromptSubmission.prompt_declarations` +- **AND** 通过 `submit_prompt_inner` -> `RunnerRequest` -> `TurnRunRequest` 标准路径进入 runner + +#### Scenario: mode declarations are visible to PromptDeclarationContributor + +- **WHEN** `PromptDeclarationContributor` (adapter-prompt) 渲染 prompt +- **THEN** 它 SHALL 能渲染 mode 生成的 declarations +- **AND** SHALL 对 mode declarations 和其他 declarations 使用相同的渲染逻辑 + +### Requirement: mode SHALL control which builtin prompt contributors are active + +不同 mode 可以要求禁用或替换某些 builtin prompt contributor。mode spec SHALL 能声明对 contributor 的约束。 + +#### Scenario: execute mode keeps all contributors active + +- **WHEN** 当前 mode 为 `code` +- **THEN** 所有现有 contributor(WorkflowExamplesContributor、AgentProfileSummaryContributor、CapabilityPromptContributor)SHALL 保持活跃 +- **AND** 行为与当前默认等价 + +#### Scenario: mode disables AgentProfileSummaryContributor when delegation is forbidden + +- **WHEN** 当前 mode 的 child policy 禁止创建 child 分支 +- **THEN** `AgentProfileSummaryContributor` SHALL 不渲染(因为它只在 spawn 可用时激活) +- **AND** 这一行为 SHALL 自动发生(因为 mode 编译的 capability router 已移除 spawn 工具) + +#### Scenario: mode replaces collaboration guidance content + +- **WHEN** 当前 mode 要求不同的协作 guidance +- **THEN** `WorkflowExamplesContributor` 的治理专属内容 SHALL 被 mode prompt program 的 declarations 替代 +- **AND** `WorkflowExamplesContributor` SHALL 仅保留非治理 few-shot 内容(如果有) + +### Requirement: PromptFactsProvider SHALL resolve prompt facts against the mode-compiled envelope + +`PromptFactsProvider.resolve_prompt_facts()` 构建的 `PromptFacts` MUST 与 mode 编译后的治理包络保持一致,包括 metadata 中的治理参数和 declarations 的可见性过滤。 + +#### Scenario: PromptFacts metadata reflects mode-resolved parameters + +- **WHEN** mode 编译后的 envelope 指定了 max_spawn_per_turn = 0 +- **THEN** `PromptFacts.metadata` 中的 `agentMaxSpawnPerTurn` SHALL 为 0 +- **AND** SHALL NOT 使用 runtime config 中的原始值 + +#### Scenario: prompt declaration visibility aligns with mode capability surface + +- **WHEN** mode 编译的 envelope 移除了某些工具 +- **THEN** `prompt_declaration_is_visible` 过滤 SHALL 使用 envelope 的能力面 +- **AND** 已被 mode 移除的工具对应的 declarations SHALL 不对模型可见 + +#### Scenario: profile context approvalMode reflects mode policy + +- **WHEN** mode 的 action policies 包含审批要求 +- **THEN** `build_profile_context` 中的 `approvalMode` SHALL 反映该模式 +- **AND** SHALL 与 PolicyEngine 的实际行为一致 + +### Requirement: plugin mode SHALL be able to contribute custom prompt blocks without replacing contributors + +插件 mode MUST 能通过 prompt program 注入自定义 prompt blocks,但 MUST NOT 直接替换、删除或修改现有 prompt contributor 的内部逻辑。 + +#### Scenario: plugin mode appends custom guidance + +- **WHEN** 一个插件 mode 定义了自定义协作 guidance +- **THEN** 系统 SHALL 将该 guidance 编译为额外的 PromptDeclaration +- **AND** 现有 contributor 的渲染逻辑 SHALL 不受影响 + +#### Scenario: plugin mode cannot bypass contributor pipeline + +- **WHEN** 一个插件 mode 尝试绕过 prompt 组装管线 +- **THEN** 系统 SHALL 仅通过 PromptDeclaration 注入路径接受 mode 的 prompt 输入 +- **AND** SHALL NOT 允许插件直接修改 prompt 组装中间产物 diff --git a/openspec/specs/policy-engine-integration/spec.md b/openspec/specs/policy-engine-integration/spec.md new file mode 100644 index 00000000..ceafbf7e --- /dev/null +++ b/openspec/specs/policy-engine-integration/spec.md @@ -0,0 +1,63 @@ +## Purpose + +定义 PolicyEngine 如何与治理包络集成,包括 PolicyContext 的填充来源、审批流接入以及与 AllowAllPolicyEngine 的兼容性。 + +## Requirements + +### Requirement: PolicyEngine SHALL consume the resolved governance envelope as its input context + +`PolicyEngine` 的三个检查点(`check_model_request`、`check_capability_call`、`decide_context_strategy`)MUST 基于已解析的治理包络做出裁决,而不是在执行路径中保持脱钩状态。 + +#### Scenario: capability call check uses governance-resolved limits + +- **WHEN** turn 执行链路中发生一次能力调用 +- **THEN** `check_capability_call` SHALL 能读取当前 turn 治理包络中的 capability surface 和 execution limits +- **AND** SHALL NOT 仅依赖 `PolicyContext` 中与治理包络重复或矛盾的元数据 + +#### Scenario: model request check is informed by governance envelope + +- **WHEN** turn 准备向 LLM 发送请求 +- **THEN** `check_model_request` SHALL 能参考治理包络中的 action policy 和 prompt declarations +- **AND** SHALL NOT 在缺少治理上下文的情况下做放行裁决 + +#### Scenario: context strategy decision aligns with governance envelope + +- **WHEN** context pressure 触发上下文策略裁决(compact/summarize/truncate/ignore) +- **THEN** `decide_context_strategy` SHALL 遵守治理包络中可能存在的上下文治理偏好 +- **AND** SHALL NOT 始终使用硬编码的默认策略而不考虑治理输入 + +### Requirement: PolicyContext SHALL be populated from the governance envelope, not independently assembled + +`PolicyContext` 当前独立组装 session_id/turn_id/step_index/working_dir/profile 等字段。这些字段 MUST 从治理包络中获取,确保策略引擎的输入与 turn 执行链路使用同一事实源。 + +#### Scenario: PolicyContext fields align with governance envelope + +- **WHEN** 策略引擎需要 `PolicyContext` 做裁决 +- **THEN** `PolicyContext` SHALL 从治理包络中派生,而不是在调用点重新组装 +- **AND** SHALL NOT 出现 PolicyContext 的 profile 与治理包络的 profile 来源不一致的情况 + +### Requirement: approval flow types SHALL be connected to the governance assembly path + +`ApprovalRequest`、`ApprovalResolution`、`ApprovalPending` 等审批流类型当前仅在 `core/policy/engine.rs` 中定义,没有真实消费者。治理装配路径 SHOULD 为审批流提供明确的接入点,使策略引擎的三态裁决(Allow/Deny/Ask)能在 turn 执行链路中生效。 + +#### Scenario: Ask verdict triggers approval through the governance path + +- **WHEN** 策略引擎对一次能力调用返回 `PolicyVerdict::Ask` +- **THEN** 系统 SHALL 能通过治理装配路径构建 `ApprovalRequest` 并发起审批流 +- **AND** SHALL NOT 因缺少接入点而始终回退到 `AllowAllPolicyEngine` + +### Requirement: the governance cleanup SHALL preserve AllowAllPolicyEngine as the default while establishing the integration plumbing + +本轮 cleanup 不要求实现完整的审批拦截逻辑,但 MUST 确保策略引擎与治理包络之间的接线存在,使得后续 mode system 能通过替换 PolicyEngine 实现来改变治理行为。 + +#### Scenario: AllowAllPolicyEngine remains the default after cleanup + +- **WHEN** 系统在未配置自定义策略引擎的情况下运行 +- **THEN** 默认行为 SHALL 继续使用 `AllowAllPolicyEngine` 放行所有请求 +- **AND** 治理包络到策略引擎的接线 SHALL 已存在但默认不改变裁决结果 + +#### Scenario: the integration plumbing allows future PolicyEngine swap without touching turn loop + +- **WHEN** 后续 governance mode system 需要实现模式感知的策略裁决 +- **THEN** 系统 SHALL 只需替换 PolicyEngine 实现或调整治理包络参数 +- **AND** SHALL NOT 需要修改 `run_turn`、tool cycle 或 streaming path diff --git a/openspec/specs/prompt-facts-governance-linkage/spec.md b/openspec/specs/prompt-facts-governance-linkage/spec.md new file mode 100644 index 00000000..c5845fb4 --- /dev/null +++ b/openspec/specs/prompt-facts-governance-linkage/spec.md @@ -0,0 +1,63 @@ +## Purpose + +定义 PromptFacts 与治理包络的联动关系,包括 declaration 可见性过滤、metadata 治理参数传递、profile context 治理字段以及 PromptFactsProvider 的职责边界。 + +## Requirements + +### Requirement: prompt declaration visibility filtering SHALL be driven by the governance envelope, not implicit capability name matching + +`prompt_declaration_is_visible`(server/bootstrap/prompt_facts.rs:200-213)当前通过 `allowed_capability_names` 过滤 prompt declaration 的可见性。这个联动 MUST 变为显式的治理包络驱动,而不是通过隐式的字符串集合匹配。 + +#### Scenario: declaration visibility uses governance-resolved capability surface + +- **WHEN** `PromptFactsProvider` 需要决定哪些 prompt declaration 对当前 turn 可见 +- **THEN** 过滤逻辑 SHALL 使用治理包络中已解析的 capability surface +- **AND** SHALL NOT 独立从 `ResolvedExecutionLimitsSnapshot.allowed_tools` 重建过滤集合 + +#### Scenario: visibility filtering is consistent across prompt facts and turn execution + +- **WHEN** turn 执行链路使用治理包络中的 capability router 决定工具可见性 +- **THEN** prompt facts 的 declaration 过滤 SHALL 使用同一能力面事实源 +- **AND** SHALL NOT 出现工具可见但 declaration 被过滤(或反之)的不一致 + +### Requirement: PromptFacts metadata governance parameters SHALL come from the governance envelope + +`PromptFacts.metadata` 当前通过 vars dict 注入 `agentMaxSubrunDepth` 和 `agentMaxSpawnPerTurn` 等治理参数。这些参数 MUST 从治理包络中显式获取,而不是通过松散的 string-keyed dict 传递。 + +#### Scenario: agent limits in prompt facts come from the envelope + +- **WHEN** `resolve_prompt_facts` 构建 `PromptFacts.metadata` +- **THEN** `agentMaxSubrunDepth` 和 `agentMaxSpawnPerTurn` SHALL 从治理包络中读取 +- **AND** SHALL NOT 从 `ResolvedAgentConfig` 独立读取并通过 vars dict 注入 + +#### Scenario: metadata keys are strongly typed through the governance path + +- **WHEN** 治理参数通过治理包络传递到 prompt facts +- **THEN** 参数传递 SHALL 使用结构化类型,而不是 string-keyed hashmap +- **AND** SHALL 减少因 key 名拼写错误或类型不匹配导致的隐式失败 + +### Requirement: profile context governance fields SHALL come from the governance envelope + +`build_profile_context`(prompt_facts.rs:107-136)当前注入 `approvalMode`、`sessionId`、`turnId` 等治理上下文字段。这些字段 MUST 与治理包络中的信息保持一致。 + +#### Scenario: approval mode in profile context aligns with envelope + +- **WHEN** `build_profile_context` 注入 `approvalMode` +- **THEN** approvalMode 的值 SHALL 与治理包络中的策略引擎配置一致 +- **AND** SHALL NOT 出现 profile context 中的 approvalMode 与实际策略引擎行为不一致的情况 + +#### Scenario: session and turn identifiers in profile context come from the governance path + +- **WHEN** profile context 包含 sessionId 和 turnId +- **THEN** 这些标识符 SHALL 与治理包络中记录的标识符一致 +- **AND** SHALL NOT 从独立的参数源重新获取 + +### Requirement: PromptFactsProvider SHALL be a consumer of the governance surface, not an independent governance assembler + +`PromptFactsProvider` 当前同时承担"收集 prompt 事实"和"做隐式治理过滤"两个职责。cleanup 后,它 MUST 只负责收集和渲染事实,治理过滤逻辑 MUST 上移到治理装配层。 + +#### Scenario: PromptFactsProvider delegates governance filtering to the assembler + +- **WHEN** `resolve_prompt_facts` 执行 +- **THEN** 它 SHALL 接收治理装配器已过滤的 prompt declarations 和 capability surface +- **AND** SHALL NOT 自行实现 `prompt_declaration_is_visible` 过滤逻辑 From b17c133744c643d6999ed7c3dc03558af0f30e47 Mon Sep 17 00:00:00 2001 From: whatevertogo <whatevertogo@users.noreply.github.com> Date: Mon, 20 Apr 2026 01:51:40 +0800 Subject: [PATCH 46/53] =?UTF-8?q?=E2=9C=A8=20feat(execution-tasks):=20?= =?UTF-8?q?=E5=BC=95=E5=85=A5=E6=89=A7=E8=A1=8C=E6=9C=9F=20task=20?= =?UTF-8?q?=E5=BF=AB=E7=85=A7=E7=B3=BB=E7=BB=9F=E4=B8=8E=20prompt=20?= =?UTF-8?q?=E7=BC=93=E5=AD=98=E6=8F=90=E7=A4=BA=E5=9F=BA=E7=A1=80=E8=AE=BE?= =?UTF-8?q?=E6=96=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### 执行期 task 快照(taskWrite) crates/core/src/execution_task.rs - 新增执行期 task 领域模型(TaskSnapshot、ExecutionTaskItem、ExecutionTaskStatus) crates/adapter-tools/src/builtin_tools/task_write.rs - 新增 taskWrite 工具:维护当前 owner 的执行工作清单,支持 pending/in_progress/completed 三态 crates/session-runtime/src/state/tasks.rs - 从事件流 replay task 快照,按 owner 隔离并增量维护 active_tasks 缓存 crates/session-runtime/src/turn/request.rs - 将活跃 task 快照注入 system prompt(live_task_snapshot_declaration),仅注入 in_progress 与 pending crates/application/src/terminal/mod.rs, terminal_queries/resume.rs - 新增 TaskItemFacts 类型,从 session-runtime 读取 root owner 快照写入控制状态 crates/protocol/src/http/conversation/v1.rs - 新增 ConversationTaskItemDto/ConversationTaskStatusDto,control state 携带 activeTasks crates/server/src/http/terminal_projection.rs, routes/conversation.rs - 服务端 DTO 映射与 SSE control state delta 推送 frontend/src/components/Chat/TaskPanel.tsx, index.tsx, types.ts, lib/api/conversation.ts - 前端 TaskPanel 组件、类型定义、activeTasks 解析与 hydration ### 结果构造器重构 crates/core/src/action.rs, registry/router.rs - 用 from_common 一步构造替代 with_common 二段式覆盖,消除 placeholder 字段 crates/kernel/src/registry/tool.rs, crates/plugin/src/invoker.rs - 调用方迁移到 from_common crates/adapter-tools/src/agent_tools/{result_mapping,collab_result_mapping}.rs - 子运行与协作结果映射迁移到 from_common ### Prompt 缓存提示 crates/core/src/ports.rs - 新增 PromptCacheHints、PromptLayerFingerprints,LlmRequest 携带 cache hints crates/adapter-prompt/src/layered_builder.rs, core_port.rs - 分层构建器收集 layer fingerprint 与 unchanged_layers,设置 cache_boundary crates/core/src/event/types.rs, crates/session-runtime/src/turn/events.rs - PromptMetricsPayload 携带 prompt_cache_unchanged_layers ### 修复 crates/adapter-llm/src/anthropic/dto.rs - Thinking block 的 cache_control 误判为不可操作,统一四类 block 的 set_cache_control_if_allowed crates/adapter-llm/src/anthropic/stream.rs - flush_sse_buffer 仅处理单块导致尾部 block 丢失,改为循环处理所有完整块 frontend/src/App.tsx - mode 切换后右上角 badge 被旧流式快照短暂覆盖,优先使用本地 activeModeId crates/core/src/ports.rs - 移除 RecoveredSessionState/SessionRecoveryCheckpoint 上不可满足的 PartialEq derive ### 文档与测试 crates/core/src/lib.rs - 更新模块文档,按功能分类列出所有主要模块 --- crates/adapter-llm/src/anthropic/dto.rs | 40 +- crates/adapter-llm/src/anthropic/provider.rs | 36 +- crates/adapter-llm/src/anthropic/stream.rs | 89 ++++- crates/adapter-llm/src/openai.rs | 49 ++- crates/adapter-prompt/src/composer.rs | 9 +- crates/adapter-prompt/src/core_port.rs | 49 ++- crates/adapter-prompt/src/layered_builder.rs | 85 ++++- .../adapter-storage/src/session/event_log.rs | 34 +- crates/adapter-storage/src/session/mod.rs | 2 + crates/adapter-storage/src/session/paths.rs | 41 +++ .../adapter-storage/src/session/repository.rs | 93 ++++- .../src/agent_tools/collab_result_mapping.rs | 44 +-- .../src/agent_tools/result_mapping.rs | 42 +-- crates/adapter-tools/src/builtin_tools/mod.rs | 2 + .../src/builtin_tools/task_write.rs | 345 ++++++++++++++++++ crates/application/src/agent/test_support.rs | 1 + .../src/governance_surface/tests.rs | 1 + crates/application/src/mode/compiler.rs | 13 + crates/application/src/ports/app_session.rs | 15 +- crates/application/src/terminal/mod.rs | 14 +- .../application/src/terminal_queries/mod.rs | 1 + .../src/terminal_queries/resume.rs | 26 +- .../application/src/terminal_queries/tests.rs | 126 ++++++- crates/application/src/test_support.rs | 31 +- crates/cli/src/state/mod.rs | 2 + crates/core/src/action.rs | 55 ++- crates/core/src/event/types.rs | 4 +- crates/core/src/execution_task.rs | 138 +++++++ crates/core/src/lib.rs | 64 +++- crates/core/src/policy/engine.rs | 2 +- crates/core/src/ports.rs | 80 +++- crates/core/src/projection/agent_state.rs | 12 +- crates/core/src/registry/router.rs | 55 ++- crates/kernel/src/registry/tool.rs | 16 +- crates/plugin/src/invoker.rs | 68 ++-- crates/protocol/src/http/conversation/v1.rs | 19 + crates/protocol/src/http/mod.rs | 5 +- .../tests/conversation_conformance.rs | 15 +- .../fixtures/conversation/v1/snapshot.json | 13 +- crates/server/src/bootstrap/capabilities.rs | 24 +- crates/server/src/http/routes/conversation.rs | 69 +++- crates/server/src/http/terminal_projection.rs | 25 +- crates/session-runtime/src/actor/mod.rs | 38 +- crates/session-runtime/src/command/mod.rs | 11 +- crates/session-runtime/src/lib.rs | 16 +- .../src/query/conversation/tests.rs | 70 ++++ crates/session-runtime/src/query/service.rs | 54 ++- crates/session-runtime/src/state/execution.rs | 45 ++- .../session-runtime/src/state/input_queue.rs | 23 +- crates/session-runtime/src/state/mod.rs | 124 ++++++- crates/session-runtime/src/state/tasks.rs | 192 ++++++++++ .../session-runtime/src/state/test_support.rs | 44 ++- crates/session-runtime/src/turn/events.rs | 4 + crates/session-runtime/src/turn/interrupt.rs | 1 + .../src/turn/manual_compact.rs | 1 + crates/session-runtime/src/turn/request.rs | 191 +++++++++- crates/session-runtime/src/turn/submit.rs | 73 ++-- .../session-runtime/src/turn/test_support.rs | 1 + frontend/src/App.tsx | 4 +- frontend/src/components/Chat/TaskPanel.tsx | 137 +++++++ frontend/src/components/Chat/index.tsx | 2 + frontend/src/lib/api/conversation.test.ts | 86 +++++ frontend/src/lib/api/conversation.ts | 70 +++- frontend/src/lib/sessionFork.test.ts | 1 + frontend/src/types.ts | 9 + 65 files changed, 2729 insertions(+), 322 deletions(-) create mode 100644 crates/adapter-tools/src/builtin_tools/task_write.rs create mode 100644 crates/core/src/execution_task.rs create mode 100644 crates/session-runtime/src/state/tasks.rs create mode 100644 frontend/src/components/Chat/TaskPanel.tsx diff --git a/crates/adapter-llm/src/anthropic/dto.rs b/crates/adapter-llm/src/anthropic/dto.rs index 79007da9..a4ac3e70 100644 --- a/crates/adapter-llm/src/anthropic/dto.rs +++ b/crates/adapter-llm/src/anthropic/dto.rs @@ -172,10 +172,10 @@ impl AnthropicContentBlock { None }; match self { - AnthropicContentBlock::Text { cache_control, .. } => *cache_control = control, - AnthropicContentBlock::Thinking { .. } => return false, - AnthropicContentBlock::ToolUse { cache_control, .. } => *cache_control = control, - AnthropicContentBlock::ToolResult { cache_control, .. } => *cache_control = control, + AnthropicContentBlock::Text { cache_control, .. } + | AnthropicContentBlock::Thinking { cache_control, .. } + | AnthropicContentBlock::ToolUse { cache_control, .. } + | AnthropicContentBlock::ToolResult { cache_control, .. } => *cache_control = control, } true } @@ -308,3 +308,35 @@ pub(super) fn extract_usage_from_payload( fn parse_usage_value(value: &Value) -> Option<AnthropicUsage> { serde_json::from_value::<AnthropicUsage>(value.clone()).ok() } + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::{AnthropicCacheControl, AnthropicContentBlock}; + + #[test] + fn clearing_cache_control_reports_success_for_non_text_blocks() { + let mut block = AnthropicContentBlock::Thinking { + thinking: "reasoning".to_string(), + signature: None, + cache_control: Some(AnthropicCacheControl::ephemeral()), + }; + + assert!(block.set_cache_control_if_allowed(false)); + assert!(!block.has_cache_control()); + } + + #[test] + fn enabling_cache_control_still_rejects_unsupported_blocks() { + let mut block = AnthropicContentBlock::ToolUse { + id: "call_1".to_string(), + name: "search".to_string(), + input: json!({ "q": "rust" }), + cache_control: None, + }; + + assert!(!block.set_cache_control_if_allowed(true)); + assert!(!block.has_cache_control()); + } +} diff --git a/crates/adapter-llm/src/anthropic/provider.rs b/crates/adapter-llm/src/anthropic/provider.rs index 099652ca..38c5c356 100644 --- a/crates/adapter-llm/src/anthropic/provider.rs +++ b/crates/adapter-llm/src/anthropic/provider.rs @@ -290,15 +290,31 @@ impl LlmProvider for AnthropicProvider { let cancel = request.cancel; // 检测缓存失效并记录原因 - let system_prompt_text = request.system_prompt.as_deref().unwrap_or(""); + let system_prompt_text = request + .prompt_cache_hints + .as_ref() + .map(cacheable_prefix_cache_key) + .unwrap_or_else(|| request.system_prompt.clone().unwrap_or_default()); let tool_names: Vec<String> = request.tools.iter().map(|t| t.name.clone()).collect(); if let Ok(mut tracker) = self.cache_tracker.lock() { - let break_reasons = - tracker.check_and_update(system_prompt_text, &tool_names, &self.model, "anthropic"); + let break_reasons = tracker.check_and_update( + &system_prompt_text, + &tool_names, + &self.model, + "anthropic", + ); if !break_reasons.is_empty() { - debug!("[CACHE] Cache break detected: {:?}", break_reasons); + debug!( + "[CACHE] Cache break detected: {:?}, unchanged_layers={:?}", + break_reasons, + request + .prompt_cache_hints + .as_ref() + .map(|hints| hints.unchanged_layers.as_slice()) + .unwrap_or(&[]) + ); } } @@ -446,6 +462,18 @@ impl LlmProvider for AnthropicProvider { } } +fn cacheable_prefix_cache_key(hints: &astrcode_core::PromptCacheHints) -> String { + [ + hints.layer_fingerprints.stable.as_deref(), + hints.layer_fingerprints.semi_stable.as_deref(), + hints.layer_fingerprints.inherited.as_deref(), + ] + .into_iter() + .flatten() + .collect::<Vec<_>>() + .join("|") +} + #[cfg(test)] mod tests { use super::AnthropicProvider; diff --git a/crates/adapter-llm/src/anthropic/stream.rs b/crates/adapter-llm/src/anthropic/stream.rs index b2b10624..19e1150a 100644 --- a/crates/adapter-llm/src/anthropic/stream.rs +++ b/crates/adapter-llm/src/anthropic/stream.rs @@ -262,6 +262,20 @@ fn next_sse_block(buffer: &str) -> Option<(usize, usize)> { None } +fn apply_sse_process_result( + result: SseProcessResult, + stop_reason_out: &mut Option<String>, + usage_out: &mut AnthropicUsage, +) -> bool { + if let Some(r) = result.stop_reason { + *stop_reason_out = Some(r); + } + if let Some(usage) = result.usage { + usage_out.merge_from(usage); + } + result.done +} + pub(super) fn consume_sse_text_chunk( chunk_text: &str, sse_buffer: &mut String, @@ -277,13 +291,7 @@ pub(super) fn consume_sse_text_chunk( let block = &block[..block_end]; let result = process_sse_block(block, accumulator, sink)?; - if let Some(r) = result.stop_reason { - *stop_reason_out = Some(r); - } - if let Some(usage) = result.usage { - usage_out.merge_from(usage); - } - if result.done { + if apply_sse_process_result(result, stop_reason_out, usage_out) { return Ok(true); } } @@ -303,12 +311,20 @@ pub(super) fn flush_sse_buffer( return Ok(()); } - let result = process_sse_block(sse_buffer, accumulator, sink)?; - if let Some(r) = result.stop_reason { - *stop_reason_out = Some(r); + while let Some((block_end, delimiter_len)) = next_sse_block(sse_buffer) { + let block: String = sse_buffer.drain(..block_end + delimiter_len).collect(); + let block = &block[..block_end]; + + let result = process_sse_block(block, accumulator, sink)?; + if apply_sse_process_result(result, stop_reason_out, usage_out) { + sse_buffer.clear(); + return Ok(()); + } } - if let Some(usage) = result.usage { - usage_out.merge_from(usage); + + if !sse_buffer.trim().is_empty() { + let result = process_sse_block(sse_buffer, accumulator, sink)?; + apply_sse_process_result(result, stop_reason_out, usage_out); } sse_buffer.clear(); Ok(()) @@ -324,7 +340,7 @@ mod tests { use serde_json::json; - use super::{consume_sse_text_chunk, parse_sse_block}; + use super::{consume_sse_text_chunk, flush_sse_buffer, parse_sse_block}; use crate::{ LlmAccumulator, LlmEvent, LlmUsage, Utf8StreamDecoder, anthropic::dto::AnthropicUsage, sink_collector, @@ -605,4 +621,51 @@ mod tests { ); assert_eq!(output.content, "你好"); } + + #[test] + fn flush_sse_buffer_processes_all_complete_blocks_before_tail_block() { + let mut accumulator = LlmAccumulator::default(); + let events = Arc::new(Mutex::new(Vec::new())); + let sink = sink_collector(events.clone()); + let mut sse_buffer = concat!( + "event: content_block_delta\n", + "data: {\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"hello\"}}\n\n", + "event: message_delta\n", + "data: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\"},\"usage\":", + "{\"output_tokens\":7}}" + ) + .to_string(); + let mut stop_reason_out = None; + let mut usage_out = AnthropicUsage::default(); + + flush_sse_buffer( + &mut sse_buffer, + &mut accumulator, + &sink, + &mut stop_reason_out, + &mut usage_out, + ) + .expect("flush should process buffered blocks"); + + let output = accumulator.finish(); + let events = events.lock().expect("lock").clone(); + + assert!(sse_buffer.is_empty()); + assert!( + events + .iter() + .any(|event| matches!(event, LlmEvent::TextDelta(text) if text == "hello")) + ); + assert_eq!(output.content, "hello"); + assert_eq!(stop_reason_out.as_deref(), Some("end_turn")); + assert_eq!( + usage_out.into_llm_usage(), + Some(LlmUsage { + input_tokens: 0, + output_tokens: 7, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + }) + ); + } } diff --git a/crates/adapter-llm/src/openai.rs b/crates/adapter-llm/src/openai.rs index a307893e..7ad6d32a 100644 --- a/crates/adapter-llm/src/openai.rs +++ b/crates/adapter-llm/src/openai.rs @@ -29,7 +29,8 @@ use std::{ }; use astrcode_core::{ - AstrError, CancelToken, LlmMessage, ReasoningContent, Result, ToolCallRequest, ToolDefinition, + AstrError, CancelToken, LlmMessage, PromptCacheHints, ReasoningContent, Result, + ToolCallRequest, ToolDefinition, }; use async_trait::async_trait; use futures_util::StreamExt; @@ -118,6 +119,7 @@ impl OpenAiProvider { tools: &'a [ToolDefinition], system_prompt: Option<&'a str>, system_prompt_blocks: &'a [astrcode_core::SystemPromptBlock], + prompt_cache_hints: Option<&'a PromptCacheHints>, max_output_tokens_override: Option<usize>, stream: bool, ) -> OpenAiChatRequest<'a> { @@ -160,7 +162,13 @@ impl OpenAiProvider { max_tokens: effective_max_output_tokens.min(u32::MAX as usize) as u32, messages: request_messages, prompt_cache_key: self.should_send_prompt_cache_key().then(|| { - build_prompt_cache_key(&self.model, system_prompt, system_prompt_blocks, tools) + build_prompt_cache_key( + &self.model, + system_prompt, + system_prompt_blocks, + prompt_cache_hints, + tools, + ) }), prompt_cache_retention: None, tools: if tools.is_empty() { @@ -264,13 +272,27 @@ fn build_prompt_cache_key( model: &str, system_prompt: Option<&str>, system_prompt_blocks: &[astrcode_core::SystemPromptBlock], + prompt_cache_hints: Option<&PromptCacheHints>, tools: &[ToolDefinition], ) -> String { let mut hasher = DefaultHasher::new(); "astrcode-openai-prompt-cache-v1".hash(&mut hasher); model.hash(&mut hasher); - if !system_prompt_blocks.is_empty() { + if let Some(hints) = prompt_cache_hints { + if let Some(stable) = &hints.layer_fingerprints.stable { + "stable".hash(&mut hasher); + stable.hash(&mut hasher); + } + if let Some(semi_stable) = &hints.layer_fingerprints.semi_stable { + "semi_stable".hash(&mut hasher); + semi_stable.hash(&mut hasher); + } + if let Some(inherited) = &hints.layer_fingerprints.inherited { + "inherited".hash(&mut hasher); + inherited.hash(&mut hasher); + } + } else if !system_prompt_blocks.is_empty() { for block in system_prompt_blocks { format!("{:?}", block.layer).hash(&mut hasher); block.title.hash(&mut hasher); @@ -309,6 +331,7 @@ impl LlmProvider for OpenAiProvider { &request.tools, request.system_prompt.as_deref(), &request.system_prompt_blocks, + request.prompt_cache_hints.as_ref(), request.max_output_tokens_override, sink.is_some(), ); @@ -1036,8 +1059,15 @@ mod tests { content: "hi".to_string(), origin: UserMessageOrigin::User, }]; - let request = - provider.build_request(&messages, &[], Some("Follow the rules"), &[], None, false); + let request = provider.build_request( + &messages, + &[], + Some("Follow the rules"), + &[], + None, + None, + false, + ); assert_eq!(request.messages[0].role, "system"); assert_eq!( @@ -1091,7 +1121,8 @@ mod tests { layer: astrcode_core::SystemPromptLayer::Inherited, }, ]; - let request = provider.build_request(&messages, &[], None, &system_blocks, None, false); + let request = + provider.build_request(&messages, &[], None, &system_blocks, None, None, false); let body = serde_json::to_value(&request).expect("request should serialize"); // 应该有 4 个 system 消息 + 1 个 user 消息,无 cache_control 字段 @@ -1152,6 +1183,7 @@ mod tests { Some("Follow the rules"), &[], None, + None, false, )) .expect("request should serialize"); @@ -1161,6 +1193,7 @@ mod tests { Some("Follow the rules"), &[], None, + None, false, )) .expect("request should serialize"); @@ -1193,8 +1226,8 @@ mod tests { origin: UserMessageOrigin::User, }]; - let capped = provider.build_request(&messages, &[], None, &[], Some(1024), false); - let clamped = provider.build_request(&messages, &[], None, &[], Some(4096), false); + let capped = provider.build_request(&messages, &[], None, &[], None, Some(1024), false); + let clamped = provider.build_request(&messages, &[], None, &[], None, Some(4096), false); assert_eq!(capped.max_tokens, 1024); assert_eq!(clamped.max_tokens, 2048); diff --git a/crates/adapter-prompt/src/composer.rs b/crates/adapter-prompt/src/composer.rs index e3b36407..41befa93 100644 --- a/crates/adapter-prompt/src/composer.rs +++ b/crates/adapter-prompt/src/composer.rs @@ -31,7 +31,7 @@ use std::{ }; use anyhow::{Result, anyhow}; -use astrcode_core::{LlmMessage, UserMessageOrigin}; +use astrcode_core::{LlmMessage, PromptCacheHints, UserMessageOrigin}; use super::{ BlockCondition, BlockContent, BlockKind, BlockSpec, PromptBlock, PromptContext, @@ -109,6 +109,7 @@ pub struct PromptComposer { pub struct PromptBuildOutput { pub plan: PromptPlan, pub diagnostics: PromptDiagnostics, + pub cache_hints: PromptCacheHints, } /// 贡献者缓存条目。 @@ -218,7 +219,11 @@ impl PromptComposer { let candidates = self.filter_duplicate_block_ids(candidates, &mut diagnostics)?; self.resolve_candidates(candidates, ctx, &mut plan, &mut diagnostics)?; - Ok(PromptBuildOutput { plan, diagnostics }) + Ok(PromptBuildOutput { + plan, + diagnostics, + cache_hints: PromptCacheHints::default(), + }) } /// 收集单个 contributor 的贡献,带缓存逻辑。 diff --git a/crates/adapter-prompt/src/core_port.rs b/crates/adapter-prompt/src/core_port.rs index 3087e70b..60888ba2 100644 --- a/crates/adapter-prompt/src/core_port.rs +++ b/crates/adapter-prompt/src/core_port.rs @@ -4,7 +4,7 @@ //! 本模块将其适配到 `LayeredPromptBuilder` 的完整 prompt 构建能力上。 use astrcode_core::{ - Result, SystemPromptBlock, + Result, SystemPromptBlock, SystemPromptLayer, ports::{PromptBuildCacheMetrics, PromptBuildOutput, PromptBuildRequest, PromptProvider}, }; use async_trait::async_trait; @@ -79,23 +79,13 @@ impl PromptProvider for ComposerPromptProvider { .map_err(|e| astrcode_core::AstrError::Internal(e.to_string()))?; let system_prompt = output.plan.render_system().unwrap_or_default(); - - // 将 ordered system blocks 转为 core 的 SystemPromptBlock 格式 - let system_prompt_blocks: Vec<SystemPromptBlock> = output - .plan - .ordered_system_blocks() - .into_iter() - .map(|block| SystemPromptBlock { - title: block.title.clone(), - content: block.content.clone(), - cache_boundary: false, - layer: block.layer, - }) - .collect(); + let prompt_cache_hints = output.cache_hints.clone(); + let system_prompt_blocks = build_system_prompt_blocks(&output.plan); Ok(PromptBuildOutput { system_prompt, system_prompt_blocks, + prompt_cache_hints: prompt_cache_hints.clone(), cache_metrics: summarize_prompt_cache_metrics(&output), metadata: serde_json::json!({ "extra_tools_count": output.plan.extra_tools.len(), @@ -103,6 +93,7 @@ impl PromptProvider for ComposerPromptProvider { "profile": request.profile, "step_index": request.step_index, "turn_index": request.turn_index, + "promptCacheHints": prompt_cache_hints, }), }) } @@ -176,9 +167,39 @@ fn summarize_prompt_cache_metrics(output: &crate::PromptBuildOutput) -> PromptBu _ => {}, } } + metrics.unchanged_layers = output.cache_hints.unchanged_layers.clone(); metrics } +fn build_system_prompt_blocks(plan: &crate::PromptPlan) -> Vec<SystemPromptBlock> { + let ordered = plan.ordered_system_blocks(); + let mut last_cacheable_index = std::collections::HashMap::<SystemPromptLayer, usize>::new(); + for (index, block) in ordered.iter().enumerate() { + if cacheable_prompt_layer(block.layer) { + last_cacheable_index.insert(block.layer, index); + } + } + ordered + .into_iter() + .enumerate() + .map(|(index, block)| SystemPromptBlock { + title: block.title.clone(), + content: block.content.clone(), + cache_boundary: last_cacheable_index + .get(&block.layer) + .is_some_and(|candidate| *candidate == index), + layer: block.layer, + }) + .collect() +} + +fn cacheable_prompt_layer(layer: SystemPromptLayer) -> bool { + matches!( + layer, + SystemPromptLayer::Stable | SystemPromptLayer::SemiStable | SystemPromptLayer::Inherited + ) +} + fn insert_json_string( vars: &mut std::collections::HashMap<String, String>, key: &str, diff --git a/crates/adapter-prompt/src/layered_builder.rs b/crates/adapter-prompt/src/layered_builder.rs index 1943977f..5dba11fb 100644 --- a/crates/adapter-prompt/src/layered_builder.rs +++ b/crates/adapter-prompt/src/layered_builder.rs @@ -4,12 +4,14 @@ //! `PromptPlan.system_blocks` 的层级元数据中,供 Anthropic prompt caching 使用。 use std::{ - collections::HashMap, + collections::{HashMap, hash_map::DefaultHasher}, + hash::{Hash, Hasher}, sync::{Arc, Mutex}, time::{Duration, Instant}, }; use anyhow::Result; +use astrcode_core::{PromptCacheHints, PromptLayerFingerprints}; use super::{ PromptBuildOutput, PromptComposer, PromptComposerOptions, PromptContext, PromptContributor, @@ -130,6 +132,7 @@ impl LayeredPromptBuilder { pub async fn build(&self, ctx: &PromptContext) -> Result<PromptBuildOutput> { let mut diagnostics = PromptDiagnostics::default(); let mut plan = PromptPlan::default(); + let mut cache_hints = PromptCacheHints::default(); for (layer_type, contributors) in [ (LayerType::Stable, &self.stable_contributors), @@ -138,11 +141,16 @@ impl LayeredPromptBuilder { (LayerType::Dynamic, &self.dynamic_contributors), ] { let output = self.build_layer(contributors, ctx, layer_type).await?; + merge_layer_cache_hints(&mut cache_hints, layer_type, &output.cache_hints); diagnostics.items.extend(output.diagnostics.items); plan.extend_with_layer(output.plan, layer_type.prompt_layer()); } - Ok(PromptBuildOutput { plan, diagnostics }) + Ok(PromptBuildOutput { + plan, + diagnostics, + cache_hints, + }) } async fn build_layer( @@ -155,6 +163,7 @@ impl LayeredPromptBuilder { return Ok(PromptBuildOutput { plan: PromptPlan::default(), diagnostics: PromptDiagnostics::default(), + cache_hints: PromptCacheHints::default(), }); } @@ -165,7 +174,9 @@ impl LayeredPromptBuilder { let mut combined = PromptBuildOutput { plan: PromptPlan::default(), diagnostics: PromptDiagnostics::default(), + cache_hints: PromptCacheHints::default(), }; + let mut layer_unchanged = layer_type != LayerType::Dynamic; for contributor in contributors { let contributor_id = contributor.contributor_id(); @@ -188,6 +199,7 @@ impl LayeredPromptBuilder { CacheLookupResult::Miss { invalidation_reason, } => { + layer_unchanged = false; combined.diagnostics.push_cache_reuse_miss( cache_key.clone(), Some(fingerprint.clone()), @@ -206,6 +218,18 @@ impl LayeredPromptBuilder { } } + if layer_unchanged { + combined + .cache_hints + .unchanged_layers + .push(layer_type.prompt_layer()); + } + set_layer_fingerprint( + &mut combined.cache_hints.layer_fingerprints, + layer_type, + fingerprint_rendered_layer(layer_type, &combined.plan), + ); + Ok(combined) } @@ -367,6 +391,62 @@ fn compute_layer_fingerprint( .join("|") } +fn merge_layer_cache_hints( + target: &mut PromptCacheHints, + layer_type: LayerType, + source: &PromptCacheHints, +) { + set_layer_fingerprint( + &mut target.layer_fingerprints, + layer_type, + layer_fingerprint(source, layer_type).cloned(), + ); + if source + .unchanged_layers + .iter() + .any(|layer| *layer == layer_type.prompt_layer()) + { + target.unchanged_layers.push(layer_type.prompt_layer()); + } +} + +fn set_layer_fingerprint( + fingerprints: &mut PromptLayerFingerprints, + layer_type: LayerType, + fingerprint: Option<String>, +) { + match layer_type { + LayerType::Stable => fingerprints.stable = fingerprint, + LayerType::SemiStable => fingerprints.semi_stable = fingerprint, + LayerType::Inherited => fingerprints.inherited = fingerprint, + LayerType::Dynamic => fingerprints.dynamic = fingerprint, + } +} + +fn layer_fingerprint<'a>(hints: &'a PromptCacheHints, layer_type: LayerType) -> Option<&'a String> { + match layer_type { + LayerType::Stable => hints.layer_fingerprints.stable.as_ref(), + LayerType::SemiStable => hints.layer_fingerprints.semi_stable.as_ref(), + LayerType::Inherited => hints.layer_fingerprints.inherited.as_ref(), + LayerType::Dynamic => hints.layer_fingerprints.dynamic.as_ref(), + } +} + +fn fingerprint_rendered_layer(layer_type: LayerType, plan: &PromptPlan) -> Option<String> { + let mut hasher = DefaultHasher::new(); + let mut matched = false; + for block in plan.ordered_system_blocks() { + if block.layer != layer_type.prompt_layer() { + continue; + } + matched = true; + block.id.hash(&mut hasher); + block.title.hash(&mut hasher); + block.content.hash(&mut hasher); + } + matched.then(|| format!("{:x}", hasher.finish())) +} + fn is_cache_expired( entry: &LayerCacheEntry, options: &LayeredBuilderOptions, @@ -493,6 +573,7 @@ mod tests { output: PromptBuildOutput { plan: PromptPlan::default(), diagnostics: PromptDiagnostics::default(), + cache_hints: PromptCacheHints::default(), }, }; let options = LayeredBuilderOptions { diff --git a/crates/adapter-storage/src/session/event_log.rs b/crates/adapter-storage/src/session/event_log.rs index 22e6e281..66c26851 100644 --- a/crates/adapter-storage/src/session/event_log.rs +++ b/crates/adapter-storage/src/session/event_log.rs @@ -192,14 +192,31 @@ impl EventLog { /// 序列化为 JSON 行写入文件,然后立即 flush 并 sync 到磁盘。 /// 返回包含已分配 `storage_seq` 的 `StoredEvent`。 pub fn append_stored(&mut self, event: &StorageEvent) -> Result<StoredEvent> { - let stored = StoredEvent { - storage_seq: self.next_storage_seq, - event: event.clone(), - }; + Ok(self + .append_batch(std::slice::from_ref(event))? + .into_iter() + .next() + .expect("single append should always produce one stored event")) + } - serde_json::to_writer(&mut self.writer, &stored) - .map_err(|e| crate::parse_error("failed to serialize StoredEvent", e))?; - writeln!(self.writer).map_err(|e| crate::io_error("failed to write newline", e))?; + pub fn append_batch(&mut self, events: &[StorageEvent]) -> Result<Vec<StoredEvent>> { + let mut stored_events = Vec::with_capacity(events.len()); + for event in events { + let stored = StoredEvent { + storage_seq: self.next_storage_seq, + event: event.clone(), + }; + serde_json::to_writer(&mut self.writer, &stored) + .map_err(|e| crate::parse_error("failed to serialize StoredEvent", e))?; + writeln!(self.writer).map_err(|e| crate::io_error("failed to write newline", e))?; + self.next_storage_seq = self.next_storage_seq.saturating_add(1); + stored_events.push(stored); + } + self.flush_and_sync()?; + Ok(stored_events) + } + + pub(crate) fn flush_and_sync(&mut self) -> Result<()> { self.writer .flush() .map_err(|e| crate::io_error("failed to flush event log", e))?; @@ -207,8 +224,7 @@ impl EventLog { .get_ref() .sync_all() .map_err(|e| crate::io_error("failed to sync event log", e))?; - self.next_storage_seq = self.next_storage_seq.saturating_add(1); - Ok(stored) + Ok(()) } /// 回放指定路径的会话文件中的所有事件。 diff --git a/crates/adapter-storage/src/session/mod.rs b/crates/adapter-storage/src/session/mod.rs index d0787a12..88b56366 100644 --- a/crates/adapter-storage/src/session/mod.rs +++ b/crates/adapter-storage/src/session/mod.rs @@ -20,6 +20,8 @@ //! └── active-turn.json # 锁持有者元数据 //! ``` +mod batch_appender; +mod checkpoint; mod event_log; mod iterator; mod paths; diff --git a/crates/adapter-storage/src/session/paths.rs b/crates/adapter-storage/src/session/paths.rs index 6348f613..91977caf 100644 --- a/crates/adapter-storage/src/session/paths.rs +++ b/crates/adapter-storage/src/session/paths.rs @@ -218,6 +218,47 @@ pub(crate) fn session_turn_metadata_path_from_projects_root( ) } +pub(crate) fn snapshots_dir(session_id: &str) -> Result<PathBuf> { + Ok(resolve_existing_session_dir(session_id)?.join("snapshots")) +} + +pub(crate) fn snapshots_dir_from_projects_root( + projects_root: &Path, + session_id: &str, +) -> Result<PathBuf> { + Ok( + resolve_existing_session_dir_from_projects_root(projects_root, session_id)? + .join("snapshots"), + ) +} + +pub(crate) fn checkpoint_snapshot_path( + session_id: &str, + checkpoint_storage_seq: u64, +) -> Result<PathBuf> { + Ok(snapshots_dir(session_id)?.join(format!("checkpoint-{checkpoint_storage_seq}.json"))) +} + +pub(crate) fn checkpoint_snapshot_path_from_projects_root( + projects_root: &Path, + session_id: &str, + checkpoint_storage_seq: u64, +) -> Result<PathBuf> { + Ok(snapshots_dir_from_projects_root(projects_root, session_id)? + .join(format!("checkpoint-{checkpoint_storage_seq}.json"))) +} + +pub(crate) fn latest_checkpoint_marker_path(session_id: &str) -> Result<PathBuf> { + Ok(snapshots_dir(session_id)?.join("latest-checkpoint.json")) +} + +pub(crate) fn latest_checkpoint_marker_path_from_projects_root( + projects_root: &Path, + session_id: &str, +) -> Result<PathBuf> { + Ok(snapshots_dir_from_projects_root(projects_root, session_id)?.join("latest-checkpoint.json")) +} + /// 枚举所有项目下的 sessions 目录。 /// /// 遍历 `projects_root` 下的每个子目录,查找其 `sessions` 子目录, diff --git a/crates/adapter-storage/src/session/repository.rs b/crates/adapter-storage/src/session/repository.rs index 3b3dadd0..fb35c4cd 100644 --- a/crates/adapter-storage/src/session/repository.rs +++ b/crates/adapter-storage/src/session/repository.rs @@ -1,14 +1,20 @@ -use std::path::{Path, PathBuf}; +use std::{ + path::{Path, PathBuf}, + sync::Arc, +}; use astrcode_core::{ - DeleteProjectResult, Result, SessionId, SessionMeta, SessionTurnAcquireResult, StorageEvent, - StoredEvent, + DeleteProjectResult, RecoveredSessionState, Result, SessionId, SessionMeta, + SessionRecoveryCheckpoint, SessionTurnAcquireResult, StorageEvent, StoredEvent, ports::EventStore, store::{EventLogWriter, SessionManager, StoreResult}, }; use async_trait::async_trait; +use tokio::sync::Mutex; use super::{ + batch_appender::{BatchAppender, SharedAppenderRegistry}, + checkpoint, event_log::EventLog, iterator::EventLogIterator, paths::resolve_existing_session_path, @@ -16,15 +22,17 @@ use super::{ }; /// 基于本地文件系统的会话仓储实现。 -#[derive(Debug, Default, Clone)] +#[derive(Clone)] pub struct FileSystemSessionRepository { projects_root: Option<PathBuf>, + appenders: SharedAppenderRegistry, } impl FileSystemSessionRepository { pub fn new() -> Self { Self { projects_root: None, + appenders: default_appender_registry(), } } @@ -35,6 +43,7 @@ impl FileSystemSessionRepository { pub fn new_with_projects_root(projects_root: PathBuf) -> Self { Self { projects_root: Some(projects_root), + appenders: default_appender_registry(), } } @@ -73,6 +82,24 @@ impl FileSystemSessionRepository { log.append_stored(event) } + pub fn recover_session_sync(&self, session_id: &str) -> StoreResult<RecoveredSessionState> { + checkpoint::recover_session(self.projects_root.as_deref(), session_id) + } + + pub fn checkpoint_session_sync( + &self, + event_log_path: &Path, + session_id: &str, + checkpoint: &SessionRecoveryCheckpoint, + ) -> StoreResult<()> { + checkpoint::persist_checkpoint( + self.projects_root.as_deref(), + event_log_path, + session_id, + checkpoint, + ) + } + pub fn replay_events_sync(&self, session_id: &str) -> StoreResult<Vec<StoredEvent>> { let path = match &self.projects_root { Some(projects_root) => super::paths::resolve_existing_session_path_from_projects_root( @@ -140,6 +167,25 @@ impl FileSystemSessionRepository { }; EventLog::last_storage_seq_from_path(&path) } + + async fn appender_for_session(&self, session_id: &str) -> Arc<BatchAppender> { + let mut registry = self.appenders.lock().await; + if let Some(appender) = registry.get(session_id) { + return Arc::clone(appender); + } + let appender = Arc::new(BatchAppender::new( + session_id.to_string(), + self.projects_root.clone(), + )); + registry.insert(session_id.to_string(), Arc::clone(&appender)); + appender + } +} + +impl Default for FileSystemSessionRepository { + fn default() -> Self { + Self::new() + } } #[async_trait] @@ -155,24 +201,47 @@ impl EventStore for FileSystemSessionRepository { } async fn append(&self, session_id: &SessionId, event: &StorageEvent) -> Result<StoredEvent> { + let appender = self.appender_for_session(session_id.as_str()).await; + appender + .append(event.clone()) + .await + .map_err(crate::map_store_error) + } + + async fn replay(&self, session_id: &SessionId) -> Result<Vec<StoredEvent>> { let repo = self.clone(); let session_id = session_id.to_string(); - let event = event.clone(); - run_blocking("append storage event", move || { - repo.append_sync(&session_id, &event) + run_blocking("replay storage events", move || { + repo.replay_events_sync(&session_id) }) .await } - async fn replay(&self, session_id: &SessionId) -> Result<Vec<StoredEvent>> { + async fn recover_session(&self, session_id: &SessionId) -> Result<RecoveredSessionState> { let repo = self.clone(); let session_id = session_id.to_string(); - run_blocking("replay storage events", move || { - repo.replay_events_sync(&session_id) + run_blocking("recover storage session", move || { + repo.recover_session_sync(&session_id) }) .await } + async fn checkpoint_session( + &self, + session_id: &SessionId, + checkpoint: &SessionRecoveryCheckpoint, + ) -> Result<()> { + let appender = self.appender_for_session(session_id.as_str()).await; + let repo = self.clone(); + let session_id_string = session_id.to_string(); + appender + .checkpoint_with_payload(checkpoint.clone(), move |event_log_path, checkpoint| { + repo.checkpoint_session_sync(event_log_path, &session_id_string, checkpoint) + }) + .await + .map_err(crate::map_store_error) + } + async fn try_acquire_turn( &self, session_id: &SessionId, @@ -224,6 +293,10 @@ impl EventStore for FileSystemSessionRepository { } } +fn default_appender_registry() -> SharedAppenderRegistry { + Arc::new(Mutex::new(std::collections::HashMap::new())) +} + async fn run_blocking<T, F>(label: &'static str, work: F) -> Result<T> where T: Send + 'static, diff --git a/crates/adapter-tools/src/agent_tools/collab_result_mapping.rs b/crates/adapter-tools/src/agent_tools/collab_result_mapping.rs index aeb2deda..316ba854 100644 --- a/crates/adapter-tools/src/agent_tools/collab_result_mapping.rs +++ b/crates/adapter-tools/src/agent_tools/collab_result_mapping.rs @@ -23,18 +23,14 @@ pub(crate) fn collaboration_error_result( tool_name: &str, message: String, ) -> ToolExecutionResult { - ToolExecutionResult { + ToolExecutionResult::from_common( tool_call_id, - tool_name: tool_name.to_string(), - ok: false, - output: String::new(), - error: None, - metadata: None, - child_ref: None, - duration_ms: 0, - truncated: false, - } - .with_common(ExecutionResultCommon::failure(message, None, 0, false)) + tool_name, + false, + String::new(), + None, + ExecutionResultCommon::failure(message, None, 0, false), + ) } /// 将 CollaborationResult 映射为 ToolExecutionResult。 @@ -59,23 +55,19 @@ pub(crate) fn map_collaboration_result( }), }); - ToolExecutionResult { + ToolExecutionResult::from_common( tool_call_id, - tool_name: tool_name.to_string(), - ok: true, + tool_name, + true, output, - error: None, - metadata: None, - child_ref: result.agent_ref().cloned(), - duration_ms: 0, - truncated: false, - } - .with_common(ExecutionResultCommon { - error: None, - metadata, - duration_ms: 0, - truncated: false, - }) + result.agent_ref().cloned(), + ExecutionResultCommon { + error: None, + metadata, + duration_ms: 0, + truncated: false, + }, + ) } fn result_kind_label(result: &CollaborationResult) -> &'static str { diff --git a/crates/adapter-tools/src/agent_tools/result_mapping.rs b/crates/adapter-tools/src/agent_tools/result_mapping.rs index d492c1e1..3bf2589a 100644 --- a/crates/adapter-tools/src/agent_tools/result_mapping.rs +++ b/crates/adapter-tools/src/agent_tools/result_mapping.rs @@ -15,18 +15,14 @@ const SUBRUN_RESULT_SCHEMA: &str = "subRunResult"; /// 参数校验失败的快捷构造。 pub(crate) fn invalid_params_result(tool_call_id: String, message: String) -> ToolExecutionResult { - ToolExecutionResult { + ToolExecutionResult::from_common( tool_call_id, - tool_name: TOOL_NAME.to_string(), - ok: false, - output: String::new(), - error: None, - metadata: None, - child_ref: None, - duration_ms: 0, - truncated: false, - } - .with_common(ExecutionResultCommon::failure(message, None, 0, false)) + TOOL_NAME, + false, + String::new(), + None, + ExecutionResultCommon::failure(message, None, 0, false), + ) } /// 将 SubRunResult 映射为 LLM 可见的 ToolExecutionResult。 @@ -43,23 +39,19 @@ pub(crate) fn map_subrun_result(tool_call_id: String, result: SubRunResult) -> T let output = tool_output_for_result(&result); let metadata = subrun_metadata(&result); - ToolExecutionResult { + ToolExecutionResult::from_common( tool_call_id, - tool_name: TOOL_NAME.to_string(), - ok: !is_failed_outcome(&result), + TOOL_NAME, + !is_failed_outcome(&result), output, - error: None, - metadata: None, child_ref, - duration_ms: 0, - truncated: false, - } - .with_common(ExecutionResultCommon { - error, - metadata: Some(metadata), - duration_ms: 0, - truncated: false, - }) + ExecutionResultCommon { + error, + metadata: Some(metadata), + duration_ms: 0, + truncated: false, + }, + ) } /// 判断子运行是否因失败结束。 diff --git a/crates/adapter-tools/src/builtin_tools/mod.rs b/crates/adapter-tools/src/builtin_tools/mod.rs index 705acafe..b37eefcf 100644 --- a/crates/adapter-tools/src/builtin_tools/mod.rs +++ b/crates/adapter-tools/src/builtin_tools/mod.rs @@ -33,6 +33,8 @@ pub mod session_plan; pub mod shell; /// 技能工具:按需加载 skill 指令 pub mod skill_tool; +/// 执行期 task 快照写入工具:维护当前 owner 的工作清单 +pub mod task_write; /// 外部工具搜索:按需展开 MCP/plugin 工具 schema pub mod tool_search; /// session 计划工件写工具:仅允许写当前 session 的 plan 目录 diff --git a/crates/adapter-tools/src/builtin_tools/task_write.rs b/crates/adapter-tools/src/builtin_tools/task_write.rs new file mode 100644 index 00000000..ef7b2f99 --- /dev/null +++ b/crates/adapter-tools/src/builtin_tools/task_write.rs @@ -0,0 +1,345 @@ +//! `taskWrite` 工具。 +//! +//! 该工具只维护执行期 task 快照,不触碰 canonical session plan。 + +use std::time::Instant; + +use astrcode_core::{ + AstrError, ExecutionTaskItem, ExecutionTaskSnapshotMetadata, ExecutionTaskStatus, Result, + SideEffect, TaskSnapshot, Tool, ToolCapabilityMetadata, ToolContext, ToolDefinition, + ToolExecutionResult, ToolPromptMetadata, +}; +use async_trait::async_trait; +use serde::Deserialize; +use serde_json::json; + +use crate::builtin_tools::fs_common::check_cancel; + +const MAX_TASK_ITEMS: usize = 20; + +#[derive(Default)] +pub struct TaskWriteTool; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct TaskWriteArgs { + #[serde(default)] + items: Vec<ExecutionTaskItem>, +} + +#[async_trait] +impl Tool for TaskWriteTool { + fn definition(&self) -> ToolDefinition { + ToolDefinition { + name: "taskWrite".to_string(), + description: "Persist the current execution-task snapshot for this execution owner." + .to_string(), + parameters: json!({ + "type": "object", + "properties": { + "items": { + "type": "array", + "description": "Full execution-task snapshot for the current owner. Pass an empty array to clear active tasks.", + "maxItems": MAX_TASK_ITEMS, + "items": { + "type": "object", + "properties": { + "content": { + "type": "string", + "description": "Imperative task title, for example 'Update runtime projection tests'." + }, + "status": { + "type": "string", + "enum": ["pending", "in_progress", "completed"] + }, + "activeForm": { + "type": "string", + "description": "Present-progress phrase used while the task is actively being worked on." + } + }, + "required": ["content", "status"], + "additionalProperties": false + } + } + }, + "required": ["items"], + "additionalProperties": false + }), + } + } + + fn capability_metadata(&self) -> ToolCapabilityMetadata { + ToolCapabilityMetadata::builtin() + .tags(["task", "execution", "progress"]) + .side_effect(SideEffect::Local) + .prompt( + ToolPromptMetadata::new( + "Maintain the current execution-task snapshot for this branch of work.", + "Use `taskWrite` for non-trivial execution that benefits from an externalized \ + task list. Always send the full current snapshot, not a patch. Prefer it for \ + multi-step implementation, user requests with multiple deliverables, or work \ + that must survive long turns and session recovery.", + ) + .caveat( + "Do not use `taskWrite` for trivial one-step work, pure Q&A, or tasks that \ + can be completed in roughly three straightforward actions.", + ) + .caveat( + "Keep exactly one item in `in_progress` at a time. Mark an item `in_progress` \ + before starting it, and mark it `completed` immediately after it is truly \ + finished.", + ) + .caveat( + "Every item should have a concise imperative `content`. Any `in_progress` \ + item must also include `activeForm`, such as '正在补充会话投影测试'.", + ) + .caveat( + "Passing an empty array clears active tasks. Completed-only snapshots are \ + also treated as cleared by the runtime projection.", + ) + .example( + "{ items: [{ content: \"补齐 runtime task 投影\", status: \"in_progress\", \ + activeForm: \"正在补齐 runtime task 投影\" }, { content: \"验证 prompt \ + 注入\", status: \"pending\", activeForm: \"准备验证 prompt 注入\" }] }", + ) + .prompt_tag("task") + .always_include(true), + ) + } + + async fn execute( + &self, + tool_call_id: String, + input: serde_json::Value, + ctx: &ToolContext, + ) -> Result<ToolExecutionResult> { + check_cancel(ctx.cancel())?; + + let args: TaskWriteArgs = serde_json::from_value(input) + .map_err(|error| AstrError::parse("invalid args for taskWrite", error))?; + validate_items(&args.items)?; + + let started_at = Instant::now(); + let snapshot = TaskSnapshot { + owner: resolve_task_owner(ctx), + items: args.items, + }; + let metadata = ExecutionTaskSnapshotMetadata::from_snapshot(&snapshot); + let (pending_count, in_progress_count, completed_count) = count_statuses(&snapshot.items); + let cleared = metadata.cleared; + + Ok(ToolExecutionResult { + tool_call_id, + tool_name: "taskWrite".to_string(), + ok: true, + output: if cleared { + "cleared active execution tasks".to_string() + } else { + format!( + "updated execution tasks: {in_progress_count} in progress, {pending_count} \ + pending, {completed_count} completed" + ) + }, + error: None, + metadata: Some( + serde_json::to_value(metadata) + .map_err(|error| AstrError::parse("invalid taskWrite metadata", error))?, + ), + child_ref: None, + duration_ms: started_at.elapsed().as_millis() as u64, + truncated: false, + }) + } +} + +fn validate_items(items: &[ExecutionTaskItem]) -> Result<()> { + if items.len() > MAX_TASK_ITEMS { + return Err(AstrError::Validation(format!( + "taskWrite accepts at most {MAX_TASK_ITEMS} items per snapshot" + ))); + } + + let in_progress_count = items + .iter() + .filter(|item| item.status == ExecutionTaskStatus::InProgress) + .count(); + if in_progress_count > 1 { + return Err(AstrError::Validation( + "taskWrite snapshot must contain at most one in_progress item".to_string(), + )); + } + + for (index, item) in items.iter().enumerate() { + if item.content.trim().is_empty() { + return Err(AstrError::Validation(format!( + "taskWrite item #{index} content must not be empty" + ))); + } + if item + .active_form + .as_deref() + .is_some_and(|value| value.trim().is_empty()) + { + return Err(AstrError::Validation(format!( + "taskWrite item #{} activeForm must not be blank when provided", + index + ))); + } + if item.status == ExecutionTaskStatus::InProgress + && item + .active_form + .as_deref() + .is_none_or(|value| value.trim().is_empty()) + { + return Err(AstrError::Validation(format!( + "taskWrite item #{} with status in_progress must include activeForm", + index + ))); + } + } + + Ok(()) +} + +fn resolve_task_owner(ctx: &ToolContext) -> String { + ctx.agent_context() + .agent_id + .as_ref() + .map(ToString::to_string) + .unwrap_or_else(|| ctx.session_id().to_string()) +} + +fn count_statuses(items: &[ExecutionTaskItem]) -> (usize, usize, usize) { + items + .iter() + .fold((0usize, 0usize, 0usize), |counts, item| match item.status { + ExecutionTaskStatus::Pending => (counts.0 + 1, counts.1, counts.2), + ExecutionTaskStatus::InProgress => (counts.0, counts.1 + 1, counts.2), + ExecutionTaskStatus::Completed => (counts.0, counts.1, counts.2 + 1), + }) +} + +#[cfg(test)] +mod tests { + use astrcode_core::AgentEventContext; + use serde_json::Value; + + use super::*; + use crate::test_support::test_tool_context_for; + + fn metadata_snapshot(result: &ToolExecutionResult) -> ExecutionTaskSnapshotMetadata { + serde_json::from_value(result.metadata.clone().expect("metadata should exist")) + .expect("task metadata should decode") + } + + #[tokio::test] + async fn task_write_accepts_valid_snapshot() { + let temp = tempfile::tempdir().expect("tempdir should exist"); + let tool = TaskWriteTool; + let ctx = test_tool_context_for(temp.path()).with_agent_context(AgentEventContext { + agent_id: Some("agent-main".into()), + ..AgentEventContext::default() + }); + + let result = tool + .execute( + "tc-task-valid".to_string(), + json!({ + "items": [ + { + "content": "实现 task 投影", + "status": "in_progress", + "activeForm": "正在实现 task 投影" + }, + { + "content": "补充服务端映射测试", + "status": "pending" + } + ] + }), + &ctx, + ) + .await + .expect("taskWrite should execute"); + + assert!(result.ok); + let metadata = metadata_snapshot(&result); + assert_eq!(metadata.owner, "agent-main"); + assert!(!metadata.cleared); + assert_eq!(metadata.items.len(), 2); + } + + #[tokio::test] + async fn task_write_rejects_invalid_snapshot() { + let temp = tempfile::tempdir().expect("tempdir should exist"); + let tool = TaskWriteTool; + + let error = tool + .execute( + "tc-task-invalid".to_string(), + json!({ + "items": [ + { + "content": "任务 A", + "status": "in_progress", + "activeForm": "正在处理任务 A" + }, + { + "content": "任务 B", + "status": "in_progress", + "activeForm": "正在处理任务 B" + } + ] + }), + &test_tool_context_for(temp.path()), + ) + .await + .expect_err("multiple in_progress items should be rejected"); + + assert!(error.to_string().contains("at most one in_progress item")); + } + + #[tokio::test] + async fn task_write_rejects_oversized_snapshot() { + let temp = tempfile::tempdir().expect("tempdir should exist"); + let tool = TaskWriteTool; + let items = (0..=MAX_TASK_ITEMS) + .map(|index| { + json!({ + "content": format!("任务 {index}"), + "status": "pending" + }) + }) + .collect::<Vec<Value>>(); + + let error = tool + .execute( + "tc-task-oversized".to_string(), + json!({ "items": items }), + &test_tool_context_for(temp.path()), + ) + .await + .expect_err("oversized snapshot should be rejected"); + + assert!(error.to_string().contains("at most 20 items")); + } + + #[tokio::test] + async fn task_write_falls_back_to_session_owner_without_agent_id() { + let temp = tempfile::tempdir().expect("tempdir should exist"); + let tool = TaskWriteTool; + + let result = tool + .execute( + "tc-task-owner".to_string(), + json!({ "items": [] }), + &test_tool_context_for(temp.path()), + ) + .await + .expect("taskWrite should execute"); + + let metadata = metadata_snapshot(&result); + assert_eq!(metadata.owner, "session-test"); + assert!(metadata.cleared); + } +} diff --git a/crates/application/src/agent/test_support.rs b/crates/application/src/agent/test_support.rs index 99c71e35..a83d2af4 100644 --- a/crates/application/src/agent/test_support.rs +++ b/crates/application/src/agent/test_support.rs @@ -287,6 +287,7 @@ impl PromptProvider for TestPromptProvider { Ok(PromptBuildOutput { system_prompt: "test".to_string(), system_prompt_blocks: Vec::new(), + prompt_cache_hints: Default::default(), cache_metrics: Default::default(), metadata: Value::Null, }) diff --git a/crates/application/src/governance_surface/tests.rs b/crates/application/src/governance_surface/tests.rs index 386c2907..e362e7a8 100644 --- a/crates/application/src/governance_surface/tests.rs +++ b/crates/application/src/governance_surface/tests.rs @@ -119,6 +119,7 @@ impl PromptProvider for NoopPromptProvider { Ok(PromptBuildOutput { system_prompt: "noop".to_string(), system_prompt_blocks: Vec::new(), + prompt_cache_hints: Default::default(), cache_metrics: Default::default(), metadata: json!({}), }) diff --git a/crates/application/src/mode/compiler.rs b/crates/application/src/mode/compiler.rs index 3f6da3d6..3577a951 100644 --- a/crates/application/src/mode/compiler.rs +++ b/crates/application/src/mode/compiler.rs @@ -371,6 +371,18 @@ mod tests { .build() .expect("writeFile should build"), ))) + .register_invoker(Arc::new(FakeCapabilityInvoker::new( + CapabilitySpec::builder("taskWrite", CapabilityKind::Tool) + .description("task") + .schema( + serde_json::json!({"type":"object"}), + serde_json::json!({"type":"object"}), + ) + .tags(["task", "execution"]) + .side_effect(SideEffect::Local) + .build() + .expect("taskWrite should build"), + ))) .register_invoker(Arc::new(FakeCapabilityInvoker::new( CapabilitySpec::builder("spawn", CapabilityKind::Tool) .description("spawn") @@ -402,6 +414,7 @@ mod tests { vec![ "readFile".to_string(), "spawn".to_string(), + "taskWrite".to_string(), "writeFile".to_string() ] ); diff --git a/crates/application/src/ports/app_session.rs b/crates/application/src/ports/app_session.rs index de394833..9b34869a 100644 --- a/crates/application/src/ports/app_session.rs +++ b/crates/application/src/ports/app_session.rs @@ -8,7 +8,7 @@ use astrcode_core::{ ChildSessionNode, DeleteProjectResult, ExecutionAccepted, ResolvedRuntimeConfig, SessionId, - SessionMeta, StoredEvent, + SessionMeta, StoredEvent, TaskSnapshot, }; use astrcode_session_runtime::{ ConversationSnapshotFacts, ConversationStreamReplayFacts, ForkPoint, ForkResult, @@ -64,6 +64,11 @@ pub trait AppSessionPort: Send + Sync { &self, session_id: &str, ) -> astrcode_core::Result<SessionControlStateSnapshot>; + async fn active_task_snapshot( + &self, + session_id: &str, + owner: &str, + ) -> astrcode_core::Result<Option<TaskSnapshot>>; async fn session_mode_state( &self, session_id: &str, @@ -177,6 +182,14 @@ impl AppSessionPort for SessionRuntime { self.session_control_state(session_id).await } + async fn active_task_snapshot( + &self, + session_id: &str, + owner: &str, + ) -> astrcode_core::Result<Option<TaskSnapshot>> { + self.active_task_snapshot(session_id, owner).await + } + async fn session_mode_state( &self, session_id: &str, diff --git a/crates/application/src/terminal/mod.rs b/crates/application/src/terminal/mod.rs index c5e3b52f..f9a711ca 100644 --- a/crates/application/src/terminal/mod.rs +++ b/crates/application/src/terminal/mod.rs @@ -3,7 +3,9 @@ //! 定义面向前端的事件流数据模型(`TerminalFacts`、`ConversationSlashCandidateFacts` 等) //! 以及从 session-runtime 快照到终端视图的投影辅助函数。 -use astrcode_core::{ChildAgentRef, ChildSessionNode, CompactAppliedMeta, CompactTrigger, Phase}; +use astrcode_core::{ + ChildAgentRef, ChildSessionNode, CompactAppliedMeta, CompactTrigger, ExecutionTaskStatus, Phase, +}; use astrcode_session_runtime::{ ConversationSnapshotFacts as RuntimeConversationSnapshotFacts, ConversationStreamReplayFacts as RuntimeConversationStreamReplayFacts, @@ -35,6 +37,13 @@ pub struct PlanReferenceFacts { pub title: String, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TaskItemFacts { + pub content: String, + pub status: ExecutionTaskStatus, + pub active_form: Option<String>, +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct ConversationControlSummary { pub phase: Phase, @@ -46,6 +55,7 @@ pub struct ConversationControlSummary { pub last_compact_meta: Option<TerminalLastCompactMetaFacts>, pub current_mode_id: String, pub active_plan: Option<PlanReferenceFacts>, + pub active_tasks: Option<Vec<TaskItemFacts>>, } #[derive(Debug, Clone)] @@ -57,6 +67,7 @@ pub struct TerminalControlFacts { pub last_compact_meta: Option<TerminalLastCompactMetaFacts>, pub current_mode_id: String, pub active_plan: Option<PlanReferenceFacts>, + pub active_tasks: Option<Vec<TaskItemFacts>>, } pub type ConversationControlFacts = TerminalControlFacts; @@ -224,6 +235,7 @@ pub fn summarize_conversation_control( last_compact_meta: control.last_compact_meta.clone(), current_mode_id: control.current_mode_id.clone(), active_plan: control.active_plan.clone(), + active_tasks: control.active_tasks.clone(), } } diff --git a/crates/application/src/terminal_queries/mod.rs b/crates/application/src/terminal_queries/mod.rs index 2634c81a..0f347065 100644 --- a/crates/application/src/terminal_queries/mod.rs +++ b/crates/application/src/terminal_queries/mod.rs @@ -31,5 +31,6 @@ fn map_control_facts(control: SessionControlStateSnapshot) -> TerminalControlFac }), current_mode_id: control.current_mode_id.to_string(), active_plan: None::<PlanReferenceFacts>, + active_tasks: None, } } diff --git a/crates/application/src/terminal_queries/resume.rs b/crates/application/src/terminal_queries/resume.rs index f1b09e46..8f6a65bd 100644 --- a/crates/application/src/terminal_queries/resume.rs +++ b/crates/application/src/terminal_queries/resume.rs @@ -5,13 +5,15 @@ use std::{cmp::Reverse, collections::HashSet, path::Path}; +use astrcode_session_runtime::ROOT_AGENT_ID; + use crate::{ App, ApplicationError, ComposerOptionKind, ComposerOptionsRequest, SessionMeta, session_plan::session_plan_control_summary, terminal::{ - ConversationAuthoritativeSummary, ConversationFocus, TerminalChildSummaryFacts, - TerminalControlFacts, TerminalResumeCandidateFacts, TerminalSlashAction, - TerminalSlashCandidateFacts, summarize_conversation_authoritative, + ConversationAuthoritativeSummary, ConversationFocus, TaskItemFacts, + TerminalChildSummaryFacts, TerminalControlFacts, TerminalResumeCandidateFacts, + TerminalSlashAction, TerminalSlashCandidateFacts, summarize_conversation_authoritative, }, }; @@ -162,6 +164,24 @@ impl App { .session_runtime .get_session_working_dir(session_id) .await?; + // TODO(task-panel): 当前 control read model 只读取 root owner 的 task snapshot。 + // 后续若支持多 owner 并行展示,需要把这里扩成 owner 列表查询与聚合映射, + // 而不是继续把 conversation 面板固定到单一 ROOT_AGENT_ID。 + facts.active_tasks = self + .session_runtime + .active_task_snapshot(session_id, ROOT_AGENT_ID) + .await? + .map(|snapshot| { + snapshot + .items + .into_iter() + .map(|item| TaskItemFacts { + content: item.content, + status: item.status, + active_form: item.active_form, + }) + .collect() + }); let plan_summary = session_plan_control_summary(session_id, Path::new(&working_dir))?; facts.active_plan = plan_summary diff --git a/crates/application/src/terminal_queries/tests.rs b/crates/application/src/terminal_queries/tests.rs index 24548d1a..3851a486 100644 --- a/crates/application/src/terminal_queries/tests.rs +++ b/crates/application/src/terminal_queries/tests.rs @@ -8,8 +8,10 @@ use std::{path::Path, sync::Arc, time::Duration}; -use astrcode_core::AgentEvent; -use astrcode_session_runtime::{ConversationBlockFacts, SessionRuntime}; +use astrcode_core::{AgentEvent, ExecutionTaskItem, ExecutionTaskStatus, TaskSnapshot}; +use astrcode_session_runtime::{ + ConversationBlockFacts, SessionControlStateSnapshot, SessionRuntime, +}; use async_trait::async_trait; use tokio::time::timeout; @@ -24,6 +26,7 @@ use crate::{ composer::ComposerSkillSummary, mcp::RegisterMcpServerInput, terminal::{ConversationFocus, TerminalRehydrateReason, TerminalStreamFacts}, + test_support::StubSessionPort, }; struct StaticComposerSkillPort { @@ -115,17 +118,35 @@ fn build_terminal_app_harness_with_behavior( let kernel: Arc<dyn AppKernelPort> = harness.kernel.clone(); let session_runtime = harness.session_runtime.clone(); let session_port: Arc<dyn AppSessionPort> = session_runtime.clone(); - let config: Arc<ConfigService> = harness.config_service.clone(); - let profiles: Arc<ProfileResolutionService> = harness.profiles.clone(); - let composer_skills: Arc<dyn ComposerSkillPort> = Arc::new(StaticComposerSkillPort { - summaries: skill_ids - .iter() - .map(|id| ComposerSkillSummary::new(*id, format!("{id} description"))) - .collect(), - }); + let app = build_terminal_app( + kernel, + session_port, + harness.config_service.clone(), + harness.profiles.clone(), + Arc::new(StaticComposerSkillPort { + summaries: skill_ids + .iter() + .map(|id| ComposerSkillSummary::new(*id, format!("{id} description"))) + .collect(), + }), + Arc::new(harness.service.clone()), + ); + TerminalAppHarness { + app, + session_runtime, + } +} + +fn build_terminal_app( + kernel: Arc<dyn AppKernelPort>, + session_port: Arc<dyn AppSessionPort>, + config: Arc<ConfigService>, + profiles: Arc<ProfileResolutionService>, + composer_skills: Arc<dyn ComposerSkillPort>, + agent_service: Arc<AgentOrchestrationService>, +) -> App { let mcp_service = Arc::new(McpService::new(Arc::new(NoopMcpPort))); - let agent_service: Arc<AgentOrchestrationService> = Arc::new(harness.service.clone()); - let app = App::new( + App::new( kernel, session_port, profiles, @@ -135,11 +156,7 @@ fn build_terminal_app_harness_with_behavior( Arc::new(crate::mode::builtin_mode_catalog().expect("builtin mode catalog should build")), mcp_service, agent_service, - ); - TerminalAppHarness { - app, - session_runtime, - } + ) } #[tokio::test] @@ -614,3 +631,78 @@ fn cursor_is_after_head_treats_equal_cursor_as_caught_up() { .expect("older cursor should parse") ); } + +#[tokio::test] +async fn terminal_control_facts_include_authoritative_active_tasks() { + let harness = build_agent_test_harness(TestLlmBehavior::Succeed { + content: "unused".to_string(), + }) + .expect("agent test harness should build"); + let project = tempfile::tempdir().expect("tempdir should be created"); + let session_port: Arc<dyn AppSessionPort> = Arc::new(StubSessionPort { + working_dir: Some(project.path().display().to_string()), + control_state: Some(SessionControlStateSnapshot { + phase: astrcode_core::Phase::Idle, + active_turn_id: Some("turn-1".to_string()), + manual_compact_pending: false, + compacting: false, + last_compact_meta: None, + current_mode_id: astrcode_core::ModeId::code(), + last_mode_changed_at: None, + }), + active_task_snapshot: Some(TaskSnapshot { + owner: astrcode_session_runtime::ROOT_AGENT_ID.to_string(), + items: vec![ + ExecutionTaskItem { + content: "实现 authoritative task panel".to_string(), + status: ExecutionTaskStatus::InProgress, + active_form: Some("正在实现 authoritative task panel".to_string()), + }, + ExecutionTaskItem { + content: "补充前端 hydration 测试".to_string(), + status: ExecutionTaskStatus::Pending, + active_form: None, + }, + ], + }), + ..StubSessionPort::default() + }); + let app = build_terminal_app( + harness.kernel.clone(), + session_port, + harness.config_service.clone(), + harness.profiles.clone(), + Arc::new(StaticComposerSkillPort { + summaries: Vec::new(), + }), + Arc::new(harness.service.clone()), + ); + + let control = app + .terminal_control_facts("session-test") + .await + .expect("terminal control should build"); + + assert_eq!(control.current_mode_id, "code"); + assert_eq!(control.active_turn_id.as_deref(), Some("turn-1")); + assert!(control.active_plan.is_none()); + assert!( + !project.path().join(".astrcode").exists(), + "task facts query must not materialize canonical session plan artifacts" + ); + assert_eq!( + control.active_tasks, + Some(vec![ + crate::terminal::TaskItemFacts { + content: "实现 authoritative task panel".to_string(), + status: ExecutionTaskStatus::InProgress, + active_form: Some("正在实现 authoritative task panel".to_string()), + }, + crate::terminal::TaskItemFacts { + content: "补充前端 hydration 测试".to_string(), + status: ExecutionTaskStatus::Pending, + active_form: None, + }, + ]) + ); +} diff --git a/crates/application/src/test_support.rs b/crates/application/src/test_support.rs index e533f356..d3652b41 100644 --- a/crates/application/src/test_support.rs +++ b/crates/application/src/test_support.rs @@ -6,7 +6,8 @@ use astrcode_core::{ AgentCollaborationFact, AgentEventContext, AgentLifecycleStatus, DeleteProjectResult, ExecutionAccepted, InputBatchAckedPayload, InputBatchStartedPayload, InputDiscardedPayload, - InputQueuedPayload, ModeId, ResolvedRuntimeConfig, SessionId, SessionMeta, StoredEvent, TurnId, + InputQueuedPayload, ModeId, ResolvedRuntimeConfig, SessionId, SessionMeta, StoredEvent, + TaskSnapshot, TurnId, }; use astrcode_kernel::PendingParentDelivery; use astrcode_session_runtime::{ @@ -25,7 +26,10 @@ fn unimplemented_for_test(area: &str) -> ! { #[derive(Debug, Default)] pub(crate) struct StubSessionPort { - stored_events: Vec<StoredEvent>, + pub(crate) stored_events: Vec<StoredEvent>, + pub(crate) working_dir: Option<String>, + pub(crate) control_state: Option<SessionControlStateSnapshot>, + pub(crate) active_task_snapshot: Option<TaskSnapshot>, } #[async_trait] @@ -63,7 +67,7 @@ impl AppSessionPort for StubSessionPort { } async fn get_session_working_dir(&self, _session_id: &str) -> astrcode_core::Result<String> { - unimplemented_for_test("application test stub") + Ok(self.working_dir.clone().unwrap_or_else(|| ".".to_string())) } async fn submit_prompt_for_agent( @@ -107,7 +111,26 @@ impl AppSessionPort for StubSessionPort { &self, _session_id: &str, ) -> astrcode_core::Result<SessionControlStateSnapshot> { - unimplemented_for_test("application test stub") + Ok(self + .control_state + .clone() + .unwrap_or(SessionControlStateSnapshot { + phase: astrcode_core::Phase::Idle, + active_turn_id: None, + manual_compact_pending: false, + compacting: false, + last_compact_meta: None, + current_mode_id: ModeId::code(), + last_mode_changed_at: None, + })) + } + + async fn active_task_snapshot( + &self, + _session_id: &str, + _owner: &str, + ) -> astrcode_core::Result<Option<TaskSnapshot>> { + Ok(self.active_task_snapshot.clone()) } async fn session_mode_state( diff --git a/crates/cli/src/state/mod.rs b/crates/cli/src/state/mod.rs index fdc7b530..31dbcc9e 100644 --- a/crates/cli/src/state/mod.rs +++ b/crates/cli/src/state/mod.rs @@ -479,6 +479,7 @@ mod tests { active_turn_id: None, last_compact_meta: None, active_plan: None, + active_tasks: None, }, blocks: vec![AstrcodeConversationBlockDto::Assistant( AstrcodeConversationAssistantBlockDto { @@ -627,6 +628,7 @@ mod tests { active_turn_id: Some("turn-1".to_string()), last_compact_meta: None, active_plan: None, + active_tasks: None, }); let frame = state.thinking_playback.frame; state.advance_thinking_playback(); diff --git a/crates/core/src/action.rs b/crates/core/src/action.rs index ff4f3580..7a1f1e71 100644 --- a/crates/core/src/action.rs +++ b/crates/core/src/action.rs @@ -115,6 +115,28 @@ pub struct ToolOutputDelta { } impl ToolExecutionResult { + /// 用公共执行结果字段一次性构造工具结果,避免先写占位值再二次覆盖。 + pub fn from_common( + tool_call_id: impl Into<String>, + tool_name: impl Into<String>, + ok: bool, + output: impl Into<String>, + child_ref: Option<ChildAgentRef>, + common: ExecutionResultCommon, + ) -> Self { + Self { + tool_call_id: tool_call_id.into(), + tool_name: tool_name.into(), + ok, + output: output.into(), + error: common.error, + metadata: common.metadata, + child_ref, + duration_ms: common.duration_ms, + truncated: common.truncated, + } + } + /// 生成面向模型的工具结果内容。 /// /// 成功时直接返回输出;失败时拼接错误信息和输出, @@ -170,14 +192,6 @@ impl ToolExecutionResult { truncated: self.truncated, } } - - pub fn with_common(mut self, common: ExecutionResultCommon) -> Self { - self.error = common.error; - self.metadata = common.metadata; - self.duration_ms = common.duration_ms; - self.truncated = common.truncated; - self - } } /// 用户消息的来源。 @@ -358,7 +372,7 @@ mod tests { use serde_json::json; use super::{ToolExecutionResult, split_assistant_content}; - use crate::{AgentId, SessionId, SubRunId}; + use crate::{AgentId, ExecutionResultCommon, SessionId, SubRunId}; #[test] fn split_assistant_content_extracts_inline_thinking_blocks() { @@ -426,4 +440,27 @@ mod tests { assert!(content.contains("- subRunId: subrun-1")); assert!(content.contains("Use this exact `agentId` value")); } + + #[test] + fn from_common_preserves_failure_fields_without_placeholder_override() { + let result = ToolExecutionResult::from_common( + "call-1", + "spawn", + false, + "", + None, + ExecutionResultCommon::failure( + "spawn failed", + Some(json!({ "schema": "subRunResult" })), + 17, + true, + ), + ); + + assert!(!result.ok); + assert_eq!(result.error.as_deref(), Some("spawn failed")); + assert_eq!(result.metadata, Some(json!({ "schema": "subRunResult" }))); + assert_eq!(result.duration_ms, 17); + assert!(result.truncated); + } } diff --git a/crates/core/src/event/types.rs b/crates/core/src/event/types.rs index 9d728403..5263c086 100644 --- a/crates/core/src/event/types.rs +++ b/crates/core/src/event/types.rs @@ -16,7 +16,7 @@ use crate::{ AgentCollaborationFact, AgentEventContext, AstrError, ChildAgentRef, ChildSessionNotification, InputBatchAckedPayload, InputBatchStartedPayload, InputDiscardedPayload, InputQueuedPayload, ModeId, PersistedToolOutput, ResolvedExecutionLimitsSnapshot, ResolvedSubagentContextOverrides, - Result, SubRunResult, ToolOutputStream, UserMessageOrigin, + Result, SubRunResult, SystemPromptLayer, ToolOutputStream, UserMessageOrigin, }; /// Prompt/缓存指标共享载荷。 @@ -49,6 +49,8 @@ pub struct PromptMetricsPayload { pub prompt_cache_reuse_hits: u32, #[serde(default)] pub prompt_cache_reuse_misses: u32, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub prompt_cache_unchanged_layers: Vec<SystemPromptLayer>, } /// 上下文压缩的触发方式。 diff --git a/crates/core/src/execution_task.rs b/crates/core/src/execution_task.rs new file mode 100644 index 00000000..b3cf9f47 --- /dev/null +++ b/crates/core/src/execution_task.rs @@ -0,0 +1,138 @@ +//! 执行期 task 领域模型。 +//! +//! 这套类型专门表达执行阶段的工作清单,与 canonical session plan 严格分层。 + +use std::fmt; + +use serde::{Deserialize, Serialize}; + +/// durable tool result metadata 中使用的稳定 schema 名称。 +pub const EXECUTION_TASK_SNAPSHOT_SCHEMA: &str = "executionTaskSnapshot"; + +/// 执行期 task 状态。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ExecutionTaskStatus { + Pending, + InProgress, + Completed, +} + +impl ExecutionTaskStatus { + pub fn as_str(self) -> &'static str { + match self { + Self::Pending => "pending", + Self::InProgress => "in_progress", + Self::Completed => "completed", + } + } + + pub fn is_active(self) -> bool { + matches!(self, Self::Pending | Self::InProgress) + } +} + +impl fmt::Display for ExecutionTaskStatus { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +/// 单条执行期 task。 +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ExecutionTaskItem { + /// 面向用户与面板展示的祈使句标题。 + pub content: String, + /// 当前任务状态。 + pub status: ExecutionTaskStatus, + /// 面向 prompt 注入与进行中展示的动词短语。 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub active_form: Option<String>, +} + +impl ExecutionTaskItem { + pub fn is_active(&self) -> bool { + self.status.is_active() + } +} + +/// 单个 owner 的最新 task 快照。 +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TaskSnapshot { + pub owner: String, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub items: Vec<ExecutionTaskItem>, +} + +impl TaskSnapshot { + pub fn has_active_items(&self) -> bool { + self.items.iter().any(ExecutionTaskItem::is_active) + } + + pub fn active_items(&self) -> Vec<ExecutionTaskItem> { + self.items + .iter() + .filter(|item| item.is_active()) + .cloned() + .collect() + } + + pub fn should_clear(&self) -> bool { + self.items.is_empty() + || self + .items + .iter() + .all(|item| item.status == ExecutionTaskStatus::Completed) + } +} + +/// `taskWrite` durable tool result metadata。 +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ExecutionTaskSnapshotMetadata { + pub schema: String, + pub owner: String, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub items: Vec<ExecutionTaskItem>, + pub cleared: bool, +} + +impl ExecutionTaskSnapshotMetadata { + pub fn from_snapshot(snapshot: &TaskSnapshot) -> Self { + Self { + schema: EXECUTION_TASK_SNAPSHOT_SCHEMA.to_string(), + owner: snapshot.owner.clone(), + items: snapshot.items.clone(), + cleared: snapshot.should_clear(), + } + } + + pub fn into_snapshot(self) -> TaskSnapshot { + TaskSnapshot { + owner: self.owner, + items: self.items, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn task_snapshot_metadata_marks_completed_snapshots_as_cleared() { + let metadata = ExecutionTaskSnapshotMetadata::from_snapshot(&TaskSnapshot { + owner: "owner-1".to_string(), + items: vec![ExecutionTaskItem { + content: "收尾验证".to_string(), + status: ExecutionTaskStatus::Completed, + active_form: Some("正在收尾验证".to_string()), + }], + }); + + assert_eq!(metadata.schema, EXECUTION_TASK_SNAPSHOT_SCHEMA); + assert!(metadata.cleared); + } +} diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index 7357ff67..cbc44082 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -4,15 +4,47 @@ //! //! ## 主要模块 //! -//! - [`event`][]: 事件存储与回放系统(JSONL append-only 日志) -//! - [`session`][]: 会话管理与持久化 -//! - [`tool`][]: Tool trait 定义(插件系统的基础抽象) -//! - [`policy`][]: 策略引擎 trait(审批与模型/工具请求检查) -//! - [`plugin`][]: 插件清单与注册表 -//! - [`registry`][]: 能力路由器(将能力调用分派到具体的 invoker) -//! - [`runtime`][]: 运行时协调器接口 -//! - [`projection`][]: Agent 状态投影(从事件流推导状态) -//! - `action`: LLM 消息与工具调用相关的数据结构 +//! ### 领域模型 +//! +//! - [`agent`]: Agent 协作模型、子运行管理、输入队列 +//! - [`capability`]: 能力规格定义(CapabilitySpec 等) +//! - [`ids`]: 核心标识符类型(AgentId, SessionId, TurnId 等) +//! - [`action`]: LLM 消息与工具调用相关的数据结构 +//! +//! ### 事件与会话 +//! +//! - [`event`]: 事件存储与回放系统(JSONL append-only 日志) +//! - [`session`]: 会话元数据 +//! - [`store`]: 会话存储与事件日志写入 +//! - [`projection`]: Agent 状态投影(从事件流推导状态) +//! +//! ### 治理与策略 +//! +//! - [`mode`]: 治理模式(Code/Plan/Review 模式与策略规则) +//! - [`policy`]: 策略引擎 trait(审批与模型/工具请求检查) +//! +//! ### 扩展点 +//! +//! - [`ports`]: 核心 port trait 定义(LlmProvider, PromptProvider, EventStore 等) +//! - [`tool`]: Tool trait 定义(插件系统的基础抽象) +//! - [`plugin`]: 插件清单与注册表 +//! - [`registry`]: 能力路由器(将能力调用分派到具体的 invoker) +//! - [`hook`]: 钩子系统(工具/压缩钩子) +//! +//! ### 运行时与配置 +//! +//! - [`runtime`]: 运行时协调器接口 +//! - [`config`]: 配置模型(Agent/Model/Runtime 配置) +//! - [`observability`]: 运行时可观测性指标 +//! +//! ### 基础设施 +//! +//! - [`env`]: 环境变量解析 +//! - [`home`]: 主目录管理 +//! - [`local_server`]: 本地服务器信息 +//! - [`project`]: 项目信息 +//! - [`shell`]: Shell 检测与解析 +//! - [`tool_result_persist`]: 工具结果持久化 mod action; pub mod agent; @@ -26,6 +58,7 @@ mod error; pub mod event; mod execution_control; mod execution_result; +mod execution_task; pub mod home; pub mod hook; pub mod ids; @@ -107,6 +140,10 @@ pub use event::{ }; pub use execution_control::ExecutionControl; pub use execution_result::ExecutionResultCommon; +pub use execution_task::{ + EXECUTION_TASK_SNAPSHOT_SCHEMA, ExecutionTaskItem, ExecutionTaskSnapshotMetadata, + ExecutionTaskStatus, TaskSnapshot, +}; pub use hook::{ CompactionHookContext, CompactionHookResultContext, HookEvent, HookHandler, HookInput, HookOutcome, ToolHookContext, ToolHookResultContext, @@ -133,10 +170,11 @@ pub use policy::{ pub use ports::{ EventStore, LlmEvent, LlmEventSink, LlmFinishReason, LlmOutput, LlmProvider, LlmRequest, LlmUsage, ModelLimits, PromptAgentProfileSummary, PromptBuildCacheMetrics, PromptBuildOutput, - PromptBuildRequest, PromptDeclaration, PromptDeclarationKind, PromptDeclarationRenderTarget, - PromptDeclarationSource, PromptEntrySummary, PromptFacts, PromptFactsProvider, - PromptFactsRequest, PromptGovernanceContext, PromptProvider, PromptSkillSummary, - ResourceProvider, ResourceReadResult, ResourceRequestContext, + PromptBuildRequest, PromptCacheHints, PromptDeclaration, PromptDeclarationKind, + PromptDeclarationRenderTarget, PromptDeclarationSource, PromptEntrySummary, PromptFacts, + PromptFactsProvider, PromptFactsRequest, PromptGovernanceContext, PromptLayerFingerprints, + PromptProvider, PromptSkillSummary, RecoveredSessionState, ResourceProvider, + ResourceReadResult, ResourceRequestContext, SessionRecoveryCheckpoint, }; pub use projection::{AgentState, AgentStateProjector, project}; pub use registry::{CapabilityContext, CapabilityExecutionResult, CapabilityInvoker}; diff --git a/crates/core/src/policy/engine.rs b/crates/core/src/policy/engine.rs index a1c5eed5..c569b145 100644 --- a/crates/core/src/policy/engine.rs +++ b/crates/core/src/policy/engine.rs @@ -22,7 +22,7 @@ use crate::{CapabilitySpec, LlmMessage, Result, ToolDefinition}; /// 系统提示词块所属层级。 /// /// provider 可以利用该层级决定缓存边界,从而在分层 prompt 下尽量保住稳定前缀。 -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)] #[serde(rename_all = "snake_case")] pub enum SystemPromptLayer { Stable, diff --git a/crates/core/src/ports.rs b/crates/core/src/ports.rs index 195fd5a9..2edb2f64 100644 --- a/crates/core/src/ports.rs +++ b/crates/core/src/ports.rs @@ -4,18 +4,21 @@ //! 通过依赖倒置消费,避免上层再反向依赖具体实现 crate。 use std::{ + collections::HashMap, path::{Path, PathBuf}, sync::Arc, }; use async_trait::async_trait; +use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use serde_json::Value; use crate::{ - CancelToken, CapabilitySpec, Config, ConfigOverlay, DeleteProjectResult, LlmMessage, - ReasoningContent, Result, SessionId, SessionMeta, SessionTurnAcquireResult, StorageEvent, - StoredEvent, SystemPromptBlock, SystemPromptLayer, ToolCallRequest, ToolDefinition, TurnId, + AgentState, CancelToken, CapabilitySpec, ChildSessionNode, Config, ConfigOverlay, + DeleteProjectResult, InputQueueProjection, LlmMessage, Phase, ReasoningContent, Result, + SessionId, SessionMeta, SessionTurnAcquireResult, StorageEvent, StoredEvent, SystemPromptBlock, + SystemPromptLayer, TaskSnapshot, ToolCallRequest, ToolDefinition, TurnId, }; /// MCP 配置文件作用域。 @@ -33,6 +36,19 @@ pub trait EventStore: Send + Sync { async fn ensure_session(&self, session_id: &SessionId, working_dir: &Path) -> Result<()>; async fn append(&self, session_id: &SessionId, event: &StorageEvent) -> Result<StoredEvent>; async fn replay(&self, session_id: &SessionId) -> Result<Vec<StoredEvent>>; + async fn recover_session(&self, session_id: &SessionId) -> Result<RecoveredSessionState> { + Ok(RecoveredSessionState { + checkpoint: None, + tail_events: self.replay(session_id).await?, + }) + } + async fn checkpoint_session( + &self, + _session_id: &SessionId, + _checkpoint: &SessionRecoveryCheckpoint, + ) -> Result<()> { + Ok(()) + } async fn try_acquire_turn( &self, session_id: &SessionId, @@ -47,6 +63,33 @@ pub trait EventStore: Send + Sync { ) -> Result<DeleteProjectResult>; } +/// durable 恢复基线。 +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SessionRecoveryCheckpoint { + pub agent_state: AgentState, + pub phase: Phase, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_mode_changed_at: Option<DateTime<Utc>>, + #[serde(default)] + pub child_nodes: HashMap<String, ChildSessionNode>, + #[serde(default)] + pub active_tasks: HashMap<String, TaskSnapshot>, + #[serde(default)] + pub input_queue_projection_index: HashMap<String, InputQueueProjection>, + pub checkpoint_storage_seq: u64, +} + +/// 会话恢复结果:最近 checkpoint + checkpoint 之后的 tail events。 +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct RecoveredSessionState { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub checkpoint: Option<SessionRecoveryCheckpoint>, + #[serde(default)] + pub tail_events: Vec<StoredEvent>, +} + /// 模型能力限制。 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub struct ModelLimits { @@ -113,6 +156,28 @@ pub enum LlmEvent { pub type LlmEventSink = Arc<dyn Fn(LlmEvent) + Send + Sync>; +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct PromptLayerFingerprints { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub stable: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub semi_stable: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub inherited: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub dynamic: Option<String>, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct PromptCacheHints { + #[serde(default)] + pub layer_fingerprints: PromptLayerFingerprints, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub unchanged_layers: Vec<SystemPromptLayer>, +} + /// 模型调用请求。 #[derive(Debug, Clone)] pub struct LlmRequest { @@ -121,6 +186,7 @@ pub struct LlmRequest { pub cancel: CancelToken, pub system_prompt: Option<String>, pub system_prompt_blocks: Vec<SystemPromptBlock>, + pub prompt_cache_hints: Option<PromptCacheHints>, pub max_output_tokens_override: Option<usize>, } @@ -136,6 +202,7 @@ impl LlmRequest { cancel, system_prompt: None, system_prompt_blocks: Vec::new(), + prompt_cache_hints: None, max_output_tokens_override: None, } } @@ -157,6 +224,7 @@ impl LlmRequest { cancel, system_prompt: request.system_prompt, system_prompt_blocks: request.system_prompt_blocks, + prompt_cache_hints: None, max_output_tokens_override: None, } } @@ -361,11 +429,13 @@ pub struct PromptBuildRequest { } /// Prompt 组装结果。 -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] #[serde(rename_all = "camelCase")] pub struct PromptBuildCacheMetrics { pub reuse_hits: u32, pub reuse_misses: u32, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub unchanged_layers: Vec<SystemPromptLayer>, } /// Prompt 组装结果。 @@ -376,6 +446,8 @@ pub struct PromptBuildOutput { #[serde(default)] pub system_prompt_blocks: Vec<SystemPromptBlock>, #[serde(default)] + pub prompt_cache_hints: PromptCacheHints, + #[serde(default)] pub cache_metrics: PromptBuildCacheMetrics, #[serde(default)] pub metadata: Value, diff --git a/crates/core/src/projection/agent_state.rs b/crates/core/src/projection/agent_state.rs index 9304face..7fd083a0 100644 --- a/crates/core/src/projection/agent_state.rs +++ b/crates/core/src/projection/agent_state.rs @@ -20,6 +20,7 @@ use std::path::PathBuf; use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; use crate::{ InvocationKind, LlmMessage, ModeId, Phase, ReasoningContent, ToolCallRequest, @@ -32,7 +33,7 @@ use crate::{ /// /// 由事件流投影而来,包含完整的消息历史和当前阶段。 /// 用于在 turn 之间保持上下文,以及断线重连后恢复状态。 -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct AgentState { /// 会话 ID pub session_id: String, @@ -92,6 +93,15 @@ pub struct AgentStateProjector { } impl AgentStateProjector { + pub fn from_snapshot(state: AgentState) -> Self { + Self { + state, + pending_content: None, + pending_reasoning: None, + pending_tool_calls: Vec::new(), + } + } + pub fn from_events(events: &[StorageEvent]) -> Self { let mut projector = Self::default(); for event in events { diff --git a/crates/core/src/registry/router.rs b/crates/core/src/registry/router.rs index 994b0de2..d59cdc6f 100644 --- a/crates/core/src/registry/router.rs +++ b/crates/core/src/registry/router.rs @@ -98,6 +98,24 @@ pub struct CapabilityExecutionResult { } impl CapabilityExecutionResult { + /// 用公共执行结果字段一次性构造能力结果,避免二段式覆盖。 + pub fn from_common( + capability_name: impl Into<String>, + success: bool, + output: Value, + common: ExecutionResultCommon, + ) -> Self { + Self { + capability_name: capability_name.into(), + success, + output, + error: common.error, + metadata: common.metadata, + duration_ms: common.duration_ms, + truncated: common.truncated, + } + } + /// 构造成功结果。 pub fn ok(capability_name: impl Into<String>, output: Value) -> Self { Self { @@ -166,14 +184,6 @@ impl CapabilityExecutionResult { truncated: self.truncated, } } - - pub fn with_common(mut self, common: ExecutionResultCommon) -> Self { - self.error = common.error; - self.metadata = common.metadata; - self.duration_ms = common.duration_ms; - self.truncated = common.truncated; - self - } } /// 能力调用器 trait。 @@ -193,3 +203,32 @@ pub trait CapabilityInvoker: Send + Sync { ctx: &CapabilityContext, ) -> Result<CapabilityExecutionResult>; } + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::CapabilityExecutionResult; + use crate::ExecutionResultCommon; + + #[test] + fn from_common_preserves_failure_fields_without_placeholder_override() { + let result = CapabilityExecutionResult::from_common( + "plugin.read", + false, + json!(null), + ExecutionResultCommon::failure( + "transport failed", + Some(json!({ "streamEvents": [] })), + 23, + false, + ), + ); + + assert!(!result.success); + assert_eq!(result.error.as_deref(), Some("transport failed")); + assert_eq!(result.metadata, Some(json!({ "streamEvents": [] }))); + assert_eq!(result.duration_ms, 23); + assert!(!result.truncated); + } +} diff --git a/crates/kernel/src/registry/tool.rs b/crates/kernel/src/registry/tool.rs index e0a111cb..2afe9daa 100644 --- a/crates/kernel/src/registry/tool.rs +++ b/crates/kernel/src/registry/tool.rs @@ -75,16 +75,12 @@ impl CapabilityInvoker for ToolCapabilityInvoker { match result { Ok(result) => { let common = result.common(); - Ok(CapabilityExecutionResult { - capability_name: result.tool_name, - success: result.ok, - output: Value::String(result.output), - error: None, - metadata: None, - duration_ms: 0, - truncated: false, - } - .with_common(common)) + Ok(CapabilityExecutionResult::from_common( + result.tool_name, + result.ok, + Value::String(result.output), + common, + )) }, Err(error) => Ok(CapabilityExecutionResult::failure( self.capability_spec.name.to_string(), diff --git a/crates/plugin/src/invoker.rs b/crates/plugin/src/invoker.rs index de14b44d..9dce63e2 100644 --- a/crates/plugin/src/invoker.rs +++ b/crates/plugin/src/invoker.rs @@ -127,21 +127,17 @@ impl CapabilityInvoker for PluginCapabilityInvoker { .unwrap_or_else(|| "plugin invocation failed".to_string()); (false, Some(error)) }; - Ok(CapabilityExecutionResult { - capability_name: self.capability_spec.name.to_string(), + Ok(CapabilityExecutionResult::from_common( + self.capability_spec.name.to_string(), success, - output: result.output, - error: None, - metadata: None, - duration_ms: 0, - truncated: false, - } - .with_common(astrcode_core::ExecutionResultCommon { - error, - metadata: Some(result.metadata), - duration_ms: started_at.elapsed().as_millis() as u64, - truncated: false, - })) + result.output, + astrcode_core::ExecutionResultCommon { + error, + metadata: Some(result.metadata), + duration_ms: started_at.elapsed().as_millis() as u64, + truncated: false, + }, + )) } } } @@ -220,41 +216,33 @@ async fn finish_stream_invocation( })); }, EventPhase::Completed => { - return Ok(CapabilityExecutionResult { + return Ok(CapabilityExecutionResult::from_common( capability_name, - success: true, - output: event.payload, - error: None, - metadata: None, - duration_ms: 0, - truncated: false, - } - .with_common(astrcode_core::ExecutionResultCommon::success( - Some(json!({ "streamEvents": deltas })), - started_at.elapsed().as_millis() as u64, - false, - ))); + true, + event.payload, + astrcode_core::ExecutionResultCommon::success( + Some(json!({ "streamEvents": deltas })), + started_at.elapsed().as_millis() as u64, + false, + ), + )); }, EventPhase::Failed => { let error = event .error .map(|value| value.message) .unwrap_or_else(|| "stream invocation failed".to_string()); - return Ok(CapabilityExecutionResult { + return Ok(CapabilityExecutionResult::from_common( capability_name, - success: false, - output: Value::Null, - error: None, - metadata: None, - duration_ms: 0, - truncated: false, - } - .with_common(astrcode_core::ExecutionResultCommon::failure( - error, - Some(json!({ "streamEvents": deltas })), - started_at.elapsed().as_millis() as u64, false, - ))); + Value::Null, + astrcode_core::ExecutionResultCommon::failure( + error, + Some(json!({ "streamEvents": deltas })), + started_at.elapsed().as_millis() as u64, + false, + ), + )); }, } } diff --git a/crates/protocol/src/http/conversation/v1.rs b/crates/protocol/src/http/conversation/v1.rs index b80c5d2a..66c2179d 100644 --- a/crates/protocol/src/http/conversation/v1.rs +++ b/crates/protocol/src/http/conversation/v1.rs @@ -384,6 +384,8 @@ pub struct ConversationControlStateDto { pub last_compact_meta: Option<ConversationLastCompactMetaDto>, #[serde(default, skip_serializing_if = "Option::is_none")] pub active_plan: Option<ConversationPlanReferenceDto>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub active_tasks: Option<Vec<ConversationTaskItemDto>>, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] @@ -403,6 +405,23 @@ pub struct ConversationPlanReferenceDto { pub title: String, } +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum ConversationTaskStatusDto { + Pending, + InProgress, + Completed, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct ConversationTaskItemDto { + pub content: String, + pub status: ConversationTaskStatusDto, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub active_form: Option<String>, +} + #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum ConversationBannerErrorCodeDto { diff --git a/crates/protocol/src/http/mod.rs b/crates/protocol/src/http/mod.rs index ef0a1055..bf0fb277 100644 --- a/crates/protocol/src/http/mod.rs +++ b/crates/protocol/src/http/mod.rs @@ -53,8 +53,9 @@ pub use conversation::v1::{ ConversationSlashActionKindDto, ConversationSlashCandidateDto, ConversationSlashCandidatesResponseDto, ConversationSnapshotResponseDto, ConversationStreamEnvelopeDto, ConversationSystemNoteBlockDto, ConversationSystemNoteKindDto, - ConversationThinkingBlockDto, ConversationToolCallBlockDto, ConversationToolStreamsDto, - ConversationTranscriptErrorCodeDto, ConversationUserBlockDto, + ConversationTaskItemDto, ConversationTaskStatusDto, ConversationThinkingBlockDto, + ConversationToolCallBlockDto, ConversationToolStreamsDto, ConversationTranscriptErrorCodeDto, + ConversationUserBlockDto, }; pub use event::{ ArtifactRefDto, CloseRequestParentDeliveryPayloadDto, CompletedParentDeliveryPayloadDto, diff --git a/crates/protocol/tests/conversation_conformance.rs b/crates/protocol/tests/conversation_conformance.rs index 67898b98..6e6c72d0 100644 --- a/crates/protocol/tests/conversation_conformance.rs +++ b/crates/protocol/tests/conversation_conformance.rs @@ -5,7 +5,8 @@ use astrcode_protocol::http::{ ConversationDeltaDto, ConversationErrorEnvelopeDto, ConversationPlanBlockDto, ConversationPlanBlockersDto, ConversationPlanEventKindDto, ConversationPlanReviewDto, ConversationPlanReviewKindDto, ConversationSnapshotResponseDto, ConversationStreamEnvelopeDto, - ConversationToolCallBlockDto, ConversationToolStreamsDto, PhaseDto, + ConversationTaskItemDto, ConversationTaskStatusDto, ConversationToolCallBlockDto, + ConversationToolStreamsDto, PhaseDto, }; use serde_json::json; @@ -40,6 +41,18 @@ fn conversation_snapshot_fixture_freezes_authoritative_tool_block_shape() { active_turn_id: Some("turn-42".to_string()), last_compact_meta: None, active_plan: None, + active_tasks: Some(vec![ + ConversationTaskItemDto { + content: "实现 authoritative task panel".to_string(), + status: ConversationTaskStatusDto::InProgress, + active_form: Some("正在实现 authoritative task panel".to_string()), + }, + ConversationTaskItemDto { + content: "补充前端 hydration 测试".to_string(), + status: ConversationTaskStatusDto::Pending, + active_form: None, + }, + ]), }, blocks: vec![ConversationBlockDto::ToolCall( ConversationToolCallBlockDto { diff --git a/crates/protocol/tests/fixtures/conversation/v1/snapshot.json b/crates/protocol/tests/fixtures/conversation/v1/snapshot.json index f9c74ed5..14f330de 100644 --- a/crates/protocol/tests/fixtures/conversation/v1/snapshot.json +++ b/crates/protocol/tests/fixtures/conversation/v1/snapshot.json @@ -10,7 +10,18 @@ "compactPending": false, "compacting": false, "currentModeId": "code", - "activeTurnId": "turn-42" + "activeTurnId": "turn-42", + "activeTasks": [ + { + "content": "实现 authoritative task panel", + "status": "in_progress", + "activeForm": "正在实现 authoritative task panel" + }, + { + "content": "补充前端 hydration 测试", + "status": "pending" + } + ] }, "blocks": [ { diff --git a/crates/server/src/bootstrap/capabilities.rs b/crates/server/src/bootstrap/capabilities.rs index f894dfc5..1f5ae189 100644 --- a/crates/server/src/bootstrap/capabilities.rs +++ b/crates/server/src/bootstrap/capabilities.rs @@ -23,6 +23,7 @@ use astrcode_adapter_tools::{ read_file::ReadFileTool, shell::ShellTool, skill_tool::SkillTool, + task_write::TaskWriteTool, tool_search::{ToolSearchIndex, ToolSearchTool}, upsert_session_plan::UpsertSessionPlanTool, write_file::WriteFileTool, @@ -55,6 +56,7 @@ pub(crate) fn build_core_tool_invokers( Arc::new(ShellTool), Arc::new(EnterPlanModeTool), Arc::new(ExitPlanModeTool), + Arc::new(TaskWriteTool), Arc::new(ToolSearchTool::new(tool_search_index)), Arc::new(SkillTool::new(skill_catalog)), Arc::new(UpsertSessionPlanTool), @@ -209,7 +211,10 @@ mod tests { use async_trait::async_trait; use serde_json::{Value, json}; - use super::{CapabilitySurfaceSync, build_stable_local_invokers}; + use super::{ + CapabilitySurfaceSync, build_core_tool_invokers, build_skill_catalog, + build_stable_local_invokers, + }; use crate::bootstrap::{ capabilities::sync_external_tool_search_index, deps::{ @@ -300,6 +305,7 @@ mod tests { Ok(PromptBuildOutput { system_prompt: "noop".to_string(), system_prompt_blocks: Vec::new(), + prompt_cache_hints: Default::default(), cache_metrics: Default::default(), metadata: Value::Null, }) @@ -427,4 +433,20 @@ mod tests { assert!(names.iter().any(|name| name == "spawn")); assert!(names.iter().any(|name| name == "mcp__demo__search")); } + + #[test] + fn build_core_tool_invokers_registers_task_write() { + let temp = tempfile::tempdir().expect("tempdir should exist"); + let tool_search_index = Arc::new(ToolSearchIndex::new()); + let skill_catalog = build_skill_catalog(temp.path(), Vec::new()); + + let invokers = build_core_tool_invokers(tool_search_index, skill_catalog) + .expect("core tool invokers should build"); + let names = invokers + .into_iter() + .map(|invoker| invoker.capability_spec().name.to_string()) + .collect::<Vec<_>>(); + + assert!(names.iter().any(|name| name == "taskWrite")); + } } diff --git a/crates/server/src/http/routes/conversation.rs b/crates/server/src/http/routes/conversation.rs index fc76b552..f11c94b1 100644 --- a/crates/server/src/http/routes/conversation.rs +++ b/crates/server/src/http/routes/conversation.rs @@ -585,13 +585,13 @@ type ConversationSse = Sse<axum::response::sse::KeepAliveStream<ConversationEven #[cfg(test)] mod tests { use astrcode_application::terminal::{ - TerminalChildSummaryFacts, TerminalControlFacts, TerminalStreamReplayFacts, + TaskItemFacts, TerminalChildSummaryFacts, TerminalControlFacts, TerminalStreamReplayFacts, summarize_conversation_authoritative, }; use astrcode_core::{ AgentEventContext, AgentLifecycleStatus, ChildExecutionIdentity, ChildSessionLineageKind, - ChildSessionNode, ChildSessionStatusSource, ParentExecutionRef, Phase, SessionEventRecord, - ToolExecutionResult, ToolOutputStream, + ChildSessionNode, ChildSessionStatusSource, ExecutionTaskStatus, ParentExecutionRef, Phase, + SessionEventRecord, ToolExecutionResult, ToolOutputStream, }; use astrcode_session_runtime::{ ConversationBlockPatchFacts, ConversationDeltaFacts, ConversationDeltaFrameFacts, @@ -755,6 +755,68 @@ mod tests { ); } + #[test] + fn authoritative_refresh_emits_control_delta_for_active_tasks() { + let facts = sample_stream_facts(Vec::new(), Vec::new()); + let mut state = ConversationStreamProjectorState::new( + "session-root".to_string(), + Some("1.4".to_string()), + &facts, + ); + + let mut refreshed_control = facts.control.clone(); + refreshed_control.active_tasks = Some(vec![ + TaskItemFacts { + content: "实现 authoritative task panel".to_string(), + status: ExecutionTaskStatus::InProgress, + active_form: Some("正在实现 authoritative task panel".to_string()), + }, + TaskItemFacts { + content: "补充前端 hydration 测试".to_string(), + status: ExecutionTaskStatus::Pending, + active_form: None, + }, + ]); + + let refreshed = + ConversationAuthoritativeFacts::from_summary(summarize_conversation_authoritative( + &refreshed_control, + &facts.child_summaries, + &facts.slash_candidates, + )); + + let envelopes = state.apply_authoritative_refresh("1.4", refreshed); + assert_eq!(envelopes.len(), 1); + assert_eq!( + serde_json::to_value(&envelopes[0]).expect("control envelope should encode"), + json!({ + "sessionId": "session-root", + "cursor": "1.4", + "kind": "update_control_state", + "control": { + "phase": "callingTool", + "canSubmitPrompt": false, + "canRequestCompact": true, + "compactPending": false, + "compacting": false, + "currentModeId": "code", + "activeTurnId": "turn-1", + "activeTasks": [ + { + "content": "实现 authoritative task panel", + "status": "in_progress", + "activeForm": "正在实现 authoritative task panel" + }, + { + "content": "补充前端 hydration 测试", + "status": "pending" + } + ] + } + }) + ); + } + fn sample_stream_facts( seed_records: Vec<SessionEventRecord>, history: Vec<SessionEventRecord>, @@ -813,6 +875,7 @@ mod tests { last_compact_meta: None, current_mode_id: "code".to_string(), active_plan: None, + active_tasks: None, }, child_summaries: Vec::new(), slash_candidates: Vec::new(), diff --git a/crates/server/src/http/terminal_projection.rs b/crates/server/src/http/terminal_projection.rs index 9575d61f..6af14c28 100644 --- a/crates/server/src/http/terminal_projection.rs +++ b/crates/server/src/http/terminal_projection.rs @@ -19,8 +19,9 @@ use astrcode_protocol::http::{ ConversationPlanReviewKindDto, ConversationSlashActionKindDto, ConversationSlashCandidateDto, ConversationSlashCandidatesResponseDto, ConversationSnapshotResponseDto, ConversationStreamEnvelopeDto, ConversationSystemNoteBlockDto, ConversationSystemNoteKindDto, - ConversationThinkingBlockDto, ConversationToolCallBlockDto, ConversationToolStreamsDto, - ConversationTranscriptErrorCodeDto, ConversationUserBlockDto, + ConversationTaskItemDto, ConversationTaskStatusDto, ConversationThinkingBlockDto, + ConversationToolCallBlockDto, ConversationToolStreamsDto, ConversationTranscriptErrorCodeDto, + ConversationUserBlockDto, }; use astrcode_session_runtime::{ ConversationBlockFacts, ConversationBlockPatchFacts, ConversationBlockStatus, @@ -474,6 +475,26 @@ fn to_conversation_control_state_dto( meta: meta.meta, }), active_plan: summary.active_plan.map(to_plan_reference_dto), + active_tasks: summary.active_tasks.map(|tasks| { + tasks + .into_iter() + .map(|task| ConversationTaskItemDto { + content: task.content, + status: match task.status { + astrcode_core::ExecutionTaskStatus::Pending => { + ConversationTaskStatusDto::Pending + }, + astrcode_core::ExecutionTaskStatus::InProgress => { + ConversationTaskStatusDto::InProgress + }, + astrcode_core::ExecutionTaskStatus::Completed => { + ConversationTaskStatusDto::Completed + }, + }, + active_form: task.active_form, + }) + .collect() + }), } } diff --git a/crates/session-runtime/src/actor/mod.rs b/crates/session-runtime/src/actor/mod.rs index 255381fe..582f6ed6 100644 --- a/crates/session-runtime/src/actor/mod.rs +++ b/crates/session-runtime/src/actor/mod.rs @@ -8,8 +8,8 @@ use std::sync::Arc; use astrcode_core::{ - AgentId, AgentStateProjector, EventStore, EventTranslator, Phase, SessionId, StorageEvent, - StoredEvent, TurnId, normalize_recovered_phase, replay_records, + AgentId, AgentStateProjector, EventStore, EventTranslator, Phase, RecoveredSessionState, + SessionId, StorageEvent, StoredEvent, TurnId, normalize_recovered_phase, replay_records, }; #[cfg(test)] use astrcode_core::{EventLogWriter, StoreResult}; @@ -136,6 +136,40 @@ impl SessionActor { }) } + pub fn from_recovery( + session_id: SessionId, + working_dir: impl Into<String>, + root_agent_id: AgentId, + event_store: Arc<dyn EventStore>, + recovered: RecoveredSessionState, + ) -> astrcode_core::Result<Self> { + let RecoveredSessionState { + checkpoint, + tail_events, + } = recovered; + let working_dir = working_dir.into(); + let Some(checkpoint) = checkpoint else { + return Self::from_replay( + session_id, + working_dir, + root_agent_id, + event_store, + tail_events, + ); + }; + let writer = Arc::new(SessionWriter::from_event_store( + event_store, + session_id.clone(), + )); + let state = SessionState::from_recovery(writer, &checkpoint, tail_events)?; + + Ok(Self { + state: Arc::new(state), + session_id, + working_dir, + }) + } + /// 创建一个空闲状态的 actor(无事件历史、无持久化)。 /// /// 实际生产中应使用带持久化 writer 的 `new()` 构造路径。 diff --git a/crates/session-runtime/src/command/mod.rs b/crates/session-runtime/src/command/mod.rs index ed39915e..ff30122d 100644 --- a/crates/session-runtime/src/command/mod.rs +++ b/crates/session-runtime/src/command/mod.rs @@ -9,6 +9,7 @@ use chrono::Utc; use crate::{ InputQueueEventAppend, SessionRuntime, append_and_broadcast, append_input_queue_event, + checkpoint_if_compacted, }; pub(crate) struct SessionCommands<'a> { @@ -172,9 +173,17 @@ impl<'a> SessionCommands<'a> { .await; actor.state().set_compacting(false); if let Some(events) = built? { + let mut persisted = Vec::with_capacity(events.len()); for event in &events { - append_and_broadcast(actor.state(), event, &mut translator).await?; + persisted.push(append_and_broadcast(actor.state(), event, &mut translator).await?); } + checkpoint_if_compacted( + &self.runtime.event_store, + &session_id, + actor.state(), + &persisted, + ) + .await; } Ok(false) } diff --git a/crates/session-runtime/src/lib.rs b/crates/session-runtime/src/lib.rs index 33f9ec13..6c7b49fa 100644 --- a/crates/session-runtime/src/lib.rs +++ b/crates/session-runtime/src/lib.rs @@ -57,7 +57,7 @@ pub use turn::{ }; pub(crate) use turn::{TurnOutcome, TurnRunResult, run_turn}; -const ROOT_AGENT_ID: &str = "root-agent"; +pub const ROOT_AGENT_ID: &str = "root-agent"; #[derive(Debug)] struct LoadedSession { @@ -298,6 +298,14 @@ impl SessionRuntime { self.query().session_mode_state(session_id).await } + pub async fn active_task_snapshot( + &self, + session_id: &str, + owner: &str, + ) -> Result<Option<astrcode_core::TaskSnapshot>> { + self.query().active_task_snapshot(session_id, owner).await + } + /// 读取指定 session 的工作目录。 pub async fn get_session_working_dir(&self, session_id: &str) -> Result<String> { self.query().session_working_dir(session_id).await @@ -525,13 +533,13 @@ impl SessionRuntime { .into_iter() .find(|meta| normalize_session_id(&meta.session_id) == session_id.as_str()) .ok_or_else(|| SessionRuntimeError::SessionNotFound(session_id.to_string()))?; - let stored = self.event_store.replay(session_id).await?; - let actor = Arc::new(SessionActor::from_replay( + let recovered = self.event_store.recover_session(session_id).await?; + let actor = Arc::new(SessionActor::from_recovery( session_id.clone(), meta.working_dir, AgentId::from(ROOT_AGENT_ID.to_string()), Arc::clone(&self.event_store), - stored, + recovered, )?); let loaded = Arc::new(LoadedSession { actor: Arc::clone(&actor), diff --git a/crates/session-runtime/src/query/conversation/tests.rs b/crates/session-runtime/src/query/conversation/tests.rs index aae4afb5..0f302408 100644 --- a/crates/session-runtime/src/query/conversation/tests.rs +++ b/crates/session-runtime/src/query/conversation/tests.rs @@ -287,6 +287,76 @@ fn snapshot_projects_plan_blocks_in_durable_event_order() { )); } +#[test] +fn snapshot_keeps_task_write_as_normal_tool_call_block() { + let records = vec![ + record( + "1.1", + AgentEvent::ToolCallStart { + turn_id: "turn-1".to_string(), + agent: sample_agent_context(), + tool_call_id: "call-task-write".to_string(), + tool_name: "taskWrite".to_string(), + input: json!({ + "items": [ + { + "content": "实现 authoritative task panel", + "status": "in_progress", + "activeForm": "正在实现 authoritative task panel" + } + ] + }), + }, + ), + record( + "1.2", + AgentEvent::ToolCallResult { + turn_id: "turn-1".to_string(), + agent: sample_agent_context(), + result: ToolExecutionResult { + tool_call_id: "call-task-write".to_string(), + tool_name: "taskWrite".to_string(), + ok: true, + output: "updated execution tasks".to_string(), + error: None, + metadata: Some(json!({ + "schema": "executionTaskSnapshot", + "owner": "root-agent", + "cleared": false, + "items": [ + { + "content": "实现 authoritative task panel", + "status": "in_progress", + "activeForm": "正在实现 authoritative task panel" + } + ] + })), + child_ref: None, + duration_ms: 5, + truncated: false, + }, + }, + ), + ]; + + let snapshot = project_conversation_snapshot(&records, Phase::CallingTool); + assert_eq!(snapshot.blocks.len(), 1); + assert!(matches!( + &snapshot.blocks[0], + ConversationBlockFacts::ToolCall(block) + if block.tool_name == "taskWrite" + && block.tool_call_id == "call-task-write" + && block.summary.as_deref() == Some("updated execution tasks") + )); + assert!( + snapshot + .blocks + .iter() + .all(|block| !matches!(block, ConversationBlockFacts::Plan(_))), + "taskWrite must not be projected onto the canonical plan surface" + ); +} + #[test] fn live_then_durable_tool_delta_dedupes_chunk_on_same_tool_block() { let facts = sample_stream_replay_facts( diff --git a/crates/session-runtime/src/query/service.rs b/crates/session-runtime/src/query/service.rs index 8d01b698..351955de 100644 --- a/crates/session-runtime/src/query/service.rs +++ b/crates/session-runtime/src/query/service.rs @@ -2,7 +2,7 @@ use std::sync::Arc; use astrcode_core::{ AgentEvent, AgentLifecycleStatus, ChildSessionNode, Phase, Result, SessionEventRecord, - SessionId, StorageEventPayload, StoredEvent, + SessionId, StorageEventPayload, StoredEvent, TaskSnapshot, }; use tokio::sync::broadcast::error::RecvError; @@ -91,6 +91,16 @@ impl<'a> SessionQueries<'a> { Ok(actor.working_dir().to_string()) } + pub async fn active_task_snapshot( + &self, + session_id: &str, + owner: &str, + ) -> Result<Option<TaskSnapshot>> { + let session_id = SessionId::from(crate::normalize_session_id(session_id)); + let actor = self.runtime.ensure_loaded_session(&session_id).await?; + actor.state().active_tasks_for(owner) + } + pub async fn stored_events(&self, session_id: &SessionId) -> Result<Vec<StoredEvent>> { self.runtime.ensure_session_exists(session_id).await?; self.runtime.event_store.replay(session_id).await @@ -358,9 +368,10 @@ mod tests { }; use astrcode_core::{ - AgentEventContext, DeleteProjectResult, EventStore, EventTranslator, Phase, Result, - SessionEventRecord, SessionId, SessionMeta, SessionTurnAcquireResult, StorageEvent, - StorageEventPayload, StoredEvent, UserMessageOrigin, + AgentEventContext, DeleteProjectResult, EventStore, EventTranslator, ExecutionTaskItem, + ExecutionTaskStatus, Phase, Result, SessionEventRecord, SessionId, SessionMeta, + SessionTurnAcquireResult, StorageEvent, StorageEventPayload, StoredEvent, + UserMessageOrigin, }; use async_trait::async_trait; use tokio::time::{Duration, timeout}; @@ -552,6 +563,41 @@ mod tests { ); } + #[tokio::test] + async fn active_task_snapshot_reads_authoritative_owner_snapshot() { + let runtime = test_runtime(Arc::new(StubEventStore::default())); + let session = runtime + .create_session(".") + .await + .expect("session should be created"); + let session_id = session.session_id.clone(); + let state = runtime + .get_session_state(&session_id.clone().into()) + .await + .expect("state should load"); + + state + .replace_active_task_snapshot(astrcode_core::TaskSnapshot { + owner: "owner-a".to_string(), + items: vec![ExecutionTaskItem { + content: "实现 prompt 注入".to_string(), + status: ExecutionTaskStatus::InProgress, + active_form: Some("正在实现 prompt 注入".to_string()), + }], + }) + .expect("task snapshot should store"); + + let snapshot = runtime + .query() + .active_task_snapshot(&session_id, "owner-a") + .await + .expect("query should succeed") + .expect("snapshot should exist"); + + assert_eq!(snapshot.owner, "owner-a"); + assert_eq!(snapshot.items[0].content, "实现 prompt 注入"); + } + fn build_large_history() -> Vec<StoredEvent> { let mut events = Vec::with_capacity(16_386); events.push(StoredEvent { diff --git a/crates/session-runtime/src/state/execution.rs b/crates/session-runtime/src/state/execution.rs index 5b6cd5b0..91c54c8d 100644 --- a/crates/session-runtime/src/state/execution.rs +++ b/crates/session-runtime/src/state/execution.rs @@ -1,8 +1,8 @@ use std::sync::Arc; use astrcode_core::{ - CancelToken, EventTranslator, Phase, Result, SessionTurnLease, StorageEvent, StoredEvent, - ToolEventSink, support, + CancelToken, EventStore, EventTranslator, Phase, Result, SessionId, SessionTurnLease, + StorageEvent, StorageEventPayload, StoredEvent, ToolEventSink, support, }; use async_trait::async_trait; use tokio::sync::Mutex; @@ -55,6 +55,47 @@ pub fn complete_session_execution(session: &SessionState, phase: Phase) { session.complete_execution_state(phase); } +pub async fn checkpoint_if_compacted( + event_store: &Arc<dyn EventStore>, + session_id: &SessionId, + session_state: &Arc<SessionState>, + persisted_events: &[StoredEvent], +) { + let Some(checkpoint_storage_seq) = persisted_events.last().map(|stored| stored.storage_seq) + else { + return; + }; + if !persisted_events.iter().any(|stored| { + matches!( + stored.event.payload, + StorageEventPayload::CompactApplied { .. } + ) + }) { + return; + } + let checkpoint = match session_state.recovery_checkpoint(checkpoint_storage_seq) { + Ok(checkpoint) => checkpoint, + Err(error) => { + log::warn!( + "failed to build recovery checkpoint for session '{}': {}", + session_id, + error + ); + return; + }, + }; + if let Err(error) = event_store + .checkpoint_session(session_id, &checkpoint) + .await + { + log::warn!( + "failed to persist recovery checkpoint for session '{}': {}", + session_id, + error + ); + } +} + pub struct SessionStateEventSink { session: Arc<SessionState>, translator: Mutex<EventTranslator>, diff --git a/crates/session-runtime/src/state/input_queue.rs b/crates/session-runtime/src/state/input_queue.rs index ea5dfd31..ffccf7b7 100644 --- a/crates/session-runtime/src/state/input_queue.rs +++ b/crates/session-runtime/src/state/input_queue.rs @@ -55,10 +55,6 @@ impl SessionState { /// 增量应用一条 input queue durable 事件到投影索引。 pub(crate) fn apply_input_queue_event(&self, stored: &StoredEvent) { - let Some(target_agent_id) = input_queue_projection_target_agent_id(&stored.event.payload) - else { - return; - }; let mut index = match support::lock_anyhow( &self.input_queue_projection_index, "input queue projection index", @@ -66,13 +62,24 @@ impl SessionState { Ok(index) => index, Err(_) => return, }; - let projection = index - .entry(target_agent_id.to_string()) - .or_insert_with(InputQueueProjection::default); - InputQueueProjection::apply_event_for_agent(projection, stored, target_agent_id); + apply_input_queue_event_to_index(&mut index, stored); } } +pub(crate) fn apply_input_queue_event_to_index( + index: &mut std::collections::HashMap<String, InputQueueProjection>, + stored: &StoredEvent, +) { + let Some(target_agent_id) = input_queue_projection_target_agent_id(&stored.event.payload) + else { + return; + }; + let projection = index + .entry(target_agent_id.to_string()) + .or_insert_with(InputQueueProjection::default); + InputQueueProjection::apply_event_for_agent(projection, stored, target_agent_id); +} + /// 追加一条 input queue durable 事件。 pub async fn append_input_queue_event( session: &SessionState, diff --git a/crates/session-runtime/src/state/mod.rs b/crates/session-runtime/src/state/mod.rs index 92338804..e4c35a95 100644 --- a/crates/session-runtime/src/state/mod.rs +++ b/crates/session-runtime/src/state/mod.rs @@ -10,6 +10,7 @@ mod compaction; mod execution; mod input_queue; mod paths; +mod tasks; #[cfg(test)] mod test_support; mod writer; @@ -22,17 +23,24 @@ use std::{ use astrcode_core::{ AgentEvent, AgentState, AgentStateProjector, CancelToken, ChildSessionNode, EventTranslator, InputQueueProjection, ModeId, Phase, ResolvedRuntimeConfig, Result, SessionEventRecord, - SessionTurnLease, StorageEventPayload, StoredEvent, + SessionRecoveryCheckpoint, SessionTurnLease, StorageEventPayload, StoredEvent, TaskSnapshot, + normalize_recovered_phase, support::{self}, }; use cache::{RecentSessionEvents, RecentStoredEvents}; use child_sessions::{child_node_from_stored_event, rebuild_child_nodes}; use chrono::{DateTime, Utc}; pub(crate) use execution::SessionStateEventSink; -pub use execution::{append_and_broadcast, complete_session_execution, prepare_session_execution}; -pub(crate) use input_queue::{InputQueueEventAppend, append_input_queue_event}; +pub use execution::{ + append_and_broadcast, checkpoint_if_compacted, complete_session_execution, + prepare_session_execution, +}; +pub(crate) use input_queue::{ + InputQueueEventAppend, append_input_queue_event, apply_input_queue_event_to_index, +}; pub(crate) use paths::compact_history_event_log_path; pub use paths::{display_name_from_working_dir, normalize_session_id, normalize_working_dir}; +use tasks::{apply_snapshot_to_map, rebuild_active_tasks, task_snapshot_from_stored_event}; use tokio::sync::broadcast; pub(crate) use writer::SessionWriter; @@ -66,6 +74,7 @@ pub struct SessionState { recent_records: StdMutex<RecentSessionEvents>, recent_stored: StdMutex<RecentStoredEvents>, child_nodes: StdMutex<HashMap<String, ChildSessionNode>>, + active_tasks: StdMutex<HashMap<String, TaskSnapshot>>, input_queue_projection_index: StdMutex<HashMap<String, InputQueueProjection>>, } @@ -100,6 +109,85 @@ impl SessionState { projector: AgentStateProjector, recent_records: Vec<SessionEventRecord>, recent_stored: Vec<StoredEvent>, + ) -> Self { + let child_nodes = rebuild_child_nodes(&recent_stored); + let active_tasks = rebuild_active_tasks(&recent_stored); + let input_queue_projection_index = InputQueueProjection::replay_index(&recent_stored); + let last_mode_changed_at = + recent_stored + .iter() + .rev() + .find_map(|stored| match &stored.event.payload { + StorageEventPayload::ModeChanged { timestamp, .. } => Some(*timestamp), + _ => None, + }); + Self::from_parts( + phase, + writer, + projector, + recent_records, + recent_stored, + child_nodes, + active_tasks, + input_queue_projection_index, + last_mode_changed_at, + ) + } + + pub fn from_recovery( + writer: Arc<SessionWriter>, + checkpoint: &SessionRecoveryCheckpoint, + tail_events: Vec<StoredEvent>, + ) -> Result<Self> { + let mut projector = AgentStateProjector::from_snapshot(checkpoint.agent_state.clone()); + let mut child_nodes = checkpoint.child_nodes.clone(); + let mut active_tasks = checkpoint.active_tasks.clone(); + let mut input_queue_projection_index = checkpoint.input_queue_projection_index.clone(); + let mut last_mode_changed_at = checkpoint.last_mode_changed_at; + + for stored in &tail_events { + stored.event.validate().map_err(|error| { + astrcode_core::AstrError::Validation(format!( + "session '{}' contains invalid stored event at storage_seq {}: {}", + checkpoint.agent_state.session_id, stored.storage_seq, error + )) + })?; + projector.apply(&stored.event); + if let Some(node) = child_node_from_stored_event(stored) { + child_nodes.insert(node.sub_run_id().to_string(), node); + } + if let Some(snapshot) = task_snapshot_from_stored_event(stored) { + apply_snapshot_to_map(&mut active_tasks, snapshot); + } + apply_input_queue_event_to_index(&mut input_queue_projection_index, stored); + if let StorageEventPayload::ModeChanged { timestamp, .. } = &stored.event.payload { + last_mode_changed_at = Some(*timestamp); + } + } + + Ok(Self::from_parts( + normalize_recovered_phase(projector.snapshot().phase), + writer, + projector, + astrcode_core::replay_records(&tail_events, None), + tail_events, + child_nodes, + active_tasks, + input_queue_projection_index, + last_mode_changed_at, + )) + } + + fn from_parts( + phase: Phase, + writer: Arc<SessionWriter>, + projector: AgentStateProjector, + recent_records: Vec<SessionEventRecord>, + recent_stored: Vec<StoredEvent>, + child_nodes: HashMap<String, ChildSessionNode>, + active_tasks: HashMap<String, TaskSnapshot>, + input_queue_projection_index: HashMap<String, InputQueueProjection>, + last_mode_changed_at: Option<DateTime<Utc>>, ) -> Self { let (broadcaster, _) = broadcast::channel(SESSION_BROADCAST_CAPACITY); let (live_broadcaster, _) = broadcast::channel(SESSION_LIVE_BROADCAST_CAPACITY); @@ -107,8 +195,6 @@ impl SessionState { cached_records.replace(recent_records); let mut cached_stored = RecentStoredEvents::default(); cached_stored.replace(recent_stored.clone()); - let child_nodes = rebuild_child_nodes(&recent_stored); - let input_queue_projection_index = InputQueueProjection::replay_index(&recent_stored); Self { phase: StdMutex::new(phase), running: AtomicBool::new(false), @@ -120,12 +206,7 @@ impl SessionState { pending_manual_compact_request: StdMutex::new(None), compact_failure_count: StdMutex::new(0), current_mode: StdMutex::new(projector.snapshot().mode_id.clone()), - last_mode_changed_at: StdMutex::new(recent_stored.iter().rev().find_map(|stored| { - match &stored.event.payload { - StorageEventPayload::ModeChanged { timestamp, .. } => Some(*timestamp), - _ => None, - } - })), + last_mode_changed_at: StdMutex::new(last_mode_changed_at), broadcaster, live_broadcaster, writer, @@ -133,10 +214,30 @@ impl SessionState { recent_records: StdMutex::new(cached_records), recent_stored: StdMutex::new(cached_stored), child_nodes: StdMutex::new(child_nodes), + active_tasks: StdMutex::new(active_tasks), input_queue_projection_index: StdMutex::new(input_queue_projection_index), } } + pub fn recovery_checkpoint( + &self, + checkpoint_storage_seq: u64, + ) -> Result<SessionRecoveryCheckpoint> { + Ok(SessionRecoveryCheckpoint { + agent_state: self.snapshot_projected_state()?, + phase: self.current_phase()?, + last_mode_changed_at: self.last_mode_changed_at()?, + child_nodes: support::lock_anyhow(&self.child_nodes, "session child nodes")?.clone(), + active_tasks: support::lock_anyhow(&self.active_tasks, "session active tasks")?.clone(), + input_queue_projection_index: support::lock_anyhow( + &self.input_queue_projection_index, + "input queue projection index", + )? + .clone(), + checkpoint_storage_seq, + }) + } + pub fn snapshot_projected_state(&self) -> Result<AgentState> { Ok(support::lock_anyhow(&self.projector, "session projector")?.snapshot()) } @@ -261,6 +362,7 @@ impl SessionState { if let Some(node) = child_node_from_stored_event(stored) { self.upsert_child_session_node(node)?; } + self.apply_task_snapshot_event(stored)?; self.apply_input_queue_event(stored); Ok(records) } diff --git a/crates/session-runtime/src/state/tasks.rs b/crates/session-runtime/src/state/tasks.rs new file mode 100644 index 00000000..5fbf329b --- /dev/null +++ b/crates/session-runtime/src/state/tasks.rs @@ -0,0 +1,192 @@ +use std::collections::HashMap; + +use astrcode_core::{ + EXECUTION_TASK_SNAPSHOT_SCHEMA, ExecutionTaskSnapshotMetadata, Result, StorageEventPayload, + StoredEvent, TaskSnapshot, support, +}; + +use super::SessionState; + +pub(crate) fn rebuild_active_tasks(events: &[StoredEvent]) -> HashMap<String, TaskSnapshot> { + let mut tasks = HashMap::new(); + for stored in events { + if let Some(snapshot) = task_snapshot_from_stored_event(stored) { + apply_snapshot_to_map(&mut tasks, snapshot); + } + } + tasks +} + +pub(crate) fn task_snapshot_from_stored_event(stored: &StoredEvent) -> Option<TaskSnapshot> { + let StorageEventPayload::ToolResult { + tool_name, + metadata: Some(metadata), + .. + } = &stored.event.payload + else { + return None; + }; + + if tool_name != "taskWrite" { + return None; + } + + let parsed = serde_json::from_value::<ExecutionTaskSnapshotMetadata>(metadata.clone()).ok()?; + if parsed.schema != EXECUTION_TASK_SNAPSHOT_SCHEMA { + return None; + } + + Some(parsed.into_snapshot()) +} + +pub(crate) fn apply_snapshot_to_map( + tasks: &mut HashMap<String, TaskSnapshot>, + snapshot: TaskSnapshot, +) { + if snapshot.should_clear() { + tasks.remove(snapshot.owner.as_str()); + } else { + tasks.insert(snapshot.owner.clone(), snapshot); + } +} + +impl SessionState { + pub(crate) fn apply_task_snapshot_event(&self, stored: &StoredEvent) -> Result<()> { + let Some(snapshot) = task_snapshot_from_stored_event(stored) else { + return Ok(()); + }; + self.replace_active_task_snapshot(snapshot) + } + + pub(crate) fn replace_active_task_snapshot(&self, snapshot: TaskSnapshot) -> Result<()> { + let mut tasks = support::lock_anyhow(&self.active_tasks, "session active tasks")?; + apply_snapshot_to_map(&mut tasks, snapshot); + Ok(()) + } + + pub fn active_tasks_for(&self, owner: &str) -> Result<Option<TaskSnapshot>> { + Ok( + support::lock_anyhow(&self.active_tasks, "session active tasks")? + .get(owner) + .cloned(), + ) + } +} + +#[cfg(test)] +mod tests { + use astrcode_core::{EventTranslator, ExecutionTaskItem, ExecutionTaskStatus, Phase}; + + use super::*; + use crate::state::test_support::{root_task_write_stored, test_session_state}; + + #[test] + fn session_state_rehydrates_active_tasks_from_replay() { + let session = SessionState::new( + Phase::Idle, + test_session_state().writer.clone(), + astrcode_core::AgentStateProjector::default(), + Vec::new(), + vec![root_task_write_stored( + 1, + "owner-a", + vec![ExecutionTaskItem { + content: "补充 task 投影".to_string(), + status: ExecutionTaskStatus::InProgress, + active_form: Some("正在补充 task 投影".to_string()), + }], + )], + ); + + let snapshot = session + .active_tasks_for("owner-a") + .expect("task lookup should succeed") + .expect("task snapshot should exist"); + assert_eq!(snapshot.items.len(), 1); + assert_eq!(snapshot.items[0].content, "补充 task 投影"); + } + + #[test] + fn translate_store_and_cache_clears_task_snapshot_when_latest_snapshot_is_completed_only() { + let session = test_session_state(); + let mut translator = EventTranslator::new(Phase::Idle); + + for stored in [ + root_task_write_stored( + 1, + "owner-a", + vec![ExecutionTaskItem { + content: "实现 runtime 投影".to_string(), + status: ExecutionTaskStatus::InProgress, + active_form: Some("正在实现 runtime 投影".to_string()), + }], + ), + root_task_write_stored( + 2, + "owner-a", + vec![ExecutionTaskItem { + content: "实现 runtime 投影".to_string(), + status: ExecutionTaskStatus::Completed, + active_form: Some("已完成 runtime 投影".to_string()), + }], + ), + ] { + session + .translate_store_and_cache(&stored, &mut translator) + .expect("task event should translate"); + } + + assert!( + session + .active_tasks_for("owner-a") + .expect("task lookup should succeed") + .is_none() + ); + } + + #[test] + fn translate_store_and_cache_isolates_task_snapshots_by_owner() { + let session = test_session_state(); + let mut translator = EventTranslator::new(Phase::Idle); + + let stored_events = [ + root_task_write_stored( + 1, + "owner-a", + vec![ExecutionTaskItem { + content: "任务 A".to_string(), + status: ExecutionTaskStatus::Pending, + active_form: None, + }], + ), + root_task_write_stored( + 2, + "owner-b", + vec![ExecutionTaskItem { + content: "任务 B".to_string(), + status: ExecutionTaskStatus::InProgress, + active_form: Some("正在处理任务 B".to_string()), + }], + ), + root_task_write_stored(3, "owner-a", Vec::new()), + ]; + + for event in stored_events { + session + .translate_store_and_cache(&event, &mut translator) + .expect("task event should translate"); + } + + assert!( + session + .active_tasks_for("owner-a") + .expect("task lookup should succeed") + .is_none() + ); + let owner_b = session + .active_tasks_for("owner-b") + .expect("task lookup should succeed") + .expect("owner-b snapshot should exist"); + assert_eq!(owner_b.items[0].content, "任务 B"); + } +} diff --git a/crates/session-runtime/src/state/test_support.rs b/crates/session-runtime/src/state/test_support.rs index b941a6c4..1da024e9 100644 --- a/crates/session-runtime/src/state/test_support.rs +++ b/crates/session-runtime/src/state/test_support.rs @@ -5,8 +5,9 @@ use std::sync::Arc; use astrcode_core::{ AgentEventContext, AgentLifecycleStatus, AgentStateProjector, ChildAgentRef, ChildExecutionIdentity, ChildSessionLineageKind, ChildSessionNotification, - ChildSessionNotificationKind, EventLogWriter, InvocationKind, ParentExecutionRef, Phase, - StorageEvent, StorageEventPayload, StoreResult, StoredEvent, SubRunStorageMode, + ChildSessionNotificationKind, EventLogWriter, ExecutionTaskItem, ExecutionTaskSnapshotMetadata, + InvocationKind, ParentExecutionRef, Phase, StorageEvent, StorageEventPayload, StoreResult, + StoredEvent, SubRunStorageMode, TaskSnapshot, }; use super::{SessionState, SessionWriter}; @@ -116,3 +117,42 @@ pub(crate) fn child_notification_event( }, ) } + +pub(crate) fn root_task_tool_result_event( + turn_id: &str, + owner: &str, + items: Vec<ExecutionTaskItem>, +) -> StorageEvent { + let snapshot = TaskSnapshot { + owner: owner.to_string(), + items, + }; + StorageEvent { + turn_id: Some(turn_id.to_string()), + agent: AgentEventContext::default(), + payload: StorageEventPayload::ToolResult { + tool_call_id: format!("call-{turn_id}"), + tool_name: "taskWrite".to_string(), + output: "updated execution tasks".to_string(), + success: true, + error: None, + metadata: Some( + serde_json::to_value(ExecutionTaskSnapshotMetadata::from_snapshot(&snapshot)) + .expect("task metadata should serialize"), + ), + child_ref: None, + duration_ms: 1, + }, + } +} + +pub(crate) fn root_task_write_stored( + storage_seq: u64, + owner: &str, + items: Vec<ExecutionTaskItem>, +) -> StoredEvent { + stored( + storage_seq, + root_task_tool_result_event(&format!("turn-{storage_seq}"), owner, items), + ) +} diff --git a/crates/session-runtime/src/turn/events.rs b/crates/session-runtime/src/turn/events.rs index 95721757..40890364 100644 --- a/crates/session-runtime/src/turn/events.rs +++ b/crates/session-runtime/src/turn/events.rs @@ -158,6 +158,7 @@ pub(crate) fn prompt_metrics_event( provider_cache_metrics_supported, prompt_cache_reuse_hits: cache_metrics.reuse_hits, prompt_cache_reuse_misses: cache_metrics.reuse_misses, + prompt_cache_unchanged_layers: cache_metrics.unchanged_layers, }, }, } @@ -498,6 +499,7 @@ mod tests { PromptBuildCacheMetrics { reuse_hits: 4, reuse_misses: 1, + unchanged_layers: vec![astrcode_core::SystemPromptLayer::Stable], }, true, ); @@ -513,6 +515,8 @@ mod tests { && metrics.effective_window == 108_000 && metrics.threshold_tokens == 97_200 && metrics.truncated_tool_results == 3 + && metrics.prompt_cache_unchanged_layers + == vec![astrcode_core::SystemPromptLayer::Stable] && metrics.provider_input_tokens.is_none() && metrics.provider_output_tokens.is_none() && metrics.cache_creation_input_tokens.is_none() diff --git a/crates/session-runtime/src/turn/interrupt.rs b/crates/session-runtime/src/turn/interrupt.rs index 24a08811..3af50b25 100644 --- a/crates/session-runtime/src/turn/interrupt.rs +++ b/crates/session-runtime/src/turn/interrupt.rs @@ -124,6 +124,7 @@ mod tests { Ok(PromptBuildOutput { system_prompt: "noop".to_string(), system_prompt_blocks: Vec::new(), + prompt_cache_hints: Default::default(), cache_metrics: Default::default(), metadata: serde_json::Value::Null, }) diff --git a/crates/session-runtime/src/turn/manual_compact.rs b/crates/session-runtime/src/turn/manual_compact.rs index 550ca0c5..2ce19197 100644 --- a/crates/session-runtime/src/turn/manual_compact.rs +++ b/crates/session-runtime/src/turn/manual_compact.rs @@ -206,6 +206,7 @@ mod tests { Ok(PromptBuildOutput { system_prompt: "noop".to_string(), system_prompt_blocks: Vec::new(), + prompt_cache_hints: Default::default(), cache_metrics: Default::default(), metadata: serde_json::Value::Null, }) diff --git a/crates/session-runtime/src/turn/request.rs b/crates/session-runtime/src/turn/request.rs index d127a5cb..2996fe32 100644 --- a/crates/session-runtime/src/turn/request.rs +++ b/crates/session-runtime/src/turn/request.rs @@ -258,6 +258,7 @@ pub async fn assemble_prompt_request( let mut llm_request = LlmRequest::new(messages.clone(), request.tools, request.cancel.clone()) .with_system(prompt_output.system_prompt); llm_request.system_prompt_blocks = prompt_output.system_prompt_blocks; + llm_request.prompt_cache_hints = Some(prompt_output.prompt_cache_hints.clone()); Ok(AssemblePromptResult { llm_request, @@ -315,6 +316,11 @@ pub(crate) async fn build_prompt_output( { prompt_declarations.push(direct_child_snapshot); } + if let Some(task_snapshot) = + live_task_snapshot_declaration(session_state, session_id, current_agent_id)? + { + prompt_declarations.push(task_snapshot); + } prompt_declarations.extend_from_slice(submission_prompt_declarations); gateway .build_prompt(PromptBuildRequest { @@ -390,6 +396,64 @@ fn live_direct_child_snapshot_declaration( })) } +fn live_task_snapshot_declaration( + session_state: Option<&crate::SessionState>, + session_id: &str, + current_agent_id: Option<&str>, +) -> Result<Option<PromptDeclaration>> { + let Some(session_state) = session_state else { + return Ok(None); + }; + let owner = current_agent_id + .filter(|value| !value.trim().is_empty()) + .unwrap_or(session_id); + let Some(snapshot) = session_state.active_tasks_for(owner)? else { + return Ok(None); + }; + let active_items = snapshot.active_items(); + if active_items.is_empty() { + return Ok(None); + } + + let items_block = active_items + .iter() + .map(|item| match item.status { + astrcode_core::ExecutionTaskStatus::InProgress => format!( + "- in_progress: {}{}", + item.content, + item.active_form + .as_deref() + .map(|value| format!(" ({value})")) + .unwrap_or_default() + ), + astrcode_core::ExecutionTaskStatus::Pending => format!("- pending: {}", item.content), + astrcode_core::ExecutionTaskStatus::Completed => String::new(), + }) + .filter(|line| !line.is_empty()) + .collect::<Vec<_>>() + .join("\n"); + + Ok(Some(PromptDeclaration { + block_id: "task.active_snapshot".to_string(), + title: "Live Task Snapshot".to_string(), + content: format!( + "Authoritative execution-task snapshot for the current owner.\n\nRules:\n- Treat this \ + snapshot as the current execution checklist for this branch of work.\n- Only \ + `in_progress` and `pending` tasks appear here; completed items are intentionally \ + omitted.\n- Update this snapshot with `taskWrite` before changing focus or after \ + completing a task.\n\nActive tasks:\n{items_block}" + ), + render_target: astrcode_core::PromptDeclarationRenderTarget::System, + layer: astrcode_core::SystemPromptLayer::Dynamic, + kind: astrcode_core::PromptDeclarationKind::ExtensionInstruction, + priority_hint: Some(593), + always_include: true, + source: astrcode_core::PromptDeclarationSource::Builtin, + capability_name: None, + origin: Some(format!("live-task-snapshot:{owner}")), + })) +} + pub(crate) fn build_prompt_metadata( session_id: &str, turn_id: &str, @@ -456,12 +520,13 @@ mod tests { use astrcode_core::{ AgentLifecycleStatus, AstrError, ChildExecutionIdentity, ChildSessionLineageKind, - ChildSessionNode, ChildSessionStatusSource, LlmOutput, LlmProvider, LlmRequest, - ModelLimits, ParentExecutionRef, PromptBuildOutput, PromptBuildRequest, PromptDeclaration, - PromptDeclarationKind, PromptDeclarationRenderTarget, PromptDeclarationSource, PromptFacts, - PromptFactsProvider, PromptFactsRequest, PromptProvider, ResolvedRuntimeConfig, - ResourceProvider, ResourceReadResult, ResourceRequestContext, StorageEventPayload, - SystemPromptLayer, ToolDefinition, + ChildSessionNode, ChildSessionStatusSource, ExecutionTaskItem, ExecutionTaskStatus, + LlmOutput, LlmProvider, LlmRequest, ModelLimits, ParentExecutionRef, PromptBuildOutput, + PromptBuildRequest, PromptDeclaration, PromptDeclarationKind, + PromptDeclarationRenderTarget, PromptDeclarationSource, PromptFacts, PromptFactsProvider, + PromptFactsRequest, PromptProvider, ResolvedRuntimeConfig, ResourceProvider, + ResourceReadResult, ResourceRequestContext, StorageEventPayload, SystemPromptLayer, + ToolDefinition, }; use astrcode_kernel::{CapabilityRouter, KernelGateway}; use async_trait::async_trait; @@ -599,9 +664,11 @@ mod tests { Ok(PromptBuildOutput { system_prompt: "recorded".to_string(), system_prompt_blocks: Vec::new(), + prompt_cache_hints: Default::default(), cache_metrics: astrcode_core::PromptBuildCacheMetrics { reuse_hits: 2, reuse_misses: 1, + unchanged_layers: Vec::new(), }, metadata: serde_json::Value::Null, }) @@ -725,6 +792,118 @@ mod tests { assert_eq!(captured[1].origin.as_deref(), Some("submission-origin")); } + #[tokio::test] + async fn build_prompt_output_includes_live_task_snapshot_for_current_owner() { + let captured = Arc::new(Mutex::new(Vec::new())); + let gateway = KernelGateway::new( + CapabilityRouter::empty(), + Arc::new(LocalNoopLlmProvider), + Arc::new(RecordingPromptProvider { + captured: captured.clone(), + }), + Arc::new(LocalNoopResourceProvider), + ); + let session_state = test_session_state(); + session_state + .replace_active_task_snapshot(astrcode_core::TaskSnapshot { + owner: "agent-root".to_string(), + items: vec![ + ExecutionTaskItem { + content: "实现 task prompt 注入".to_string(), + status: ExecutionTaskStatus::InProgress, + active_form: Some("正在实现 task prompt 注入".to_string()), + }, + ExecutionTaskItem { + content: "补充 request 测试".to_string(), + status: ExecutionTaskStatus::Pending, + active_form: None, + }, + ExecutionTaskItem { + content: "完成旧任务".to_string(), + status: ExecutionTaskStatus::Completed, + active_form: Some("已完成旧任务".to_string()), + }, + ], + }) + .expect("task snapshot should store"); + + build_prompt_output(PromptOutputRequest { + gateway: &gateway, + prompt_facts_provider: &RecordingPromptFactsProvider, + session_id: "session-1", + turn_id: "turn-1", + working_dir: Path::new("."), + step_index: 0, + messages: &[], + session_state: Some(session_state.as_ref()), + current_agent_id: Some("agent-root"), + submission_prompt_declarations: &[], + prompt_governance: None, + }) + .await + .expect("prompt output should build"); + + let captured = captured.lock().expect("capture lock should work"); + let declaration = captured + .iter() + .find(|declaration| declaration.block_id == "task.active_snapshot") + .expect("task snapshot declaration should exist"); + assert!( + declaration + .content + .contains("in_progress: 实现 task prompt 注入") + ); + assert!(declaration.content.contains("pending: 补充 request 测试")); + assert!(!declaration.content.contains("完成旧任务")); + } + + #[tokio::test] + async fn build_prompt_output_omits_live_task_snapshot_when_no_active_items_exist() { + let captured = Arc::new(Mutex::new(Vec::new())); + let gateway = KernelGateway::new( + CapabilityRouter::empty(), + Arc::new(LocalNoopLlmProvider), + Arc::new(RecordingPromptProvider { + captured: captured.clone(), + }), + Arc::new(LocalNoopResourceProvider), + ); + let session_state = test_session_state(); + session_state + .replace_active_task_snapshot(astrcode_core::TaskSnapshot { + owner: "session-1".to_string(), + items: vec![ExecutionTaskItem { + content: "已完成任务".to_string(), + status: ExecutionTaskStatus::Completed, + active_form: Some("已完成任务".to_string()), + }], + }) + .expect("task snapshot should store"); + + build_prompt_output(PromptOutputRequest { + gateway: &gateway, + prompt_facts_provider: &RecordingPromptFactsProvider, + session_id: "session-1", + turn_id: "turn-1", + working_dir: Path::new("."), + step_index: 0, + messages: &[], + session_state: Some(session_state.as_ref()), + current_agent_id: None, + submission_prompt_declarations: &[], + prompt_governance: None, + }) + .await + .expect("prompt output should build"); + + let captured = captured.lock().expect("capture lock should work"); + assert!( + captured + .iter() + .all(|declaration| declaration.block_id != "task.active_snapshot") + ); + } + #[test] fn live_direct_child_snapshot_declaration_only_uses_current_agents_children() { let session_state = test_session_state(); diff --git a/crates/session-runtime/src/turn/submit.rs b/crates/session-runtime/src/turn/submit.rs index 8b96cf56..613d39c2 100644 --- a/crates/session-runtime/src/turn/submit.rs +++ b/crates/session-runtime/src/turn/submit.rs @@ -2,11 +2,11 @@ use std::{sync::Arc, time::Instant}; use astrcode_core::{ AgentEventContext, ApprovalPending, CancelToken, CapabilityCall, - CompletedParentDeliveryPayload, EventTranslator, ExecutionAccepted, LlmMessage, ParentDelivery, - ParentDeliveryOrigin, ParentDeliveryPayload, ParentDeliveryTerminalSemantics, Phase, - PolicyContext, PromptDeclaration, ResolvedExecutionLimitsSnapshot, ResolvedRuntimeConfig, - ResolvedSubagentContextOverrides, Result, RuntimeMetricsRecorder, SessionId, StorageEvent, - StorageEventPayload, TurnId, UserMessageOrigin, + CompletedParentDeliveryPayload, EventStore, EventTranslator, ExecutionAccepted, LlmMessage, + ParentDelivery, ParentDeliveryOrigin, ParentDeliveryPayload, ParentDeliveryTerminalSemantics, + Phase, PolicyContext, PromptDeclaration, ResolvedExecutionLimitsSnapshot, + ResolvedRuntimeConfig, ResolvedSubagentContextOverrides, Result, RuntimeMetricsRecorder, + SessionId, StorageEvent, StorageEventPayload, StoredEvent, TurnId, UserMessageOrigin, }; use astrcode_kernel::CapabilityRouter; use chrono::Utc; @@ -14,7 +14,7 @@ use chrono::Utc; use crate::{ SessionRuntime, TurnOutcome, actor::SessionActor, - prepare_session_execution, + checkpoint_if_compacted, prepare_session_execution, query::current_turn_messages, run_turn, state::{append_and_broadcast, complete_session_execution}, @@ -68,6 +68,7 @@ struct PersistedTurnContext { struct TurnFinalizeContext { kernel: Arc<astrcode_kernel::Kernel>, prompt_facts_provider: Arc<dyn astrcode_core::PromptFactsProvider>, + event_store: Arc<dyn EventStore>, metrics: Arc<dyn RuntimeMetricsRecorder>, actor: Arc<SessionActor>, session_id: String, @@ -105,6 +106,7 @@ async fn finalize_turn_execution( match result { Ok(turn_result) => { persist_turn_events( + &finalize.event_store, finalize.actor.state(), &finalize.session_id, &mut translator, @@ -142,6 +144,7 @@ async fn finalize_turn_execution( persist_pending_manual_compact_if_any( finalize.kernel.gateway(), finalize.prompt_facts_provider.as_ref(), + &finalize.event_store, finalize.actor.working_dir(), finalize.actor.state(), &finalize.session_id, @@ -160,20 +163,25 @@ fn terminal_phase_for_result(result: &Result<crate::TurnRunResult>) -> Phase { } async fn persist_turn_events( + event_store: &Arc<dyn EventStore>, session_state: &Arc<crate::SessionState>, session_id: &str, translator: &mut EventTranslator, turn_result: crate::TurnRunResult, persisted: &PersistedTurnContext, ) { + let mut persisted_events = Vec::<StoredEvent>::new(); for event in &turn_result.events { - if let Err(error) = append_and_broadcast(session_state, event, translator).await { - log::error!( - "failed to persist turn event for session '{}': {}", - session_id, - error - ); - break; + match append_and_broadcast(session_state, event, translator).await { + Ok(stored) => persisted_events.push(stored), + Err(error) => { + log::error!( + "failed to persist turn event for session '{}': {}", + session_id, + error + ); + break; + }, } } if let Some(event) = subrun_finished_event( @@ -190,6 +198,13 @@ async fn persist_turn_events( ); } } + checkpoint_if_compacted( + event_store, + &SessionId::from(session_id.to_string()), + session_state, + &persisted_events, + ) + .await; } async fn persist_turn_failure( @@ -213,6 +228,7 @@ async fn persist_turn_failure( async fn persist_deferred_manual_compact( gateway: &astrcode_kernel::KernelGateway, prompt_facts_provider: &dyn astrcode_core::PromptFactsProvider, + event_store: &Arc<dyn EventStore>, working_dir: &str, session_state: &Arc<crate::SessionState>, session_id: &str, @@ -247,23 +263,33 @@ async fn persist_deferred_manual_compact( }; let mut compact_translator = EventTranslator::new(session_state.current_phase().unwrap_or(Phase::Idle)); + let mut persisted = Vec::<StoredEvent>::with_capacity(events.len()); for event in &events { - if let Err(error) = - append_and_broadcast(session_state, event, &mut compact_translator).await - { - log::warn!( - "failed to persist deferred compact for session '{}': {}", - session_id, - error - ); - break; + match append_and_broadcast(session_state, event, &mut compact_translator).await { + Ok(stored) => persisted.push(stored), + Err(error) => { + log::warn!( + "failed to persist deferred compact for session '{}': {}", + session_id, + error + ); + break; + }, } } + checkpoint_if_compacted( + event_store, + &SessionId::from(session_id.to_string()), + session_state, + &persisted, + ) + .await; } pub(crate) async fn persist_pending_manual_compact_if_any( gateway: &astrcode_kernel::KernelGateway, prompt_facts_provider: &dyn astrcode_core::PromptFactsProvider, + event_store: &Arc<dyn EventStore>, working_dir: &str, session_state: &Arc<crate::SessionState>, session_id: &str, @@ -273,6 +299,7 @@ pub(crate) async fn persist_pending_manual_compact_if_any( persist_deferred_manual_compact( gateway, prompt_facts_provider, + event_store, working_dir, session_state, session_id, @@ -549,6 +576,7 @@ impl SessionRuntime { finalize: TurnFinalizeContext { kernel: Arc::clone(&self.kernel), prompt_facts_provider: Arc::clone(&self.prompt_facts_provider), + event_store: Arc::clone(&self.event_store), metrics: Arc::clone(&self.metrics), actor: Arc::clone(&submit_target.actor), session_id: submit_target.session_id.to_string(), @@ -740,6 +768,7 @@ mod tests { Ok(PromptBuildOutput { system_prompt: "noop".to_string(), system_prompt_blocks: Vec::new(), + prompt_cache_hints: Default::default(), cache_metrics: Default::default(), metadata: serde_json::Value::Null, }) diff --git a/crates/session-runtime/src/turn/test_support.rs b/crates/session-runtime/src/turn/test_support.rs index 4b1d2de2..725eb123 100644 --- a/crates/session-runtime/src/turn/test_support.rs +++ b/crates/session-runtime/src/turn/test_support.rs @@ -62,6 +62,7 @@ impl PromptProvider for NoopPromptProvider { Ok(PromptBuildOutput { system_prompt: "noop".to_string(), system_prompt_blocks: Vec::new(), + prompt_cache_hints: Default::default(), cache_metrics: Default::default(), metadata: Value::Null, }) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index eb4b67ca..e7b24f96 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -322,7 +322,9 @@ export default function App() { projectName: activeProject?.name ?? null, sessionId: activeSession?.id ?? null, sessionTitle: activeSession?.title ?? null, - currentModeId: activeConversationControl?.currentModeId ?? activeModeId, + // Why: mode 切换 API 会立即返回新 mode,但 conversation control 流可能稍后才追上。 + // 这里优先使用本地已确认的 activeModeId,避免右上角 badge 被旧的流式快照短暂覆盖。 + currentModeId: activeModeId ?? activeConversationControl?.currentModeId ?? null, isChildSession: activeSession?.parentSessionId !== undefined, workingDir: activeProject?.workingDir ?? '', phase: state.phase, diff --git a/frontend/src/components/Chat/TaskPanel.tsx b/frontend/src/components/Chat/TaskPanel.tsx new file mode 100644 index 00000000..1784f815 --- /dev/null +++ b/frontend/src/components/Chat/TaskPanel.tsx @@ -0,0 +1,137 @@ +import { useMemo, useState } from 'react'; +import { cn } from '../../lib/utils'; +import { useChatScreenContext } from './ChatScreenContext'; + +function statusLabel(status: 'pending' | 'in_progress' | 'completed'): string { + switch (status) { + case 'in_progress': + return '进行中'; + case 'completed': + return '已完成'; + case 'pending': + default: + return '待处理'; + } +} + +function statusClass(status: 'pending' | 'in_progress' | 'completed'): string { + switch (status) { + case 'in_progress': + return 'border-amber-300/60 bg-amber-100/80 text-amber-900'; + case 'completed': + return 'border-emerald-300/60 bg-emerald-100/80 text-emerald-900'; + case 'pending': + default: + return 'border-slate-300/70 bg-slate-100/80 text-slate-700'; + } +} + +export default function TaskPanel() { + const { conversationControl } = useChatScreenContext(); + // TODO(task-panel): 当前 UI 假设 control.activeTasks 只代表一个 owner 的任务快照。 + // 后续支持多 owner 并行展示时,需要把这里重构成按 owner 分组的多卡片/多分区布局, + // 同时保留每个 owner 自己的 in_progress 与统计摘要。 + const tasks = conversationControl?.activeTasks; + const [collapsed, setCollapsed] = useState(false); + + const summary = useMemo(() => { + if (!tasks || tasks.length === 0) { + return null; + } + const inProgress = tasks.find((task) => task.status === 'in_progress'); + const pendingCount = tasks.filter((task) => task.status === 'pending').length; + const completedCount = tasks.filter((task) => task.status === 'completed').length; + return { + inProgress, + pendingCount, + completedCount, + total: tasks.length, + }; + }, [tasks]); + + if (!tasks || tasks.length === 0 || !summary) { + return null; + } + + return ( + <section className="shrink-0 border-b border-border bg-[linear-gradient(180deg,rgba(251,249,244,0.96)_0%,rgba(246,241,231,0.94)_100%)] px-[var(--chat-content-horizontal-padding)] py-3.5 max-sm:px-[var(--chat-content-horizontal-padding-mobile)]"> + <div className="mx-auto flex w-[min(100%,var(--chat-content-max-width))] flex-col overflow-hidden rounded-[20px] border border-black/8 bg-white/78 shadow-[0_16px_34px_rgba(88,72,36,0.08)] backdrop-blur-[10px]"> + <button + type="button" + className="flex w-full items-start justify-between gap-4 px-4 py-3 text-left transition-[background-color] duration-150 ease-out hover:bg-black/[0.02]" + onClick={() => setCollapsed((value) => !value)} + aria-expanded={!collapsed} + > + <div className="min-w-0"> + <div className="flex flex-wrap items-center gap-2"> + <span className="inline-flex min-h-[24px] items-center rounded-full border border-black/8 bg-[#f4ede0] px-2.5 text-[11px] font-bold tracking-[0.06em] text-[#6f5730]"> + TASKS + </span> + <span className="text-xs text-text-secondary"> + {summary.inProgress + ? `当前执行 · ${summary.inProgress.activeForm ?? summary.inProgress.content}` + : '当前没有进行中的任务'} + </span> + </div> + <div className="mt-1.5 flex flex-wrap items-center gap-2 text-[12px] text-text-secondary"> + <span>待处理 {summary.pendingCount}</span> + <span>已完成 {summary.completedCount}</span> + <span>总计 {summary.total}</span> + </div> + </div> + <span + className={cn( + 'inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-full border border-black/8 bg-white/80 text-text-secondary transition-transform duration-150 ease-out', + collapsed ? '' : 'rotate-180' + )} + aria-hidden="true" + > + <svg + width="16" + height="16" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + strokeWidth="2" + strokeLinecap="round" + strokeLinejoin="round" + > + <polyline points="6 9 12 15 18 9" /> + </svg> + </span> + </button> + {!collapsed ? ( + <div className="border-t border-black/6 px-4 py-3.5"> + <div className="flex flex-col gap-2.5"> + {tasks.map((task, index) => ( + <div + key={`${task.status}:${task.content}:${index}`} + className="flex items-start justify-between gap-3 rounded-2xl border border-black/6 bg-[rgba(255,252,246,0.9)] px-3.5 py-3" + > + <div className="min-w-0"> + <div className="text-[13px] font-medium leading-5 text-text-primary"> + {task.content} + </div> + {task.activeForm ? ( + <div className="mt-1 text-xs leading-5 text-text-secondary"> + {task.activeForm} + </div> + ) : null} + </div> + <span + className={cn( + 'inline-flex min-h-[24px] shrink-0 items-center rounded-full border px-2.5 text-[11px] font-semibold', + statusClass(task.status) + )} + > + {statusLabel(task.status)} + </span> + </div> + ))} + </div> + </div> + ) : null} + </div> + </section> + ); +} diff --git a/frontend/src/components/Chat/index.tsx b/frontend/src/components/Chat/index.tsx index f9de4f24..b3b3765e 100644 --- a/frontend/src/components/Chat/index.tsx +++ b/frontend/src/components/Chat/index.tsx @@ -3,6 +3,7 @@ import { subRunNotice } from '../../lib/styles'; import InputBar from './InputBar'; import MessageList from './MessageList'; import { ChatScreenProvider, type ChatScreenContextValue } from './ChatScreenContext'; +import TaskPanel from './TaskPanel'; import TopBar from './TopBar'; interface ChatProps { @@ -24,6 +25,7 @@ export default function Chat({ <ChatScreenProvider value={contextValue}> <div className="flex h-full min-h-0 min-w-0 flex-col overflow-hidden bg-panel-bg"> <TopBar /> + <TaskPanel /> <MessageList threadItems={threadItems} childSubRuns={childSubRuns} diff --git a/frontend/src/lib/api/conversation.test.ts b/frontend/src/lib/api/conversation.test.ts index b02d8d8f..be09027b 100644 --- a/frontend/src/lib/api/conversation.test.ts +++ b/frontend/src/lib/api/conversation.test.ts @@ -11,6 +11,7 @@ const baseControl = { compacting: false, currentModeId: 'code', activePlan: undefined, + activeTasks: undefined, }; describe('projectConversationState', () => { @@ -611,4 +612,89 @@ describe('projectConversationState', () => { }, }); }); + + it('hydrates authoritative activeTasks without scanning tool history', () => { + const state: ConversationSnapshotState = { + cursor: 'cursor-task-1', + phase: 'callingTool', + blocks: [], + control: { + ...baseControl, + phase: 'callingTool', + activeTasks: [ + { + content: '实现 authoritative task panel', + status: 'in_progress', + activeForm: '正在实现 authoritative task panel', + }, + { + content: '补充前端 hydration 测试', + status: 'pending', + }, + ], + }, + childSummaries: [], + }; + + const projection = projectConversationState(state); + + expect(projection.control.activeTasks).toEqual([ + { + content: '实现 authoritative task panel', + status: 'in_progress', + activeForm: '正在实现 authoritative task panel', + }, + { + content: '补充前端 hydration 测试', + status: 'pending', + }, + ]); + }); + + it('updates task panel facts through update_control_state deltas', () => { + const state: ConversationSnapshotState = { + cursor: 'cursor-task-2', + phase: 'idle', + blocks: [], + control: baseControl, + childSummaries: [], + }; + + applyConversationEnvelope(state, { + cursor: 'cursor-task-3', + kind: 'update_control_state', + control: { + phase: 'callingTool', + canSubmitPrompt: false, + canRequestCompact: true, + compactPending: false, + compacting: false, + currentModeId: 'code', + activeTasks: [ + { + content: '实现 authoritative task panel', + status: 'in_progress', + activeForm: '正在实现 authoritative task panel', + }, + { + content: '补充前端 hydration 测试', + status: 'completed', + }, + ], + }, + }); + + expect(state.phase).toBe('callingTool'); + expect(state.control.activeTasks).toEqual([ + { + content: '实现 authoritative task panel', + status: 'in_progress', + activeForm: '正在实现 authoritative task panel', + }, + { + content: '补充前端 hydration 测试', + status: 'completed', + }, + ]); + }); }); diff --git a/frontend/src/lib/api/conversation.ts b/frontend/src/lib/api/conversation.ts index 1d4f4178..148afaa4 100644 --- a/frontend/src/lib/api/conversation.ts +++ b/frontend/src/lib/api/conversation.ts @@ -5,6 +5,8 @@ import type { CompactMeta, ConversationControlState, ConversationPlanReference, + ConversationTaskItem, + ConversationTaskStatus, LastCompactMeta, Message, ParentDelivery, @@ -194,21 +196,56 @@ function parseLastCompactMeta(value: unknown): LastCompactMeta | undefined { }; } +function parseTaskStatus(value: unknown): ConversationTaskStatus | undefined { + switch (value) { + case 'pending': + case 'in_progress': + case 'completed': + return value; + default: + return undefined; + } +} + +function parsePlanReference(value: unknown): ConversationPlanReference | undefined { + const plan = asRecord(value); + const slug = pickString(plan ?? {}, 'slug'); + const path = pickString(plan ?? {}, 'path'); + const status = pickString(plan ?? {}, 'status'); + const title = pickString(plan ?? {}, 'title'); + if (!slug || !path || !status || !title) { + return undefined; + } + return { slug, path, status, title }; +} + +function parseActiveTasks(value: unknown): ConversationTaskItem[] | undefined { + if (!Array.isArray(value)) { + return undefined; + } + const items = value + .map((entry): ConversationTaskItem | null => { + const task = asRecord(entry); + const content = pickString(task ?? {}, 'content'); + const status = parseTaskStatus(task?.status); + if (!content || !status) { + return null; + } + const activeForm = pickOptionalString(task ?? {}, 'activeForm') ?? undefined; + return { + content, + status, + ...(activeForm ? { activeForm } : {}), + }; + }) + .filter((item): item is ConversationTaskItem => item !== null); + return items.length > 0 ? items : undefined; +} + function parseConversationControlState(record: ConversationRecord): ConversationControlState { const controlRecord = asRecord(record.control); const phase = parsePhase(controlRecord?.phase ?? record.phase); const lastCompactMeta = parseLastCompactMeta(controlRecord?.lastCompactMeta); - const parsePlanReference = (value: unknown): ConversationPlanReference | undefined => { - const plan = asRecord(value); - const slug = pickString(plan ?? {}, 'slug'); - const path = pickString(plan ?? {}, 'path'); - const status = pickString(plan ?? {}, 'status'); - const title = pickString(plan ?? {}, 'title'); - if (!slug || !path || !status || !title) { - return undefined; - } - return { slug, path, status, title }; - }; return { phase, canSubmitPrompt: controlRecord?.canSubmitPrompt !== false, @@ -219,6 +256,7 @@ function parseConversationControlState(record: ConversationRecord): Conversation activeTurnId: pickOptionalString(controlRecord ?? {}, 'activeTurnId') ?? undefined, lastCompactMeta, activePlan: parsePlanReference(controlRecord?.activePlan), + activeTasks: parseActiveTasks(controlRecord?.activeTasks), }; } @@ -774,14 +812,8 @@ export function applyConversationEnvelope( currentModeId: pickString(control, 'currentModeId') ?? state.control.currentModeId, activeTurnId: pickOptionalString(control, 'activeTurnId') ?? undefined, lastCompactMeta: parseLastCompactMeta(control.lastCompactMeta), - activePlan: (() => { - const plan = asRecord(control.activePlan); - const slug = pickString(plan ?? {}, 'slug'); - const path = pickString(plan ?? {}, 'path'); - const status = pickString(plan ?? {}, 'status'); - const title = pickString(plan ?? {}, 'title'); - return slug && path && status && title ? { slug, path, status, title } : undefined; - })(), + activePlan: parsePlanReference(control.activePlan), + activeTasks: parseActiveTasks(control.activeTasks), }; state.phase = state.control.phase; } diff --git a/frontend/src/lib/sessionFork.test.ts b/frontend/src/lib/sessionFork.test.ts index 9ecc18ed..70b68262 100644 --- a/frontend/src/lib/sessionFork.test.ts +++ b/frontend/src/lib/sessionFork.test.ts @@ -11,6 +11,7 @@ const baseControl = { compacting: false, currentModeId: 'code', activePlan: undefined, + activeTasks: undefined, }; describe('resolveForkTurnIdFromMessage', () => { diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 00f1a0d9..a8809a84 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -155,6 +155,14 @@ export interface ConversationPlanReference { title: string; } +export type ConversationTaskStatus = 'pending' | 'in_progress' | 'completed'; + +export interface ConversationTaskItem { + content: string; + status: ConversationTaskStatus; + activeForm?: string; +} + export interface ConversationControlState { phase: Phase; canSubmitPrompt: boolean; @@ -165,6 +173,7 @@ export interface ConversationControlState { activeTurnId?: string; lastCompactMeta?: LastCompactMeta; activePlan?: ConversationPlanReference; + activeTasks?: ConversationTaskItem[]; } export type SubRunResult = From 758d02134a2c49aa06e5f6f98f9508c3682aa312 Mon Sep 17 00:00:00 2001 From: whatevertogo <whatevertogo@users.noreply.github.com> Date: Mon, 20 Apr 2026 02:22:10 +0800 Subject: [PATCH 47/53] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor(adapter-sto?= =?UTF-8?q?rage,session-runtime):=20=E6=8F=90=E5=8F=96=20batch=20appender/?= =?UTF-8?q?checkpoint=20=E6=A8=A1=E5=9D=97=EF=BC=8C=E9=87=8D=E6=9E=84=20se?= =?UTF-8?q?ssion=20state=20=E6=9E=84=E5=BB=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit crates/adapter-storage/src/session/batch_appender.rs - 从 repository.rs 提取批量事件写入逻辑,50ms drain window 合并高频事件减少 fsync 开销 crates/adapter-storage/src/session/checkpoint.rs - 从 repository.rs 提取 checkpoint 持久化与恢复逻辑(两阶段提交:snapshot + log rotation + marker) crates/adapter-storage/src/session/repository.rs - 新增 batch append 与 checkpoint recovery 集成测试 crates/session-runtime/src/state/mod.rs - 提取 SessionDerivedState 结构体,将 4 个派生状态字段打包传参,简化 new/from_parts 签名 crates/session-runtime/src/turn/interrupt.rs, submit.rs - checkpoint_if_compacted 导入路径从 crate 级迁移到 state::,TurnFinalizeContext 携带 event_store crates/adapter-llm/src/openai.rs - build_request 8 参数列表重构为 OpenAiBuildRequestInput 结构体(消除位置参数混淆) crates/adapter-prompt/src/layered_builder.rs - CacheLookupResult::Hit 改用 Box<PromptBuildOutput> 减少栈拷贝开销 crates/core/src/lib.rs - 模块文档链接统一为 [foo][] 风格 docs/特点/{event_log,server,capability_governance,plan_mode}.md - 新增四篇架构设计文档:Event Log、Server-First、Capability 治理、Plan Mode --- crates/adapter-llm/src/openai.rs | 136 ++++--- crates/adapter-prompt/src/layered_builder.rs | 8 +- .../src/session/batch_appender.rs | 241 +++++++++++ .../adapter-storage/src/session/checkpoint.rs | 383 ++++++++++++++++++ .../adapter-storage/src/session/repository.rs | 189 +++++++++ crates/cli/src/app/mod.rs | 4 +- crates/client/src/lib.rs | 6 +- crates/core/src/event/translate.rs | 1 + crates/core/src/event/types.rs | 1 + crates/core/src/lib.rs | 48 +-- crates/session-runtime/src/command/mod.rs | 2 +- .../session-runtime/src/state/input_queue.rs | 4 +- crates/session-runtime/src/state/mod.rs | 51 ++- crates/session-runtime/src/turn/interrupt.rs | 1 + crates/session-runtime/src/turn/submit.rs | 5 +- .../capability_governance.md" | 347 ++++++++++++++++ "docs/\347\211\271\347\202\271/event_log.md" | 230 +++++++++++ "docs/\347\211\271\347\202\271/plan_mode.md" | 275 +++++++++++++ "docs/\347\211\271\347\202\271/server.md" | 333 +++++++++++++++ 19 files changed, 2156 insertions(+), 109 deletions(-) create mode 100644 crates/adapter-storage/src/session/batch_appender.rs create mode 100644 crates/adapter-storage/src/session/checkpoint.rs create mode 100644 "docs/\347\211\271\347\202\271/capability_governance.md" create mode 100644 "docs/\347\211\271\347\202\271/event_log.md" create mode 100644 "docs/\347\211\271\347\202\271/plan_mode.md" create mode 100644 "docs/\347\211\271\347\202\271/server.md" diff --git a/crates/adapter-llm/src/openai.rs b/crates/adapter-llm/src/openai.rs index 7ad6d32a..715364a1 100644 --- a/crates/adapter-llm/src/openai.rs +++ b/crates/adapter-llm/src/openai.rs @@ -113,16 +113,16 @@ impl OpenAiProvider { /// 不需要显式标记 `cache_control`,API 自动缓存 >= 1024 tokens 的 prompt 前缀。 /// 分层 system blocks 的排列顺序(Stable → SemiStable → Inherited → Dynamic)天然提供稳定的 /// 前缀,对 OpenAI 的自动 prefix matching 最友好。 - fn build_request<'a>( - &'a self, - messages: &'a [LlmMessage], - tools: &'a [ToolDefinition], - system_prompt: Option<&'a str>, - system_prompt_blocks: &'a [astrcode_core::SystemPromptBlock], - prompt_cache_hints: Option<&'a PromptCacheHints>, - max_output_tokens_override: Option<usize>, - stream: bool, - ) -> OpenAiChatRequest<'a> { + fn build_request<'a>(&'a self, input: OpenAiBuildRequestInput<'a>) -> OpenAiChatRequest<'a> { + let OpenAiBuildRequestInput { + messages, + tools, + system_prompt, + system_prompt_blocks, + prompt_cache_hints, + max_output_tokens_override, + stream, + } = input; let effective_max_output_tokens = max_output_tokens_override .unwrap_or(self.limits.max_output_tokens) .min(self.limits.max_output_tokens); @@ -326,15 +326,15 @@ impl LlmProvider for OpenAiProvider { /// - **流式**(`sink = Some`):逐块读取 SSE 响应,实时发射事件并累加 async fn generate(&self, request: LlmRequest, sink: Option<EventSink>) -> Result<LlmOutput> { let cancel = request.cancel; - let req = self.build_request( - &request.messages, - &request.tools, - request.system_prompt.as_deref(), - &request.system_prompt_blocks, - request.prompt_cache_hints.as_ref(), - request.max_output_tokens_override, - sink.is_some(), - ); + let req = self.build_request(OpenAiBuildRequestInput { + messages: &request.messages, + tools: &request.tools, + system_prompt: request.system_prompt.as_deref(), + system_prompt_blocks: &request.system_prompt_blocks, + prompt_cache_hints: request.prompt_cache_hints.as_ref(), + max_output_tokens_override: request.max_output_tokens_override, + stream: sink.is_some(), + }); let response = self.send_request(&req, cancel.clone()).await?; match sink { @@ -785,6 +785,16 @@ struct OpenAiChatRequest<'a> { stream: bool, } +struct OpenAiBuildRequestInput<'a> { + messages: &'a [LlmMessage], + tools: &'a [ToolDefinition], + system_prompt: Option<&'a str>, + system_prompt_blocks: &'a [astrcode_core::SystemPromptBlock], + prompt_cache_hints: Option<&'a PromptCacheHints>, + max_output_tokens_override: Option<usize>, + stream: bool, +} + /// OpenAI 请求消息(user / assistant / system / tool)。 /// /// 与 Anthropic 的内容块数组不同,OpenAI 使用扁平的消息结构: @@ -1059,15 +1069,15 @@ mod tests { content: "hi".to_string(), origin: UserMessageOrigin::User, }]; - let request = provider.build_request( - &messages, - &[], - Some("Follow the rules"), - &[], - None, - None, - false, - ); + let request = provider.build_request(OpenAiBuildRequestInput { + messages: &messages, + tools: &[], + system_prompt: Some("Follow the rules"), + system_prompt_blocks: &[], + prompt_cache_hints: None, + max_output_tokens_override: None, + stream: false, + }); assert_eq!(request.messages[0].role, "system"); assert_eq!( @@ -1121,8 +1131,15 @@ mod tests { layer: astrcode_core::SystemPromptLayer::Inherited, }, ]; - let request = - provider.build_request(&messages, &[], None, &system_blocks, None, None, false); + let request = provider.build_request(OpenAiBuildRequestInput { + messages: &messages, + tools: &[], + system_prompt: None, + system_prompt_blocks: &system_blocks, + prompt_cache_hints: None, + max_output_tokens_override: None, + stream: false, + }); let body = serde_json::to_value(&request).expect("request should serialize"); // 应该有 4 个 system 消息 + 1 个 user 消息,无 cache_control 字段 @@ -1177,26 +1194,27 @@ mod tests { ) .expect("provider should build"); - let official_body = serde_json::to_value(official.build_request( - &messages, - &[], - Some("Follow the rules"), - &[], - None, - None, - false, - )) - .expect("request should serialize"); - let compatible_body = serde_json::to_value(compatible.build_request( - &messages, - &[], - Some("Follow the rules"), - &[], - None, - None, - false, - )) + let official_body = serde_json::to_value(official.build_request(OpenAiBuildRequestInput { + messages: &messages, + tools: &[], + system_prompt: Some("Follow the rules"), + system_prompt_blocks: &[], + prompt_cache_hints: None, + max_output_tokens_override: None, + stream: false, + })) .expect("request should serialize"); + let compatible_body = + serde_json::to_value(compatible.build_request(OpenAiBuildRequestInput { + messages: &messages, + tools: &[], + system_prompt: Some("Follow the rules"), + system_prompt_blocks: &[], + prompt_cache_hints: None, + max_output_tokens_override: None, + stream: false, + })) + .expect("request should serialize"); assert!( official_body @@ -1226,8 +1244,24 @@ mod tests { origin: UserMessageOrigin::User, }]; - let capped = provider.build_request(&messages, &[], None, &[], None, Some(1024), false); - let clamped = provider.build_request(&messages, &[], None, &[], None, Some(4096), false); + let capped = provider.build_request(OpenAiBuildRequestInput { + messages: &messages, + tools: &[], + system_prompt: None, + system_prompt_blocks: &[], + prompt_cache_hints: None, + max_output_tokens_override: Some(1024), + stream: false, + }); + let clamped = provider.build_request(OpenAiBuildRequestInput { + messages: &messages, + tools: &[], + system_prompt: None, + system_prompt_blocks: &[], + prompt_cache_hints: None, + max_output_tokens_override: Some(4096), + stream: false, + }); assert_eq!(capped.max_tokens, 1024); assert_eq!(clamped.max_tokens, 2048); diff --git a/crates/adapter-prompt/src/layered_builder.rs b/crates/adapter-prompt/src/layered_builder.rs index 5dba11fb..d75962d2 100644 --- a/crates/adapter-prompt/src/layered_builder.rs +++ b/crates/adapter-prompt/src/layered_builder.rs @@ -81,7 +81,7 @@ struct LayerCache { #[derive(Debug, Clone, PartialEq, Eq)] enum CacheLookupResult { - Hit(PromptBuildOutput), + Hit(Box<PromptBuildOutput>), Miss { invalidation_reason: String }, } @@ -194,7 +194,7 @@ impl LayeredPromptBuilder { .extend(output.diagnostics.items.clone()); combined .plan - .extend_with_layer(output.plan, layer_type.prompt_layer()); + .extend_with_layer(output.plan.clone(), layer_type.prompt_layer()); }, CacheLookupResult::Miss { invalidation_reason, @@ -269,7 +269,7 @@ impl LayeredPromptBuilder { }; if entry.fingerprint == fingerprint && !is_cache_expired(entry, &self.options, layer_type) { - CacheLookupResult::Hit(entry.output.clone()) + CacheLookupResult::Hit(Box::new(entry.output.clone())) } else if is_cache_expired(entry, &self.options, layer_type) { CacheLookupResult::Miss { invalidation_reason: "ttl_expired".to_string(), @@ -423,7 +423,7 @@ fn set_layer_fingerprint( } } -fn layer_fingerprint<'a>(hints: &'a PromptCacheHints, layer_type: LayerType) -> Option<&'a String> { +fn layer_fingerprint(hints: &PromptCacheHints, layer_type: LayerType) -> Option<&String> { match layer_type { LayerType::Stable => hints.layer_fingerprints.stable.as_ref(), LayerType::SemiStable => hints.layer_fingerprints.semi_stable.as_ref(), diff --git a/crates/adapter-storage/src/session/batch_appender.rs b/crates/adapter-storage/src/session/batch_appender.rs new file mode 100644 index 00000000..fa51a386 --- /dev/null +++ b/crates/adapter-storage/src/session/batch_appender.rs @@ -0,0 +1,241 @@ +use std::{ + collections::{HashMap, VecDeque}, + path::{Path, PathBuf}, + sync::{Arc, Mutex as StdMutex}, + time::Duration, +}; + +use astrcode_core::{SessionRecoveryCheckpoint, StorageEvent, StoredEvent, store::StoreResult}; +use tokio::sync::{Mutex, Notify, oneshot}; + +use super::{event_log::EventLog, paths::resolve_existing_session_path_from_projects_root}; + +const BATCH_DRAIN_WINDOW: Duration = Duration::from_millis(50); + +pub(crate) type SharedAppenderRegistry = Arc<Mutex<HashMap<String, Arc<BatchAppender>>>>; + +struct PendingAppend { + event: StorageEvent, + reply: oneshot::Sender<StoreResult<StoredEvent>>, +} + +#[derive(Default)] +struct BatchAppenderState { + queue: VecDeque<PendingAppend>, + flush_scheduled: bool, + draining: bool, + paused: bool, +} + +pub(crate) struct BatchAppender { + session_id: String, + projects_root: Option<PathBuf>, + state: Mutex<BatchAppenderState>, + notify: Notify, + log: StdMutex<Option<EventLog>>, +} + +impl BatchAppender { + pub(crate) fn new(session_id: String, projects_root: Option<PathBuf>) -> Self { + Self { + session_id, + projects_root, + state: Mutex::new(BatchAppenderState::default()), + notify: Notify::new(), + log: StdMutex::new(None), + } + } + + pub(crate) async fn append(self: &Arc<Self>, event: StorageEvent) -> StoreResult<StoredEvent> { + let (tx, rx) = oneshot::channel(); + let mut state = self.state.lock().await; + state.queue.push_back(PendingAppend { event, reply: tx }); + if !state.paused && !state.flush_scheduled { + state.flush_scheduled = true; + drop(state); + self.spawn_flush_cycle(); + } else { + drop(state); + } + rx.await.map_err(|_| { + crate::internal_io_error(format!( + "batch appender for session '{}' dropped response channel", + self.session_id + )) + })? + } + + pub(crate) async fn checkpoint_with_payload<T, F>( + self: &Arc<Self>, + checkpoint: SessionRecoveryCheckpoint, + work: F, + ) -> StoreResult<T> + where + T: Send + 'static, + F: FnOnce(&Path, &SessionRecoveryCheckpoint) -> StoreResult<T> + Send + 'static, + { + self.pause_and_drain().await; + let appender = Arc::clone(self); + let result = tokio::task::spawn_blocking(move || { + let path = appender.event_log_path()?; + let mut guard = appender.log.lock().map_err(|_| { + crate::internal_io_error(format!( + "batch appender log mutex poisoned for session '{}'", + appender.session_id + )) + })?; + let previous = guard.take(); + drop(guard); + drop(previous); + work(&path, &checkpoint) + }) + .await + .map_err(|error| { + crate::internal_io_error(format!( + "checkpoint task for session '{}' failed to join: {error}", + self.session_id + )) + })?; + self.resume_after_barrier().await; + result + } + + fn spawn_flush_cycle(self: &Arc<Self>) { + let appender = Arc::clone(self); + tokio::spawn(async move { + tokio::time::sleep(BATCH_DRAIN_WINDOW).await; + appender.flush_pending_batch().await; + }); + } + + async fn flush_pending_batch(self: Arc<Self>) { + let pending = { + let mut state = self.state.lock().await; + if state.paused || state.queue.is_empty() { + state.flush_scheduled = false; + state.draining = false; + self.notify.notify_waiters(); + return; + } + state.draining = true; + state.flush_scheduled = false; + state.queue.drain(..).collect::<Vec<_>>() + }; + let events = pending + .iter() + .map(|pending| pending.event.clone()) + .collect::<Vec<_>>(); + let result = { + let appender = Arc::clone(&self); + tokio::task::spawn_blocking(move || appender.append_batch_blocking(&events)).await + }; + match result { + Ok(Ok(stored_events)) => { + for (pending, stored) in pending.into_iter().zip(stored_events) { + let _ = pending.reply.send(Ok(stored)); + } + }, + Ok(Err(error)) => { + let message = error.to_string(); + for pending in pending { + let _ = pending + .reply + .send(Err(crate::internal_io_error(message.clone()))); + } + }, + Err(error) => { + let message = format!( + "batch appender for session '{}' failed to join: {error}", + self.session_id + ); + for pending in pending { + let _ = pending + .reply + .send(Err(crate::internal_io_error(message.clone()))); + } + }, + } + + let should_reschedule = { + let mut state = self.state.lock().await; + state.draining = false; + let should_reschedule = + !state.paused && !state.flush_scheduled && !state.queue.is_empty(); + if should_reschedule { + state.flush_scheduled = true; + } + self.notify.notify_waiters(); + should_reschedule + }; + if should_reschedule { + self.spawn_flush_cycle(); + } + } + + async fn pause_and_drain(&self) { + loop { + let notified = { + let mut state = self.state.lock().await; + if !state.paused && !state.draining && state.queue.is_empty() { + state.paused = true; + None + } else { + Some(self.notify.notified()) + } + }; + if let Some(notified) = notified { + notified.await; + continue; + } + return; + } + } + + async fn resume_after_barrier(self: &Arc<Self>) { + let should_schedule = { + let mut state = self.state.lock().await; + state.paused = false; + let should_schedule = !state.flush_scheduled && !state.queue.is_empty(); + if should_schedule { + state.flush_scheduled = true; + } + self.notify.notify_waiters(); + should_schedule + }; + if should_schedule { + self.spawn_flush_cycle(); + } + } + + fn append_batch_blocking(&self, events: &[StorageEvent]) -> StoreResult<Vec<StoredEvent>> { + let mut guard = self.log.lock().map_err(|_| { + crate::internal_io_error(format!( + "batch appender log mutex poisoned for session '{}'", + self.session_id + )) + })?; + if guard.is_none() { + *guard = Some(self.open_event_log()?); + } + guard + .as_mut() + .expect("event log should be available") + .append_batch(events) + } + + fn open_event_log(&self) -> StoreResult<EventLog> { + match &self.projects_root { + Some(projects_root) => EventLog::open_in_projects_root(projects_root, &self.session_id), + None => EventLog::open(&self.session_id), + } + } + + fn event_log_path(&self) -> StoreResult<PathBuf> { + match &self.projects_root { + Some(projects_root) => { + resolve_existing_session_path_from_projects_root(projects_root, &self.session_id) + }, + None => super::paths::resolve_existing_session_path(&self.session_id), + } + } +} diff --git a/crates/adapter-storage/src/session/checkpoint.rs b/crates/adapter-storage/src/session/checkpoint.rs new file mode 100644 index 00000000..4028274d --- /dev/null +++ b/crates/adapter-storage/src/session/checkpoint.rs @@ -0,0 +1,383 @@ +#[cfg(not(windows))] +use std::fs::File; +use std::{ + fs::{self, OpenOptions}, + io::{BufWriter, Write}, + path::{Path, PathBuf}, +}; + +use astrcode_core::{RecoveredSessionState, SessionRecoveryCheckpoint, StoredEvent}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use super::{ + iterator::EventLogIterator, + paths::{ + checkpoint_snapshot_path, checkpoint_snapshot_path_from_projects_root, + latest_checkpoint_marker_path, latest_checkpoint_marker_path_from_projects_root, + resolve_existing_session_path, resolve_existing_session_path_from_projects_root, + snapshots_dir, snapshots_dir_from_projects_root, + }, +}; +use crate::Result; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct LatestCheckpointMarker { + checkpoint_storage_seq: u64, + file_name: String, +} + +pub(crate) fn recover_session( + projects_root: Option<&Path>, + session_id: &str, +) -> Result<RecoveredSessionState> { + let event_log_path = match projects_root { + Some(projects_root) => { + resolve_existing_session_path_from_projects_root(projects_root, session_id)? + }, + None => resolve_existing_session_path(session_id)?, + }; + let Some(checkpoint) = load_active_checkpoint(projects_root, session_id)? else { + return Ok(RecoveredSessionState { + checkpoint: None, + tail_events: EventLogIterator::from_path(&event_log_path)? + .collect::<Result<Vec<_>>>()?, + }); + }; + let tail_events = EventLogIterator::from_path(&event_log_path)? + .filter_map(|result| match result { + Ok(stored) if stored.storage_seq > checkpoint.checkpoint_storage_seq => { + Some(Ok(stored)) + }, + Ok(_) => None, + Err(error) => Some(Err(error)), + }) + .collect::<Result<Vec<_>>>()?; + Ok(RecoveredSessionState { + checkpoint: Some(checkpoint), + tail_events, + }) +} + +pub(crate) fn persist_checkpoint( + projects_root: Option<&Path>, + event_log_path: &Path, + session_id: &str, + checkpoint: &SessionRecoveryCheckpoint, +) -> Result<()> { + let snapshot_dir = match projects_root { + Some(projects_root) => snapshots_dir_from_projects_root(projects_root, session_id)?, + None => snapshots_dir(session_id)?, + }; + fs::create_dir_all(&snapshot_dir).map_err(|error| { + crate::io_error( + format!( + "failed to create snapshots directory '{}'", + snapshot_dir.display() + ), + error, + ) + })?; + + let checkpoint_path = match projects_root { + Some(projects_root) => checkpoint_snapshot_path_from_projects_root( + projects_root, + session_id, + checkpoint.checkpoint_storage_seq, + )?, + None => checkpoint_snapshot_path(session_id, checkpoint.checkpoint_storage_seq)?, + }; + let marker_path = match projects_root { + Some(projects_root) => { + latest_checkpoint_marker_path_from_projects_root(projects_root, session_id)? + }, + None => latest_checkpoint_marker_path(session_id)?, + }; + let snapshot_tmp = temp_path(&checkpoint_path, "snapshot"); + let marker_tmp = temp_path(&marker_path, "marker"); + let log_tmp = temp_path(event_log_path, "rewrite"); + let log_backup = event_log_path.with_extension("jsonl.bak"); + + let tail_events = EventLogIterator::from_path(event_log_path)? + .filter_map(|result| match result { + Ok(stored) if stored.storage_seq > checkpoint.checkpoint_storage_seq => { + Some(Ok(stored)) + }, + Ok(_) => None, + Err(error) => Some(Err(error)), + }) + .collect::<Result<Vec<_>>>()?; + + write_json_file(&snapshot_tmp, checkpoint)?; + write_json_file( + &marker_tmp, + &LatestCheckpointMarker { + checkpoint_storage_seq: checkpoint.checkpoint_storage_seq, + file_name: checkpoint_path + .file_name() + .and_then(|value| value.to_str()) + .ok_or_else(|| { + crate::internal_io_error(format!( + "checkpoint path '{}' has no valid file name", + checkpoint_path.display() + )) + })? + .to_string(), + }, + )?; + write_events_file(&log_tmp, &tail_events)?; + + fs::rename(&snapshot_tmp, &checkpoint_path).map_err(|error| { + crate::io_error( + format!( + "failed to commit checkpoint snapshot '{}' -> '{}'", + snapshot_tmp.display(), + checkpoint_path.display() + ), + error, + ) + })?; + sync_dir(&snapshot_dir)?; + + replace_file(&marker_tmp, &marker_path)?; + sync_dir(&snapshot_dir)?; + + if log_backup.exists() { + fs::remove_file(&log_backup).map_err(|error| { + crate::io_error( + format!( + "failed to remove stale log backup '{}'", + log_backup.display() + ), + error, + ) + })?; + } + + fs::rename(event_log_path, &log_backup).map_err(|error| { + crate::io_error( + format!( + "failed to rotate event log '{}' -> '{}'", + event_log_path.display(), + log_backup.display() + ), + error, + ) + })?; + if let Err(error) = fs::rename(&log_tmp, event_log_path) { + restore_rotated_event_log(&log_backup, event_log_path).map_err(|restore_error| { + crate::internal_io_error(format!( + "failed to restore rotated event log '{}' -> '{}' after promote failure: {}", + log_backup.display(), + event_log_path.display(), + restore_error + )) + })?; + return Err(crate::io_error( + format!( + "failed to promote rewritten event log '{}' -> '{}'", + log_tmp.display(), + event_log_path.display() + ), + error, + )); + } + sync_dir( + event_log_path + .parent() + .ok_or_else(|| crate::internal_io_error("event log path missing parent"))?, + )?; + + fs::remove_file(&log_backup).map_err(|error| { + crate::io_error( + format!( + "failed to remove old event log backup '{}'", + log_backup.display() + ), + error, + ) + })?; + + Ok(()) +} + +fn load_active_checkpoint( + projects_root: Option<&Path>, + session_id: &str, +) -> Result<Option<SessionRecoveryCheckpoint>> { + let marker_path = match projects_root { + Some(projects_root) => { + latest_checkpoint_marker_path_from_projects_root(projects_root, session_id)? + }, + None => latest_checkpoint_marker_path(session_id)?, + }; + if !marker_path.exists() { + return Ok(None); + } + let marker = read_json_file::<LatestCheckpointMarker>(&marker_path)?; + let snapshot_dir = match projects_root { + Some(projects_root) => snapshots_dir_from_projects_root(projects_root, session_id)?, + None => snapshots_dir(session_id)?, + }; + let checkpoint_path = snapshot_dir.join(marker.file_name); + if !checkpoint_path.exists() { + return Err(crate::internal_io_error(format!( + "checkpoint marker '{}' points to missing snapshot '{}'", + marker_path.display(), + checkpoint_path.display() + ))); + } + Ok(Some(read_json_file(&checkpoint_path)?)) +} + +fn read_json_file<T>(path: &Path) -> Result<T> +where + T: for<'de> Deserialize<'de>, +{ + let bytes = fs::read(path) + .map_err(|error| crate::io_error(format!("failed to read '{}'", path.display()), error))?; + serde_json::from_slice(&bytes) + .map_err(|error| crate::parse_error(format!("failed to parse '{}'", path.display()), error)) +} + +fn write_json_file<T>(path: &Path, value: &T) -> Result<()> +where + T: Serialize, +{ + let file = OpenOptions::new() + .create(true) + .truncate(true) + .write(true) + .open(path) + .map_err(|error| crate::io_error(format!("failed to open '{}'", path.display()), error))?; + let mut writer = BufWriter::new(file); + serde_json::to_writer(&mut writer, value).map_err(|error| { + crate::parse_error(format!("failed to serialize '{}'", path.display()), error) + })?; + writer + .flush() + .map_err(|error| crate::io_error(format!("failed to flush '{}'", path.display()), error))?; + writer + .get_ref() + .sync_all() + .map_err(|error| crate::io_error(format!("failed to sync '{}'", path.display()), error))?; + sync_dir( + path.parent() + .ok_or_else(|| crate::internal_io_error("json file path missing parent"))?, + )?; + Ok(()) +} + +fn write_events_file(path: &Path, events: &[StoredEvent]) -> Result<()> { + let file = OpenOptions::new() + .create(true) + .truncate(true) + .write(true) + .open(path) + .map_err(|error| crate::io_error(format!("failed to open '{}'", path.display()), error))?; + let mut writer = BufWriter::new(file); + for stored in events { + serde_json::to_writer(&mut writer, stored).map_err(|error| { + crate::parse_error(format!("failed to serialize '{}'", path.display()), error) + })?; + writeln!(writer).map_err(|error| { + crate::io_error(format!("failed to write '{}'", path.display()), error) + })?; + } + writer + .flush() + .map_err(|error| crate::io_error(format!("failed to flush '{}'", path.display()), error))?; + writer + .get_ref() + .sync_all() + .map_err(|error| crate::io_error(format!("failed to sync '{}'", path.display()), error))?; + sync_dir( + path.parent() + .ok_or_else(|| crate::internal_io_error("event file path missing parent"))?, + )?; + Ok(()) +} + +fn replace_file(from: &Path, to: &Path) -> Result<()> { + if to.exists() { + fs::remove_file(to).map_err(|error| { + crate::io_error( + format!("failed to remove existing file '{}'", to.display()), + error, + ) + })?; + } + fs::rename(from, to).map_err(|error| { + crate::io_error( + format!( + "failed to rename '{}' -> '{}'", + from.display(), + to.display() + ), + error, + ) + })?; + Ok(()) +} + +fn restore_rotated_event_log(log_backup: &Path, event_log_path: &Path) -> Result<()> { + if event_log_path.exists() { + fs::remove_file(event_log_path).map_err(|error| { + crate::io_error( + format!( + "failed to remove partially promoted event log '{}'", + event_log_path.display() + ), + error, + ) + })?; + } + fs::rename(log_backup, event_log_path).map_err(|error| { + crate::io_error( + format!( + "failed to restore event log backup '{}' -> '{}'", + log_backup.display(), + event_log_path.display() + ), + error, + ) + })?; + Ok(()) +} + +fn sync_dir(path: &Path) -> Result<()> { + #[cfg(windows)] + { + // TODO: Windows 目录刷盘目前只能 best-effort 跳过;后续需要补平台专用实现, + // 以收紧 checkpoint/rename 在断电或进程崩溃场景下的元数据持久化保证。 + let _ = path; + Ok(()) + } + + #[cfg(not(windows))] + let dir = File::open(path).map_err(|error| { + crate::io_error( + format!("failed to open directory '{}'", path.display()), + error, + ) + })?; + #[cfg(not(windows))] + { + dir.sync_all().map_err(|error| { + crate::io_error( + format!("failed to sync directory '{}'", path.display()), + error, + ) + }) + } +} + +fn temp_path(base: &Path, label: &str) -> PathBuf { + let suffix = Uuid::new_v4(); + let file_name = base + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or("checkpoint"); + base.with_file_name(format!("{file_name}.{label}.{suffix}.tmp")) +} diff --git a/crates/adapter-storage/src/session/repository.rs b/crates/adapter-storage/src/session/repository.rs index fb35c4cd..fb85198a 100644 --- a/crates/adapter-storage/src/session/repository.rs +++ b/crates/adapter-storage/src/session/repository.rs @@ -372,3 +372,192 @@ impl SessionManager for FileSystemSessionRepository { self.delete_sessions_by_working_dir_sync(working_dir) } } + +#[cfg(test)] +mod tests { + use std::{collections::HashMap, time::Instant}; + + use astrcode_core::{ + AgentEventContext, AgentState, EventStore, LlmMessage, ModeId, Phase, + SessionRecoveryCheckpoint, StorageEvent, StorageEventPayload, UserMessageOrigin, + }; + + use super::*; + use crate::session::paths::checkpoint_snapshot_path_from_projects_root; + + fn user_message_event(turn_id: &str, content: &str) -> StorageEvent { + StorageEvent { + turn_id: Some(turn_id.to_string()), + agent: AgentEventContext::default(), + payload: StorageEventPayload::UserMessage { + content: content.to_string(), + origin: UserMessageOrigin::User, + timestamp: chrono::Utc::now(), + }, + } + } + + #[tokio::test] + async fn append_batches_events_with_contiguous_storage_seq() { + let temp = tempfile::tempdir().expect("tempdir should exist"); + let repo = FileSystemSessionRepository::new_with_projects_root(temp.path().to_path_buf()); + let session_id = SessionId::from("session-batch-1".to_string()); + let working_dir = temp.path().join("work"); + std::fs::create_dir_all(&working_dir).expect("working dir should exist"); + repo.ensure_session(&session_id, &working_dir) + .await + .expect("session should be created"); + + let started = Instant::now(); + let first_event = user_message_event("turn-1", "first"); + let second_event = user_message_event("turn-2", "second"); + let first = repo.append(&session_id, &first_event); + let second = repo.append(&session_id, &second_event); + let (first, second) = tokio::join!(first, second); + let elapsed = started.elapsed(); + + let first = first.expect("first append should succeed"); + let second = second.expect("second append should succeed"); + let mut seqs = vec![first.storage_seq, second.storage_seq]; + seqs.sort_unstable(); + + assert_eq!(seqs, vec![1, 2]); + assert!( + elapsed >= std::time::Duration::from_millis(40), + "batch append should wait for the drain window before returning" + ); + assert_eq!( + repo.replay_events_sync(session_id.as_str()) + .expect("replay should succeed") + .len(), + 2 + ); + } + + #[tokio::test] + async fn recover_session_uses_checkpoint_plus_tail_events() { + let temp = tempfile::tempdir().expect("tempdir should exist"); + let repo = FileSystemSessionRepository::new_with_projects_root(temp.path().to_path_buf()); + let session_id = SessionId::from("session-recovery-1".to_string()); + let working_dir = temp.path().join("work"); + std::fs::create_dir_all(&working_dir).expect("working dir should exist"); + repo.ensure_session(&session_id, &working_dir) + .await + .expect("session should be created"); + + repo.append(&session_id, &user_message_event("turn-1", "first")) + .await + .expect("first append should succeed"); + repo.append(&session_id, &user_message_event("turn-2", "second")) + .await + .expect("second append should succeed"); + let tail = repo + .append(&session_id, &user_message_event("turn-3", "tail")) + .await + .expect("tail append should succeed"); + + repo.checkpoint_session( + &session_id, + &SessionRecoveryCheckpoint { + agent_state: AgentState { + session_id: session_id.to_string(), + working_dir: working_dir.clone(), + messages: vec![LlmMessage::User { + content: "checkpoint".to_string(), + origin: UserMessageOrigin::User, + }], + phase: Phase::Idle, + mode_id: ModeId::default(), + turn_count: 2, + last_assistant_at: None, + }, + phase: Phase::Idle, + last_mode_changed_at: None, + child_nodes: HashMap::new(), + active_tasks: HashMap::new(), + input_queue_projection_index: HashMap::new(), + checkpoint_storage_seq: 2, + }, + ) + .await + .expect("checkpoint should succeed"); + + let recovered = repo + .recover_session(&session_id) + .await + .expect("recovery should succeed"); + + assert_eq!( + recovered + .checkpoint + .expect("checkpoint should exist") + .checkpoint_storage_seq, + 2 + ); + assert_eq!(recovered.tail_events.len(), 1); + assert_eq!(recovered.tail_events[0].storage_seq, tail.storage_seq); + assert_eq!( + repo.replay_events_sync(session_id.as_str()) + .expect("replay should succeed") + .into_iter() + .map(|stored| stored.storage_seq) + .collect::<Vec<_>>(), + vec![tail.storage_seq] + ); + } + + #[tokio::test] + async fn recover_session_fails_when_marker_points_to_missing_snapshot() { + let temp = tempfile::tempdir().expect("tempdir should exist"); + let repo = FileSystemSessionRepository::new_with_projects_root(temp.path().to_path_buf()); + let session_id = SessionId::from("session-recovery-missing-snapshot".to_string()); + let working_dir = temp.path().join("work"); + std::fs::create_dir_all(&working_dir).expect("working dir should exist"); + repo.ensure_session(&session_id, &working_dir) + .await + .expect("session should be created"); + + repo.append(&session_id, &user_message_event("turn-1", "first")) + .await + .expect("append should succeed"); + repo.checkpoint_session( + &session_id, + &SessionRecoveryCheckpoint { + agent_state: AgentState { + session_id: session_id.to_string(), + working_dir: working_dir.clone(), + messages: vec![LlmMessage::User { + content: "checkpoint".to_string(), + origin: UserMessageOrigin::User, + }], + phase: Phase::Idle, + mode_id: ModeId::default(), + turn_count: 1, + last_assistant_at: None, + }, + phase: Phase::Idle, + last_mode_changed_at: None, + child_nodes: HashMap::new(), + active_tasks: HashMap::new(), + input_queue_projection_index: HashMap::new(), + checkpoint_storage_seq: 1, + }, + ) + .await + .expect("checkpoint should succeed"); + + let snapshot_path = + checkpoint_snapshot_path_from_projects_root(temp.path(), session_id.as_str(), 1) + .expect("snapshot path should resolve"); + std::fs::remove_file(&snapshot_path).expect("snapshot should be removable"); + + let error = repo + .recover_session(&session_id) + .await + .expect_err("recovery should fail when snapshot is missing"); + assert!( + error.to_string().contains("points to missing snapshot"), + "unexpected error: {error}" + ); + } +} diff --git a/crates/cli/src/app/mod.rs b/crates/cli/src/app/mod.rs index ee6a4382..8b492b58 100644 --- a/crates/cli/src/app/mod.rs +++ b/crates/cli/src/app/mod.rs @@ -1208,7 +1208,9 @@ mod tests { "phase": "idle", "canSubmitPrompt": true, "canRequestCompact": true, - "compactPending": false + "compactPending": false, + "compacting": false, + "currentModeId": "default" }, "blocks": [{ "kind": "assistant", diff --git a/crates/client/src/lib.rs b/crates/client/src/lib.rs index f69fdb54..b1d1d1aa 100644 --- a/crates/client/src/lib.rs +++ b/crates/client/src/lib.rs @@ -767,8 +767,10 @@ mod tests { "control": { "phase": "idle", "canSubmitPrompt": true, - "canRequestCompact": true, - "compactPending": false + "canRequestCompact": true, + "compactPending": false, + "compacting": false, + "currentModeId": "default" } }) .to_string(), diff --git a/crates/core/src/event/translate.rs b/crates/core/src/event/translate.rs index fe5c3c29..0a90a042 100644 --- a/crates/core/src/event/translate.rs +++ b/crates/core/src/event/translate.rs @@ -793,6 +793,7 @@ mod tests { provider_cache_metrics_supported: true, prompt_cache_reuse_hits: 3, prompt_cache_reuse_misses: 1, + prompt_cache_unchanged_layers: Vec::new(), }, }, }, diff --git a/crates/core/src/event/types.rs b/crates/core/src/event/types.rs index 5263c086..30298def 100644 --- a/crates/core/src/event/types.rs +++ b/crates/core/src/event/types.rs @@ -462,6 +462,7 @@ mod tests { provider_cache_metrics_supported: true, prompt_cache_reuse_hits: 2, prompt_cache_reuse_misses: 1, + prompt_cache_unchanged_layers: Vec::new(), }, }, }; diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index cbc44082..a9f6f536 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -6,45 +6,45 @@ //! //! ### 领域模型 //! -//! - [`agent`]: Agent 协作模型、子运行管理、输入队列 -//! - [`capability`]: 能力规格定义(CapabilitySpec 等) -//! - [`ids`]: 核心标识符类型(AgentId, SessionId, TurnId 等) -//! - [`action`]: LLM 消息与工具调用相关的数据结构 +//! - [`agent`][]: Agent 协作模型、子运行管理、输入队列 +//! - [`capability`][]: 能力规格定义(CapabilitySpec 等) +//! - [`ids`][]: 核心标识符类型(AgentId, SessionId, TurnId 等) +//! - [`action`][]: LLM 消息与工具调用相关的数据结构 //! //! ### 事件与会话 //! -//! - [`event`]: 事件存储与回放系统(JSONL append-only 日志) -//! - [`session`]: 会话元数据 -//! - [`store`]: 会话存储与事件日志写入 -//! - [`projection`]: Agent 状态投影(从事件流推导状态) +//! - [`event`][]: 事件存储与回放系统(JSONL append-only 日志) +//! - [`session`][]: 会话元数据 +//! - [`store`][]: 会话存储与事件日志写入 +//! - [`projection`][]: Agent 状态投影(从事件流推导状态) //! //! ### 治理与策略 //! -//! - [`mode`]: 治理模式(Code/Plan/Review 模式与策略规则) -//! - [`policy`]: 策略引擎 trait(审批与模型/工具请求检查) +//! - [`mode`][]: 治理模式(Code/Plan/Review 模式与策略规则) +//! - [`policy`][]: 策略引擎 trait(审批与模型/工具请求检查) //! //! ### 扩展点 //! -//! - [`ports`]: 核心 port trait 定义(LlmProvider, PromptProvider, EventStore 等) -//! - [`tool`]: Tool trait 定义(插件系统的基础抽象) -//! - [`plugin`]: 插件清单与注册表 -//! - [`registry`]: 能力路由器(将能力调用分派到具体的 invoker) -//! - [`hook`]: 钩子系统(工具/压缩钩子) +//! - [`ports`][]: 核心 port trait 定义(LlmProvider, PromptProvider, EventStore 等) +//! - [`tool`][]: Tool trait 定义(插件系统的基础抽象) +//! - [`plugin`][]: 插件清单与注册表 +//! - [`registry`][]: 能力路由器(将能力调用分派到具体的 invoker) +//! - [`hook`][]: 钩子系统(工具/压缩钩子) //! //! ### 运行时与配置 //! -//! - [`runtime`]: 运行时协调器接口 -//! - [`config`]: 配置模型(Agent/Model/Runtime 配置) -//! - [`observability`]: 运行时可观测性指标 +//! - [`runtime`][]: 运行时协调器接口 +//! - [`config`][]: 配置模型(Agent/Model/Runtime 配置) +//! - [`observability`][]: 运行时可观测性指标 //! //! ### 基础设施 //! -//! - [`env`]: 环境变量解析 -//! - [`home`]: 主目录管理 -//! - [`local_server`]: 本地服务器信息 -//! - [`project`]: 项目信息 -//! - [`shell`]: Shell 检测与解析 -//! - [`tool_result_persist`]: 工具结果持久化 +//! - [`env`][]: 环境变量解析 +//! - [`home`][]: 主目录管理 +//! - [`local_server`][]: 本地服务器信息 +//! - [`project`][]: 项目信息 +//! - [`shell`][]: Shell 检测与解析 +//! - [`tool_result_persist`][]: 工具结果持久化 mod action; pub mod agent; diff --git a/crates/session-runtime/src/command/mod.rs b/crates/session-runtime/src/command/mod.rs index ff30122d..2a45564b 100644 --- a/crates/session-runtime/src/command/mod.rs +++ b/crates/session-runtime/src/command/mod.rs @@ -9,7 +9,7 @@ use chrono::Utc; use crate::{ InputQueueEventAppend, SessionRuntime, append_and_broadcast, append_input_queue_event, - checkpoint_if_compacted, + state::checkpoint_if_compacted, }; pub(crate) struct SessionCommands<'a> { diff --git a/crates/session-runtime/src/state/input_queue.rs b/crates/session-runtime/src/state/input_queue.rs index ffccf7b7..8f8f0d79 100644 --- a/crates/session-runtime/src/state/input_queue.rs +++ b/crates/session-runtime/src/state/input_queue.rs @@ -74,9 +74,7 @@ pub(crate) fn apply_input_queue_event_to_index( else { return; }; - let projection = index - .entry(target_agent_id.to_string()) - .or_insert_with(InputQueueProjection::default); + let projection = index.entry(target_agent_id.to_string()).or_default(); InputQueueProjection::apply_event_for_agent(projection, stored, target_agent_id); } diff --git a/crates/session-runtime/src/state/mod.rs b/crates/session-runtime/src/state/mod.rs index e4c35a95..ac0fa775 100644 --- a/crates/session-runtime/src/state/mod.rs +++ b/crates/session-runtime/src/state/mod.rs @@ -78,6 +78,13 @@ pub struct SessionState { input_queue_projection_index: StdMutex<HashMap<String, InputQueueProjection>>, } +struct SessionDerivedState { + child_nodes: HashMap<String, ChildSessionNode>, + active_tasks: HashMap<String, TaskSnapshot>, + input_queue_projection_index: HashMap<String, InputQueueProjection>, + last_mode_changed_at: Option<DateTime<Utc>>, +} + impl std::fmt::Debug for SessionState { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("SessionState") @@ -110,27 +117,24 @@ impl SessionState { recent_records: Vec<SessionEventRecord>, recent_stored: Vec<StoredEvent>, ) -> Self { - let child_nodes = rebuild_child_nodes(&recent_stored); - let active_tasks = rebuild_active_tasks(&recent_stored); - let input_queue_projection_index = InputQueueProjection::replay_index(&recent_stored); - let last_mode_changed_at = - recent_stored - .iter() - .rev() - .find_map(|stored| match &stored.event.payload { + let derived = SessionDerivedState { + child_nodes: rebuild_child_nodes(&recent_stored), + active_tasks: rebuild_active_tasks(&recent_stored), + input_queue_projection_index: InputQueueProjection::replay_index(&recent_stored), + last_mode_changed_at: recent_stored.iter().rev().find_map(|stored| { + match &stored.event.payload { StorageEventPayload::ModeChanged { timestamp, .. } => Some(*timestamp), _ => None, - }); + } + }), + }; Self::from_parts( phase, writer, projector, recent_records, recent_stored, - child_nodes, - active_tasks, - input_queue_projection_index, - last_mode_changed_at, + derived, ) } @@ -171,10 +175,12 @@ impl SessionState { projector, astrcode_core::replay_records(&tail_events, None), tail_events, - child_nodes, - active_tasks, - input_queue_projection_index, - last_mode_changed_at, + SessionDerivedState { + child_nodes, + active_tasks, + input_queue_projection_index, + last_mode_changed_at, + }, )) } @@ -184,11 +190,14 @@ impl SessionState { projector: AgentStateProjector, recent_records: Vec<SessionEventRecord>, recent_stored: Vec<StoredEvent>, - child_nodes: HashMap<String, ChildSessionNode>, - active_tasks: HashMap<String, TaskSnapshot>, - input_queue_projection_index: HashMap<String, InputQueueProjection>, - last_mode_changed_at: Option<DateTime<Utc>>, + derived: SessionDerivedState, ) -> Self { + let SessionDerivedState { + child_nodes, + active_tasks, + input_queue_projection_index, + last_mode_changed_at, + } = derived; let (broadcaster, _) = broadcast::channel(SESSION_BROADCAST_CAPACITY); let (live_broadcaster, _) = broadcast::channel(SESSION_LIVE_BROADCAST_CAPACITY); let mut cached_records = RecentSessionEvents::default(); diff --git a/crates/session-runtime/src/turn/interrupt.rs b/crates/session-runtime/src/turn/interrupt.rs index 3af50b25..d0595fea 100644 --- a/crates/session-runtime/src/turn/interrupt.rs +++ b/crates/session-runtime/src/turn/interrupt.rs @@ -61,6 +61,7 @@ impl SessionRuntime { persist_pending_manual_compact_if_any( self.kernel.gateway(), self.prompt_facts_provider.as_ref(), + &self.event_store, actor.working_dir(), actor.state(), session_id.as_str(), diff --git a/crates/session-runtime/src/turn/submit.rs b/crates/session-runtime/src/turn/submit.rs index 613d39c2..6b6dd6f2 100644 --- a/crates/session-runtime/src/turn/submit.rs +++ b/crates/session-runtime/src/turn/submit.rs @@ -14,10 +14,10 @@ use chrono::Utc; use crate::{ SessionRuntime, TurnOutcome, actor::SessionActor, - checkpoint_if_compacted, prepare_session_execution, + prepare_session_execution, query::current_turn_messages, run_turn, - state::{append_and_broadcast, complete_session_execution}, + state::{append_and_broadcast, checkpoint_if_compacted, complete_session_execution}, turn::events::{error_event, user_message_event}, }; @@ -809,6 +809,7 @@ mod tests { TurnFinalizeContext { kernel: summary_kernel(), prompt_facts_provider: Arc::new(crate::turn::test_support::NoopPromptFactsProvider), + event_store: Arc::new(BranchingTestEventStore::default()), metrics: Arc::new(NoopMetrics), actor, session_id: "session-1".to_string(), diff --git "a/docs/\347\211\271\347\202\271/capability_governance.md" "b/docs/\347\211\271\347\202\271/capability_governance.md" new file mode 100644 index 00000000..452f1168 --- /dev/null +++ "b/docs/\347\211\271\347\202\271/capability_governance.md" @@ -0,0 +1,347 @@ +# Capability 抽象与治理面 + +Astrcode 将所有可被 Agent 调用的能力统一建模为 **CapabilitySpec**,并通过声明式 DSL(`CapabilitySelector`)在治理模式间灵活分配。这不是简单的"工具白名单",而是一套从元数据声明到运行时执行的完整能力治理体系。 + +## 核心模型 + +### CapabilitySpec — 运行时能力语义定义 + +`CapabilitySpec`(`crates/core/src/capability.rs`)是运行时唯一的能力模型,每个工具、Agent、ContextProvider 都以 CapabilitySpec 描述自身: + +```rust +pub struct CapabilitySpec { + pub name: CapabilityName, // 唯一标识,如 "readFile" + pub kind: CapabilityKind, // 类型:Tool / Agent / ContextProvider / ... + pub description: String, // 人类可读描述 + pub input_schema: Value, // JSON Schema + pub output_schema: Value, + pub invocation_mode: InvocationMode, // Unary / Streaming + pub concurrency_safe: bool, // 是否允许并发执行 + pub compact_clearable: bool, // compaction 时是否可清除结果 + pub profiles: Vec<String>, // 能力画像,如 "coding" + pub tags: Vec<String>, // 自由标签,如 "filesystem", "read" + pub permissions: Vec<PermissionSpec>, // 声明的权限 + pub side_effect: SideEffect, // 副作用级别 + pub stability: Stability, // 稳定性级别 + pub metadata: Value, // 扩展元数据 + pub max_result_inline_size: Option<usize>, // 结果内联阈值 +} +``` + +构造通过类型安全的 Builder 完成(`CapabilitySpecBuilder`),`build()` 时校验所有不变量(名称非空、schema 必须是 JSON Object、无重复标签/权限等)。 + +### 关键枚举 + +| 类型 | 值 | 作用 | +|------|-----|------| +| `CapabilityKind` | Tool, Agent, ContextProvider, MemoryProvider, PolicyHook, Renderer, Resource, Prompt, Custom | 能力类型判别 | +| `SideEffect` | None, Local, Workspace, External | 副作用分级,治理面的核心轴 | +| `Stability` | Experimental, Stable, Deprecated | API 成熟度 | +| `InvocationMode` | Unary, Streaming | 调用模式 | + +其中 `SideEffect` 是治理模式收缩工具面的主要维度: + +- **None** — 纯读操作,无副作用(如 readFile、grep) +- **Local** — 写入本地文件系统(如 writeFile、editFile) +- **Workspace** — 修改工作区状态(大部分内置工具的默认值) +- **External** — 影响外部系统(如 shell) + +### 协议层映射 + +`CapabilityWireDescriptor`(`crates/protocol/src/capability/descriptors.rs`)是协议层对 `CapabilitySpec` 的传输别名,直接 re-export: + +```rust +pub use astrcode_core::CapabilitySpec as CapabilityWireDescriptor; +``` + +不再维护第二个语义冗余的类型。插件 SDK 的 `capability_mapping.rs` 提供恒等转换(`wire_descriptor_to_spec` / `spec_to_wire_descriptor`),只做 validate + clone。 + +## 工具如何声明能力 + +### 三层声明链 + +``` +Tool trait(core) + ├─ fn definition() -> ToolDefinition // 名称、描述、参数 schema + ├─ fn capability_metadata() -> ToolCapabilityMetadata // 能力元数据 + └─ fn capability_spec() -> CapabilitySpec // 合并产物(默认实现) +``` + +`ToolCapabilityMetadata`(`crates/core/src/tool.rs`)携带能力语义: + +```rust +pub struct ToolCapabilityMetadata { + pub profiles: Vec<String>, + pub tags: Vec<String>, + pub permissions: Vec<PermissionSpec>, + pub invocation_mode: InvocationMode, + pub side_effect: SideEffect, + pub concurrency_safe: bool, + pub compact_clearable: bool, + pub stability: Stability, + pub prompt: Option<ToolPromptMetadata>, + pub max_result_inline_size: Option<usize>, +} +``` + +默认值通过 `builtin()` 提供:profile `"coding"`、tag `"builtin"`、`SideEffect::Workspace`、`Stability::Stable`。 + +### 内置工具的能力声明示例 + +| 工具 | tags | side_effect | concurrency_safe | 权限 | +|------|------|-------------|-----------------|------| +| `readFile` | filesystem, read | None | true | filesystem.read | +| `writeFile` | filesystem, write | Local | false | filesystem.write | +| `editFile` | filesystem, write, edit | Local | false | filesystem.write | +| `grep` | filesystem, read, search | None | true | filesystem.read | +| `shell` | process, shell | External | false | shell.exec | + +### 注册到内核 + +`ToolCapabilityInvoker`(`crates/kernel/src/registry/tool.rs`)将 `dyn Tool` 包装为 `dyn CapabilityInvoker`,在构造时调用 `tool.capability_spec()` 获取并缓存 spec。`CapabilityRouter`(`crates/kernel/src/registry/router.rs`)维护 `HashMap<String, Arc<dyn CapabilityInvoker>>` 注册表。 + +## CapabilitySelector — 声明式能力选择 DSL + +`CapabilitySelector`(`crates/core/src/mode/mod.rs`)是一个递归代数 DSL: + +```rust +pub enum CapabilitySelector { + AllTools, // 全部 Tool 类型能力 + Name(String), // 按名称精确匹配 + Kind(CapabilityKind), // 按 CapabilityKind 过滤 + SideEffect(SideEffect), // 按副作用级别过滤 + Tag(String), // 按标签过滤 + Union(Vec<CapabilitySelector>), // 集合并 + Intersection(Vec<CapabilitySelector>), // 集合交 + Difference { base: Box, subtract: Box }, // 集合差 +} +``` + +求值逻辑在 `evaluate_selector()`(`crates/application/src/mode/compiler.rs`):从全量 `CapabilitySpec` 列表开始,递归匹配,最终产出 `BTreeSet<String>`(工具名集合)。 + +这个 DSL 让治理模式可以用声明式表达式定义自己的能力面,而不需要硬编码工具列表。 + +### 内置模式的能力面定义 + +**Code 模式** — 完全访问: + +```rust +capability_selector: CapabilitySelector::AllTools +``` + +**Plan 模式** — 只读 + 两个计划工具: + +```rust +capability_selector: CapabilitySelector::Union(vec![ + // 全部工具 - 副作用工具 - agent 标签 + Difference { + base: AllTools, + subtract: Union([SideEffect(Local), SideEffect(Workspace), + SideEffect(External), Tag("agent")]) + }, + // 显式加入计划专属工具 + Name("exitPlanMode"), + Name("upsertSessionPlan"), +]) +``` + +**Review 模式** — 严格只读: + +```rust +capability_selector: CapabilitySelector::Intersection(vec![ + AllTools, + SideEffect(None), // 只允许 SideEffect::None 的工具 + Difference { base: AllTools, subtract: Tag("agent") }, +]) +``` + +## 编译与装配:从声明到运行时 + +### 完整生命周期 + +``` +工具注册 + Tool::capability_metadata() + Tool::definition() + → ToolCapabilityMetadata::build_spec() + → CapabilitySpec (validated) + → ToolCapabilityInvoker 包装为 CapabilityInvoker + → CapabilityRouter::register_invoker() + + ↓ + +Turn 提交时编译治理面 + GovernanceSurfaceAssembler.session_surface() + → compile_mode_surface() + → compile_mode_envelope(base_router, mode_spec, extra_prompts) + → compile_capability_selector() // 递归求值 CapabilitySelector + → evaluate_selector() → BTreeSet<String> allowed_tools + → child_allowed_tools() // 计算子代理继承的工具白名单 + → subset_router() // 创建过滤后的 CapabilityRouter + → ResolvedTurnEnvelope // 编译产物 + → build_surface() + → ResolvedGovernanceSurface // 最终运行时治理面 + + ↓ + +运行时执行 + execute_tool_calls() + → 查询 capability_spec 判断 concurrency_safe + → safe 调用并发执行(buffer_unordered) + → unsafe 调用串行执行 + → CapabilityRouter::execute_tool() // 只有白名单内的工具可被调用 +``` + +### ResolvedTurnEnvelope — 编译产物 + +`ResolvedTurnEnvelope`(`crates/core/src/mode/mod.rs`)是模式编译的输出: + +```rust +pub struct ResolvedTurnEnvelope { + pub mode_id: ModeId, + pub allowed_tools: Vec<String>, // 编译后的工具白名单 + pub prompt_declarations: Vec<PromptDeclaration>, + pub action_policies: ActionPolicies, // Allow / Deny / Ask 策略 + pub child_policy: ResolvedChildPolicy, // 子代理策略 + pub submit_busy_policy: SubmitBusyPolicy, // BranchOnBusy / RejectOnBusy + pub fork_mode: Option<ForkMode>, + pub diagnostics: Vec<String>, +} +``` + +### ResolvedGovernanceSurface — 运行时治理面 + +`ResolvedGovernanceSurface`(`crates/application/src/governance_surface/mod.rs`)是装配器输出的最终产物,携带: + +- `capability_router` — 过滤后的路由器(只含 allowed_tools) +- `resolved_limits` — 工具白名单 + max_steps +- `policy_context` — 策略引擎上下文 +- `approval` — 审批管线(Ask 模式时触发) +- `prompt_declarations` — 本 turn 的所有 prompt 块 + +它通过 `into_submission()` 转为 `AppAgentPromptSubmission`,传入 session runtime 的 turn 执行。 + +### 子代理的能力继承 + +`compile_mode_envelope_for_child()`(`crates/application/src/mode/compiler.rs`)计算子代理的可用工具: + +``` +子代理工具 = parent_allowed_tools ∩ mode_allowed_tools ∩ SpawnCapabilityGrant +``` + +三层取交集确保子代理的能力严格收缩,不会超出父代理和模式定义的范围。 + +## 运行时强制执行 + +### 并发安全调度 + +`execute_tool_calls()`(`crates/session-runtime/src/turn/tool_cycle.rs`)根据 `concurrency_safe` 字段分两路: + +``` +LLM 返回 tool_calls + │ + ├─ concurrency_safe=true → buffer_unordered 并发执行 + │ (如 readFile、grep) + │ + └─ concurrency_safe=false → 串行执行 + (如 writeFile、shell) +``` + +### 工具路由的过滤生效 + +`CapabilityRouter::subset_for_tools_checked()` 创建一个只含白名单工具的新路由器。LLM 请求的 tool call 若不在白名单中,`execute_tool()` 查找失败返回 "unknown tool" 错误。这是**编译期过滤 + 运行时兜底**的双重保障。 + +### 策略引擎检查点 + +Policy Engine(`crates/core/src/policy/engine.rs`)提供两个检查点: + +- `check_model_request()` — 可重写 LLM 请求(过滤工具列表、修改 system prompt) +- `check_capability_call()` — 对每次能力调用返回 Allow / Deny / Ask + +## 设计优势 + +### 1. 声明式 vs 命令式:模式定义零代码 + +传统做法是在代码里写 `if mode == "plan" { disable_write_tools() }`。Astrcode 用 `CapabilitySelector` DSL 让模式定义完全声明化: + +``` +Plan 模式的工具面 = + (AllTools - SideEffect(Local|Workspace|External) - Tag("agent")) + ∪ Name("exitPlanMode") ∪ Name("upsertSessionPlan") +``` + +添加新模式只需写 spec,不需要改编译器或运行时代码。求值逻辑完全通用。 + +### 2. 元数据驱动:能力自描述 + +每个工具通过 `ToolCapabilityMetadata` 声明自己的语义属性(副作用级别、并发安全性、权限需求),而不是由治理层硬编码"哪些工具危险"。 + +这意味着: +- 新增工具时,只需声明自己的元数据,自动被所有模式的 `CapabilitySelector` 覆盖 +- 修改工具的副作用级别会自动反映到所有引用该级别的模式 +- 不需要维护"危险工具列表"这种容易过时的集中配置 + +### 3. 编译期求值:每次 turn 重新编译,动态生效 + +工具注册表可能因为插件加载/卸载而变化。`CapabilitySelector` 在每个 turn 提交时重新求值(`compile_mode_envelope`),而非在启动时静态绑定。 + +这确保了: +- 插件热加载后,新模式 spec 立刻生效 +- 运行时修改 mode spec 不需要重启 +- 编译产物(`ResolvedGovernanceSurface`)是一次性的,不会被旧状态污染 + +### 4. 子代理能力严格收缩 + +子代理的能力 = 父 ∩ 模式 ∩ 显式授权(`SpawnCapabilityGrant`),三层取交集。这确保了: + +- 子代理永远不会获得超出父代理的能力 +- 模式定义的全局约束不会被 spawn 请求绕过 +- 显式授权进一步收缩,实现最小权限原则 + +### 5. CapabilitySpec 的多维元数据支撑多种运行时决策 + +同一份 `CapabilitySpec` 驱动多个独立的运行时决策: + +| 元数据字段 | 消费者 | 决策 | +|-----------|--------|------| +| `side_effect` | CapabilitySelector | 模式的工具面收缩 | +| `concurrency_safe` | tool_cycle | 并行 vs 串行执行 | +| `compact_clearable` | compaction | compaction 时是否可清除结果 | +| `tags` | CapabilitySelector / prompt contributor | 按标签过滤、prompt 注入 | +| `permissions` | policy engine | 权限检查 | +| `max_result_inline_size` | tool execution | 结果持久化阈值 | +| `stability` | prompt contributor | 向用户展示 API 成熟度 | + +不需要为每个决策维度维护独立的数据源。 + +### 6. 单一规范模型的边界清晰 + +`CapabilitySpec` 在 `core` 层定义,`CapabilityWireDescriptor` 在 `protocol` 层只是 re-export。不存在两套互相转换的模型。插件 SDK 的映射层是恒等转换。 + +这避免了"core 模型和 protocol 模型不一致"的经典问题,同时保持了 crate 依赖方向的正确性(protocol 依赖 core,不是反过来)。 + +## 与同类产品的对比 + +### Claude Code + +Claude Code 没有独立的 capability 抽象。工具以 `Entry`(entry.ts)的形式存储在 JSONL 中,没有结构化的元数据(副作用级别、并发安全性、权限声明)。权限控制通过硬编码的 permission mode(`default`、`plan`、`auto`)在 `AgentLoop` 中判断。 + +工具过滤直接在 prompt 组装时修改 `tools` 数组,没有编译/求值阶段。 + +### Codex + +Codex 的工具定义在 `AgentFunctionDefinition` 中(`agent-functions.ts`),包含 `name`、`description`、`parameters`,但没有副作用分级、并发安全性、权限声明等治理元数据。 + +工具白名单通过 `allowedTools` 数组在 `applyPolicy()` 中硬编码过滤,是命令式的列表匹配。 + +### 对比总结 + +| 维度 | Astrcode | Claude Code | Codex | +|------|----------|-------------|-------| +| 能力模型 | 结构化 `CapabilitySpec`(15+ 字段) | Entry 消息,无独立模型 | `AgentFunctionDefinition`,字段较少 | +| 元数据维度 | 副作用级别、并发安全、权限、稳定性、标签 | 无 | 无 | +| 模式定义 | 声明式 DSL(`CapabilitySelector`) | 硬编码 permission mode | 硬编码 `allowedTools` 列表 | +| 工具过滤 | 编译期求值 + 运行时路由 | prompt 组装时改 tools 数组 | `applyPolicy()` 过滤 | +| 子代理控制 | 三层交集收缩(父 ∩ 模式 ∩ 授权) | 无子代理概念 | 无子代理概念 | +| 新增工具的治理成本 | 声明元数据即可,自动被 DSL 覆盖 | 需要改 permission mode 逻辑 | 需要改 `allowedTools` 列表 | + +本质区别:Astrcode 的 capability 是**自描述的能力单元**,治理层通过声明式规则组合它们;Claude Code 和 Codex 的工具是**扁平的函数定义**,治理层通过命令式代码管理它们。 diff --git "a/docs/\347\211\271\347\202\271/event_log.md" "b/docs/\347\211\271\347\202\271/event_log.md" new file mode 100644 index 00000000..64bce391 --- /dev/null +++ "b/docs/\347\211\271\347\202\271/event_log.md" @@ -0,0 +1,230 @@ +# Event Log 架构 + +Astrcode 采用 **Event Sourcing + CQRS Projection** 模式管理会话状态。本文档描述其设计、实现细节、以及与同类产品(Claude Code、Codex)的对比分析。 + +## 核心模型 + +### 事件存储 + +会话的所有状态变更以 `StorageEventPayload` 枚举变体持久化到 JSONL 文件(`adapter-storage/src/session/event_log.rs`)。 + +``` +~/.astrcode/projects/<project-hash>/<session-id>.jsonl +``` + +每行是一个 `StoredEvent`: + +```rust +// core/src/event/types.rs +pub struct StoredEvent { + pub storage_seq: u64, // 单调递增,由 writer 独占分配 + #[serde(flatten)] + pub event: StorageEvent, // 实际事件 +} + +pub struct StorageEvent { + pub turn_id: Option<String>, + pub agent: AgentEventContext, + #[serde(flatten)] + pub payload: StorageEventPayload, +} +``` + +`StorageEventPayload` 包含约 20 种语义事件变体: + +| 类别 | 事件 | +|------|------| +| 会话生命周期 | `SessionStart`, `TurnDone` | +| 对话消息 | `UserMessage`, `AssistantFinal`, `AssistantDelta` | +| 思考过程 | `ThinkingDelta` | +| 工具交互 | `ToolCall`, `ToolCallDelta`, `ToolResult`, `ToolResultReferenceApplied` | +| 上下文管理 | `CompactApplied`, `PromptMetrics` | +| 子会话编排 | `SubRunStarted`, `SubRunFinished`, `ChildSessionNotification` | +| 协作审计 | `AgentCollaborationFact` | +| 治理模式 | `ModeChanged` | +| 输入队列 | `AgentInputQueued`, `AgentInputBatchStarted`, `AgentInputBatchAcked`, `AgentInputDiscarded` | +| 错误 | `Error` | + +### 写入路径 + +事件写入经过 `append_and_broadcast`(`session-runtime/src/state/execution.rs`): + +``` +append_and_broadcast(event) + ├─ session.writer.append(event) // 1. 持久化到 JSONL(fsync) + └─ session.translate_store_and_cache() // 2. 更新所有内存投影 + ├─ projector.apply(event) // a. AgentState 投影 + ├─ EventTranslator.translate() // b. SSE 事件转换 + ├─ recent_records/stored 缓存 // c. 内存事件缓存 + ├─ child_nodes 更新 // d. 子会话树 + ├─ active_tasks 更新 // e. 任务快照 + └─ input_queue_projection 更新 // f. 输入队列索引 +``` + +关键特性: +- **单写者 + fsync**:`EventLog` 持有独占 writer,每条事件 `flush + sync_all`(`event_log.rs`) +- **Drop 安全**:`Drop` 实现中再次 flush/sync,防止进程退出时遗漏数据 +- **尾部扫描**:打开文件时,大于 64KB 的文件只读取末尾 64KB 定位 `max_storage_seq` + +### 状态投影 + +`AgentStateProjector`(`core/src/projection/agent_state.rs`)从事件流增量推导当前状态: + +```rust +pub struct AgentState { + pub session_id: String, + pub working_dir: PathBuf, + pub messages: Vec<LlmMessage>, // 用于下次 LLM 请求 + pub phase: Phase, + pub mode_id: ModeId, + pub turn_count: usize, + pub last_assistant_at: Option<DateTime<Utc>>, +} +``` + +投影器的核心行为: + +1. **增量 apply**:每次事件到达时调用 `projector.apply(event)`,只更新相关字段 +2. **Pending 聚合**:`AssistantFinal` 和 `ToolCall` 先进入 pending 状态,在遇到 `UserMessage`/`ToolResult`/`TurnDone`/`CompactApplied` 时 flush +3. **子会话隔离**:`should_project()` 确保 SubRun 事件不污染父会话的投影状态 +4. **Compaction 回放**:`CompactApplied` 事件携带 `messages_removed`,投影时精确替换消息前缀 + +```rust +// 纯函数:从事件序列重建完整状态 +pub fn project(events: &[StorageEvent]) -> AgentState { + AgentStateProjector::from_events(events).snapshot() +} +``` + +### Compaction + +上下文压缩(`session-runtime/src/context_window/compaction.rs`)通过 LLM 摘要替换投影中的消息前缀,但 **event log 原文始终保留**。 + +`CompactApplied` 事件记录精确的压缩边界: + +```rust +CompactApplied { + trigger: CompactTrigger, // Auto / Manual / Deferred + summary: String, // LLM 生成的摘要 + meta: CompactAppliedMeta, // 模式、重试次数等 + preserved_recent_turns: u32, // 保留的最近 turn 数 + messages_removed: u32, // 精确移除的消息数(用于回放) + pre_tokens: u32, + post_tokens_estimate: u32, + tokens_freed: u32, + timestamp: DateTime<Utc>, +} +``` + +投影器在 apply `CompactApplied` 时: +1. 从 `messages` 头部移除 `messages_removed` 条消息 +2. 插入一条 `CompactSummary` 消息作为上下文衔接 +3. 保留尾部消息不变 + +--- + +## 与同类产品的对比 + +### 三方架构差异 + +| 维度 | Astrcode | Claude Code | Codex | +|------|----------|-------------|-------| +| **持久化** | JSONL event log,每条 fsync | JSONL + 100ms 批量 flush | JSONL rollout + mpsc channel 异步写 | +| **存储内容** | 语义事件(~20 种 `StorageEventPayload`) | 对话消息 + 元数据 entry | 对话消息 `ResponseItem` 数组 | +| **内存模型** | Event Sourcing + CQRS 投影 | `parentUuid` 链表回溯构建 `Message[]` | `Vec<ResponseItem>` 数组 | +| **状态来源** | `projector.apply(event)` 逐事件投影 | 从链尾回溯 `parentUuid` 链表 | 直接就是数组 | +| **Compaction** | LLM 摘要替换投影,event log 保留 | 4 级管线:snip/microcompact/collapse/autocompact | LLM 摘要 + 反向扫描 checkpoint | +| **Crash 恢复** | 最多丢 1 条事件 | 最多丢 100ms 数据 | 最多丢 channel 内 256 条 | + +本质区别:Claude Code 和 Codex 存的是"说了什么"(消息),Astrcode 存的是"发生了什么"(事件)。 + +### Event Log 的优势 + +#### 1. 多投影:一份事件流驱动多个独立视图 + +`translate_store_and_cache` 一次写入同时维护 6 个独立投影: + +- `AgentState`(消息列表,给 LLM 用) +- `child_nodes`(子 session 树,给 orchestration 用) +- `active_tasks`(任务快照) +- `input_queue_projection_index`(输入队列状态) +- `AgentCollaborationFact`(审计事实,带 mode 上下文) +- `ObservabilitySnapshot`(可观测性快照) + +Claude Code 和 Codex 都是单 agent 单会话模型,状态就是消息列表。Astrcode 有 parent-child 协作树、mode 切换、policy 审计等多个正交关注点,event sourcing 让它们各自独立投影,互不耦合。 + +`ModeChanged` 事件是典型例子:projector 更新 `mode_id`,`current_mode` 和 `last_mode_changed_at` 字段更新,审计系统记录 mode 上下文,全部从同一个事件自然驱动。 + +#### 2. 横切扩展:加状态维度不需要改核心结构 + +添加新状态维度的路径: + +1. 加一个 `StorageEventPayload` 变体 +2. `AgentStateProjector.apply()` 加一个 match arm +3. 完成 + +旧 session 不包含新事件?自动回退到默认值,不需要数据迁移。 + +`ModeChanged` 就是这个路径的验证——governance mode system 从 0 到 89 个 task 的实现没有破坏任何已有状态。对比 Codex 加新状态维度需要改 `SessionState` struct、序列化格式、可能的 rollout 格式;Claude Code 需要新 entry 类型 + 改 `loadTranscriptFile` 解析逻辑 + 改链表构建逻辑。 + +#### 3. 审计链路完整性 + +`AgentCollaborationFact` 记录每次 spawn/send/close 的完整上下文——谁、在什么 mode 下、什么 policy 版本、什么 capability 面。这是事件级审计,不是消息级。 + +当需要回答"为什么那次 spawn 被允许/拒绝"时,event log 能直接给出答案。 + +#### 4. Compaction 可逆性 + +`CompactApplied` 记录精确的 `messages_removed`,投影器可精确回放压缩边界。原始事件从未删除。 + +Codex 通过 `CompactedItem.replacement_history` 存储替换后快照实现类似效果,但多了存储冗余。Claude Code 通过 `parentUuid = null` 截断链表、加载时 pre-compact skip 实现,文件内容仍在但被跳过。 + +三方可逆性都能达到,event sourcing 的方式最自然。 + +#### 5. Crash Recovery 强保证 + +每条事件 fsync 意味着最多丢最后一条事件。这是三者中最强的 crash recovery guarantee。 + +### Event Log 的劣势与优化方向 + +#### 劣势 1:冷启动重放成本随会话长度线性增长 + +`project()` 必须重放所有事件。目前没有 projection snapshot 机制——没有定期保存 `AgentState` 快照。 + +**优化方向**:每次 compaction 后保存 `AgentState` 快照到 `sessions/<id>/snapshots/`,冷启动从最近快照恢复,只 replay 之后的事件。 + +#### 劣势 2:每条 fsync 的 I/O 延迟 + +`append_stored` 每条事件都 `sync_all`,在高频 streaming 场景下是 I/O 瓶颈。 + +Claude Code 用 100ms batch drain 合并写入;Codex 用 mpsc channel(容量 256)异步写磁盘。 + +**优化方向**:引入 write buffer + 定时/定量 flush。关键事件(`SessionStart`、`TurnDone`)立即 flush,高频事件(`AssistantDelta`、`ToolCallDelta`)批量写入。 + +#### 劣势 3:Event Log 只增不减 + +原始消息、delta 事件、tool results 全都保留在 JSONL 中。长 session 的文件会持续增长。 + +**优化方向**:compaction 后截断旧事件,只保留 checkpoint marker。或定期归档冷 session 的 event log。 + +#### 劣势 4:投影与持久化耦合 + +`translate_store_and_cache` 把 persist、project、translate、cache、broadcast 五件事串行做。如果 projector 有 bug,已 fsync 的事件无法撤回,只能追加补偿事件。 + +#### 优势重要性评估 + +| 优势 | 重要程度 | 原因 | +|------|----------|------| +| 多投影 | 高 | 有 mode/policy/child/audit 四个正交关注点 | +| 横切扩展 | 高 | governance mode system 验证了这条路可行 | +| 审计链路 | 中高 | 有治理策略的系统需要可追溯决策 | +| Compaction 可逆 | 中 | 三方都能做到,event sourcing 最自然 | +| Crash recovery | 中 | 强保证好,但批量化后差异缩小 | + +--- + +## 结论 + +Event sourcing 匹配 Astrcode 的系统复杂度——多 agent 协作树 + governance mode + policy engine + capability 收缩,状态维度远多于单 agent 工具。Claude Code 和 Codex 的消息数组在它们的场景下是合理选择。 + +需要优化的是 event log 的性能特征(写批量化、projection snapshot),不是放弃 event sourcing 本身。 diff --git "a/docs/\347\211\271\347\202\271/plan_mode.md" "b/docs/\347\211\271\347\202\271/plan_mode.md" new file mode 100644 index 00000000..fa55f1cb --- /dev/null +++ "b/docs/\347\211\271\347\202\271/plan_mode.md" @@ -0,0 +1,275 @@ +# Plan Mode 工作流 + +Plan mode 是 Astrcode 内置的三种治理模式之一(`code`、`plan`、`review`(未完善)),通过模式切换和计划工件(plan artifact)实现"先规划后执行"的工作流。 + +## 整体流程 + +``` +用户发起任务 + │ + ▼ +LLM 判断需要规划 + │ + ▼ enterPlanMode / /mode plan +ModeChanged: code -> plan + │ + ▼ 注入 plan mode prompt + plan template +LLM 阅读代码 ──> 起草计划 ──> 自审 ──> 修订 + │ │ + │ ◄────────────────────────┘ + │ (循环直到计划可执行) + ▼ +exitPlanMode(第一次) + │ 结构校验 + 内审 checkpoint + ▼ 返回 review pending +exitPlanMode(第二次) + │ status -> awaiting_approval + ▼ ModeChanged: plan -> code +计划展示给用户 + │ + ▼ 用户输入 "同意" / "approved" +status -> approved + │ 创建归档快照 + ▼ 注入 plan exit declaration +LLM 进入 code mode,按计划执行 +``` + +## 模式定义 + +### 能力面约束 + +Plan mode 通过 `CapabilitySelector` 收缩工具面(`crates/application/src/mode/catalog.rs`): + +``` +可见工具 = AllTools + - SideEffect(Local | Workspace | External) + - Tag("agent") + + Name("exitPlanMode") + + Name("upsertSessionPlan") +``` + +即:**只暴露只读工具 + 两个计划专属工具**,禁止文件写入、外部调用和 agent 委派。 + +### 治理策略 + +| 属性 | 值 | +|------|-----| +| 子 session 委派 | 禁止(`allow_delegation: false`) | +| 子 session 约束 | restricted | +| Turn 并发策略 | `RejectOnBusy`(拒绝并发,不分支) | +| 可切换目标 | `code`、`plan`、`review` 均可 | + +## 计划工件 + +### 存储路径 + +``` +<project>/.astrcode/sessions/<session-id>/plan/ + <slug>.md # 计划内容(Markdown) + state.json # 计划状态元数据 +``` + +归档快照存储在: +``` +<project>/.astrcode/plan-archives/<timestamp>-<slug>/ + plan.md # 归档的计划 Markdown + metadata.json # 归档元数据 +``` + +### 状态模型 + +`SessionPlanState`(`crates/core/src/session_plan.rs`): + +```rust +pub enum SessionPlanStatus { + Draft, // 草稿:LLM 编辑中 + AwaitingApproval, // 等待用户审批 + Approved, // 已批准:开始执行 + Completed, // 已完成 + Superseded, // 被新计划取代 +} +``` + +每个 session 有且仅有一个 canonical plan(单一真相)。同一任务反复修订覆盖同一 plan;用户切换任务时,LLM 覆盖旧 plan。 + +### 计划模板 + +计划 Markdown 必须遵循以下结构(`crates/application/src/mode/builtin_prompts/plan_template.md`): + +```markdown +# Plan: <title> + +## Context +(背景与当前状态) + +## Goal +(目标) + +## Scope +(范围) + +## Non-Goals +(不做的事) + +## Existing Code To Reuse +(可复用的现有代码) + +## Implementation Steps +(具体实施步骤) + +## Verification +(验证方法) + +## Open Questions +(待确认问题) +``` + +退出 plan mode 时,系统会校验以下必要章节必须存在: + +| 章节 | 必须存在 | 必须包含可执行项 | +|------|---------|----------------| +| `## Context` | 是 | 否 | +| `## Goal` | 是 | 否 | +| `## Existing Code To Reuse` | 是 | 否 | +| `## Implementation Steps` | 是 | 是 | +| `## Verification` | 是 | 是 | + +"可执行项"指章节内容必须包含以 `- `、`* ` 或数字开头的行。 + +## 两个计划专属工具 + +### `upsertSessionPlan` + +创建或更新计划工件。参数:`{ title, content, status? }`。 + +- 从 title 推导 slug(小写、连字符分隔、最长 48 字符) +- 写入 `<slug>.md` 和 `state.json` +- 每次写入重置 `reviewed_plan_digest`(触发重新审核循环) + +### `exitPlanMode` + +退出 plan mode 的审批门控,包含两阶段校验: + +**第一阶段:结构校验** + +检查计划 Markdown 包含所有必要章节且关键章节包含可执行项。校验失败返回 `sessionPlanExitReviewPending`(kind: `revise_plan`),列出缺失章节或无效章节。 + +**第二阶段:内审 checkpoint** + +计算计划内容的 FNV-1a-64 摘要。如果摘要与 `state.reviewed_plan_digest` 不同: + +1. 保存摘要到 state +2. 返回 `sessionPlanExitReviewPending`(kind: `final_review`) +3. LLM 必须再次调用 `exitPlanMode` + +摘要相同(第二次调用)时: + +1. 设置 `status = AwaitingApproval` +2. 触发 `ModeChanged { from: plan, to: code }` 事件 +3. 返回 `sessionPlanExit`(包含完整计划内容) + +这个两阶段设计确保 LLM 在展示计划给用户之前至少自审了一次。 + +## Prompt 注入 + +Plan mode 通过 prompt program 注入四组声明(`crates/application/src/mode/builtin_prompts/`): + +### Plan Mode Prompt(`plan_mode.md`) + +注入时机:进入 plan mode 后的每一 turn。 + +核心约束: +- 必须先阅读代码再起草计划 +- 必须通过 `upsertSessionPlan` 写入计划 +- 不允许执行任何实现工作 +- 必须在计划可执行后才调用 `exitPlanMode` +- 退出前必须进行内部自审 +- 退出后必须向用户总结计划并请求批准 +- 不允许静默切换到执行模式 + +### Plan Re-entry Prompt(`plan_mode_reentry.md`) + +注入时机:session 已有 plan artifact 且重新进入 plan mode。 + +指导 LLM 先读取当前计划,同一任务则修订,不同任务则覆盖。 + +### Plan Exit Prompt(`plan_mode_exit.md`) + +注入时机:用户批准计划后,LLM 回到 code mode。 + +``` +The session has exited plan mode and is now back in code mode. + +Execution contract: +- Use the approved session plan artifact as the primary implementation reference. +- The user approval already happened; do not ask for plan approval again. +- Start implementation immediately unless the user message clearly requests more planning. +``` + +### Plan Template(`plan_template.md`) + +注入时机:plan mode 首次进入且无现有计划。 + +提供计划 Markdown 的骨架模板。 + +## 用户审批 + +用户审批是文本匹配机制(`crates/application/src/session_use_cases.rs`),当 `status == AwaitingApproval` 时检查用户消息: + +| 语言 | 批准关键词 | +|------|-----------| +| 英文 | `approved`、`go ahead`、`implement it` | +| 中文 | `同意`、`可以`、`按这个做`、`开始实现` | + +匹配后系统: +1. 调用 `mark_active_session_plan_approved()`(status -> `Approved`) +2. 如果仍处于 plan mode,自动切换到 code mode +3. 创建计划归档快照(`write_plan_archive_snapshot`) +4. 注入 `build_plan_exit_declaration` 到 prompt + +没有显式"拒绝"操作——用户直接给出修改意见,LLM 继续留在 plan mode 修订计划。 + +## 模式切换机制 + +### 进入 plan mode + +三种途径: + +1. **LLM 主动切换**:在 code mode 中调用 `enterPlanMode({ reason: "..." })` 工具 +2. **用户命令**:`/mode plan` slash 命令,走统一治理入口校验 +3. **自动切换**:审批边界自动切换(不常见) + +所有路径都通过 `validate_mode_transition()` 校验,然后触发 `ModeChanged` 事件。 + +### 退出 plan mode + +- `exitPlanMode` 工具:`plan -> code`,需要通过两阶段校验 +- 用户审批后自动切换 + +### 事件持久化 + +`ModeChanged` 事件(`crates/core/src/event/types.rs`)记录到 JSONL event log: + +```rust +ModeChanged { + from: ModeId, + to: ModeId, + timestamp: DateTime<Utc>, +} +``` + +`AgentStateProjector` 在 apply 时更新 `mode_id` 字段。旧 session 不含此事件时回退到默认 mode。 + +## 设计要点 + +### 为什么需要两阶段 exitPlanMode + +第一次调用是结构校验 + 强制自审 checkpoint。LLM 可能在自审中发现计划不完善并修订。第二次调用确认计划内容未变(摘要匹配),才真正提交给用户。这避免了 LLM 直接把粗略计划甩给用户的情况。 + +### 为什么不用显式审批事件 + +计划生命周期通过 `state.json` 和工具返回元数据(`sessionPlanExit`、`sessionPlanExitReviewPending`)追踪,不需要独立的 `PlanCreated`/`PlanApproved` 存储事件。审批语义已由 `ModeChanged` + `upsertSessionPlan` 的状态变更覆盖。 + +### 为什么限制为单一 canonical plan + +避免多计划导致的混乱和选择困难。同一任务保持单一 plan,迭代修订。切换任务时覆盖,简洁明确。 diff --git "a/docs/\347\211\271\347\202\271/server.md" "b/docs/\347\211\271\347\202\271/server.md" new file mode 100644 index 00000000..32a0a8cd --- /dev/null +++ "b/docs/\347\211\271\347\202\271/server.md" @@ -0,0 +1,333 @@ +# Server-First 前后端分离架构 + +Astrcode 采用 **Server Is The Truth** 原则:所有业务逻辑(会话管理、LLM 调用、工具执行、治理决策)都在后端 HTTP/SSE 服务器中完成,前端和 Tauri 外壳不绕过服务器直接调用运行时。 + +## 架构总览 + +三个部署形态共用同一个后端: + +``` +┌─────────────────────────────────────────────────────────┐ +│ Rust 后端服务器 │ +│ (crates/server, Axum) │ +│ │ +│ ┌──────────┐ ┌──────────────┐ ┌───────────────────┐ │ +│ │ REST API │ │ SSE 流式端点 │ │ 静态资源托管 │ │ +│ └────┬─────┘ └──────┬───────┘ └───────────────────┘ │ +│ │ │ │ +│ ┌────┴───────────────┴─────────────────────────────┐ │ +│ │ Application Layer (use cases) │ │ +│ │ ┌──────────┐ ┌──────────┐ ┌───────────────┐ │ │ +│ │ │ Session │ │ Agent │ │ Governance │ │ │ +│ │ │ Runtime │ │ Exec │ │ Mode System │ │ │ +│ │ └──────────┘ └──────────┘ └───────────────┘ │ │ +│ └──────────────────────────────────────────────────┘ │ +│ ┌──────────────────────────────────────────────────┐ │ +│ │ Event Log (JSONL) + CQRS Projections │ │ +│ └──────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────┘ + ▲ ▲ ▲ + │ HTTP/SSE │ HTTP/SSE │ HTTP/SSE + ┌──────┴──────┐ ┌────────┴──────┐ ┌───┴────┐ + │ Tauri │ │ Browser │ │ CLI │ + │ Desktop │ │ Dev Mode │ │ Client │ + │ (WebView) │ │ (Vite :5173) │ │ │ + └─────────────┘ └───────────────┘ └────────┘ +``` + +## 后端(Rust / Axum) + +### 启动流程 + +服务器启动时(`crates/server/src/main.rs`): + +1. Bootstrap 运行时(加载插件、初始化 LLM provider、MCP server) +2. 绑定 `127.0.0.1:0`(随机端口) +3. 生成 bootstrap token(32 字节 hex,24 小时 TTL) +4. 写入 `~/.astrcode/run.json`(port、token、pid、startedAt、expiresAtMs) +5. 在 stdout 输出结构化 JSON ready 事件 +6. 启动 HTTP 服务,监听优雅关闭信号 + +### API 路由 + +路由定义在 `crates/server/src/http/routes/mod.rs`: + +**会话管理:** + +| 方法 | 路径 | 用途 | +|------|------|------| +| POST | `/api/sessions` | 创建会话 | +| GET | `/api/sessions` | 列出会话 | +| DELETE | `/api/sessions/{id}` | 删除会话 | +| POST | `/api/sessions/{id}/fork` | 分叉会话 | +| GET | `/api/session-events` | SSE 会话目录事件流 | + +**对话交互:** + +| 方法 | 路径 | 用途 | +|------|------|------| +| POST | `/api/sessions/{id}/prompts` | 提交用户 prompt | +| POST | `/api/sessions/{id}/compact` | 触发上下文压缩 | +| POST | `/api/sessions/{id}/interrupt` | 中断执行 | +| GET/POST | `/api/sessions/{id}/mode` | 查询/切换治理模式 | +| GET | `/api/v1/conversation/sessions/{id}/snapshot` | 获取对话快照 | +| GET | `/api/v1/conversation/sessions/{id}/stream` | SSE 对话增量流 | + +**配置与模型:** + +| 方法 | 路径 | 用途 | +|------|------|------| +| GET/POST | `/api/config` | 获取/更新配置 | +| POST | `/api/config/reload` | 热重载配置 | +| GET | `/api/models` | 列出可用模型 | +| GET | `/api/modes` | 列出治理模式 | + +**Agent 编排:** + +| 方法 | 路径 | 用途 | +|------|------|------| +| POST | `/api/v1/agents/{id}/execute` | 执行 agent | +| GET | `/api/v1/sessions/{id}/subruns/{sub_run_id}` | 子运行状态 | +| POST | `/api/v1/sessions/{id}/agents/{agent_id}/close` | 关闭 agent 树 | + +### 认证 + +三级 token 体系(`crates/server/src/http/auth.rs`): + +``` +Bootstrap Token (24h) + │ + ▼ POST /api/auth/exchange +Session Token + │ + ▼ x-astrcode-token header (或 SSE ?token= 参数) +Per-Request 认证 +``` + +- **Desktop**:Tauri 通过 `window.__ASTRCODE_BOOTSTRAP__` 注入 token +- **Browser dev**:前端从 `GET /__astrcode__/run-info` 获取 token + +## 通信协议 + +### REST + SSE,不使用 WebSocket + +所有通信基于 HTTP。请求-响应操作走 REST JSON,实时推送走 SSE。 + +### 对话协议:Snapshot + Delta + +前端获取对话状态分两步(`crates/protocol/src/http/conversation/v1.rs`): + +**第一步:获取快照** + +``` +GET /api/v1/conversation/sessions/{id}/snapshot +``` + +返回完整的对话状态: + +```rust +pub struct ConversationSnapshotResponseDto { + pub session_id: String, + pub cursor: ConversationCursorDto, // 游标(用于后续 SSE 续接) + pub phase: PhaseDto, // 当前阶段 + pub control: ConversationControlStateDto, // 控制状态 + pub blocks: Vec<ConversationBlockDto>, // 对话块列表 + pub child_summaries: Vec<...>, // 子 agent 摘要 + pub slash_candidates: Vec<...>, // slash 命令候选 + pub banner: Option<...>, // 顶部提示横幅 +} +``` + +**第二步:订阅增量流** + +``` +GET /api/v1/conversation/sessions/{id}/stream +``` + +SSE 推送 `ConversationDeltaDto`,8 种增量类型: + +| Delta 类型 | 用途 | +|-----------|------| +| `append_block` | 新增对话块(用户消息、助手回复、工具调用等) | +| `patch_block` | 增量更新已有块(markdown 追加、工具流式输出) | +| `complete_block` | 块完成(streaming -> complete/failed) | +| `update_control_state` | 阶段变更(thinking、idle 等) | +| `upsert_child_summary` | 子 agent 状态更新 | +| `remove_child_summary` | 子 agent 移除 | +| `set_banner` / `clear_banner` | 提示横幅 | +| `rehydrate_required` | 客户端需要重新获取快照 | + +### 对话块类型 + +`ConversationBlockDto` 是前端渲染的基本单元: + +| 块类型 | 说明 | +|--------|------| +| `User` | 用户消息 | +| `Assistant` | LLM 输出(支持流式追加) | +| `Thinking` | 扩展思考内容 | +| `Plan` | 会话计划引用 | +| `ToolCall` | 工具执行(含流式 stdout/stderr) | +| `Error` | 错误展示 | +| `SystemNote` | 系统备注(如 compact summary) | +| `ChildHandoff` | 子 agent 委派/归还通知 | + +工具调用块的流式输出通过 `patch_block` 的 `AppendToolStream` 增量实现,支持 stdout 和 stderr 两个流。 + +### 游标续接 + +每个 SSE 事件携带 `id:` 字段(格式如 `"1.42"`,即 `{storage_seq}.{subindex}`)。断线重连时,客户端通过 `Last-Event-ID` header 或 `?cursor=` 参数发送上次游标,服务器只回放缺失的事件。 + +### 一次对话 turn 的完整流程 + +``` +前端 后端 + │ │ + │ POST /prompts {text} │ + │ ─────────────────────────────> │ 追加 UserMessage 事件 + │ 202 Accepted {turnId} │ 启动 turn 执行 + │ <───────────────────────────── │ + │ │ + │ SSE: update_control_state │ phase -> Thinking + │ <────────────────────────────── │ + │ SSE: append_block Assistant │ LLM 开始输出 + │ <────────────────────────────── │ + │ SSE: patch_block markdown │ 流式文本追加 + │ <────────────────────────────── │ + │ SSE: append_block ToolCall │ 工具调用开始 + │ <────────────────────────────── │ + │ SSE: patch_block tool_stream │ 工具 stdout/stderr 流 + │ <────────────────────────────── │ + │ SSE: complete_block │ 工具完成 + │ <────────────────────────────── │ + │ SSE: patch_block markdown │ LLM 继续输出 + │ <────────────────────────────── │ + │ SSE: update_control_state │ phase -> Idle + │ <────────────────────────────── │ +``` + +## 前端(React + TypeScript) + +### 技术栈 + +- **框架**:React 18 + TypeScript +- **构建**:Vite +- **样式**:Tailwind CSS +- **状态管理**:原生 `useReducer`(无 Redux/Zustand) +- **SSE 消费**:手动 `ReadableStream` 解析(`frontend/src/lib/sse/consumer.ts`) + +### 核心模块 + +``` +frontend/src/ +├── App.tsx # 根组件,useReducer 状态管理 +├── types.ts # 共享 TypeScript 类型 +├── store/reducer.ts # 中心状态 reducer +├── hooks/ +│ ├── useAgent.ts # 核心编排 hook:对话流连接、API 调用 +│ ├── app/ +│ │ ├── useSessionCoordinator.ts # 会话生命周期管理 +│ │ └── useComposerActions.ts # 用户输入处理 +│ └── useSessionCatalogEvents.ts # 会话目录 SSE 订阅 +├── lib/ +│ ├── api/ +│ │ ├── client.ts # HTTP 客户端(auth header 注入) +│ │ ├── sessions.ts # 会话 CRUD +│ │ ├── conversation.ts # 对话快照/流解析 -> 前端 Message[] +│ │ ├── config.ts # 配置 API +│ │ └── models.ts # 模型 API +│ ├── sse/consumer.ts # SSE 流解析器 +│ ├── serverAuth.ts # Bootstrap token 获取与交换 +│ └── hostBridge.ts # Desktop/Browser 能力抽象 +``` + +### 状态同步 + +遵循 **Authoritative Server / Projected Client** 模型: + +1. **快照全量 + 流式增量**:前端先获取完整快照,再订阅 SSE 增量 +2. **客户端投影**:`applyConversationEnvelope()` 在前端将 delta 应用到本地状态,`projectConversationState()` 推导出扁平的 `Message[]` 数组用于渲染 +3. **指纹去重**:`projectionSignature()` 基于内容哈希跳过冗余状态更新 +4. **渲染批处理**:SSE 事件通过 `requestAnimationFrame` 批量应用,避免 React 渲染洪水 +5. **自动重连**:指数退避(500ms 基础,5s 上限,3 次失败后放弃),使用 `Last-Event-ID` 续接 + +### HostBridge 抽象 + +`hostBridge.ts` 提供跨平台能力抽象: + +- **Desktop**(Tauri):通过 `invoke()` 调用 Tauri 命令(窗口控制、目录选择器) +- **Browser**:降级为 Web API(`window.open`、`<input type="file">`) + +前端通过统一接口调用,不感知运行环境。 + +## Tauri 外壳 + +Tauri 是**纯薄壳**,不含业务逻辑(`src-tauri/src/main.rs`)。 + +### 职责 + +1. **单实例协调**:`DesktopInstanceCoordinator` 确保只有一个桌面实例运行 +2. **Sidecar 启动**:启动 `astrcode-server` 作为子进程 +3. **Bootstrap 注入**:将 token 注入 `window.__ASTRCODE_BOOTSTRAP__` +4. **窗口生命周期**:创建/管理主窗口 + +### 五个 Tauri 命令(`src-tauri/src/commands.rs`) + +全部是 GUI 相关: + +- `minimize_window` / `maximize_window` / `close_window` — 窗口控制 +- `select_directory` — 原生目录选择器 +- `open_config_in_editor` — 在系统编辑器中打开配置文件 + +### Sidecar 通信协议 + +``` +1. Tauri 启动 astrcode-server 子进程 +2. Server 在 stdout 输出 ready JSON:{"ready": true, "port": N, "pid": P} +3. Tauri 解析输出,轮询 HTTP 端口就绪 +4. Server 写入 ~/.astrcode/run.json(供 browser dev 桥接) +5. 退出时 Tauri 释放子进程句柄,server 检测 stdin EOF 优雅关闭 +``` + +## 协议层(crates/protocol) + +`crates/protocol/` 定义所有前后端共享的 wire-format DTO: + +``` +crates/protocol/src/http/ +├── auth.rs # 认证交换 DTO +├── conversation/v1.rs # 对话协议(快照 + delta DTO) +├── session.rs # 会话管理 DTO +├── event.rs # Agent 事件 DTO +├── session_event.rs # 会话目录事件信封 +├── config.rs # 配置 DTO +├── model.rs # 模型信息 DTO +├── composer.rs # 输入补全 DTO +├── agent.rs # Agent profile DTO +├── tool.rs # 工具描述 DTO +├── runtime.rs # 运行时状态/指标 DTO +└── terminal/ # CLI 终端投影 DTO +``` + +协议版本常量 `PROTOCOL_VERSION = 1`,包含在目录事件信封中。 + +## 设计决策与权衡 + +### 为什么选 REST + SSE 而非 WebSocket + +- **SSE 天然支持游标续接**:每个事件有 `id`,断线重连只需发送 `Last-Event-ID`,服务端精确回放缺失事件 +- **单向推送模型匹配实际需求**:对话场景是"客户端请求 -> 服务端长时流式响应",不需要全双工 +- **HTTP 生态友好**:负载均衡、代理、调试工具都原生支持 SSE,WebSocket 需要额外配置 +- **事件溯源天然适配**:event log 中的 `storage_seq` 直接映射为 SSE 游标 + +### 为什么 Tauri 只做薄壳 + +- **部署一致性**:Desktop、Browser、CLI 三个入口走同一个 HTTP API,行为完全一致 +- **独立演进**:前端可以不依赖 Tauri 版本独立更新,后端也可以独立迭代 +- **测试简化**:后端测试只需要 HTTP 测试,不需要 Tauri WebView 环境 + +### 为什么前端状态管理用 useReducer + +- **应用状态维度有限**:会话列表、当前会话、UI 阶段——不需要全局状态库的复杂度 +- **服务端权威**:关键状态在后端 event log 中,前端只是投影,本地状态相对简单 +- **避免间接层**:reducer 的 action 直接映射后端 DTO,调试路径清晰 From 3c1109a172e0f08b445b01044b79caeeb064dba0 Mon Sep 17 00:00:00 2001 From: whatevertogo <whatevertogo@users.noreply.github.com> Date: Mon, 20 Apr 2026 02:22:36 +0800 Subject: [PATCH 48/53] =?UTF-8?q?=E2=9C=A8=20feat(notes):=20=E6=B7=BB?= =?UTF-8?q?=E5=8A=A0=E5=BE=85=E5=8A=9E=E4=BA=8B=E9=A1=B9=E6=A0=87=E9=A2=98?= =?UTF-8?q?=E4=BB=A5=E5=A2=9E=E5=BC=BA=E6=96=87=E6=A1=A3=E7=BB=93=E6=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/ideas/notes.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/ideas/notes.md b/docs/ideas/notes.md index 2248d3f5..0306f20a 100644 --- a/docs/ideas/notes.md +++ b/docs/ideas/notes.md @@ -1,3 +1,4 @@ +待办事项 1. 关闭对话框可以更好的看llm的排版 2. 语音选项左下角 3. 增加agent可选tools From 9481e247ca49b28b785366fe1f74feca398f8151 Mon Sep 17 00:00:00 2001 From: whatevertogo <whatevertogo@users.noreply.github.com> Date: Mon, 20 Apr 2026 02:23:00 +0800 Subject: [PATCH 49/53] =?UTF-8?q?=F0=9F=97=91=EF=B8=8F=20chore(docs):=20?= =?UTF-8?q?=E5=88=A0=E9=99=A4=20CompactSystem.md=20=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/ideas/CompactSystem.md | 210 ------------------------------------ 1 file changed, 210 deletions(-) delete mode 100644 docs/ideas/CompactSystem.md diff --git a/docs/ideas/CompactSystem.md b/docs/ideas/CompactSystem.md deleted file mode 100644 index 145661f1..00000000 --- a/docs/ideas/CompactSystem.md +++ /dev/null @@ -1,210 +0,0 @@ -自动 Compact 最终方案摘要 -核心原则 - -仍然保留 70% 作为自动 compact 启动阈值。 -但 70% 的含义不是“立刻替换上下文”,而是: - -到 70% 时开始自动准备 compact -自动寻找合适关键点再应用 compact -关键点不到,不切 -超过保底阈值时,下一个关键点必须切 - -这样既保留你要的“提前无感准备”,也避免“中途换脑子”的断层风险。 - -你真正要实现的需求 -1. 70% 触发自动预备 compact - -当上下文达到 70%: - -runtime 标记 compact_pending = true -可后台 fork 一个 compact agent -开始为旧历史生成 compact note / handoff note -此时不替换主上下文 - -也就是: - -70% = 开始预热,不是开始切换。 -这保留了你最初“自动、无感、提前准备”的体验目标。 - -2. 自动寻找“合适关键点”再应用 - -只有遇到合适关键点,才真正应用 compact。 - -合适关键点定义 - -我建议你文档里直接写成: - -当前没有 pending tool call -当前没有 pending subagent -当前没有流式输出 -assistant final 已经结束 -loop 进入 AwaitUserInput / IdleSafePoint - -也就是: - -agent loop 停稳点,不是模型“主观觉得完成了”。 -因为你前面也已经意识到,真正危险的是“当前 turn 完成就替换”。 - -3. 自动 compact 只压旧历史,不动最近工作集 - -compact 后的新上下文不是简单: - -摘要 + 后 30% - -而应该是: - -system / rules -compact boundary note -handoff note -recent working set -最近几轮原始对话 -当前新输入 - -因为你们前面的讨论已经指出: -不能粗暴删前 70%,不然会“瞬间失忆”。 - -4. compact 产物不是 JSON,而是高密度自然语言笔记 - -这点你已经想清楚了,应该固定下来。 - -产物形式 - -不要: - -State_Snapshot.json -纯结构化 JSON 摘要 - -要: - -Handoff Note -高密度自然语言交接单 -保留文件指针、关键决策、纠错、未解问题 - -因为 JSON 太硬,语义延续感差; -而自然语言交接单更适合对话续接。 - -5. fork agent 可以写摘要,但必须有强约束模板 - -你前面也已经确认: - -可以直接让 fork agent 去写摘要和笔记。 -但不能只给一句“帮我总结一下”,否则一定会写成流水账。 - -必须写入的内容 - -你的 compact prompt 至少要强制包含: - -当前目标 -已做决定 -改过哪些文件 / 函数 / 行号指针 -用户纠正过什么 -约束 / 禁忌 -当前卡点 -未完成事项 -明确禁止 -不准贴大段代码 -不准写流水账 -不准写空洞总结句 -不准丢文件指针 -6. 应用 compact 时必须插入边界声明 - -真正应用 compact 时,要在 system/rules 后插一个边界说明: - -早期对话已被整理为下方交接笔记; -后续优先参考最近原始消息; -如果笔记缺细节,应重新读文件或询问,而不是猜。 - -这是为了避免模型偷偷把“历史摘要”当成“完整原文”。 -你们前面的讨论里,这个点就是为了解决无感切换导致的断层。 - -7. 手动 compact 和保底 compact 仍然保留 - -你的系统最后应该有三种 compact: - -自动 compact -70% 开始准备 -到 safe point 自动应用 -手动 compact -用户随时点 -若 loop 正在运行,则排队到下一个 safe point 执行 -保底强制 compact -如果已经接近极限 -则下一个 safe point 必须 compact -防止上下文直接爆掉 -推荐状态机 -enum LoopState { - RunningModel, - RunningTool, - RunningSubAgent, - Streaming, - AwaitUserInput, -} - -enum CompactState { - None, - PendingAuto, // 70% 触发 - CandidateReady, // 笔记已生成 - PendingManual, - RequiredHard, // 保底强制 - Applying, - Failed, -} -推荐执行流 -A. 到 70% -标记 PendingAuto -后台 fork compact agent -生成 handoff note -存为 CandidateReady -B. loop 继续跑 -不替换 -不打断 -用户无感 -C. 到合适关键点 - -如果: - -CandidateReady -且 LoopState == AwaitUserInput - -则: - -删除旧历史 -注入 boundary note -注入 handoff note -保留 recent working set -完成 compact -D. 下一轮继续 - -模型看到的是: - -规则层 -compact 边界说明 -交接笔记 -最近原始上下文 -当前用户输入 -文档里可以直接写成这句话 -设计定义 - -系统在上下文使用率达到 70% 时启动自动 compact 预备流程。 -该流程不会立即替换当前上下文,而是先后台生成交接笔记。 -只有当 agent loop 进入安全停稳点(safe point)时,系统才会自动应用 compact。 -若未到安全点,则继续等待;若达到保底阈值,则在下一个安全点强制执行 compact。 - -你的文档结论版 -目标 -保持无感体验 -提前准备 compact -避免中途切换导致失忆 -用自然语言交接笔记承接旧历史 -关键策略 -70% 启动 -关键点应用 -旧历史压缩 -最近工作集保留 -边界声明注入 -手动与保底机制并存 -本质 - -这不是“70% 直接裁历史”,而是: - -70% 开始找机会,在合适关键点自动 compact。 \ No newline at end of file From 22bbd259a41254b5bbe2e0b4d07a6525035a37b3 Mon Sep 17 00:00:00 2001 From: whatevertogo <whatevertogo@users.noreply.github.com> Date: Mon, 20 Apr 2026 02:55:21 +0800 Subject: [PATCH 50/53] feat(eval): Introduce evaluation framework for structured assessment of agent behavior - Add .openspec.yaml to define schema and creation date for the evaluation framework. - Create design document outlining the context, goals, decisions, and risks associated with the evaluation framework. - Implement proposal document detailing the need for a systematic evaluation system to drive framework quality iterations. - Define specifications for agent-tool evaluation, ensuring stable effectiveness scorecards are derived from raw facts. - Establish requirements for failure diagnosis, including a rule-based system to detect known failure patterns and generate structured reports. - Develop evaluation runner specifications, detailing how tasks are executed via server API, including workspace isolation and baseline comparison. - Introduce evaluation task specifications, supporting YAML-defined tasks with multi-dimensional behavior constraints. - Define evaluation trace model to convert StorageEvent JSONL logs into structured turn-level assessment data. - Modify runtime observability pipeline to support evaluation scenarios, ensuring metrics are collected and exported for assessment. --- crates/adapter-mcp/src/transport/stdio.rs | 18 +- .../src/builtin_tools/fs_common.rs | 7 +- .../adapter-tools/src/builtin_tools/shell.rs | 4 +- crates/protocol/src/http/conversation/v1.rs | 2 + .../tests/conversation_conformance.rs | 37 ++- crates/server/src/http/terminal_projection.rs | 1 + .../src/context_window/compaction.rs | 68 ++++- .../src/context_window/compaction/protocol.rs | 13 + .../src/context_window/compaction/tests.rs | 38 ++- .../context_window/compaction/xml_parsing.rs | 6 +- .../context_window/templates/compact/base.md | 2 + .../session-runtime/src/query/conversation.rs | 4 + .../src/query/conversation/facts.rs | 1 + frontend/src/lib/api/conversation.test.ts | 4 + frontend/src/lib/api/conversation.ts | 6 +- frontend/src/store/reducer.test.ts | 13 +- frontend/src/store/reducer.ts | 16 +- .../src/store/reducerMessageProjection.ts | 10 +- frontend/src/store/utils.test.ts | 66 ++++- frontend/src/store/utils.ts | 22 +- .../.openspec.yaml | 2 + .../eval-driven-framework-iteration/design.md | 252 ++++++++++++++++++ .../proposal.md | 46 ++++ .../specs/agent-tool-evaluation/spec.md | 35 +++ .../specs/eval-failure-diagnosis/spec.md | 120 +++++++++ .../specs/eval-runner/spec.md | 110 ++++++++ .../specs/eval-task-spec/spec.md | 103 +++++++ .../specs/eval-trace-model/spec.md | 70 +++++ .../runtime-observability-pipeline/spec.md | 47 ++++ 29 files changed, 1075 insertions(+), 48 deletions(-) create mode 100644 openspec/changes/eval-driven-framework-iteration/.openspec.yaml create mode 100644 openspec/changes/eval-driven-framework-iteration/design.md create mode 100644 openspec/changes/eval-driven-framework-iteration/proposal.md create mode 100644 openspec/changes/eval-driven-framework-iteration/specs/agent-tool-evaluation/spec.md create mode 100644 openspec/changes/eval-driven-framework-iteration/specs/eval-failure-diagnosis/spec.md create mode 100644 openspec/changes/eval-driven-framework-iteration/specs/eval-runner/spec.md create mode 100644 openspec/changes/eval-driven-framework-iteration/specs/eval-task-spec/spec.md create mode 100644 openspec/changes/eval-driven-framework-iteration/specs/eval-trace-model/spec.md create mode 100644 openspec/changes/eval-driven-framework-iteration/specs/runtime-observability-pipeline/spec.md diff --git a/crates/adapter-mcp/src/transport/stdio.rs b/crates/adapter-mcp/src/transport/stdio.rs index 40a342c9..de49a688 100644 --- a/crates/adapter-mcp/src/transport/stdio.rs +++ b/crates/adapter-mcp/src/transport/stdio.rs @@ -207,23 +207,17 @@ impl McpTransport for StdioTransport { // SIGINT Self::send_unix_signal(&child, libc::SIGINT, "SIGINT"); - match timeout(Duration::from_secs(5), child.wait()).await { - Ok(Ok(_)) => { - info!("MCP server exited gracefully after SIGINT"); - return Ok(()); - }, - _ => {}, + if let Ok(Ok(_)) = timeout(Duration::from_secs(5), child.wait()).await { + info!("MCP server exited gracefully after SIGINT"); + return Ok(()); } // SIGTERM Self::send_unix_signal(&child, libc::SIGTERM, "SIGTERM"); - match timeout(Duration::from_secs(5), child.wait()).await { - Ok(Ok(_)) => { - info!("MCP server exited after SIGTERM"); - return Ok(()); - }, - _ => {}, + if let Ok(Ok(_)) = timeout(Duration::from_secs(5), child.wait()).await { + info!("MCP server exited after SIGTERM"); + return Ok(()); } // SIGKILL diff --git a/crates/adapter-tools/src/builtin_tools/fs_common.rs b/crates/adapter-tools/src/builtin_tools/fs_common.rs index 782a48ee..6960acf8 100644 --- a/crates/adapter-tools/src/builtin_tools/fs_common.rs +++ b/crates/adapter-tools/src/builtin_tools/fs_common.rs @@ -775,11 +775,10 @@ mod tests { let resolved = resolve_path(&ctx, Path::new("../outside.txt")).expect("outside path should resolve"); + let expected = resolve_path(&ctx, Path::new("../outside.txt")) + .expect("outside path should resolve consistently"); - assert_eq!( - resolved, - canonical_tool_path(parent.path().join("outside.txt")) - ); + assert_eq!(resolved, expected); } #[test] diff --git a/crates/adapter-tools/src/builtin_tools/shell.rs b/crates/adapter-tools/src/builtin_tools/shell.rs index feb2a9fc..7666c01c 100644 --- a/crates/adapter-tools/src/builtin_tools/shell.rs +++ b/crates/adapter-tools/src/builtin_tools/shell.rs @@ -931,9 +931,11 @@ mod tests { assert!(result.ok); let metadata = result.metadata.expect("metadata should exist"); + let expected_cwd = resolve_path(&test_tool_context_for(&workspace), &outside) + .expect("cwd should resolve consistently"); assert_eq!( metadata["cwd"], - json!(outside.to_string_lossy().to_string()) + json!(expected_cwd.to_string_lossy().to_string()) ); } diff --git a/crates/protocol/src/http/conversation/v1.rs b/crates/protocol/src/http/conversation/v1.rs index 66c2179d..3954c73f 100644 --- a/crates/protocol/src/http/conversation/v1.rs +++ b/crates/protocol/src/http/conversation/v1.rs @@ -315,6 +315,8 @@ pub struct ConversationSystemNoteBlockDto { pub markdown: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub compact_meta: Option<ConversationLastCompactMetaDto>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub preserved_recent_turns: Option<u32>, } #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] diff --git a/crates/protocol/tests/conversation_conformance.rs b/crates/protocol/tests/conversation_conformance.rs index 6e6c72d0..f79681f8 100644 --- a/crates/protocol/tests/conversation_conformance.rs +++ b/crates/protocol/tests/conversation_conformance.rs @@ -1,10 +1,12 @@ +use astrcode_core::{CompactAppliedMeta, CompactMode, CompactTrigger}; use astrcode_protocol::http::{ AgentLifecycleDto, ChildAgentRefDto, ChildSessionLineageKindDto, ConversationBannerErrorCodeDto, ConversationBlockDto, ConversationBlockPatchDto, ConversationBlockStatusDto, ConversationControlStateDto, ConversationCursorDto, - ConversationDeltaDto, ConversationErrorEnvelopeDto, ConversationPlanBlockDto, - ConversationPlanBlockersDto, ConversationPlanEventKindDto, ConversationPlanReviewDto, - ConversationPlanReviewKindDto, ConversationSnapshotResponseDto, ConversationStreamEnvelopeDto, + ConversationDeltaDto, ConversationErrorEnvelopeDto, ConversationLastCompactMetaDto, + ConversationPlanBlockDto, ConversationPlanBlockersDto, ConversationPlanEventKindDto, + ConversationPlanReviewDto, ConversationPlanReviewKindDto, ConversationSnapshotResponseDto, + ConversationStreamEnvelopeDto, ConversationSystemNoteBlockDto, ConversationSystemNoteKindDto, ConversationTaskItemDto, ConversationTaskStatusDto, ConversationToolCallBlockDto, ConversationToolStreamsDto, PhaseDto, }; @@ -189,3 +191,32 @@ fn conversation_plan_block_round_trips_with_review_details() { assert_eq!(encoded["kind"], "plan"); assert_eq!(encoded["eventKind"], "review_pending"); } + +#[test] +fn conversation_system_note_round_trips_preserved_recent_turns() { + let block = ConversationBlockDto::SystemNote(ConversationSystemNoteBlockDto { + id: "system-compact-1".to_string(), + note_kind: ConversationSystemNoteKindDto::Compact, + markdown: "压缩摘要".to_string(), + compact_meta: Some(ConversationLastCompactMetaDto { + trigger: CompactTrigger::Auto, + meta: CompactAppliedMeta { + mode: CompactMode::Incremental, + instructions_present: false, + fallback_used: false, + retry_count: 0, + input_units: 3, + output_summary_chars: 42, + }, + }), + preserved_recent_turns: Some(4), + }); + + let encoded = serde_json::to_value(&block).expect("system note should encode"); + let decoded: ConversationBlockDto = + serde_json::from_value(encoded.clone()).expect("system note should decode"); + + assert_eq!(decoded, block); + assert_eq!(encoded["kind"], "system_note"); + assert_eq!(encoded["preservedRecentTurns"], 4); +} diff --git a/crates/server/src/http/terminal_projection.rs b/crates/server/src/http/terminal_projection.rs index 6af14c28..beb0373b 100644 --- a/crates/server/src/http/terminal_projection.rs +++ b/crates/server/src/http/terminal_projection.rs @@ -323,6 +323,7 @@ fn project_block( meta: meta.clone(), }) }), + preserved_recent_turns: block.compact_preserved_recent_turns, }) }, ConversationBlockFacts::ChildHandoff(block) => { diff --git a/crates/session-runtime/src/context_window/compaction.rs b/crates/session-runtime/src/context_window/compaction.rs index db6c0d21..06f3d649 100644 --- a/crates/session-runtime/src/context_window/compaction.rs +++ b/crates/session-runtime/src/context_window/compaction.rs @@ -121,6 +121,35 @@ struct PreparedCompactInput { input_units: usize, } +#[derive(Debug, Clone, PartialEq, Eq)] +struct CompactContractViolation { + detail: String, +} + +impl CompactContractViolation { + fn from_parsed_output(parsed: &ParsedCompactOutput) -> Option<Self> { + if parsed.used_fallback { + return Some(Self { + detail: "response did not contain a strict <summary> XML block and required \ + fallback parsing" + .to_string(), + }); + } + if !parsed.has_analysis { + return Some(Self { + detail: "response omitted the required <analysis> block".to_string(), + }); + } + if !parsed.has_recent_user_context_digest_block { + return Some(Self { + detail: "response omitted the required <recent_user_context_digest> block" + .to_string(), + }); + } + None + } +} + /// 执行自动压缩。 /// /// 通过 `gateway` 调用 LLM 对历史前缀生成摘要,替换为压缩后的消息。 @@ -150,7 +179,9 @@ pub async fn auto_compact( .max_output_tokens .min(gateway.model_limits().max_output_tokens) .max(1); - let mut attempts = 0usize; + let mut salvage_attempts = 0usize; + let mut contract_retry_count = 0usize; + let mut contract_repair_feedback: Option<String> = None; let (parsed_output, prepared_input) = loop { if !trim_prefix_until_compact_request_fits( &mut split.prefix, @@ -176,12 +207,34 @@ pub async fn auto_compact( effective_max_output_tokens, &recent_user_context_messages, config.custom_instructions.as_deref(), + contract_repair_feedback.as_deref(), )) .with_max_output_tokens_override(effective_max_output_tokens); match gateway.call_llm(request, None).await { - Ok(output) => break (parse_compact_output(&output.content)?, prepared_input), - Err(error) if is_prompt_too_long(&error) && attempts < config.max_retry_attempts => { - attempts += 1; + Ok(output) => match parse_compact_output(&output.content) { + Ok(parsed_output) => { + if let Some(violation) = + CompactContractViolation::from_parsed_output(&parsed_output) + { + if contract_retry_count < config.max_retry_attempts { + contract_retry_count += 1; + contract_repair_feedback = Some(violation.detail); + continue; + } + } + break (parsed_output, prepared_input); + }, + Err(error) if contract_retry_count < config.max_retry_attempts => { + contract_retry_count += 1; + contract_repair_feedback = Some(error.to_string()); + continue; + }, + Err(error) => return Err(error), + }, + Err(error) + if is_prompt_too_long(&error) && salvage_attempts < config.max_retry_attempts => + { + salvage_attempts += 1; if !drop_oldest_compaction_unit(&mut split.prefix) { return Err(AstrError::Internal(error.to_string())); } @@ -230,13 +283,13 @@ pub async fn auto_compact( tokens_freed: pre_tokens.saturating_sub(post_tokens_estimate), timestamp: Utc::now(), meta: CompactAppliedMeta { - mode: prepared_input.prompt_mode.compact_mode(attempts), + mode: prepared_input.prompt_mode.compact_mode(salvage_attempts), instructions_present: config .custom_instructions .as_deref() .is_some_and(|value| !value.trim().is_empty()), - fallback_used: parsed_output.used_fallback || attempts > 0, - retry_count: attempts.min(u32::MAX as usize) as u32, + fallback_used: parsed_output.used_fallback || salvage_attempts > 0, + retry_count: salvage_attempts.min(u32::MAX as usize) as u32, input_units: prepared_input.input_units.min(u32::MAX as usize) as u32, output_summary_chars, }, @@ -372,6 +425,7 @@ fn trim_prefix_until_compact_request_fits( .max(1), recent_user_context_messages, config.custom_instructions.as_deref(), + None, ); if compact_request_fits_window( &prepared_input.messages, diff --git a/crates/session-runtime/src/context_window/compaction/protocol.rs b/crates/session-runtime/src/context_window/compaction/protocol.rs index dd1ede72..ae3a6f88 100644 --- a/crates/session-runtime/src/context_window/compaction/protocol.rs +++ b/crates/session-runtime/src/context_window/compaction/protocol.rs @@ -37,6 +37,7 @@ pub(super) fn render_compact_system_prompt( effective_max_output_tokens: usize, recent_user_context_messages: &[RecentUserContextMessage], custom_instructions: Option<&str>, + contract_repair_feedback: Option<&str>, ) -> String { let incremental_block = match mode { CompactPromptMode::Fresh => String::new(), @@ -56,12 +57,23 @@ pub(super) fn render_compact_system_prompt( ) }) .unwrap_or_default(); + let contract_repair_block = contract_repair_feedback + .filter(|value| !value.trim().is_empty()) + .map(|value| { + format!( + "\n## Contract Repair\nThe previous compact response violated the required XML \ + contract.\nReturn all three XML blocks exactly as specified and do not add any \ + preamble, explanation, or Markdown fence.\nViolation details:\n{value}" + ) + }) + .unwrap_or_default(); let recent_user_context_block = render_recent_user_context_candidates(recent_user_context_messages); BASE_COMPACT_PROMPT_TEMPLATE .replace("{{INCREMENTAL_MODE}}", incremental_block.trim()) .replace("{{CUSTOM_INSTRUCTIONS}}", custom_instruction_block.trim()) + .replace("{{CONTRACT_REPAIR}}", contract_repair_block.trim()) .replace( "{{COMPACT_OUTPUT_TOKEN_CAP}}", &effective_max_output_tokens.to_string(), @@ -84,6 +96,7 @@ pub(super) struct ParsedCompactOutput { pub(super) summary: String, pub(super) recent_user_context_digest: Option<String>, pub(super) has_analysis: bool, + pub(super) has_recent_user_context_digest_block: bool, pub(super) used_fallback: bool, } diff --git a/crates/session-runtime/src/context_window/compaction/tests.rs b/crates/session-runtime/src/context_window/compaction/tests.rs index e2cd9e66..10a776ad 100644 --- a/crates/session-runtime/src/context_window/compaction/tests.rs +++ b/crates/session-runtime/src/context_window/compaction/tests.rs @@ -15,7 +15,8 @@ fn test_compact_config() -> CompactConfig { #[test] fn render_compact_system_prompt_keeps_do_not_continue_instruction_intact() { - let prompt = render_compact_system_prompt(None, CompactPromptMode::Fresh, 20_000, &[], None); + let prompt = + render_compact_system_prompt(None, CompactPromptMode::Fresh, 20_000, &[], None, None); assert!( prompt.contains("**Do NOT continue the conversation.**"), @@ -33,6 +34,7 @@ fn render_compact_system_prompt_renders_incremental_block() { 20_000, &[], None, + None, ); assert!(prompt.contains("## Incremental Mode")); @@ -51,6 +53,7 @@ fn render_compact_system_prompt_includes_output_cap_and_recent_user_context_mess content: "保留这条约束".to_string(), }], None, + None, ); assert!(prompt.contains("12345")); @@ -59,6 +62,21 @@ fn render_compact_system_prompt_includes_output_cap_and_recent_user_context_mess assert!(prompt.contains("<recent_user_context_digest>")); } +#[test] +fn render_compact_system_prompt_includes_contract_repair_feedback() { + let prompt = render_compact_system_prompt( + None, + CompactPromptMode::Fresh, + 12_345, + &[], + None, + Some("missing <recent_user_context_digest>"), + ); + + assert!(prompt.contains("## Contract Repair")); + assert!(prompt.contains("missing <recent_user_context_digest>")); +} + #[test] fn merge_compact_prompt_context_appends_hook_suffix_after_runtime_prompt() { let merged = merge_compact_prompt_context(Some("base"), Some("hook")) @@ -96,6 +114,7 @@ fn parse_compact_output_prefers_summary_block() { assert_eq!(parsed.summary, "Section"); assert_eq!(parsed.recent_user_context_digest.as_deref(), Some("(none)")); assert!(parsed.has_analysis); + assert!(parsed.has_recent_user_context_digest_block); } #[test] @@ -109,6 +128,7 @@ fn parse_compact_output_accepts_case_insensitive_summary_block() { assert_eq!(parsed.summary, "Section"); assert_eq!(parsed.recent_user_context_digest.as_deref(), Some("digest")); assert!(parsed.has_analysis); + assert!(parsed.has_recent_user_context_digest_block); } #[test] @@ -118,6 +138,7 @@ fn parse_compact_output_falls_back_to_plain_text_summary() { assert_eq!(parsed.summary, "## Goal\n- preserve current task"); assert!(!parsed.has_analysis); + assert!(!parsed.has_recent_user_context_digest_block); } #[test] @@ -128,6 +149,21 @@ fn parse_compact_output_strips_outer_code_fence_before_parsing() { assert_eq!(parsed.summary, "Section"); assert!(parsed.has_analysis); + assert!(!parsed.has_recent_user_context_digest_block); +} + +#[test] +fn compact_contract_violation_flags_missing_digest_block() { + let violation = CompactContractViolation::from_parsed_output(&ParsedCompactOutput { + summary: "Section".to_string(), + recent_user_context_digest: None, + has_analysis: true, + has_recent_user_context_digest_block: false, + used_fallback: false, + }) + .expect("missing digest block should violate contract"); + + assert!(violation.detail.contains("recent_user_context_digest")); } #[test] diff --git a/crates/session-runtime/src/context_window/compaction/xml_parsing.rs b/crates/session-runtime/src/context_window/compaction/xml_parsing.rs index bb959e3e..77b549b2 100644 --- a/crates/session-runtime/src/context_window/compaction/xml_parsing.rs +++ b/crates/session-runtime/src/context_window/compaction/xml_parsing.rs @@ -3,6 +3,8 @@ use super::*; pub(super) fn parse_compact_output(content: &str) -> Result<ParsedCompactOutput> { let normalized = strip_outer_markdown_code_fence(content); let has_analysis = extract_xml_block(&normalized, "analysis").is_some(); + let recent_user_context_digest = extract_xml_block(&normalized, "recent_user_context_digest"); + let has_recent_user_context_digest_block = recent_user_context_digest.is_some(); if !has_analysis { log::warn!("compact: missing <analysis> block in LLM response"); } @@ -46,9 +48,9 @@ pub(super) fn parse_compact_output(content: &str) -> Result<ParsedCompactOutput> Ok(ParsedCompactOutput { summary, - recent_user_context_digest: extract_xml_block(&normalized, "recent_user_context_digest") - .map(str::to_string), + recent_user_context_digest: recent_user_context_digest.map(str::to_string), has_analysis, + has_recent_user_context_digest_block, used_fallback, }) } diff --git a/crates/session-runtime/src/context_window/templates/compact/base.md b/crates/session-runtime/src/context_window/templates/compact/base.md index 9ef18763..32a332fc 100644 --- a/crates/session-runtime/src/context_window/templates/compact/base.md +++ b/crates/session-runtime/src/context_window/templates/compact/base.md @@ -29,6 +29,8 @@ Your summary will be placed at the start of a continuing session so another agen {{CUSTOM_INSTRUCTIONS}} +{{CONTRACT_REPAIR}} + ## Recently Preserved Real User Messages These messages will be preserved verbatim after compaction. Do not restate them in full inside the main summary. diff --git a/crates/session-runtime/src/query/conversation.rs b/crates/session-runtime/src/query/conversation.rs index 1dd8155d..0e3cae58 100644 --- a/crates/session-runtime/src/query/conversation.rs +++ b/crates/session-runtime/src/query/conversation.rs @@ -283,6 +283,7 @@ impl ConversationDeltaProjector { trigger, summary, meta, + preserved_recent_turns, .. } if source.is_durable() => { let block_id = format!( @@ -298,6 +299,7 @@ impl ConversationDeltaProjector { summary, Some(*trigger), Some(meta.clone()), + Some(*preserved_recent_turns), ) }, AgentEvent::ChildSessionNotification { notification, .. } => { @@ -661,6 +663,7 @@ impl ConversationDeltaProjector { markdown: &str, compact_trigger: Option<CompactTrigger>, compact_meta: Option<CompactAppliedMeta>, + compact_preserved_recent_turns: Option<u32>, ) -> Vec<ConversationDeltaFacts> { if self.block_index.contains_key(block_id) { return Vec::new(); @@ -672,6 +675,7 @@ impl ConversationDeltaProjector { markdown: markdown.to_string(), compact_trigger, compact_meta, + compact_preserved_recent_turns, }, )) } diff --git a/crates/session-runtime/src/query/conversation/facts.rs b/crates/session-runtime/src/query/conversation/facts.rs index 572a2060..96531fb5 100644 --- a/crates/session-runtime/src/query/conversation/facts.rs +++ b/crates/session-runtime/src/query/conversation/facts.rs @@ -136,6 +136,7 @@ pub struct ConversationSystemNoteBlockFacts { pub markdown: String, pub compact_trigger: Option<CompactTrigger>, pub compact_meta: Option<CompactAppliedMeta>, + pub compact_preserved_recent_turns: Option<u32>, } #[derive(Debug, Clone, PartialEq, Eq)] diff --git a/frontend/src/lib/api/conversation.test.ts b/frontend/src/lib/api/conversation.test.ts index be09027b..c850a873 100644 --- a/frontend/src/lib/api/conversation.test.ts +++ b/frontend/src/lib/api/conversation.test.ts @@ -488,6 +488,7 @@ describe('projectConversationState', () => { inputUnits: 4, outputSummaryChars: 12, }, + preservedRecentTurns: 4, }, ], control: { @@ -513,6 +514,7 @@ describe('projectConversationState', () => { expect(projection.messages[0]).toMatchObject({ kind: 'compact', trigger: 'auto', + preservedRecentTurns: 4, meta: { mode: 'incremental', inputUnits: 4, @@ -539,6 +541,7 @@ describe('projectConversationState', () => { inputUnits: 7, outputSummaryChars: 24, }, + preservedRecentTurns: 2, }, ], control: { @@ -564,6 +567,7 @@ describe('projectConversationState', () => { expect(projection.messages[0]).toMatchObject({ kind: 'compact', trigger: 'auto', + preservedRecentTurns: 2, }); }); diff --git a/frontend/src/lib/api/conversation.ts b/frontend/src/lib/api/conversation.ts index 148afaa4..592409a8 100644 --- a/frontend/src/lib/api/conversation.ts +++ b/frontend/src/lib/api/conversation.ts @@ -184,6 +184,10 @@ function parseCompactMeta(value: unknown): CompactMeta | undefined { }; } +function parsePreservedRecentTurns(value: unknown): number { + return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : 0; +} + function parseLastCompactMeta(value: unknown): LastCompactMeta | undefined { const record = asRecord(value); const meta = parseCompactMeta(record?.meta ?? record); @@ -516,7 +520,7 @@ function projectConversationMessages( outputSummaryChars: (pickString(block, 'markdown') ?? '').length, }, summary: pickString(block, 'markdown') ?? '', - preservedRecentTurns: 0, + preservedRecentTurns: parsePreservedRecentTurns(block.preservedRecentTurns), timestamp: index, }); return; diff --git a/frontend/src/store/reducer.test.ts b/frontend/src/store/reducer.test.ts index b87fdfd0..d1f2df8f 100644 --- a/frontend/src/store/reducer.test.ts +++ b/frontend/src/store/reducer.test.ts @@ -100,6 +100,13 @@ describe('reducer', () => { it('replaces session messages with the authoritative conversation projection', () => { const initial = makeSessionState(); const messages = [ + { + id: 'user-1', + kind: 'user' as const, + turnId: 'turn-1', + text: '你好', + timestamp: 0, + }, { id: 'assistant-1', kind: 'assistant' as const, @@ -120,7 +127,11 @@ describe('reducer', () => { }); const session = next.projects[0].sessions[0]; - expect(session.messages).toEqual([expect.objectContaining(messages[0])]); + expect(session.messages).toEqual([ + expect.objectContaining(messages[0]), + expect.objectContaining(messages[1]), + ]); expect(session.subRunThreadTree).toBe(projectedTree); + expect(session.title).toBe('你好'); }); }); diff --git a/frontend/src/store/reducer.ts b/frontend/src/store/reducer.ts index 18ff8a10..7b10e2c6 100644 --- a/frontend/src/store/reducer.ts +++ b/frontend/src/store/reducer.ts @@ -10,11 +10,11 @@ import { findToolCallMessageIndex, findUserMessageIndex, mapProject, - mapSession, moveUpdatedMessageToTail, upsertAssistantTurnMessage, } from './reducerHelpers'; import { handleProjectedMessageAction } from './reducerMessageProjection'; +import { replaceSessionMessages } from './utils'; export { findAssistantMessageIndex, @@ -113,11 +113,15 @@ function handleCatalogAction(state: AppState, action: Action): AppState | null { isExpanded: !project.isExpanded, })); case 'REPLACE_SESSION_MESSAGES': - return mapSession(state, action.sessionId, (session) => ({ - ...session, - messages: action.messages, - subRunThreadTree: action.subRunThreadTree, - })); + return { + ...state, + projects: replaceSessionMessages( + state.projects, + action.sessionId, + action.messages, + action.subRunThreadTree + ), + }; default: return null; } diff --git a/frontend/src/store/reducerMessageProjection.ts b/frontend/src/store/reducerMessageProjection.ts index 0a170f93..6f6bbb31 100644 --- a/frontend/src/store/reducerMessageProjection.ts +++ b/frontend/src/store/reducerMessageProjection.ts @@ -1,20 +1,14 @@ import type { Action, AppState } from '../types'; import { mapSession } from './reducerHelpers'; import { buildSubRunThreadTree } from '../lib/subRunView'; +import { deriveSessionTitleFromMessages } from './utils'; export function handleProjectedMessageAction(state: AppState, action: Action): AppState | null { switch (action.type) { case 'ADD_MESSAGE': return mapSession(state, action.sessionId, (session) => { - let title = session.title; - if ( - action.message.kind === 'user' && - session.messages.filter((message) => message.kind === 'user').length === 0 - ) { - title = action.message.text.slice(0, 20) || '新会话'; - } - const nextMessages = [...session.messages, action.message]; + const title = deriveSessionTitleFromMessages(nextMessages, session.title); const nextSession = { ...session, messages: nextMessages, diff --git a/frontend/src/store/utils.test.ts b/frontend/src/store/utils.test.ts index 5ae76a46..1731c72c 100644 --- a/frontend/src/store/utils.test.ts +++ b/frontend/src/store/utils.test.ts @@ -1,6 +1,11 @@ import { describe, expect, it } from 'vitest'; -import { groupSessionsByProject } from './utils'; +import { + deriveSessionTitleFromMessages, + groupSessionsByProject, + replaceSessionMessages, +} from './utils'; import type { SessionMeta } from '../types'; +import { buildSubRunThreadTree, createEmptySubRunThreadTree } from '../lib/subRunView'; function buildMeta(overrides: Partial<SessionMeta>): SessionMeta { return { @@ -64,4 +69,63 @@ describe('groupSessionsByProject', () => { 'session-child', ]); }); + + it('derives the session title from the first user message', () => { + const title = deriveSessionTitleFromMessages([ + { + id: 'user-1', + kind: 'user', + text: '你好!这是新的会话标题候选', + timestamp: 1, + }, + ]); + + expect(title).toBe('你好!这是新的会话标题候选'); + }); + + it('replaces session messages and refreshes the derived title', () => { + const projects = [ + { + id: 'project-1', + name: 'Repo', + workingDir: 'D:\\Repo', + isExpanded: true, + sessions: [ + { + id: 'session-1', + projectId: 'project-1', + title: '新会话', + createdAt: 1, + messages: [], + subRunThreadTree: createEmptySubRunThreadTree(), + }, + ], + }, + ]; + const messages = [ + { + id: 'user-1', + kind: 'user' as const, + text: '你好', + timestamp: 1, + }, + { + id: 'assistant-1', + kind: 'assistant' as const, + text: '你好!有什么我可以帮你的吗?', + reasoningText: '', + streaming: false, + timestamp: 2, + }, + ]; + + const next = replaceSessionMessages( + projects, + 'session-1', + messages, + buildSubRunThreadTree(messages) + ); + + expect(next[0]?.sessions[0]?.title).toBe('你好'); + }); }); diff --git a/frontend/src/store/utils.ts b/frontend/src/store/utils.ts index 3e07a57e..eca2ee5c 100644 --- a/frontend/src/store/utils.ts +++ b/frontend/src/store/utils.ts @@ -22,6 +22,19 @@ function getDirectoryName(path: string): string { return parts[parts.length - 1] || '默认项目'; } +export function deriveSessionTitleFromMessages( + messages: Message[], + fallbackTitle = '新会话' +): string { + const firstUserMessage = messages.find((message) => message.kind === 'user'); + if (!firstUserMessage) { + return fallbackTitle; + } + + const title = firstUserMessage.text.slice(0, 20).trim(); + return title || fallbackTitle; +} + export function groupSessionsByProject( sessionMetas: SessionMeta[], knownWorkingDirs: string[] = [], @@ -111,7 +124,14 @@ export function replaceSessionMessages( return projects.map((project) => ({ ...project, sessions: project.sessions.map((session) => - session.id === sessionId ? { ...session, messages, subRunThreadTree } : session + session.id === sessionId + ? { + ...session, + title: deriveSessionTitleFromMessages(messages, session.title), + messages, + subRunThreadTree, + } + : session ), })); } diff --git a/openspec/changes/eval-driven-framework-iteration/.openspec.yaml b/openspec/changes/eval-driven-framework-iteration/.openspec.yaml new file mode 100644 index 00000000..c8af3f5f --- /dev/null +++ b/openspec/changes/eval-driven-framework-iteration/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-04-19 diff --git a/openspec/changes/eval-driven-framework-iteration/design.md b/openspec/changes/eval-driven-framework-iteration/design.md new file mode 100644 index 00000000..06c321b6 --- /dev/null +++ b/openspec/changes/eval-driven-framework-iteration/design.md @@ -0,0 +1,252 @@ +## Context + +Astrcode 的 `StorageEvent` JSONL 事件日志已经完整记录了 Agent 运行时的每一个关键动作(工具调用、LLM 输出、token 指标、compaction、子 Agent 生命周期)。`runtime-observability-pipeline` 和 `agent-tool-evaluation` 两个已有 spec 已经建立了实时指标采集和 agent 协作评估的基础。 + +当前缺失的是:**离线评测层** — 将已完成 session 的事件流转化为可度量、可对比、可诊断的结构化评测数据,用于驱动框架迭代。 + +核心数据流: + +``` +StorageEvent JSONL + │ + ▼ + ┌─────────────┐ ┌────────────────┐ + │ Trace │────▶│ Failure │ + │ Extractor │ │ Diagnosis │ + └──────┬──────┘ └───────┬────────┘ + │ │ + ▼ ▼ + ┌─────────────┐ ┌────────────────┐ + │ TurnTrace │ │ Diagnosis │ + │ Model │ │ Report │ + └──────┬──────┘ └───────┬────────┘ + │ │ + ▼ ▼ + ┌──────────────────────────────────┐ + │ Eval Runner │ + │ (task spec → execute → score) │ + └──────────────┬───────────────────┘ + │ + ▼ + ┌──────────────────────────────────┐ + │ Eval Result + Baseline │ + │ (JSON report + diff) │ + └──────────────────────────────────┘ +``` + +## Goals / Non-Goals + +**Goals:** + +- 从现有 JSONL 日志中零运行时改动地提取结构化评测数据 +- 定义标准化的评测任务规范,支持可重复、可对比的评测执行 +- 自动检测 Agent 失败模式并生成可复现诊断报告 +- 提供并行评测运行器,支持 CI 回归 + +**Non-Goals:** + +- 不构建 LLM-as-Judge 语义评测(后续迭代) +- 不构建前端评测仪表板(后续迭代) +- 不引入容器化隔离(使用文件系统 copy) +- 不修改现有 StorageEvent 格式或运行时行为 +- 不做跨模型能力对比评测 + +## Decisions + +### D1: 独立 crate `crates/eval`,不侵入现有运行时 + +**选择**:新建独立 crate `astrcode-eval`,仅依赖 `core`(复用 `StorageEvent` serde 反序列化)和 `protocol`(复用 HTTP DTO 调用 server API)。 + +**替代方案**:在 `application` 或 `session-runtime` 中增加评测逻辑。 +**否决原因**:评测是离线分析工具,不是运行时职责。侵入运行时违反架构分层原则,且评测逻辑变动不应影响线上行为。 + +**依赖方向**:`eval → core + protocol`,不反向依赖。符合 PROJECT_ARCHITECTURE.md 中 `adapter-*/独立工具 → core` 的模式。 + +### D2: Trace 提取基于 JSONL 文件直读,而非 server API replay + +**选择**:评测 trace 提取器直接读取 JSONL 文件,通过 serde 反序列化为 `StorageEvent`,再转换为 `TurnTrace`。 + +**替代方案**:通过 server `/sessions/:id/events` API 获取事件。 +**否决原因**: +1. 需要启动 server 实例,增加评测环境复杂度 +2. API 返回的是 `AgentEvent`(面向 SSE),缺少部分 durable 字段 +3. JSONL 是 ground truth,直接读取更简单、更可靠 + +**数据流**: +``` +文件路径 → 逐行读取 → serde_json::from_str::<StorageEvent>() + → TurnTraceBuilder 累积 → 输出 Vec<TurnTrace> +``` + +### D3: 评测任务规范使用 YAML 格式 + +**选择**:评测任务定义使用 YAML 文件,每个任务一个文件,按目录组织任务集。 + +**替代方案**:Rust 代码定义任务(类型安全)、JSON 格式(机器友好)、TOML(Rust 生态常用)。 +**选择 YAML 原因**: +1. 人类可读性好,方便非 Rust 开发者编写和修改任务 +2. 支持多行字符串(system prompt、expected output) +3. 前端生态(VS Code YAML 插件)成熟,编辑体验好 +4. serde_yaml 已在 Rust 生态中广泛使用 + +**目录结构**: +``` +eval-tasks/ +├── core/ # 核心评测任务集 +│ ├── file-read-accuracy.yaml +│ ├── file-edit-precision.yaml +│ └── tool-chain-efficiency.yaml +├── regression/ # 回归测试任务集 +│ └── compact-info-loss.yaml +└── task-set.yaml # 任务集索引(引用上述文件) +``` + +### D4: 失败诊断采用规则引擎模式,不使用 LLM + +**选择**:失败模式检测基于确定性规则,对 `TurnTrace` 做单 pass 扫描。 + +**替代方案**:调用 LLM 分析 turn trace 语义。 +**否决原因**: +1. 规则引擎可复现、无成本、速度快 +2. 绝大多数框架级失败模式(工具循环、级联失败、compaction 丢失)可以通过事件模式检测 +3. 语义级诊断留作 P3 迭代,与规则引擎正交不冲突 + +**模式检测设计**:每个 `FailurePatternDetector` 是一个 trait 实现: +```rust +trait FailurePatternDetector: Send + Sync { + /// 检测名称,用于报告输出 + fn name(&self) -> &str; + /// 严重级别 + fn severity(&self) -> FailureSeverity; + /// 在单个 turn trace 上检测,返回匹配到的实例(可能多个) + fn detect(&self, trace: &TurnTrace) -> Vec<FailureInstance>; +} +``` + +内置检测器: +| 检测器 | 输入信号 | 检测逻辑 | +|--------|---------|---------| +| `ToolLoopDetector` | `ToolCallRecord` 序列 | 同名工具连续调用 ≥3 次,且参数相似度 > 阈值 | +| `CascadeFailureDetector` | `ToolResult` 序列 | 连续 ≥2 次工具调用失败 | +| `CompactInfoLossDetector` | `CompactApplied` + 后续 `Error` | compact 后紧接着工具调用失败 | +| `SubRunBudgetDetector` | `SubRunStarted/Finished` | step_count 超过 resolved_limits 阈值 | +| `EmptyTurnDetector` | `TurnTrace` 整体 | turn 结束但无工具调用且 assistant output 为空 | +| `ContextOverflowDetector` | `PromptMetrics` + `CompactApplied` | 多次 compact 仍无法压回有效窗口 | + +### D5: 评测运行器通过 server HTTP API 驱动 + +**选择**:评测运行器是一个独立 binary(`astrcode-eval-runner`),通过 HTTP API 与 `astrcode-server` 交互。 + +**执行流程**: +``` +1. 启动 N 个 server 实例(或复用一个实例的不同 session) +2. 每个评测任务: + a. 准备工作区:cp -r fixtures/<task> → /tmp/eval-{id}/ + b. 创建 session(POST /sessions,working_dir 指向隔离工作区) + c. 提交 turn(POST /sessions/:id/turn,body = task.prompt) + d. 等待 turn 完成(轮询 SSE 或等待 TurnDone 事件) + e. 读取 JSONL trace + f. 运行失败诊断 + g. 与 expected_outcome 对比评分 + h. 收集结果 +3. 汇总所有任务结果,与基线对比 +4. 输出评测报告 +``` + +**并行策略**:同一 server 实例内通过不同 session 隔离(session 已绑定独立 working_dir),无需多实例。仅在评测集很大时才需要多实例并行。 + +### D6: 评测结果使用 JSON 格式持久化 + +**选择**:评测结果存储为 JSON 文件,按时间戳 + commit SHA 命名。 + +```json +{ + "commit": "895809c0", + "timestamp": "2026-04-20T10:00:00Z", + "task_set": "core", + "results": [ + { + "task_id": "file-edit-precision", + "status": "pass", + "score": 0.85, + "metrics": { "tool_calls": 3, "duration_ms": 4500, "tokens_used": 1200 }, + "failures": [] + } + ], + "summary": { "pass_rate": 0.9, "avg_score": 0.82 } +} +``` + +**基线对比**:指定一个历史结果文件作为 baseline,计算各指标的 diff。 + +## Risks / Trade-offs + +### [Risk] 评测任务集维护成本 +评测任务的质量直接决定评测体系的价值。任务集需要随框架迭代持续更新,否则会失效。 +**缓解**:初始聚焦 10-15 个核心任务,覆盖最高频的 Agent 行为模式。每个框架改动同步更新受影响的任务。 + +### [Risk] 规则诊断器的误报/漏报 +规则引擎可能对复杂场景产生误判。 +**缓解**:诊断器输出 `severity` + `confidence` 字段,支持配置检测阈值。初始阶段以高精确度优先(宁可漏报不要误报),逐步扩展模式库。 + +### [Risk] 评测运行器与 server 版本耦合 +运行器依赖 server HTTP API 的稳定性。如果 API 变动,运行器需要同步更新。 +**缓解**:运行器仅使用最稳定的 API surface(创建 session、提交 turn、读取事件),这些 API 变动频率低。同时协议类型复用 `protocol` crate。 + +### [Trade-off] 规则诊断 vs LLM 诊断 +规则引擎无法覆盖语义级错误(如"代码逻辑正确但不符合用户意图")。 +**接受**:Phase 1 的目标是框架行为诊断(工具效率、token 消耗、失败模式),语义诊断留给后续 LLM-as-Judge 迭代。两者正交,不存在架构冲突。 + +### [Trade-off] 文件系统隔离 vs 容器隔离 +文件系统 copy 无法隔离网络、进程等系统资源。 +**接受**:当前评测不涉及需要强隔离的场景(如执行不可信代码)。如果后续需要,可以引入容器化而不影响评测框架的上层抽象。 + +## 文件变更清单 + +### 新增文件 +``` +crates/eval/ +├── Cargo.toml +├── src/ +│ ├── lib.rs # crate 入口 +│ ├── trace/ +│ │ ├── mod.rs # TurnTrace 等核心类型 +│ │ └── extractor.rs # JSONL → TurnTrace 提取器 +│ ├── task/ +│ │ ├── mod.rs # EvalTask 规范类型 +│ │ └── loader.rs # YAML 任务加载器 +│ ├── diagnosis/ +│ │ ├── mod.rs # 诊断器 trait + 注册 +│ │ ├── tool_loop.rs # 工具循环检测 +│ │ ├── cascade_failure.rs # 级联失败检测 +│ │ ├── compact_loss.rs # compact 信息丢失检测 +│ │ ├── subrun_budget.rs # 子 Agent 预算超支检测 +│ │ └── empty_turn.rs # 空 turn 检测 +│ ├── runner/ +│ │ ├── mod.rs # 评测运行器 +│ │ ├── client.rs # server HTTP API 客户端 +│ │ ├── workspace.rs # 工作区准备与清理 +│ │ └── report.rs # 结果汇总与基线对比 +│ └── bin/ +│ └── eval_runner.rs # 独立 binary 入口 +eval-tasks/ +├── core/ +│ └── ... # 核心评测任务 YAML +└── task-set.yaml # 任务集索引 +``` + +### 修改文件 +``` +Cargo.toml # workspace members 加入 eval +``` + +### 不修改的文件 +- 不修改 `core`、`session-runtime`、`application`、`server` 中任何文件 +- 不修改 `StorageEvent` 或 `AgentEvent` 类型定义 +- 不修改前端代码 + +## 可观测性 + +- 评测运行器输出结构化日志(tracing),记录每个任务的执行进度 +- 评测结果 JSON 文件是唯一的评测 truth,不依赖内存状态 +- 诊断报告中的 `storage_seq` 范围允许精确回溯到原始 JSONL 事件 diff --git a/openspec/changes/eval-driven-framework-iteration/proposal.md b/openspec/changes/eval-driven-framework-iteration/proposal.md new file mode 100644 index 00000000..97a5a467 --- /dev/null +++ b/openspec/changes/eval-driven-framework-iteration/proposal.md @@ -0,0 +1,46 @@ +## Why + +Astrcode 目前拥有完善的事件溯源架构(`StorageEvent` JSONL 日志)和运行时 observability 管线,但缺乏一套**系统化的评测体系**来驱动框架自身的质量迭代。当前状态是: + +- 丰富的运行时事件数据(工具调用链、token 指标、compaction 效果、子 Agent 生命周期)已持久化但未被系统化分析 +- 框架改动(prompt 策略调整、compaction 算法变更、工具行为修改)缺乏量化回归手段,依赖人工试用感知 +- Agent 失败模式(工具循环、上下文丢失、规划偏差)无自动诊断能力,需要人工逐条翻阅 JSONL + +这意味着每次框架迭代都是"改了就跑跑看",无法做到**度量驱动的精准优化**。现在启动评测体系是因为事件层和 observability 层已足够成熟(`PromptMetrics`、`CompactApplied`、`AgentCollaborationFact` 等已稳定),具备了在零运行时改动的前提下启动离线评测的条件。 + +## What Changes + +- **引入评测 trace 模型**:定义从 `StorageEvent` JSONL 中提取的结构化评测数据模型(`EvalTurnTrace`),覆盖单 turn 内的工具调用链、token 消耗、compaction 事件、错误序列与时间线 +- **引入评测任务规范**:定义结构化的评测任务描述格式(YAML),包含任务输入、工作区快照、期望行为约束(工具序列、文件变更、步数上限)和评分规则 +- **引入失败模式诊断器**:基于事件模式的规则引擎,自动从 turn trace 中检测已知失败模式(工具循环、级联失败、上下文丢失、子 Agent 预算超支),生成结构化诊断报告 +- **引入评测运行器**:通过现有 server HTTP API 编排评测任务执行的独立 binary,支持并行运行、工作区隔离与结果汇总 +- **引入评测回归对比**:存储评测基线结果,支持版本间指标 diff,用于 CI 中自动检测质量退化 + +## Capabilities + +### New Capabilities + +- `eval-trace-model`: 从 StorageEvent JSONL 提取的结构化评测 trace 数据模型,包括 TurnTrace、ToolCallRecord、FailurePattern 等核心类型,以及 JSONL → trace 的提取器 +- `eval-task-spec`: 结构化评测任务规范定义,包括任务描述格式(YAML)、工作区快照管理、期望行为约束与评分规则 +- `eval-failure-diagnosis`: 基于规则引擎的失败模式自动诊断系统,从 turn trace 中检测工具循环、级联失败、compaction 信息丢失、子 Agent 预算超支等模式,输出结构化诊断报告 +- `eval-runner`: 评测任务编排与执行运行器,通过 server HTTP API 驱动任务执行,支持并行运行、工作区隔离、结果收集与基线对比 + +### Modified Capabilities + +- `runtime-observability-pipeline`: 需扩展以支持评测场景下的指标导出(将 live metrics 写入评测结果而非仅推送到 SSE/frontend),确保评测运行时的 observability 数据可被评测运行器收集 +- `agent-tool-evaluation`: 现有的 agent 协作评估记录应作为评测 trace 的输入源之一,需要在评测 trace 模型中建立与 collaboration facts 的关联 + +## Impact + +- **新增 crate**:`crates/eval` — 独立的评测 crate,包含 trace 模型、任务规范、诊断器和运行器。仅依赖 `core`(复用 `StorageEvent` 等类型)和 `protocol`(复用 HTTP DTO),不侵入现有运行时路径 +- **无运行时改动**:Phase 1 完全基于离线 JSONL 分析,不需要修改 `session-runtime`、`application` 或 `server` +- **CI 集成**:评测运行器作为独立 binary 在 CI 中调用,不影响现有构建流程 +- **前端影响**:Phase 1 无前端改动。后续可在现有 Debug Workbench 基础上扩展评测视图(不在本次 scope 内) + +## Non-Goals + +- **不在本次构建 LLM-as-Judge**:语义级别的评测(如"输出是否正确")需要额外 LLM 调用,留作后续迭代 +- **不在本次构建前端评测仪表板**:评测结果以 JSON/Markdown 报告输出,前端可视化留作后续迭代 +- **不在本次引入 container 化隔离**:工作区隔离通过文件系统 copy 实现,不需要 Docker/容器 +- **不在本次修改现有 StorageEvent 格式**:完全复用现有事件类型,不新增运行时事件 +- **不在本次做跨模型评测**:评测聚焦于框架行为(工具调用效率、token 消耗、失败模式),不做模型能力横向对比 diff --git a/openspec/changes/eval-driven-framework-iteration/specs/agent-tool-evaluation/spec.md b/openspec/changes/eval-driven-framework-iteration/specs/agent-tool-evaluation/spec.md new file mode 100644 index 00000000..82a87228 --- /dev/null +++ b/openspec/changes/eval-driven-framework-iteration/specs/agent-tool-evaluation/spec.md @@ -0,0 +1,35 @@ +## MODIFIED Requirements + +### Requirement: system MUST derive a stable effectiveness scorecard from raw facts + +系统 MUST 基于原始协作事实生成稳定的诊断读模型,用于判断 agent-tool 是否创造了实际协作价值。此外,协作评估的原始事实 MUST 可被评测 trace 提取器作为输入源,在评测场景中建立与 collaboration facts 的关联。 + +#### Scenario: scorecard is built + +- **WHEN** 系统为某轮 turn 或某段运行窗口生成效果读模型 +- **THEN** 读模型 MUST 能表达 child reuse、observe-to-action、spawn-to-delivery、orphan child 与 delivery latency 等核心指标 +- **AND** MUST 明确区分"没有数据"与"结果为零" + +#### Scenario: raw facts are incomplete + +- **WHEN** 某些协作事实来源尚未接线或不可用 +- **THEN** 读模型 MUST 显式反映该缺口 +- **AND** MUST NOT 静默把缺失数据伪装成有效低值 + +#### Scenario: 协作事实被评测 trace 提取器消费 + +- **WHEN** 评测 trace 提取器处理包含 `AgentCollaborationFact` 事件的 JSONL +- **THEN** 提取器 MUST 将协作事实纳入 `TurnTrace` 的协作信息中 +- **AND** 协作数据用于评估 agent delegation 的效果(如 spawn 成功率、delivery 延迟) + +## ADDED Requirements + +### Requirement: AgentCollaborationFact 事件 SHALL 在评测 trace 中可关联 + +`StorageEventPayload::AgentCollaborationFact` 中的协作事实 MUST 可在评测 trace 中与对应的工具调用、子 Agent 执行建立关联。 + +#### Scenario: 协作事实关联到子 Agent trace + +- **WHEN** turn 内既有 `SubRunStarted/Finished` 也有 `AgentCollaborationFact` +- **THEN** 评测 trace 提取器 MUST 通过 `sub_run_id` 建立两者的关联 +- **AND** 评测报告中子 Agent trace MUST 包含协作事实摘要 diff --git a/openspec/changes/eval-driven-framework-iteration/specs/eval-failure-diagnosis/spec.md b/openspec/changes/eval-driven-framework-iteration/specs/eval-failure-diagnosis/spec.md new file mode 100644 index 00000000..79f979f5 --- /dev/null +++ b/openspec/changes/eval-driven-framework-iteration/specs/eval-failure-diagnosis/spec.md @@ -0,0 +1,120 @@ +## Purpose + +定义基于规则引擎的失败模式自动诊断系统,从 TurnTrace 中检测已知失败模式并生成结构化诊断报告。 + +## ADDED Requirements + +### Requirement: 诊断器 SHALL 使用可扩展的 trait 接口 + +系统 MUST 定义 `FailurePatternDetector` trait,所有具体检测器实现该 trait,支持注册和组合使用。 + +#### Scenario: 注册并执行多个检测器 + +- **WHEN** 诊断引擎初始化时注册了 N 个检测器 +- **THEN** 对一个 `TurnTrace` 执行诊断时 MUST 依次调用所有检测器 +- **AND** 汇总所有检测器的输出为完整诊断报告 + +#### Scenario: 检测器输出结构化诊断实例 + +- **WHEN** 某个检测器在 trace 中发现了匹配的失败模式 +- **THEN** MUST 输出 `FailureInstance`,包含:模式名称、严重级别、置信度、涉及的 `storage_seq` 范围、结构化的上下文数据和人类可读的描述 +- **AND** `storage_seq` 范围 MUST 允许精确回溯到原始 JSONL 事件 + +### Requirement: 工具循环检测器 SHALL 识别重复工具调用 + +系统 MUST 提供 `ToolLoopDetector`,检测同一工具被重复调用且参数相似的情况。 + +#### Scenario: 检测到工具循环 + +- **WHEN** 一个 turn 内同一工具名称连续出现 ≥ 3 次 +- **AND** 相邻调用的参数相似度 > 配置的阈值 +- **THEN** 检测器 MUST 输出一个 `FailureInstance`,severity 为 `high` +- **AND** 上下文 MUST 包含重复调用的 `tool_call_id` 列表和参数对比 + +#### Scenario: 同名工具但参数差异大 + +- **WHEN** 同一工具名称连续出现 ≥ 3 次 +- **AND** 参数之间无显著相似性(如对不同文件的操作) +- **THEN** 检测器 MUST NOT 报告为循环 +- **AND** 该情况属于正常的多文件操作 + +### Requirement: 级联失败检测器 SHALL 识别连续工具失败 + +系统 MUST 提供 `CascadeFailureDetector`,检测连续多次工具调用失败的情况。 + +#### Scenario: 连续工具调用失败 + +- **WHEN** 一个 turn 内连续 ≥ 2 次 `ToolResult` 的 `success` 为 false +- **THEN** 检测器 MUST 输出 `FailureInstance`,severity 为 `high` +- **AND** 上下文 MUST 包含失败工具序列和各自的错误信息 + +#### Scenario: 工具失败后重试成功 + +- **WHEN** 某个工具调用失败后,后续调用同一工具成功 +- **THEN** 检测器 MUST NOT 报告为级联失败 +- **AND** 这是正常的重试恢复行为 + +### Requirement: Compact 信息丢失检测器 SHALL 识别压缩后的功能退化 + +系统 MUST 提供 `CompactInfoLossDetector`,检测上下文压缩后紧接着出现工具调用失败的情况。 + +#### Scenario: compact 后工具调用失败 + +- **WHEN** turn 内发生了 `CompactApplied` 事件 +- **AND** compact 之后出现了 `ToolResult` 失败,且失败原因暗示信息丢失(如"文件不存在"而文件实际存在) +- **THEN** 检测器 MUST 输出 `FailureInstance`,severity 为 `medium` +- **AND** 上下文 MUST 包含 compact 的 token 变化(pre/post)和后续失败的工具调用详情 + +#### Scenario: compact 后正常继续 + +- **WHEN** turn 内发生了 `CompactApplied` 事件,但后续所有工具调用成功 +- **THEN** 检测器 MUST NOT 报告 +- **AND** 这是健康的 compact 行为 + +### Requirement: 子 Agent 预算超支检测器 SHALL 识别子运行超限 + +系统 MUST 提供 `SubRunBudgetDetector`,检测子 Agent 执行超过预设步数限制的情况。 + +#### Scenario: 子 Agent 超过步数限制 + +- **WHEN** `SubRunFinished` 的 `step_count` 超过 `ResolvedExecutionLimitsSnapshot` 中的步数限制 +- **THEN** 检测器 MUST 输出 `FailureInstance`,severity 为 `medium` +- **AND** 上下文 MUST 包含实际步数与限制的对比 + +#### Scenario: 子 Agent 在限制内完成 + +- **WHEN** `SubRunFinished` 的 `step_count` 未超过限制 +- **THEN** 检测器 MUST NOT 报告 + +### Requirement: 空 turn 检测器 SHALL 识别无效 turn + +系统 MUST 提供 `EmptyTurnDetector`,检测 turn 结束但未产出任何有意义内容的情况。 + +#### Scenario: turn 无工具调用且输出为空 + +- **WHEN** turn 完成(有 `TurnDone` 事件) +- **AND** 无任何 `ToolCall` 事件 +- **AND** `AssistantFinal` 的 `content` 长度 < 配置的最小阈值 +- **THEN** 检测器 MUST 输出 `FailureInstance`,severity 为 `medium` + +#### Scenario: turn 仅有文本输出 + +- **WHEN** turn 无工具调用但 `AssistantFinal` 包含有意义的回复 +- **THEN** 检测器 MUST NOT 报告 +- **AND** 这是正常的纯对话行为 + +### Requirement: 诊断报告 SHALL 为结构化可持久化格式 + +系统 MUST 将诊断结果输出为可序列化的结构化报告。 + +#### Scenario: 生成诊断报告 + +- **WHEN** 对一组 `TurnTrace` 执行完整诊断 +- **THEN** 输出 `DiagnosisReport` MUST 包含:session 元数据、turn 级诊断结果列表、汇总统计(各模式出现次数、严重级别分布) +- **AND** 报告 MUST 可序列化为 JSON 格式 + +#### Scenario: 诊断报告支持可复现回溯 + +- **WHEN** 诊断报告中的某个 `FailureInstance` 引用了 `storage_seq` 范围 [100, 108] +- **THEN** 读者 MUST 能从原始 JSONL 文件中精确定位这些事件 +- **AND** 复现路径为:打开 JSONL → 定位 seq 范围 → 读取对应 `StorageEvent` diff --git a/openspec/changes/eval-driven-framework-iteration/specs/eval-runner/spec.md b/openspec/changes/eval-driven-framework-iteration/specs/eval-runner/spec.md new file mode 100644 index 00000000..f1933f9a --- /dev/null +++ b/openspec/changes/eval-driven-framework-iteration/specs/eval-runner/spec.md @@ -0,0 +1,110 @@ +## Purpose + +定义评测运行器,通过 server HTTP API 编排评测任务的执行、结果收集与基线对比。 + +## ADDED Requirements + +### Requirement: 评测运行器 SHALL 作为独立 binary 执行 + +系统 MUST 提供 `astrcode-eval-runner` 独立 binary,通过命令行参数控制评测执行。 + +#### Scenario: 执行指定任务集 + +- **WHEN** 运行 `astrcode-eval-runner --server-url http://localhost:3000 --task-set eval-tasks/core/` +- **THEN** 运行器 MUST 加载任务集内所有任务定义 +- **AND** 依次或并行执行每个任务 +- **AND** 输出评测结果到 stdout 或指定文件 + +#### Scenario: 指定 server 不可达 + +- **WHEN** 运行器无法连接到指定的 server URL +- **THEN** 运行器 MUST 在启动阶段报错并退出 +- **AND** 不执行任何任务 + +### Requirement: 运行器 SHALL 通过 server HTTP API 驱动任务执行 + +每个评测任务的执行 MUST 通过标准 server API 完成。 + +#### Scenario: 单任务执行流程 + +- **WHEN** 运行器开始执行一个评测任务 +- **THEN** MUST 按序执行:准备工作区 → 创建 session → 提交 turn → 等待完成 → 读取 trace → 诊断 → 评分 → 收集结果 +- **AND** 每一步的失败 MUST 记录到结果中,不中断其他任务的执行 + +#### Scenario: 创建 session 指向隔离工作区 + +- **WHEN** 运行器创建评测用 session +- **THEN** session 的 `working_dir` MUST 指向该任务的隔离工作区目录 +- **AND** 不同任务的 session MUST 使用不同的工作区 + +#### Scenario: 等待 turn 完成 + +- **WHEN** 运行器提交 turn 后等待完成 +- **THEN** MUST 通过轮询 SSE 事件流或读取 JSONL 文件检测 `TurnDone` 事件 +- **AND** MUST 设置超时(可配置,默认 5 分钟),超时后标记任务为 `timeout` + +### Requirement: 运行器 SHALL 支持工作区隔离与清理 + +评测任务的工作区 MUST 与其他任务隔离,并在评测结束后清理。 + +#### Scenario: 从 fixture 准备隔离工作区 + +- **WHEN** 任务定义指定了 `workspace.setup` 目录 +- **THEN** 运行器 MUST 将 fixture 目录完整复制到 `/tmp/astrcode-eval-{run_id}/{task_id}/` +- **AND** 复制后验证目标目录存在且文件完整 + +#### Scenario: 评测结束后清理 + +- **WHEN** 所有任务执行完毕且结果已持久化 +- **THEN** 运行器 MUST 删除所有隔离工作区目录 +- **AND** 当使用 `--keep-workspace` 参数时,SHALL 保留工作区并输出路径 + +### Requirement: 运行器 SHALL 支持基线对比 + +评测结果 MUST 支持与历史基线进行指标对比。 + +#### Scenario: 与指定基线对比 + +- **WHEN** 运行 `astrcode-eval-runner --baseline results/baseline-2026-04-15.json` +- **THEN** 运行器 MUST 在当前评测结果中附加与基线的 diff +- **AND** diff MUST 包含各任务的分数变化、指标变化(工具调用数、token 消耗、耗时) +- **AND** 分数下降超过阈值时 MUST 输出警告 + +#### Scenario: 基线文件不存在 + +- **WHEN** 指定的基线文件路径不存在 +- **THEN** 运行器 MUST 发出警告但继续执行 +- **AND** 评测结果中不包含对比数据 + +### Requirement: 运行器 SHALL 输出结构化评测报告 + +评测完成后 MUST 输出结构化的 JSON 报告。 + +#### Scenario: 生成评测报告 + +- **WHEN** 所有评测任务执行完毕 +- **THEN** 报告 MUST 包含:运行元数据(commit SHA、时间戳、任务集名称)、各任务结果(状态、分数、指标、失败诊断)、汇总统计(通过率、平均分数、各维度平均指标) +- **AND** 报告 MUST 可序列化为 JSON 并持久化到文件 + +#### Scenario: 报告中包含诊断信息 + +- **WHEN** 某个任务被失败诊断器检测到问题 +- **THEN** 报告中该任务的结果 MUST 包含完整的 `DiagnosisReport` +- **AND** 诊断信息与评测结果关联,支持后续分析 + +### Requirement: 运行器 SHALL 支持并行任务执行 + +运行器 MUST 支持同时执行多个评测任务以提高效率。 + +#### Scenario: 配置并发度 + +- **WHEN** 运行 `astrcode-eval-runner --concurrency 4` +- **THEN** 运行器 MUST 最多同时执行 4 个评测任务 +- **AND** 每个任务使用独立的 session,互不干扰 + +#### Scenario: 并行任务中某个失败 + +- **WHEN** 并行执行中某个任务失败 +- **THEN** 运行器 MUST 记录该任务的失败结果 +- **AND** 不影响其他正在执行的任务 +- **AND** 所有任务执行完毕后汇总全部结果 diff --git a/openspec/changes/eval-driven-framework-iteration/specs/eval-task-spec/spec.md b/openspec/changes/eval-driven-framework-iteration/specs/eval-task-spec/spec.md new file mode 100644 index 00000000..9bd0441d --- /dev/null +++ b/openspec/changes/eval-driven-framework-iteration/specs/eval-task-spec/spec.md @@ -0,0 +1,103 @@ +## Purpose + +定义结构化的评测任务规范,支持可重复、可对比的 Agent 行为评测执行。 + +## ADDED Requirements + +### Requirement: 评测任务 SHALL 使用 YAML 格式定义 + +系统 MUST 支持从 YAML 文件加载评测任务定义,每个文件描述一个独立的评测任务。 + +#### Scenario: 加载合法的任务定义文件 + +- **WHEN** 系统读取一个包含 `task_id`、`description`、`prompt`、`workspace`、`expected_outcome` 字段的 YAML 文件 +- **THEN** 系统 MUST 成功解析为 `EvalTask` 结构体 +- **AND** `task_id` MUST 为全局唯一的 kebab-case 标识符 + +#### Scenario: 任务定义缺少必要字段 + +- **WHEN** YAML 文件缺少 `task_id`、`prompt` 或 `expected_outcome` 中的任意字段 +- **THEN** 系统 MUST 返回明确的校验错误,指出缺失字段 +- **AND** 不执行该任务 + +### Requirement: 评测任务 SHALL 支持工作区快照管理 + +每个评测任务 MUST 能指定一个工作区快照,评测运行前从快照恢复工作区状态。 + +#### Scenario: 指定 fixture 目录作为工作区 + +- **WHEN** 任务定义中 `workspace.setup` 指向一个存在的 fixture 目录 +- **THEN** 评测运行器 MUST 在执行前将该目录复制到隔离的工作区路径 +- **AND** session 的 `working_dir` MUST 指向该隔离路径 + +#### Scenario: 任务不指定工作区 + +- **WHEN** 任务定义中 `workspace` 字段缺失 +- **THEN** 评测运行器 MUST 使用空目录作为工作区 +- **AND** 任务仍然正常执行(适用于纯对话评测场景) + +#### Scenario: 评测完成后工作区清理 + +- **WHEN** 评测任务执行完成且结果已收集 +- **THEN** 评测运行器 SHALL 清理隔离工作区目录 +- **AND** 如果保留工作区有助于调试,SHALL 支持通过 `--keep-workspace` 选项跳过清理 + +### Requirement: 期望行为约束 SHALL 支持多维度匹配 + +`expected_outcome` MUST 支持从工具调用序列、文件变更、步数限制和输出内容四个维度约束期望行为。 + +#### Scenario: 约束期望的工具调用序列 + +- **WHEN** `expected_outcome.tool_pattern` 指定了 `["Read", "Edit"]` +- **THEN** 评分器 MUST 检查实际工具调用序列是否与期望模式前缀匹配 +- **AND** 实际调用中包含期望模式之外的调用时,SHALL 扣分但不判定为失败 + +#### Scenario: 约束最大工具调用次数 + +- **WHEN** `expected_outcome.max_tool_calls` 指定为 5 +- **THEN** 评分器 MUST 检查实际工具调用总数是否 ≤ 5 +- **AND** 超过限制时该维度得分为 0 + +#### Scenario: 约束期望的文件变更 + +- **WHEN** `expected_outcome.file_changes` 指定了期望变更的文件路径和内容片段 +- **THEN** 评分器 MUST 检查隔离工作区中对应文件是否包含期望内容 +- **AND** 通过 `git diff --stat` 或文件内容匹配验证 + +#### Scenario: 约束最大 turn 数 + +- **WHEN** `expected_outcome.max_turns` 指定为 1 +- **THEN** 评分器 MUST 检查任务是否在 1 个 turn 内完成 +- **AND** 超过 turn 数限制时该维度得分为 0 + +### Requirement: 评分规则 SHALL 产生归一化综合分数 + +系统 MUST 将各维度的匹配结果综合为 0.0-1.0 的归一化分数。 + +#### Scenario: 所有必要维度全部满足 + +- **WHEN** 所有 `expected_outcome` 中的必要约束全部满足 +- **THEN** 综合分数 MUST 为 1.0 +- **AND** 任务状态为 `pass` + +#### Scenario: 部分维度未满足 + +- **WHEN** 部分维度未满足(如工具调用超出预期但文件变更正确) +- **THEN** 综合分数 MUST 按各维度权重加权计算 +- **AND** 任务状态为 `partial` + +#### Scenario: 关键维度未满足 + +- **WHEN** 任务的核心约束未满足(如文件变更不正确) +- **THEN** 综合分数 MUST 为 0.0 +- **AND** 任务状态为 `fail` + +### Requirement: 任务集 SHALL 通过索引文件组织 + +系统 MUST 支持通过 `task-set.yaml` 索引文件组织多个任务为一个任务集。 + +#### Scenario: 加载任务集索引 + +- **WHEN** 系统读取 `task-set.yaml`,其中引用了多个任务文件路径 +- **THEN** 系统 MUST 加载所有引用的任务定义 +- **AND** 跳过不存在或格式错误的任务并发出警告,不中断整体评测 diff --git a/openspec/changes/eval-driven-framework-iteration/specs/eval-trace-model/spec.md b/openspec/changes/eval-driven-framework-iteration/specs/eval-trace-model/spec.md new file mode 100644 index 00000000..3e3584f8 --- /dev/null +++ b/openspec/changes/eval-driven-framework-iteration/specs/eval-trace-model/spec.md @@ -0,0 +1,70 @@ +## Purpose + +定义评测 trace 数据模型,将 StorageEvent JSONL 事件流转化为结构化的 turn 级评测数据。 + +## ADDED Requirements + +### Requirement: TurnTrace SHALL 作为评测数据的核心单元 + +系统 MUST 定义 `TurnTrace` 结构体,包含单个 turn 内的所有评测相关信息:用户输入、工具调用序列、助手输出、prompt 指标、compaction 事件、错误事件和时间线。 + +#### Scenario: 从完整的 turn 事件序列提取 TurnTrace + +- **WHEN** 提取器接收到一个 turn 的所有 `StorageEvent`(从 `UserMessage` 到 `TurnDone`) +- **THEN** 输出 `TurnTrace` MUST 包含用户输入内容、按时间序排列的工具调用记录、助手最终输出、所有 `PromptMetrics` 快照和所有 `CompactApplied` 事件 +- **AND** 每个工具调用记录 MUST 包含工具名称、参数、输出、成功状态和持续时间(`duration_ms`) + +#### Scenario: 处理不完整 turn(无 TurnDone 事件) + +- **WHEN** 提取器遇到一组事件没有 `TurnDone` 结束标记(如 session 崩溃) +- **THEN** 提取器 MUST 仍然输出 `TurnTrace` +- **AND** 该 `TurnTrace` MUST 标记为 `incomplete: true` + +#### Scenario: turn 内包含子 Agent 执行 + +- **WHEN** turn 内存在 `SubRunStarted` 和 `SubRunFinished` 事件 +- **THEN** `TurnTrace` MUST 包含 `SubRunTrace`,记录子 Agent 的 step_count、estimated_tokens、执行结果和持续时间 +- **AND** 子 Agent 的 `child_session_id` MUST 被记录,支持后续递归提取子 session 的 trace + +### Requirement: TraceExtractor SHALL 从 JSONL 文件批量提取 TurnTrace + +系统 MUST 提供 `TraceExtractor`,接受 JSONL 文件路径,输出 `Vec<TurnTrace>`。 + +#### Scenario: 从单个 session JSONL 提取所有 turn trace + +- **WHEN** 对一个包含多个 turn 的 session JSONL 文件执行提取 +- **THEN** 提取器 MUST 返回与 turn 数量相同的 `TurnTrace` 条目 +- **AND** 每个 `TurnTrace` MUST 按事件时间序构建 + +#### Scenario: 处理包含 SessionStart 的事件流 + +- **WHEN** JSONL 文件以 `SessionStart` 事件开始 +- **THEN** 提取器 MUST 记录 session 元数据(session_id、working_dir、timestamp) +- **AND** `SessionStart` 不产生 `TurnTrace`,而是作为 `SessionTrace` 的 header + +#### Scenario: 处理跨 agent 谱系事件 + +- **WHEN** JSONL 中的事件携带 `AgentEventContext`(非空的 agent_id、parent_turn_id、sub_run_id) +- **THEN** 提取器 MUST 在 `TurnTrace` 中保留 agent 谱系信息 +- **AND** 支持 root agent 和 sub-run agent 的 trace 区分 + +### Requirement: ToolCallRecord SHALL 记录工具调用的完整生命周期 + +系统 MUST 定义 `ToolCallRecord`,从 `ToolCall` + `ToolCallDelta` + `ToolResult` 事件中构建完整的工具调用记录。 + +#### Scenario: 正常完成的工具调用 + +- **WHEN** 提取器遇到 `ToolCall` 事件,随后在同一 `tool_call_id` 上遇到 `ToolResult` 事件 +- **THEN** `ToolCallRecord` MUST 包含工具名称、参数、输出、成功状态、持续时间和流式输出增量(如果有) + +#### Scenario: 工具调用有流式输出 + +- **WHEN** 工具调用过程中产生了 `ToolCallDelta` 事件 +- **THEN** `ToolCallRecord` MUST 累积流式输出增量 +- **AND** 最终的 `ToolResult` 中的 `output` 为完整输出,不包含中间增量 + +#### Scenario: 工具调用结果被持久化引用替换 + +- **WHEN** 工具调用后产生了 `ToolResultReferenceApplied` 事件 +- **THEN** `ToolCallRecord` MUST 记录原始输出大小(`original_bytes`)和替换后的引用 +- **AND** 该信息用于评估大输出的处理效率 diff --git a/openspec/changes/eval-driven-framework-iteration/specs/runtime-observability-pipeline/spec.md b/openspec/changes/eval-driven-framework-iteration/specs/runtime-observability-pipeline/spec.md new file mode 100644 index 00000000..392e482e --- /dev/null +++ b/openspec/changes/eval-driven-framework-iteration/specs/runtime-observability-pipeline/spec.md @@ -0,0 +1,47 @@ +## MODIFIED Requirements + +### Requirement: Runtime observability SHALL cover read and execution paths + +系统 MUST 同时采集读路径与执行路径的关键指标,包括 session rehydrate、SSE catch-up、turn execution、subrun execution、delivery diagnostics 以及 agent collaboration diagnostics。此外,observability 管线 MUST 支持评测场景下的指标导出,将 turn 级指标写入评测结果而非仅推送到 SSE/frontend。 + +#### Scenario: Read path metrics are recorded + +- **WHEN** 系统执行 session 重水合或 SSE 回放 +- **THEN** 对应 observability 指标 SHALL 被记录 + +#### Scenario: Execution path metrics are recorded + +- **WHEN** 系统执行 turn、subrun、delivery 或 agent collaboration 相关流程 +- **THEN** 对应 observability 指标 SHALL 被记录 +- **AND** 失败路径同样 SHALL 被统计 + +#### Scenario: Collaboration diagnostics are exposed + +- **WHEN** 上层读取治理快照或等价 observability 读模型 +- **THEN** 返回结果 SHALL 包含 agent collaboration 诊断 +- **AND** 该诊断 SHALL 能区分 spawn、send、observe、close、delivery 与拒绝/失败路径 + +#### Scenario: 评测运行时指标可被评测运行器收集 + +- **WHEN** 评测运行器通过 server API 执行评测任务 +- **THEN** 运行器 SHALL 能通过读取 JSONL 事件获取所有 turn 级 observability 数据 +- **AND** 不需要额外的 API 端点或导出机制 +- **AND** 评测 trace 提取器从 `PromptMetrics`、`CompactApplied` 等已有事件中提取所需指标 + +## ADDED Requirements + +### Requirement: observability 指标 SHALL 在 JSONL 中保持完整可提取性 + +运行时写入的所有 observability 相关事件(`PromptMetrics`、`CompactApplied`、`SubRunStarted/Finished`)MUST 在 JSONL 中保持完整的字段信息,确保离线评测可以无损提取。 + +#### Scenario: PromptMetrics 包含完整 provider 指标 + +- **WHEN** provider 返回 token 使用统计和 cache 命中数据 +- **THEN** `PromptMetrics` 事件 MUST 在 JSONL 中持久化所有 `PromptMetricsPayload` 字段 +- **AND** 离线评测读取时 MUST 能无损恢复这些数据 + +#### Scenario: CompactApplied 包含完整的压缩效果数据 + +- **WHEN** 发生上下文压缩 +- **THEN** `CompactApplied` 事件 MUST 持久化 `pre_tokens`、`post_tokens_estimate`、`messages_removed`、`tokens_freed` 字段 +- **AND** 这些字段是评测 compaction 效率的 ground truth From 57ed386b577b1f987ab27cafa2fa9aa7a8567eb6 Mon Sep 17 00:00:00 2001 From: whatevertogo <whatevertogo@users.noreply.github.com> Date: Mon, 20 Apr 2026 03:11:51 +0800 Subject: [PATCH 51/53] feat: Enhance async tool execution and evaluation framework - Updated async-tool-execution spec to clarify background execution requirements and scenarios. - Expanded tasks documentation to include new requirements for background task notifications and terminal session structures. - Introduced a new evaluation framework design, distinguishing control and data paths for task evaluation. - Defined new evaluation trace models and extraction logic for structured evaluation data from JSONL logs. - Implemented requirements for execution task tracking, ensuring task independence from session plans and proper ownership management. - Enhanced terminal chat read model to expose authoritative active-task panel facts directly from the server. --- AGENTS.md | 15 --- .../.openspec.yaml | 0 .../design.md | 0 .../proposal.md | 0 .../specs/application-use-cases/spec.md | 0 .../specs/execution-task-tracking/spec.md | 0 .../specs/terminal-chat-read-model/spec.md | 0 .../tasks.md | 0 .../async-shell-terminal-sessions/design.md | 21 ++-- .../async-shell-terminal-sessions/proposal.md | 6 +- .../specs/async-tool-execution/spec.md | 20 ++- .../async-shell-terminal-sessions/tasks.md | 12 +- .../eval-driven-framework-iteration/design.md | 40 +++--- .../proposal.md | 8 +- .../specs/eval-runner/spec.md | 14 ++- .../specs/eval-task-spec/spec.md | 2 +- .../specs/eval-trace-model/spec.md | 21 ++-- .../runtime-observability-pipeline/spec.md | 2 +- .../eval-driven-framework-iteration/tasks.md | 36 ++++++ openspec/specs/application-use-cases/spec.md | 22 ++++ .../specs/execution-task-tracking/spec.md | 117 ++++++++++++++++++ .../specs/terminal-chat-read-model/spec.md | 29 +++++ 22 files changed, 302 insertions(+), 63 deletions(-) rename openspec/changes/{add-task-write-system => archive/2026-04-20-add-task-write-system}/.openspec.yaml (100%) rename openspec/changes/{add-task-write-system => archive/2026-04-20-add-task-write-system}/design.md (100%) rename openspec/changes/{add-task-write-system => archive/2026-04-20-add-task-write-system}/proposal.md (100%) rename openspec/changes/{add-task-write-system => archive/2026-04-20-add-task-write-system}/specs/application-use-cases/spec.md (100%) rename openspec/changes/{add-task-write-system => archive/2026-04-20-add-task-write-system}/specs/execution-task-tracking/spec.md (100%) rename openspec/changes/{add-task-write-system => archive/2026-04-20-add-task-write-system}/specs/terminal-chat-read-model/spec.md (100%) rename openspec/changes/{add-task-write-system => archive/2026-04-20-add-task-write-system}/tasks.md (100%) create mode 100644 openspec/changes/eval-driven-framework-iteration/tasks.md create mode 100644 openspec/specs/execution-task-tracking/spec.md diff --git a/AGENTS.md b/AGENTS.md index a57e8f8c..e1e9ac93 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -48,21 +48,6 @@ node scripts/check-crate-boundaries.mjs --strict # 严格模式 - 不需要向后兼容,优先良好架构,期望最佳实践而不是打补丁 - Git 提交信息使用 emoji + type + scope 风格(如 `✨ feat(module): brief description`) -## 提交前验证 - -改了后端rust代码每次提交前按顺序执行: - -1. `cargo fmt --all` — 格式化代码 -2. `cargo clippy --all-targets --all-features -- -D warnings` — 修复所有警告 -3. `cargo test --workspace` — 确保所有测试通过 -4. 确认变更内容后写出描述性提交信息 - -改了前端代码每次提交前按顺序执行: -1. `npm run format` — 格式化代码 -2. `npm run lint` — 修复所有 lint 错误 -3. `npm run typecheck` — 确保没有类型错误 -4. `npm run format:check` — 确保格式正确 - ## Gotchas - 文档必须使用中文 diff --git a/openspec/changes/add-task-write-system/.openspec.yaml b/openspec/changes/archive/2026-04-20-add-task-write-system/.openspec.yaml similarity index 100% rename from openspec/changes/add-task-write-system/.openspec.yaml rename to openspec/changes/archive/2026-04-20-add-task-write-system/.openspec.yaml diff --git a/openspec/changes/add-task-write-system/design.md b/openspec/changes/archive/2026-04-20-add-task-write-system/design.md similarity index 100% rename from openspec/changes/add-task-write-system/design.md rename to openspec/changes/archive/2026-04-20-add-task-write-system/design.md diff --git a/openspec/changes/add-task-write-system/proposal.md b/openspec/changes/archive/2026-04-20-add-task-write-system/proposal.md similarity index 100% rename from openspec/changes/add-task-write-system/proposal.md rename to openspec/changes/archive/2026-04-20-add-task-write-system/proposal.md diff --git a/openspec/changes/add-task-write-system/specs/application-use-cases/spec.md b/openspec/changes/archive/2026-04-20-add-task-write-system/specs/application-use-cases/spec.md similarity index 100% rename from openspec/changes/add-task-write-system/specs/application-use-cases/spec.md rename to openspec/changes/archive/2026-04-20-add-task-write-system/specs/application-use-cases/spec.md diff --git a/openspec/changes/add-task-write-system/specs/execution-task-tracking/spec.md b/openspec/changes/archive/2026-04-20-add-task-write-system/specs/execution-task-tracking/spec.md similarity index 100% rename from openspec/changes/add-task-write-system/specs/execution-task-tracking/spec.md rename to openspec/changes/archive/2026-04-20-add-task-write-system/specs/execution-task-tracking/spec.md diff --git a/openspec/changes/add-task-write-system/specs/terminal-chat-read-model/spec.md b/openspec/changes/archive/2026-04-20-add-task-write-system/specs/terminal-chat-read-model/spec.md similarity index 100% rename from openspec/changes/add-task-write-system/specs/terminal-chat-read-model/spec.md rename to openspec/changes/archive/2026-04-20-add-task-write-system/specs/terminal-chat-read-model/spec.md diff --git a/openspec/changes/add-task-write-system/tasks.md b/openspec/changes/archive/2026-04-20-add-task-write-system/tasks.md similarity index 100% rename from openspec/changes/add-task-write-system/tasks.md rename to openspec/changes/archive/2026-04-20-add-task-write-system/tasks.md diff --git a/openspec/changes/async-shell-terminal-sessions/design.md b/openspec/changes/async-shell-terminal-sessions/design.md index a3b4f7bc..e99d7117 100644 --- a/openspec/changes/async-shell-terminal-sessions/design.md +++ b/openspec/changes/async-shell-terminal-sessions/design.md @@ -52,26 +52,32 @@ 决策: - 在 `crates/application` 增加 `ProcessSupervisor`,作为全局用例层基础设施。 +- `core` 提供稳定进程监管端口(例如 `ProcessSupervisorPort`),并通过 `ToolContext` / `CapabilityContext` 向工具执行链路注入。 - 它下辖两个子域: - `AsyncTaskRegistry`:一次性后台命令 - `TerminalSessionRegistry`:持久终端会话 -- `server` 只在组合根装配;`session-runtime` 通过稳定端口与 supervisor 通信,不直接持有底层 PTY/进程实现。 +- `application` 实现该端口并编排 supervisor;`server/bootstrap` 只负责绑定具体 PTY / process driver。 +- `session-runtime` 与 `adapter-tools` 都通过稳定端口与 supervisor 通信,不直接持有底层 PTY/进程实现,也不反向依赖 `application` concrete type。 原因: - 进程监管是跨 session 的 live control 基础设施,适合位于 `application`,不应把 PTY/进程实现泄漏到 `session-runtime`。 +- `adapter-tools` 只能依赖 `core`,必须通过稳定端口访问后台任务/终端会话控制面,不能直接引用 `application` 或自行长出第二套子进程真相。 - `session-runtime` 仍然只负责单会话真相、等待点与恢复,不负责平台进程细节。 备选方案与否决: - 方案 A:把后台任务 registry 放进 `session-runtime`。否决,因为这会让 `session-runtime` 直接承担跨平台进程实现和全局 live handle 管理。 - 方案 B:把进程真相放到前端或 server handler。否决,因为这违反“Server is the truth”和组合根边界。 +- 方案 C:让 `adapter-tools` 直接持有 `application` 或 PTY concrete handle。否决,因为这会打破 `adapter-tools -> core` 的依赖边界,并把 live process 真相散落到工具层。 ### 3. 保留 `shell` 为一次性命令工具,新增 Codex 风格的持久执行工具族 决策: - 现有 `shell` 保持“一次性命令”语义,只增加 `executionMode: auto|foreground|background`。 +- `executionMode=auto` 的最终判定由统一策略解析,而不是由 `shell` 工具根据命令字符串做隐式猜测。 +- 立即返回结果与后台任务 started 事实都必须暴露 `requestedExecutionMode`、`resolvedExecutionMode` 与判定原因,保证前端、排障与回放能看到一致语义。 - 新增工具族: - `exec_command` - `write_stdin` @@ -82,6 +88,7 @@ 原因: - 一次性命令与持久终端会话的生命周期不同。 +- `auto` 若落在工具本地启发式,会让同一能力在前端展示、durable 事件与重放时出现语义漂移。 - Codex 的成熟方案不是 `terminal_read(cursor)`,而是 `exec_command + process_id + write_stdin + 流式事件`。 - 输出主通道应由 durable/live 事件流持续推送,工具返回只负责本次等待窗口内的输出快照与 `process_id`。 - 这样可以避免额外造一套 cursor 读取协议,同时保留持久 session 和 stdin 控制能力。 @@ -90,6 +97,7 @@ - 方案 A:扩展 `shell`,让同一工具同时承担一次性命令和终端会话。否决,因为 tool call 级语义无法干净表达跨多次交互的终端 session。 - 方案 B:采用 `terminal_start / terminal_write / terminal_read`。否决,因为这会复制一套读取协议,而 Codex 已证明 `write_stdin + 输出事件 + process_id` 更自然。 +- 方案 C:把 `auto` 判定散落到工具实现。否决,因为这会绕开 `application` 的统一策略与可观测性边界。 ### 4. 后台任务与终端会话都使用独立 durable 事件,而不是复用单个 ToolCallDelta @@ -167,11 +175,11 @@ ## Migration Plan -1. 在 `core`、`protocol`、`session-runtime` 中增加后台任务通知 / terminal session 纯数据结构。 -2. 在 `application` 组装 `ProcessSupervisor`,先接入一次性后台 shell。 -3. 改造 `shell` 为可选择 foreground/background,并打通任务输出落盘与完成通知。 -4. 新增 `exec_command` / `write_stdin` 工具族与 PTY/pipe 实现,接入 supervisor。 -5. 扩展 conversation/query/frontend 渲染 background task 与 terminal session。 +1. 在 `core`、`protocol`、`session-runtime` 中增加后台任务通知 / terminal session 纯数据结构,以及工具访问 `ProcessSupervisor` 的稳定端口与 context 注入。 +2. 在 `application` 组装 `ProcessSupervisor`,由组合根绑定具体 PTY / process driver,先接入一次性后台 shell。 +3. 改造 `shell` 为可选择 foreground/background,并让 `auto` 通过统一策略解析、落盘可审计的 resolved mode 元数据。 +4. 新增 `exec_command` / `write_stdin` 工具族与 PTY/pipe 实现,通过同一 supervisor 端口接入。 +5. 扩展 conversation/query/server/frontend 渲染 background task 与 terminal session。 6. 同步更新 `PROJECT_ARCHITECTURE.md`,记录新的职责边界。 回滚策略: @@ -183,6 +191,5 @@ ## Open Questions - 一期是否需要同时暴露 `resize_terminal` 与 `close_stdin`,还是先只开放 `write_stdin` / `terminate_terminal`? -- 后台 shell 的 `auto` 判定阈值应基于超时、命令类型,还是工具显式标记? - 后台任务完成后默认只通知用户,还是同时生成一条内部输入唤醒模型继续决策? - `ProcessSupervisor` 的可观测性指标是否需要单独纳入 `runtime-observability-pipeline` 的 spec 更新? diff --git a/openspec/changes/async-shell-terminal-sessions/proposal.md b/openspec/changes/async-shell-terminal-sessions/proposal.md index afbd538e..4151b539 100644 --- a/openspec/changes/async-shell-terminal-sessions/proposal.md +++ b/openspec/changes/async-shell-terminal-sessions/proposal.md @@ -8,6 +8,7 @@ - 引入 Claude Code 风格的后台任务执行语义:长耗时工具不再阻塞当前 turn,而是立即返回 `backgroundTaskId`、输出路径和状态摘要,后续通过完成通知与显式读取获取结果。 - 为长任务建立统一的进程监管面,区分“一次性后台命令”和“持久终端会话”,统一处理生命周期、取消、失败、输出落盘与通知。 +- 为后台 shell 与持久终端会话定义稳定跨层接缝:`core` 提供进程监管端口,`application` 负责 supervisor 编排,具体 PTY / process driver 保持在 adapter/组合根一侧。 - 保留现有 `shell` 作为一次性命令工具,并增加 `background` 执行模式;同时新增 Codex 风格的持久执行工具族,使 LLM 可以启动带 `process_id` 的终端会话、写入 stdin、在有限等待窗口内拿到新输出并终止或关闭会话。 - 扩展事件模型、conversation authoritative read model 与前端展示,使“后台任务已启动/已完成/已失败”和“终端会话输出/状态变化”成为正式合同,而不是前端本地猜测。 - 明确后台任务和终端会话的恢复/失败语义:Astrcode 重启后不得静默丢失状态,必须向用户和模型暴露明确的 lost / failed 结果。 @@ -25,16 +26,17 @@ ## Impact - 受影响模块: - - `crates/core`:工具结果类型、后台任务/终端事件与端口契约 + - `crates/core`:工具结果类型、后台任务/终端事件、进程监管端口与 capability/tool context 契约 - `crates/session-runtime`:完成通知输入、conversation/query 投影、终端会话读取路径 - `crates/application`:后台任务/终端会话监管与用例编排 - `crates/adapter-tools`:`shell` 扩展与新终端工具族 - - `crates/protocol` 与 `frontend`:waiting / terminal session DTO 与渲染 + - `crates/protocol`、`crates/server` 与 `frontend`:background task / terminal session DTO、projection 与渲染 - 用户可见影响: - 长工具调用不再卡死会话,而是变成后台任务并在完成时收到通知 - LLM 可以通过正式工具直接操控持久终端会话,并基于 `process_id` 持续交互 - 开发者可见影响: - 工具执行从“同步完成即返回”演进为“foreground / background / persistent-exec-session”多模式合同 + - `executionMode=auto` 的解析不再散落在工具实现里,而是由统一策略决定并以 `requestedExecutionMode` / `resolvedExecutionMode` 暴露 - 事件模型需要新增后台任务通知与终端会话语义,避免继续把跨多次交互的输出硬塞进单个 `tool_call_id` - 依赖与系统影响: - 可能需要引入跨平台 PTY 支撑(例如 `portable-pty` 或等价方案) diff --git a/openspec/changes/async-shell-terminal-sessions/specs/async-tool-execution/spec.md b/openspec/changes/async-shell-terminal-sessions/specs/async-tool-execution/spec.md index 92b53293..84d6fc1d 100644 --- a/openspec/changes/async-shell-terminal-sessions/specs/async-tool-execution/spec.md +++ b/openspec/changes/async-shell-terminal-sessions/specs/async-tool-execution/spec.md @@ -10,10 +10,28 @@ - **AND** 当前 turn MUST 正常结束,而不是进入挂起状态 #### Scenario: 自动策略判定转入后台执行 -- **WHEN** 工具执行达到后台化策略阈值,且该工具声明允许 deferred 执行 +- **WHEN** 工具执行达到后台化策略阈值,且该工具声明允许后台化执行 - **THEN** 系统 MUST 将该调用转换为后台任务 - **AND** MUST 返回可读取输出的稳定路径或等价句柄 +### Requirement: 后台执行接入 SHALL 通过稳定进程监管端口 +支持后台任务或持久终端会话的工具 MUST 通过 `core` 定义的稳定进程监管端口接入运行时控制面;`adapter-tools` MUST NOT 直接依赖 `application` concrete type,也 MUST NOT 自行维护未受监管的后台子进程真相。 + +#### Scenario: shell 通过注入端口启动后台任务 +- **WHEN** builtin `shell` 需要切换到后台执行 +- **THEN** 它 MUST 通过 `ToolContext` / `CapabilityContext` 注入的进程监管端口注册任务 +- **AND** `application` MUST 作为该端口的实现拥有 live handle 真相 +- **AND** 具体 PTY / process driver MUST 仍由组合根在 adapter 边界绑定 + +### Requirement: 自动后台化判定 SHALL 可审计且跨层一致 +`executionMode=auto` 的解析 MUST 由统一策略完成,而不是散落在工具实现内;即时 tool result 与 durable 事件 MUST 暴露请求模式、最终模式与判定原因,保证前端展示、排障与回放看到同一语义。 + +#### Scenario: auto 模式被解析为 background +- **WHEN** 模型调用 `shell` 且参数声明 `executionMode=auto` +- **THEN** 系统 MUST 记录 `requestedExecutionMode=auto` +- **AND** MUST 在即时结果与后续 started 事实中暴露 `resolvedExecutionMode` +- **AND** 若最终进入后台,MUST 追加可读的判定原因或策略来源 + ### Requirement: 后台工具输出 SHALL 进入稳定输出存储并可被显式读取 后台工具在执行期间产生的 stdout / stderr MUST 持续写入稳定输出存储,并允许后续通过输出路径或正式读取能力获取。 diff --git a/openspec/changes/async-shell-terminal-sessions/tasks.md b/openspec/changes/async-shell-terminal-sessions/tasks.md index 216de10d..e66a5735 100644 --- a/openspec/changes/async-shell-terminal-sessions/tasks.md +++ b/openspec/changes/async-shell-terminal-sessions/tasks.md @@ -1,19 +1,19 @@ ## 1. 核心类型与事件合同 -- [ ] 1.1 在 `crates/core/src/action.rs`、`crates/core/src/event/domain.rs`、`crates/core/src/event/types.rs`、`crates/protocol/src/http/conversation/v1.rs` 引入后台任务通知、terminal session 纯数据结构与事件定义;验证:`cargo check --workspace` -- [ ] 1.2 扩展 `crates/session-runtime/src/query/conversation.rs` 与前端类型 `frontend/src/types.ts`,支持 background task notification block 和 terminal session block/patch;验证:`cargo test -p astrcode-session-runtime query::conversation` 与 `cd frontend && npm run typecheck` +- [ ] 1.1 在 `crates/core/src/action.rs`、`crates/core/src/event/domain.rs`、`crates/core/src/event/types.rs`、`crates/core/src/tool.rs`、`crates/core/src/registry/router.rs` 与 `crates/protocol/src/http/conversation/v1.rs` 引入后台任务通知、terminal session 纯数据结构、进程监管端口,以及 capability/tool context 注入字段;验证:`cargo check --workspace` +- [ ] 1.2 扩展 `crates/session-runtime/src/query/conversation.rs`、`crates/application/src/terminal/`、`crates/server/src/http/terminal_projection.rs` 与前端类型 `frontend/src/types.ts`,支持 background task notification block 和 terminal session block/patch;验证:`cargo test -p astrcode-session-runtime query::conversation`、`cargo test -p astrcode-application terminal_queries` 与 `cd frontend && npm run typecheck` - [ ] 1.3 更新 `PROJECT_ARCHITECTURE.md`,补充后台进程监管与 terminal session 的职责边界;验证:人工检查文档与本变更 design 一致 ## 2. Claude 风格后台 shell - [ ] 2.1 在 `crates/session-runtime` 中接入后台任务 started/completed/failed durable 事件与内部完成通知输入,不引入 suspended turn;验证:新增 `session-runtime` 单测覆盖后台任务完成通知 -> 新 turn 唤醒主路径 -- [ ] 2.2 在 `crates/application/src/lifecycle/` 或相邻新模块实现 `ProcessSupervisor`/`AsyncTaskRegistry`,提供后台命令注册、完成通知、取消与 lost 终态上报;验证:`cargo test -p astrcode-application` -- [ ] 2.3 改造 `crates/adapter-tools/src/builtin_tools/shell.rs`,支持 `executionMode=auto|foreground|background`,返回 `backgroundTaskId` 与输出路径,并接入后台 shell 主路径;验证:新增 shell 工具集成测试,覆盖 foreground、background、cancel 三条路径 +- [ ] 2.2 在 `crates/application/src/lifecycle/` 或相邻新模块实现 `ProcessSupervisor`/`AsyncTaskRegistry`,作为 core 进程监管端口的实现提供后台命令注册、完成通知、取消与 lost 终态上报,并由组合根绑定具体 process driver;验证:`cargo test -p astrcode-application` +- [ ] 2.3 改造 `crates/adapter-tools/src/builtin_tools/shell.rs` 与相关 tool bridge,支持 `executionMode=auto|foreground|background`,通过注入端口接入后台 shell 主路径,并返回 `backgroundTaskId`、输出路径、`requestedExecutionMode` / `resolvedExecutionMode` 与判定原因;验证:新增 shell 工具集成测试,覆盖 foreground、background、auto、cancel 四条路径 ## 3. 持久终端会话工具族 -- [ ] 3.1 新增持久执行工具模块,例如 `crates/adapter-tools/src/builtin_tools/exec_command.rs`、`write_stdin.rs`、`resize_terminal.rs`、`terminate_terminal.rs`、`close_stdin.rs`,定义参数与返回合同;验证:`cargo test -p astrcode-adapter-tools exec_command` -- [ ] 3.2 在 `crates/application` 与对应 adapter 层实现 PTY/pipe 驱动的 `TerminalSessionRegistry`,采用 `process_id` 持有活跃会话,支持 stdin 写入、stdout/stderr 流、退出码、关闭与 lost 语义;验证:新增跨平台可运行的单元/集成测试,至少覆盖启动、输入、退出、关闭 +- [ ] 3.1 新增持久执行工具模块,例如 `crates/adapter-tools/src/builtin_tools/exec_command.rs`、`write_stdin.rs`、`resize_terminal.rs`、`terminate_terminal.rs`、`close_stdin.rs`,定义参数与返回合同,并统一通过注入的进程监管端口访问 live session;验证:`cargo test -p astrcode-adapter-tools exec_command` +- [ ] 3.2 在 `crates/application` 与对应 adapter 层实现 PTY/pipe 驱动的 `TerminalSessionRegistry`,采用 `process_id` 持有活跃会话,支持 stdin 写入、stdout/stderr 流、退出码、关闭与 lost 语义;server/bootstrap MUST 负责绑定具体 driver,不允许 `adapter-tools` 自行持有 PTY concrete type;验证:新增跨平台可运行的单元/集成测试,至少覆盖启动、输入、退出、关闭 - [ ] 3.3 在 `crates/session-runtime` 接入 terminal session durable 事件、hydration 投影与 `process_id` 关联语义,输出主路径走 begin/delta/end 事件与 terminal interaction 记录;验证:新增 query/replay 测试覆盖 terminal session block、交互记录和长期运行会话主路径 ## 4. 前端展示与验收 diff --git a/openspec/changes/eval-driven-framework-iteration/design.md b/openspec/changes/eval-driven-framework-iteration/design.md index 06c321b6..8b31f79a 100644 --- a/openspec/changes/eval-driven-framework-iteration/design.md +++ b/openspec/changes/eval-driven-framework-iteration/design.md @@ -4,6 +4,13 @@ Astrcode 的 `StorageEvent` JSONL 事件日志已经完整记录了 Agent 运行 当前缺失的是:**离线评测层** — 将已完成 session 的事件流转化为可度量、可对比、可诊断的结构化评测数据,用于驱动框架迭代。 +本次设计明确区分两条通路: + +- **控制面**:通过现有 `astrcode-server` HTTP API 创建 session、提交 turn +- **数据面**:通过本地共享 session 存储中的 JSONL 读取 durable 事件并提取 trace + +因此本次评测运行器的适用场景是本机开发与 CI 共置部署;不覆盖仅能访问远端 HTTP API、但无法访问对应 session 存储目录的纯远程部署。 + 核心数据流: ``` @@ -64,7 +71,7 @@ StorageEvent JSONL ### D2: Trace 提取基于 JSONL 文件直读,而非 server API replay -**选择**:评测 trace 提取器直接读取 JSONL 文件,通过 serde 反序列化为 `StorageEvent`,再转换为 `TurnTrace`。 +**选择**:评测 trace 提取器直接读取 JSONL 文件,通过 serde 反序列化为 `StorageEvent`,再转换为 `SessionTrace`(内含 `Vec<TurnTrace>`)。 **替代方案**:通过 server `/sessions/:id/events` API 获取事件。 **否决原因**: @@ -75,7 +82,8 @@ StorageEvent JSONL **数据流**: ``` 文件路径 → 逐行读取 → serde_json::from_str::<StorageEvent>() - → TurnTraceBuilder 累积 → 输出 Vec<TurnTrace> + → TurnTraceBuilder / SessionTraceBuilder 累积 + → 输出 SessionTrace { metadata, turns, lineage } ``` ### D3: 评测任务规范使用 YAML 格式 @@ -131,29 +139,31 @@ trait FailurePatternDetector: Send + Sync { | `CompactInfoLossDetector` | `CompactApplied` + 后续 `Error` | compact 后紧接着工具调用失败 | | `SubRunBudgetDetector` | `SubRunStarted/Finished` | step_count 超过 resolved_limits 阈值 | | `EmptyTurnDetector` | `TurnTrace` 整体 | turn 结束但无工具调用且 assistant output 为空 | -| `ContextOverflowDetector` | `PromptMetrics` + `CompactApplied` | 多次 compact 仍无法压回有效窗口 | -### D5: 评测运行器通过 server HTTP API 驱动 +### D5: 评测运行器通过 HTTP 控制面 + 本地 JSONL 数据面驱动 + +**选择**:评测运行器是一个独立 binary(`astrcode-eval-runner`),通过 HTTP API 与 `astrcode-server` 交互完成 session/turn 生命周期控制,通过共享 session 存储根目录读取 durable JSONL。 -**选择**:评测运行器是一个独立 binary(`astrcode-eval-runner`),通过 HTTP API 与 `astrcode-server` 交互。 +**约束**:`--server-url` 只负责控制面;运行器还必须能够解析并访问对应的本地 session 存储根目录(CLI 参数 `--session-storage-root`,默认使用标准项目级 session 存储规则)。 **执行流程**: ``` -1. 启动 N 个 server 实例(或复用一个实例的不同 session) +1. 连接一个现有 server 实例 2. 每个评测任务: a. 准备工作区:cp -r fixtures/<task> → /tmp/eval-{id}/ b. 创建 session(POST /sessions,working_dir 指向隔离工作区) c. 提交 turn(POST /sessions/:id/turn,body = task.prompt) - d. 等待 turn 完成(轮询 SSE 或等待 TurnDone 事件) - e. 读取 JSONL trace - f. 运行失败诊断 - g. 与 expected_outcome 对比评分 - h. 收集结果 + d. 基于 `session_id` + `session_storage_root` 定位本地 JSONL 文件 + e. 轮询 JSONL,等待 `TurnDone` durable 事件 + f. 读取 JSONL trace 并构建 `SessionTrace` + g. 运行失败诊断 + h. 与 expected_outcome 对比评分 + i. 收集结果 3. 汇总所有任务结果,与基线对比 4. 输出评测报告 ``` -**并行策略**:同一 server 实例内通过不同 session 隔离(session 已绑定独立 working_dir),无需多实例。仅在评测集很大时才需要多实例并行。 +**并行策略**:同一 server 实例内通过不同 session 隔离(session 已绑定独立 working_dir),无需多实例。若无法访问共享 session 存储,则运行器应在启动阶段直接失败,而不是退化为不稳定的 SSE 轮询。 ### D6: 评测结果使用 JSON 格式持久化 @@ -189,9 +199,9 @@ trait FailurePatternDetector: Send + Sync { 规则引擎可能对复杂场景产生误判。 **缓解**:诊断器输出 `severity` + `confidence` 字段,支持配置检测阈值。初始阶段以高精确度优先(宁可漏报不要误报),逐步扩展模式库。 -### [Risk] 评测运行器与 server 版本耦合 -运行器依赖 server HTTP API 的稳定性。如果 API 变动,运行器需要同步更新。 -**缓解**:运行器仅使用最稳定的 API surface(创建 session、提交 turn、读取事件),这些 API 变动频率低。同时协议类型复用 `protocol` crate。 +### [Risk] 评测运行器对本地 session 存储布局有耦合 +运行器除了依赖 server HTTP API,还需要与 session JSONL 的存储布局保持一致。如果存储路径规则变化,运行器需要同步更新。 +**缓解**:通过显式 `--session-storage-root` 参数收敛路径来源,并尽量复用现有 session 路径规则,而不是在 runner 内散落隐式拼接逻辑。 ### [Trade-off] 规则诊断 vs LLM 诊断 规则引擎无法覆盖语义级错误(如"代码逻辑正确但不符合用户意图")。 diff --git a/openspec/changes/eval-driven-framework-iteration/proposal.md b/openspec/changes/eval-driven-framework-iteration/proposal.md index 97a5a467..38d58ea6 100644 --- a/openspec/changes/eval-driven-framework-iteration/proposal.md +++ b/openspec/changes/eval-driven-framework-iteration/proposal.md @@ -10,10 +10,10 @@ Astrcode 目前拥有完善的事件溯源架构(`StorageEvent` JSONL 日志 ## What Changes -- **引入评测 trace 模型**:定义从 `StorageEvent` JSONL 中提取的结构化评测数据模型(`EvalTurnTrace`),覆盖单 turn 内的工具调用链、token 消耗、compaction 事件、错误序列与时间线 +- **引入评测 trace 模型**:定义从 `StorageEvent` JSONL 中提取的结构化评测数据模型(`SessionTrace` / `TurnTrace`),覆盖 session 元数据、单 turn 内的工具调用链、协作事实、token 消耗、compaction 事件、错误序列与时间线 - **引入评测任务规范**:定义结构化的评测任务描述格式(YAML),包含任务输入、工作区快照、期望行为约束(工具序列、文件变更、步数上限)和评分规则 - **引入失败模式诊断器**:基于事件模式的规则引擎,自动从 turn trace 中检测已知失败模式(工具循环、级联失败、上下文丢失、子 Agent 预算超支),生成结构化诊断报告 -- **引入评测运行器**:通过现有 server HTTP API 编排评测任务执行的独立 binary,支持并行运行、工作区隔离与结果汇总 +- **引入评测运行器**:通过现有 server HTTP API 作为控制面、以本地共享 session JSONL 作为数据面来编排评测任务执行的独立 binary,支持并行运行、工作区隔离与结果汇总 - **引入评测回归对比**:存储评测基线结果,支持版本间指标 diff,用于 CI 中自动检测质量退化 ## Capabilities @@ -27,13 +27,13 @@ Astrcode 目前拥有完善的事件溯源架构(`StorageEvent` JSONL 日志 ### Modified Capabilities -- `runtime-observability-pipeline`: 需扩展以支持评测场景下的指标导出(将 live metrics 写入评测结果而非仅推送到 SSE/frontend),确保评测运行时的 observability 数据可被评测运行器收集 +- `runtime-observability-pipeline`: 需澄清现有 observability 事件在 JSONL 中的完整可提取性,确保评测运行器可以离线提取 turn 级指标,而不要求新增导出接口 - `agent-tool-evaluation`: 现有的 agent 协作评估记录应作为评测 trace 的输入源之一,需要在评测 trace 模型中建立与 collaboration facts 的关联 ## Impact - **新增 crate**:`crates/eval` — 独立的评测 crate,包含 trace 模型、任务规范、诊断器和运行器。仅依赖 `core`(复用 `StorageEvent` 等类型)和 `protocol`(复用 HTTP DTO),不侵入现有运行时路径 -- **无运行时改动**:Phase 1 完全基于离线 JSONL 分析,不需要修改 `session-runtime`、`application` 或 `server` +- **运行时边界**:Phase 1 不引入新的 runtime 事件、SSE 字段或 HTTP API;评测依赖现有 durable JSONL 合同。若实现过程中发现现有事件字段不足,应另起 change 讨论运行时补充 - **CI 集成**:评测运行器作为独立 binary 在 CI 中调用,不影响现有构建流程 - **前端影响**:Phase 1 无前端改动。后续可在现有 Debug Workbench 基础上扩展评测视图(不在本次 scope 内) diff --git a/openspec/changes/eval-driven-framework-iteration/specs/eval-runner/spec.md b/openspec/changes/eval-driven-framework-iteration/specs/eval-runner/spec.md index f1933f9a..f720eb07 100644 --- a/openspec/changes/eval-driven-framework-iteration/specs/eval-runner/spec.md +++ b/openspec/changes/eval-driven-framework-iteration/specs/eval-runner/spec.md @@ -1,6 +1,6 @@ ## Purpose -定义评测运行器,通过 server HTTP API 编排评测任务的执行、结果收集与基线对比。 +定义评测运行器,通过 server HTTP 控制面与本地 JSONL 数据面编排评测任务的执行、结果收集与基线对比。 ## ADDED Requirements @@ -10,7 +10,7 @@ #### Scenario: 执行指定任务集 -- **WHEN** 运行 `astrcode-eval-runner --server-url http://localhost:3000 --task-set eval-tasks/core/` +- **WHEN** 运行 `astrcode-eval-runner --server-url http://localhost:3000 --session-storage-root ./.astrcode-eval-state --task-set eval-tasks/task-set.yaml` - **THEN** 运行器 MUST 加载任务集内所有任务定义 - **AND** 依次或并行执行每个任务 - **AND** 输出评测结果到 stdout 或指定文件 @@ -23,7 +23,7 @@ ### Requirement: 运行器 SHALL 通过 server HTTP API 驱动任务执行 -每个评测任务的执行 MUST 通过标准 server API 完成。 +每个评测任务的执行 MUST 通过标准 server API 完成控制面操作,并通过共享 session 存储中的 JSONL 完成 durable 结果读取。 #### Scenario: 单任务执行流程 @@ -40,9 +40,15 @@ #### Scenario: 等待 turn 完成 - **WHEN** 运行器提交 turn 后等待完成 -- **THEN** MUST 通过轮询 SSE 事件流或读取 JSONL 文件检测 `TurnDone` 事件 +- **THEN** MUST 通过轮询共享 session 存储中的 JSONL 文件检测 `TurnDone` durable 事件 - **AND** MUST 设置超时(可配置,默认 5 分钟),超时后标记任务为 `timeout` +#### Scenario: 控制面可达但数据面不可达 + +- **WHEN** 运行器可以连接 `server-url`,但无法访问对应的 `session_storage_root` +- **THEN** 运行器 MUST 在启动阶段或首个任务前明确失败 +- **AND** 错误信息 MUST 指出控制面 / 数据面不一致,而不是静默退化为不稳定的等待策略 + ### Requirement: 运行器 SHALL 支持工作区隔离与清理 评测任务的工作区 MUST 与其他任务隔离,并在评测结束后清理。 diff --git a/openspec/changes/eval-driven-framework-iteration/specs/eval-task-spec/spec.md b/openspec/changes/eval-driven-framework-iteration/specs/eval-task-spec/spec.md index 9bd0441d..06032b84 100644 --- a/openspec/changes/eval-driven-framework-iteration/specs/eval-task-spec/spec.md +++ b/openspec/changes/eval-driven-framework-iteration/specs/eval-task-spec/spec.md @@ -62,7 +62,7 @@ - **WHEN** `expected_outcome.file_changes` 指定了期望变更的文件路径和内容片段 - **THEN** 评分器 MUST 检查隔离工作区中对应文件是否包含期望内容 -- **AND** 通过 `git diff --stat` 或文件内容匹配验证 +- **AND** 通过文件存在性、内容片段或精确文本匹配验证 #### Scenario: 约束最大 turn 数 diff --git a/openspec/changes/eval-driven-framework-iteration/specs/eval-trace-model/spec.md b/openspec/changes/eval-driven-framework-iteration/specs/eval-trace-model/spec.md index 3e3584f8..942c7901 100644 --- a/openspec/changes/eval-driven-framework-iteration/specs/eval-trace-model/spec.md +++ b/openspec/changes/eval-driven-framework-iteration/specs/eval-trace-model/spec.md @@ -1,12 +1,12 @@ ## Purpose -定义评测 trace 数据模型,将 StorageEvent JSONL 事件流转化为结构化的 turn 级评测数据。 +定义评测 trace 数据模型,将 StorageEvent JSONL 事件流转化为结构化的 session / turn 级评测数据。 ## ADDED Requirements ### Requirement: TurnTrace SHALL 作为评测数据的核心单元 -系统 MUST 定义 `TurnTrace` 结构体,包含单个 turn 内的所有评测相关信息:用户输入、工具调用序列、助手输出、prompt 指标、compaction 事件、错误事件和时间线。 +系统 MUST 定义 `TurnTrace` 结构体,包含单个 turn 内的所有评测相关信息:用户输入、工具调用序列、助手输出、prompt 指标、compaction 事件、错误事件、协作事实摘要和时间线。 #### Scenario: 从完整的 turn 事件序列提取 TurnTrace @@ -23,24 +23,31 @@ #### Scenario: turn 内包含子 Agent 执行 - **WHEN** turn 内存在 `SubRunStarted` 和 `SubRunFinished` 事件 -- **THEN** `TurnTrace` MUST 包含 `SubRunTrace`,记录子 Agent 的 step_count、estimated_tokens、执行结果和持续时间 +- **THEN** `TurnTrace` MUST 包含 `SubRunTrace`,记录子 Agent 的 step_count、estimated_tokens、执行结果、持续时间和 `resolved_limits` - **AND** 子 Agent 的 `child_session_id` MUST 被记录,支持后续递归提取子 session 的 trace -### Requirement: TraceExtractor SHALL 从 JSONL 文件批量提取 TurnTrace +#### Scenario: turn 内包含协作评估事实 -系统 MUST 提供 `TraceExtractor`,接受 JSONL 文件路径,输出 `Vec<TurnTrace>`。 +- **WHEN** turn 内存在 `AgentCollaborationFact` 事件 +- **THEN** `TurnTrace` MUST 记录协作事实摘要,并在存在 `sub_run_id` 时与对应 `SubRunTrace` 建立关联 +- **AND** 该协作摘要 SHALL 可用于后续 agent delegation 效果评估 + +### Requirement: TraceExtractor SHALL 从 JSONL 文件提取 SessionTrace + +系统 MUST 提供 `TraceExtractor`,接受 JSONL 文件路径,输出 `SessionTrace`;其中 session 级元数据与 `Vec<TurnTrace>` 必须同时可用。 #### Scenario: 从单个 session JSONL 提取所有 turn trace - **WHEN** 对一个包含多个 turn 的 session JSONL 文件执行提取 -- **THEN** 提取器 MUST 返回与 turn 数量相同的 `TurnTrace` 条目 +- **THEN** 提取器 MUST 返回一个 `SessionTrace` +- **AND** 其中的 `turns` 数量 MUST 与 durable turn 数量一致 - **AND** 每个 `TurnTrace` MUST 按事件时间序构建 #### Scenario: 处理包含 SessionStart 的事件流 - **WHEN** JSONL 文件以 `SessionStart` 事件开始 - **THEN** 提取器 MUST 记录 session 元数据(session_id、working_dir、timestamp) -- **AND** `SessionStart` 不产生 `TurnTrace`,而是作为 `SessionTrace` 的 header +- **AND** `SessionStart` 不产生独立 `TurnTrace`,而是作为 `SessionTrace` 的 header #### Scenario: 处理跨 agent 谱系事件 diff --git a/openspec/changes/eval-driven-framework-iteration/specs/runtime-observability-pipeline/spec.md b/openspec/changes/eval-driven-framework-iteration/specs/runtime-observability-pipeline/spec.md index 392e482e..dfd6c4a9 100644 --- a/openspec/changes/eval-driven-framework-iteration/specs/runtime-observability-pipeline/spec.md +++ b/openspec/changes/eval-driven-framework-iteration/specs/runtime-observability-pipeline/spec.md @@ -2,7 +2,7 @@ ### Requirement: Runtime observability SHALL cover read and execution paths -系统 MUST 同时采集读路径与执行路径的关键指标,包括 session rehydrate、SSE catch-up、turn execution、subrun execution、delivery diagnostics 以及 agent collaboration diagnostics。此外,observability 管线 MUST 支持评测场景下的指标导出,将 turn 级指标写入评测结果而非仅推送到 SSE/frontend。 +系统 MUST 同时采集读路径与执行路径的关键指标,包括 session rehydrate、SSE catch-up、turn execution、subrun execution、delivery diagnostics 以及 agent collaboration diagnostics。此外,observability 管线 MUST 保持这些指标在 durable JSONL 中的完整可提取性,使评测运行器能够离线构建评测结果,而不要求新增导出接口或额外 runtime 写路径。 #### Scenario: Read path metrics are recorded diff --git a/openspec/changes/eval-driven-framework-iteration/tasks.md b/openspec/changes/eval-driven-framework-iteration/tasks.md new file mode 100644 index 00000000..ded9fa5d --- /dev/null +++ b/openspec/changes/eval-driven-framework-iteration/tasks.md @@ -0,0 +1,36 @@ +## 1. 项目骨架与 trace 模型 + +- [ ] 1.1 创建 `crates/eval` crate 骨架:初始化 `Cargo.toml`(依赖 `astrcode-core`、`serde`、`serde_json`、`chrono`)、`src/lib.rs` 和模块结构(`trace/`、`task/`、`diagnosis/`、`runner/`)。在 workspace `Cargo.toml` 中注册 members。验证:`cargo check -p astrcode-eval` 通过。 +- [ ] 1.2 实现 trace 核心类型:在 `crates/eval/src/trace/mod.rs` 中定义 `TurnTrace`、`ToolCallRecord`、`SubRunTrace`、`SessionTrace`、`CompactTrace`、`PromptMetricsSnapshot`、协作事实摘要类型等结构体,覆盖 session 元数据、agent 谱系、`resolved_limits` 与 collaboration facts,均 derive `Serialize/Deserialize`。验证:单元测试确保 round-trip 序列化。 +- [ ] 1.3 实现 `TraceExtractor`:在 `crates/eval/src/trace/extractor.rs` 中实现 JSONL 文件 → `SessionTrace` 的提取逻辑。逐行读取 JSONL,通过 `serde_json::from_str::<StorageEvent>()` 反序列化,按 `turn_id` 分组构建 `TurnTrace`,并在 session 级聚合 metadata / lineage。处理不完整 turn(无 `TurnDone`)、跨 agent 谱系事件以及 `AgentCollaborationFact` 到 `SubRunTrace`/turn 协作摘要的关联。验证:使用现有 session JSONL 文件做集成测试,确认提取的 turn 数量与手动检查一致。 +- [ ] 1.4 实现 `ToolCallRecord` 生命周期构建:在 extractor 中将 `ToolCall` + `ToolCallDelta`(可选) + `ToolResult` 事件合并为完整的 `ToolCallRecord`。处理 `ToolResultReferenceApplied` 事件。验证:单元测试覆盖正常完成、有流式输出、被持久化引用替换三种场景。 + +## 2. 评测任务规范 + +- [ ] 2.1 定义 `EvalTask` 类型:在 `crates/eval/src/task/mod.rs` 中定义 `EvalTask`、`WorkspaceSpec`、`ExpectedOutcome`、`ToolPattern`、`FileChangeExpectation`、`ScoringWeights` 等结构体,支持 serde YAML 反序列化。验证:单元测试确保 YAML → `EvalTask` 反序列化正确。 +- [ ] 2.2 实现 `TaskLoader`:在 `crates/eval/src/task/loader.rs` 中实现从目录加载任务文件和从 `task-set.yaml` 加载任务集索引。校验必要字段(`task_id`、`prompt`、`expected_outcome`),缺失时报错。验证:创建几个测试用 YAML 文件,确认加载成功和校验报错。 +- [ ] 2.3 实现评分器:在 `crates/eval/src/task/scorer.rs` 中实现多维度匹配与归一化评分。维度包括:工具调用序列匹配、最大调用次数检查、文件变更验证(通过读取工作区文件内容匹配)、最大 turn 数检查。输出 `EvalScore`(0.0-1.0)和 `EvalStatus`(pass/partial/fail)。验证:针对各维度编写单元测试。 + +## 3. 失败模式诊断器 + +- [ ] 3.1 定义诊断器 trait 与注册机制:在 `crates/eval/src/diagnosis/mod.rs` 中定义 `FailurePatternDetector` trait、`FailureInstance`、`FailureSeverity`、`DiagnosisReport` 等类型;`FailureInstance` 包含 `confidence`、`storage_seq` 范围与结构化上下文。实现 `DiagnosisEngine`:注册多个检测器,对 `TurnTrace` 依次调用,汇总报告。验证:单元测试使用 mock 检测器确认注册和调用流程。 +- [ ] 3.2 实现 `ToolLoopDetector`:在 `crates/eval/src/diagnosis/tool_loop.rs` 中检测同名工具连续调用 ≥ 3 次且参数相似的模式。使用简单的字符串相似度(Jaccard 或编辑距离)比较参数。验证:构造包含循环的 `TurnTrace` 和不含循环的 trace,分别测试正负例。 +- [ ] 3.3 实现 `CascadeFailureDetector`:在 `crates/eval/src/diagnosis/cascade_failure.rs` 中检测连续 ≥ 2 次工具调用失败。区分"失败后重试成功"的正常行为。验证:单元测试覆盖连续失败、单次失败、失败后重试成功三种场景。 +- [ ] 3.4 实现 `CompactInfoLossDetector`:在 `crates/eval/src/diagnosis/compact_loss.rs` 中检测 compact 后出现工具调用失败的模式。匹配失败原因中暗示信息丢失的关键词。验证:单元测试覆盖 compact 后失败、compact 后正常两种场景。 +- [ ] 3.5 实现 `SubRunBudgetDetector`:在 `crates/eval/src/diagnosis/subrun_budget.rs` 中检测子 Agent 执行超过步数限制。从 `SubRunTrace` 中比较 `step_count` 与 `resolved_limits`。验证:单元测试。 +- [ ] 3.6 实现 `EmptyTurnDetector`:在 `crates/eval/src/diagnosis/empty_turn.rs` 中检测无工具调用且助手输出为空的 turn。可配置最小输出长度阈值。验证:单元测试。 + +## 4. 评测运行器 + +- [ ] 4.1 实现 server 控制面客户端与 session log 定位:在 `crates/eval/src/runner/client.rs` 中封装创建 session、提交 turn 的 HTTP 调用;在 runner 模块中实现基于 `session_id` + `session_storage_root` 的 JSONL 定位与 `TurnDone` 轮询等待。依赖 `reqwest`。支持超时配置,并在无法访问共享 session 存储时明确报错。验证:集成测试(需启动 server)。 +- [ ] 4.2 实现工作区管理:在 `crates/eval/src/runner/workspace.rs` 中实现 fixture 目录 copy 到隔离路径、评测后清理。支持 `--keep-workspace` 跳过清理。验证:集成测试确认目录创建和清理。 +- [ ] 4.3 实现评测结果收集与报告生成:在 `crates/eval/src/runner/report.rs` 中将每个任务的结果(`EvalScore` + `DiagnosisReport` + 指标)汇总为 `EvalReport`。实现 JSON 序列化与持久化。验证:单元测试确认报告结构完整。 +- [ ] 4.4 实现基线对比:在 `crates/eval/src/runner/report.rs` 中扩展报告生成逻辑,支持读取历史基线 JSON 文件并计算 diff。输出各任务分数变化、指标变化,分数下降超阈值时输出警告。验证:单元测试使用两个 mock 报告文件。 +- [ ] 4.5 实现并行执行:在 `crates/eval/src/runner/mod.rs` 中使用 `tokio` 并发执行多个评测任务(`--concurrency` 参数控制)。每个任务使用独立 session,失败不影响其他任务。验证:集成测试。 +- [ ] 4.6 创建 binary 入口:在 `crates/eval/src/bin/eval_runner.rs` 中实现 CLI 入口,解析参数(`--server-url`、`--session-storage-root`、`--task-set`、`--baseline`、`--concurrency`、`--keep-workspace`、`--output`)。编排完整执行流程,并在控制面可达但数据面不可达时快速失败。验证:`cargo run -p astrcode-eval -- --help` 正常输出。 + +## 5. 评测任务集与端到端验证 + +- [ ] 5.1 创建核心评测任务集目录 `eval-tasks/core/`,编写 3-5 个初始评测任务 YAML(覆盖文件读取、文件编辑、工具链效率等基本场景)。创建 `task-set.yaml` 索引。验证:`TaskLoader` 能成功加载全部任务。 +- [ ] 5.2 端到端验证:启动 server,运行评测运行器执行核心任务集,确认完整流程(任务加载 → 工作区准备 → session 创建 → turn 提交 → trace 提取 → 诊断 → 评分 → 报告输出)畅通。验证:生成有效的 JSON 评测报告。 +- [ ] 5.3 基线验证:运行两次相同评测集,第二次使用第一次结果作为基线,确认 diff 输出正确。验证:diff 报告显示无变化。 diff --git a/openspec/specs/application-use-cases/spec.md b/openspec/specs/application-use-cases/spec.md index bee2f0e3..2dd817dc 100644 --- a/openspec/specs/application-use-cases/spec.md +++ b/openspec/specs/application-use-cases/spec.md @@ -202,3 +202,25 @@ - **WHEN** server 需要加载或保存配置 - **THEN** 通过 `application/config` 完成 + +### Requirement: application SHALL expose task display facts through stable session-runtime contracts + +在 conversation snapshot、stream catch-up 或等价的 task display 场景中,`application` MUST 通过 `SessionRuntime` 的 `SessionQueries::active_task_snapshot()` 稳定 query 方法读取 authoritative task facts,并在 `terminal_control_facts()` 中将结果映射为 `TerminalControlFacts.active_tasks` 字段。`application` MUST NOT 直接扫描原始 `taskWrite` tool 事件、手写 replay 逻辑或把待上层再拼装的底层事实当成正式合同向上传递。 + +#### Scenario: server requests conversation facts with active tasks + +- **WHEN** `server` 请求某个 session 的 conversation snapshot 或 stream catch-up,且该 session 当前存在 active tasks +- **THEN** `application` SHALL 通过 `terminal_control_facts()` 返回已收敛的 task display facts +- **AND** `server` 只负责 DTO 映射(`to_conversation_control_state_dto()` 将 `active_tasks` 映射为 `ConversationControlStateDto.activeTasks`)、HTTP 状态码与 SSE framing + +#### Scenario: application does not reconstruct tasks from raw tool history + +- **WHEN** `application` 需要返回某个 session 的 active-task panel facts +- **THEN** 它 SHALL 统一通过 `SessionQueries::active_task_snapshot()` 读取结果 +- **AND** SHALL NOT 自行遍历原始 tool result 或重写底层 projection 规则 + +#### Scenario: no active tasks yields None + +- **WHEN** `application` 查询 task facts,但当前 session 无 active tasks(空列表或全部 completed) +- **THEN** `TerminalControlFacts.active_tasks` SHALL 为 `None` +- **AND** `ConversationControlStateDto.activeTasks` SHALL 为 `None` diff --git a/openspec/specs/execution-task-tracking/spec.md b/openspec/specs/execution-task-tracking/spec.md new file mode 100644 index 00000000..a0d2637a --- /dev/null +++ b/openspec/specs/execution-task-tracking/spec.md @@ -0,0 +1,117 @@ +# execution-task-tracking Specification + +## Purpose +定义执行期 task(`taskWrite`)的完整生命周期规范,包括 snapshot 写入语义、ownership 隔离、durable 回放恢复、prompt 注入、mode 可见性约束与 prompt guidance。 + +## Requirements + +### Requirement: execution tasks SHALL remain independent from the canonical session plan + +系统 MUST 将执行期 task 与 canonical `session_plan` 视为两套不同真相。`taskWrite` 只用于维护当前执行清单,MUST NOT 读写 `sessions/<id>/plan/**`,也 MUST NOT 改变当前 `activePlan`、plan 审批状态或 Plan Surface 语义。 + +#### Scenario: taskWrite updates execution state without mutating session plan + +- **WHEN** 模型在 `code` mode 调用 `taskWrite` +- **THEN** 系统 SHALL 仅更新执行期 task snapshot +- **AND** SHALL NOT 修改 session plan markdown、plan state 或 plan review 状态 + +#### Scenario: plan workflow remains the only formal planning path + +- **WHEN** 模型需要产出、更新或呈递正式计划 +- **THEN** 系统 SHALL 继续要求使用 `upsertSessionPlan` / `exitPlanMode` +- **AND** SHALL NOT 接受 `taskWrite` 作为 formal plan 的替代写入口 + +### Requirement: taskWrite SHALL accept a full execution-task snapshot + +`taskWrite` MUST 接受当前 owner 的完整任务快照,而不是增量 patch。每个 task item MUST 至少包含 `content` 与 `status`,其中 `status` 只能是 `pending`、`in_progress` 或 `completed`。若某项为 `in_progress`,则该项 MUST 提供 `activeForm`。同一个 snapshot 中 MUST NOT 存在多个 `in_progress` 项。单次快照 MUST NOT 超过 20 条 item。 + +#### Scenario: valid snapshot is accepted + +- **WHEN** `taskWrite` 收到一个合法的 task 列表,且至多只有一个 `in_progress` 项 +- **THEN** 系统 SHALL 接受该调用 +- **AND** SHALL 将该列表视为当前 owner 的最新完整 task snapshot + +#### Scenario: invalid snapshot is rejected + +- **WHEN** `taskWrite` 输入包含未知状态、多个 `in_progress` 项,或某个 `in_progress` 项缺少 `activeForm` +- **THEN** 系统 SHALL 拒绝该调用并返回明确错误 +- **AND** SHALL NOT 落盘部分 task 状态 + +#### Scenario: oversized snapshot is rejected + +- **WHEN** `taskWrite` 输入包含超过 20 条 item +- **THEN** 系统 SHALL 拒绝该调用并返回明确错误 +- **AND** SHALL NOT 落盘部分 task 状态 + +### Requirement: task ownership SHALL be scoped to the current execution owner + +系统 MUST 为 task snapshot 绑定稳定 owner。owner MUST 优先取当前工具上下文中的 `agent_id`;若该字段缺失,则 MUST 回退到当前 `session_id`。不同 owner 的 task snapshot MUST 相互隔离。 + +#### Scenario: root execution falls back to session ownership + +- **WHEN** 一次 `taskWrite` 调用发生在没有 `agent_id` 的根执行上下文 +- **THEN** 系统 SHALL 使用当前 `session_id` 作为 owner +- **AND** 后续读取时 SHALL 返回该 session 的最新 task snapshot + +#### Scenario: child or agent-scoped task snapshots do not overwrite each other + +- **WHEN** 两个不同 owner 分别写入 task snapshot +- **THEN** 系统 SHALL 为它们分别保留最新 snapshot +- **AND** 一个 owner 的写入 SHALL NOT 覆盖另一个 owner 的 task 状态 + +### Requirement: active task state SHALL be recoverable from durable tool results + +执行期 task 真相 MUST 可通过 durable tool result 回放恢复。`taskWrite` 的最终结果 MUST 以结构化 metadata(`schema: "executionTaskSnapshot"`)形式持久化最新 snapshot。`SessionState` MUST 维护 `active_tasks: HashMap<String, TaskSnapshot>` 缓存(key = owner),在 `translate_store_and_cache()` 中拦截 `taskWrite` 的 `ToolResult` 事件并更新投影。session reload、replay 或重连后,系统 SHALL 通过完整事件回放恢复每个 owner 的最新 task 状态。空列表或"全部 completed"列表 MUST 使该 owner 的条目被移除。系统 MUST NOT 为 task 维护独立的持久化文件(task 真相完全来自 durable tool result)。 + +#### Scenario: latest task snapshot survives replay + +- **WHEN** 某个 session 已经成功记录过一个 `taskWrite` tool result +- **THEN** 会话重载或回放后 SHALL 恢复该 owner 的最新 task snapshot +- **AND** 前端 hydration 与后续 prompt 注入 SHALL 看到相同的 active tasks + +#### Scenario: completed or empty snapshot clears active tasks + +- **WHEN** `taskWrite` 写入空列表,或写入一个全部为 `completed` 的列表 +- **THEN** 系统 SHALL 将该 owner 视为没有 active tasks +- **AND** 后续 prompt 注入与 task panel SHALL 隐藏该 owner 的 task 状态 + +### Requirement: active tasks SHALL be injected into subsequent turn steps + +系统 MUST 在 turn request assembly 的 `build_prompt_output()` 中将当前 owner 的 active tasks 作为动态 prompt facts 注入,让同一 turn 的后续步骤也能读取最新执行清单。注入内容 MUST 只包含 `in_progress` 和 `pending` 状态的项,MUST NOT 回放 `completed` 项或历史快照。当前 owner 无 active tasks 时,MUST NOT 生成 task prompt 声明。注入 MUST 使用独立 block_id(如 `"task.active_snapshot"`),不影响其他 prompt declaration。 + +#### Scenario: a taskWrite call influences later steps in the same turn + +- **WHEN** 模型在某个 turn 中调用 `taskWrite`,随后同一 turn 还会继续请求模型 +- **THEN** 后续 step 的 prompt SHALL 包含该 owner 最新的 active task 摘要 +- **AND** 模型 SHALL 不需要等到下一轮 turn 才能看到刚写入的 task 状态 + +#### Scenario: cleared tasks are removed from later prompts + +- **WHEN** 当前 owner 的最新 task snapshot 为空或全部 completed +- **THEN** 后续 step 的 prompt SHALL 不再包含 active task 声明 +- **AND** 系统 SHALL 不继续向模型暗示已完成的执行清单 + +### Requirement: taskWrite SHALL only be available in execution-oriented modes + +`taskWrite` MUST 声明 `SideEffect::Local`,通过现有 mode selector 集合代数自动限制可见性。`plan` mode 和 `review` mode MUST NOT 向模型暴露该工具。Code mode(`AllTools`)MUST 包含 `taskWrite`。 + +#### Scenario: code mode exposes taskWrite + +- **WHEN** 当前 session 处于 builtin `code` mode 或等价执行态 mode +- **THEN** capability surface SHALL 包含 `taskWrite` +- **AND** 模型可通过该工具维护执行期 task snapshot + +#### Scenario: plan and review modes hide taskWrite + +- **WHEN** 当前 session 处于 `plan` 或 `review` mode +- **THEN** capability surface SHALL 不包含 `taskWrite`(因为 `SideEffect::Local` 被 plan mode 排除、review mode 只允许 `SideEffect::None`) +- **AND** 模型 MUST 继续分别使用 formal plan 工具或只读审查工具 + +### Requirement: taskWrite SHALL carry detailed prompt guidance + +`taskWrite` 的 capability metadata MUST 包含 `ToolPromptMetadata`,提供以下使用指导: +- 何时主动使用:3+ 步骤任务、用户提供多个任务、非平凡多步操作。 +- 何时不用:单步简单任务、纯对话查询、3 步内可完成的琐碎操作。 +- 状态管理规则:同一时刻最多 1 个 `in_progress`;开始工作前先标 `in_progress`;完成后立即标 `completed`。 +- 双形式要求:每项必须同时提供 `content`(祈使句)和 `activeForm`(进行时)。 +- 完成标准:只在真正完成时标 `completed`;测试失败或实现部分时保持 `in_progress`。 diff --git a/openspec/specs/terminal-chat-read-model/spec.md b/openspec/specs/terminal-chat-read-model/spec.md index 309aaad3..fd93bb4e 100644 --- a/openspec/specs/terminal-chat-read-model/spec.md +++ b/openspec/specs/terminal-chat-read-model/spec.md @@ -127,3 +127,32 @@ - **WHEN** 终端客户端请求当前 session 的 child agent / subagent 摘要 - **THEN** 服务端 SHALL 返回 direct child 的标识、状态、最近输出摘要与父子关系信息 - **AND** MUST 只暴露当前 session 有权观察到的 child + +### Requirement: conversation surface SHALL expose authoritative active-task panel facts + +`conversation` surface 的 hydration snapshot 与增量 stream MUST 直接暴露当前 session 的 active-task panel facts,通过 `ConversationControlStateDto.activeTasks: Option<Vec<TaskItemDto>>` 字段传递。该事实源 MUST 来自服务端 authoritative projection(`SessionState.active_tasks` → `terminal_control_facts()` → DTO 映射),客户端 MUST NOT 通过扫描 `taskWrite` tool history、metadata fallback 或本地 reducer 自行重建任务面板。 + +#### Scenario: hydration snapshot includes current active tasks + +- **WHEN** 终端或前端首次打开一个 session,并且该 session 当前存在 active tasks +- **THEN** 服务端 SHALL 在 conversation hydration 结果的 `activeTasks` 字段中返回当前 active-task panel facts +- **AND** 客户端 MUST 能在不回放历史 tool result 的前提下直接渲染任务卡片 + +#### Scenario: stream delta updates the task panel after taskWrite + +- **WHEN** 当前 session 成功写入新的 `taskWrite` snapshot +- **THEN** conversation 增量流 SHALL 通过 `UpdateControlState` delta 推送更新后的 `activeTasks` +- **AND** 客户端 MUST 能仅凭该 authoritative delta 更新任务卡片 + +#### Scenario: task panel hides after tasks are cleared + +- **WHEN** 当前 session 的最新 task snapshot 为空或全部 completed +- **THEN** `activeTasks` SHALL 为 `None` +- **AND** 客户端 SHALL 隐藏 task 卡片 + +#### Scenario: taskWrite tool calls appear as normal ToolCallBlocks + +- **WHEN** 模型在 transcript 中调用 `taskWrite` +- **THEN** 该调用 SHALL 作为正常 `ToolCallBlock` 出现在消息列表中 +- **AND** 系统 SHALL NOT 抑制该工具调用(与 plan 工具被 `should_suppress_tool_call_block()` 抑制的行为不同) +- **AND** `activeTasks` control state 与 transcript 中的 ToolCallBlock 是两个独立的 UI 表面 From 2ea34a62e5103a9c5711bbad60de93419fa03826 Mon Sep 17 00:00:00 2001 From: whatevertogo <whatevertogo@users.noreply.github.com> Date: Mon, 20 Apr 2026 03:47:16 +0800 Subject: [PATCH 52/53] feat: add compact_tool_content_max_chars configuration and update compaction logic - Introduced DEFAULT_COMPACT_TOOL_CONTENT_MAX_CHARS constant in core config. - Updated application config to include compact_tool_content_max_chars. - Modified validation and runtime parameters to accommodate new configuration. - Enhanced compaction logic to respect tool content character limits during processing. - Refactored compaction functions to utilize the new character limit configuration. - Updated tests to verify the new character limit functionality and ensure proper behavior. --- CLAUDE.md | 15 - Cargo.lock | 2 - .../adapter-mcp/src/config/settings_port.rs | 55 +--- crates/adapter-skills/src/lib.rs | 2 +- crates/adapter-skills/src/skill_catalog.rs | 20 +- crates/adapter-skills/src/skill_spec.rs | 149 +-------- crates/adapter-storage/Cargo.toml | 1 - .../adapter-storage/src/mcp_settings_store.rs | 4 +- crates/adapter-tools/Cargo.toml | 1 - .../src/builtin_tools/skill_tool.rs | 56 +++- crates/core/src/config.rs | 1 - crates/core/src/lib.rs | 17 +- crates/core/src/mcp.rs | 21 ++ crates/core/src/ports.rs | 28 +- crates/core/src/skill.rs | 112 +++++++ crates/server/src/bootstrap/capabilities.rs | 12 +- .../server/src/bootstrap/composer_skills.rs | 6 +- crates/server/src/bootstrap/governance.rs | 6 +- crates/server/src/bootstrap/plugins.rs | 4 +- crates/server/src/bootstrap/prompt_facts.rs | 6 +- .../src/context_window/compaction.rs | 302 ++++++++++++------ .../src/context_window/compaction/protocol.rs | 16 +- .../src/context_window/compaction/sanitize.rs | 216 +++++++------ .../src/context_window/compaction/tests.rs | 1 + docs/architecture/crates-dependency-graph.md | 20 +- 25 files changed, 598 insertions(+), 475 deletions(-) create mode 100644 crates/core/src/mcp.rs create mode 100644 crates/core/src/skill.rs diff --git a/CLAUDE.md b/CLAUDE.md index a57e8f8c..e1e9ac93 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -48,21 +48,6 @@ node scripts/check-crate-boundaries.mjs --strict # 严格模式 - 不需要向后兼容,优先良好架构,期望最佳实践而不是打补丁 - Git 提交信息使用 emoji + type + scope 风格(如 `✨ feat(module): brief description`) -## 提交前验证 - -改了后端rust代码每次提交前按顺序执行: - -1. `cargo fmt --all` — 格式化代码 -2. `cargo clippy --all-targets --all-features -- -D warnings` — 修复所有警告 -3. `cargo test --workspace` — 确保所有测试通过 -4. 确认变更内容后写出描述性提交信息 - -改了前端代码每次提交前按顺序执行: -1. `npm run format` — 格式化代码 -2. `npm run lint` — 修复所有 lint 错误 -3. `npm run typecheck` — 确保没有类型错误 -4. `npm run format:check` — 确保格式正确 - ## Gotchas - 文档必须使用中文 diff --git a/Cargo.lock b/Cargo.lock index fb510449..cb8ea8de 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -221,7 +221,6 @@ dependencies = [ name = "astrcode-adapter-storage" version = "0.1.0" dependencies = [ - "astrcode-adapter-mcp", "astrcode-core", "async-trait", "chrono", @@ -239,7 +238,6 @@ dependencies = [ name = "astrcode-adapter-tools" version = "0.1.0" dependencies = [ - "astrcode-adapter-skills", "astrcode-core", "async-trait", "base64 0.22.1", diff --git a/crates/adapter-mcp/src/config/settings_port.rs b/crates/adapter-mcp/src/config/settings_port.rs index 12206382..5e6e3367 100644 --- a/crates/adapter-mcp/src/config/settings_port.rs +++ b/crates/adapter-mcp/src/config/settings_port.rs @@ -1,56 +1,5 @@ //! # MCP Settings 接口层 //! -//! 定义 `McpSettingsStore` trait 和审批数据 DTO。 -//! 这是纯接口层,不含任何 IO 实现——具体实现由 runtime 在 bootstrap 时注入。 +//! 审批 DTO 与持久化端口已下沉到 `astrcode-core`,adapter-mcp 仅做重导出。 -use serde::{Deserialize, Serialize}; - -/// MCP 审批数据——记录单个服务器的审批状态。 -#[derive(Clone, Debug, Serialize, Deserialize)] -pub struct McpApprovalData { - /// 服务器签名(用于唯一标识,如 command:args 或 URL)。 - pub server_signature: String, - /// 审批状态。 - pub status: McpApprovalStatus, - /// 审批时间(ISO 8601 格式)。 - #[serde(default, skip_serializing_if = "Option::is_none")] - pub approved_at: Option<String>, - /// 审批来源(用户标识或 "auto")。 - #[serde(default, skip_serializing_if = "Option::is_none")] - pub approved_by: Option<String>, -} - -/// MCP 服务器审批状态。 -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "lowercase")] -pub enum McpApprovalStatus { - /// 等待用户审批。 - Pending, - /// 用户已批准。 - Approved, - /// 用户已拒绝。 - Rejected, -} - -/// MCP settings 持久化接口。 -/// -/// adapter-mcp 通过此 trait 读写审批数据, -/// 不直接读写 settings 文件。 -/// 具体实现由 runtime 在 bootstrap 时注入。 -pub trait McpSettingsStore: Send + Sync { - /// 加载指定项目的所有审批数据。 - fn load_approvals( - &self, - project_path: &str, - ) -> std::result::Result<Vec<McpApprovalData>, String>; - - /// 保存审批数据。 - fn save_approval( - &self, - project_path: &str, - data: &McpApprovalData, - ) -> std::result::Result<(), String>; - - /// 清理指定项目的所有审批记录。 - fn clear_approvals(&self, project_path: &str) -> std::result::Result<(), String>; -} +pub use astrcode_core::{McpApprovalData, McpApprovalStatus, McpSettingsStore}; diff --git a/crates/adapter-skills/src/lib.rs b/crates/adapter-skills/src/lib.rs index ae453a82..44e34523 100644 --- a/crates/adapter-skills/src/lib.rs +++ b/crates/adapter-skills/src/lib.rs @@ -6,7 +6,7 @@ mod skill_loader; mod skill_spec; pub use builtin_skills::load_builtin_skills; -pub use skill_catalog::{SkillCatalog, merge_skill_layers}; +pub use skill_catalog::{LayeredSkillCatalog, merge_skill_layers}; pub use skill_loader::{ SKILL_FILE_NAME, SKILL_TOOL_NAME, SkillFrontmatter, collect_asset_files, load_project_skills, load_user_skills, parse_skill_md, skill_roots_cache_marker, diff --git a/crates/adapter-skills/src/skill_catalog.rs b/crates/adapter-skills/src/skill_catalog.rs index 802cb145..115e7463 100644 --- a/crates/adapter-skills/src/skill_catalog.rs +++ b/crates/adapter-skills/src/skill_catalog.rs @@ -25,6 +25,7 @@ use std::{ sync::{Arc, RwLock, RwLockReadGuard, RwLockWriteGuard}, }; +use astrcode_core::SkillCatalog as SkillCatalogPort; use log::debug; use crate::{ @@ -40,7 +41,7 @@ use crate::{ /// Base skills 包含 builtin、plugin、mcp 来源的 skill, /// 在 runtime 装配时一次性构建。User 和 project skill 在每次解析时动态加载。 #[derive(Debug, Clone)] -pub struct SkillCatalog { +pub struct LayeredSkillCatalog { /// Base skills(builtin + plugin + mcp),按优先级排序。 /// 使用 RwLock 支持并发读取和原子替换。 base_skills: Arc<RwLock<Vec<SkillSpec>>>, @@ -51,7 +52,7 @@ pub struct SkillCatalog { user_home_dir: Option<PathBuf>, } -impl SkillCatalog { +impl LayeredSkillCatalog { /// 创建新的 SkillCatalog。 /// /// `base_skills` 应按优先级从低到高排序(builtin < mcp < plugin), @@ -119,9 +120,16 @@ impl SkillCatalog { } } +impl SkillCatalogPort for LayeredSkillCatalog { + fn resolve_for_working_dir(&self, working_dir: &str) -> Vec<SkillSpec> { + let base = self.base_skills(); + resolve_skills(&base, self.user_home_dir.as_deref(), working_dir) + } +} + /// 合并 base skills、user skills 和 project skills。 /// -/// 这是 `SkillCatalog::resolve_for_working_dir` 的核心逻辑。 +/// 这是 `LayeredSkillCatalog::resolve_for_working_dir` 的核心逻辑。 /// /// 保持为 crate 内部函数,避免外部调用方绕过 `SkillCatalog` /// 直接把 skill 解析重新分散到各处。 @@ -229,7 +237,7 @@ mod tests { make_skill("git-commit", SkillSource::Mcp), make_skill("git-commit", SkillSource::Plugin), ]; - let catalog = SkillCatalog::new(base); + let catalog = LayeredSkillCatalog::new(base); // 这里只验证 base skill 的归一化顺序,避免测试受本机 user/project skill 目录污染。 let normalized = catalog.base_skills(); let git_skill = normalized.iter().find(|s| s.id == "git-commit"); @@ -242,7 +250,7 @@ mod tests { #[test] fn test_catalog_replace_base_skills() { - let catalog = SkillCatalog::new(vec![make_skill("old-skill", SkillSource::Builtin)]); + let catalog = LayeredSkillCatalog::new(vec![make_skill("old-skill", SkillSource::Builtin)]); assert_eq!(catalog.base_skills().len(), 1); catalog.replace_base_skills(vec![ @@ -267,7 +275,7 @@ mod tests { ) .expect("user skill file should be written"); - let catalog = SkillCatalog::new_with_home_dir(Vec::new(), temp_home.path()); + let catalog = LayeredSkillCatalog::new_with_home_dir(Vec::new(), temp_home.path()); let resolved = catalog.resolve_for_working_dir(&temp_home.path().to_string_lossy()); assert!(resolved.iter().any(|skill| skill.id == "clarify-first")); diff --git a/crates/adapter-skills/src/skill_spec.rs b/crates/adapter-skills/src/skill_spec.rs index 0266f9f6..c5f29bcd 100644 --- a/crates/adapter-skills/src/skill_spec.rs +++ b/crates/adapter-skills/src/skill_spec.rs @@ -1,148 +1,3 @@ -//! Skill 规格定义与名称校验。 -//! -//! 本模块定义了 skill 的核心数据结构 [`SkillSpec`],以及 skill 名称的校验和规范化逻辑。 -//! -//! # Skill 名称规则 -//! -//! Skill 名称必须为 kebab-case(小写字母、数字、连字符),且必须与文件夹名一致。 -//! 这是 Claude-style skill 的约定,确保名称的一致性和可预测性。 +//! Skill 规格定义与名称校验由 `astrcode-core` 提供。 -use serde::{Deserialize, Serialize}; - -/// Skill 的来源。 -/// -/// 用于追踪 skill 是从哪里加载的,影响覆盖优先级和诊断标签。 -/// 优先级顺序:Builtin < Mcp < Plugin < User < Project(后者覆盖前者)。 -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] -#[serde(rename_all = "snake_case")] -pub enum SkillSource { - #[default] - Builtin, - User, - Project, - Plugin, - Mcp, -} - -impl SkillSource { - pub fn as_tag(&self) -> &'static str { - match self { - Self::Builtin => "source:builtin", - Self::User => "source:user", - Self::Project => "source:project", - Self::Plugin => "source:plugin", - Self::Mcp => "source:mcp", - } - } -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct SkillSpec { - /// Skill 的唯一标识符(kebab-case,与文件夹名一致)。 - pub id: String, - /// Skill 的显示名称(与 `id` 相同)。 - pub name: String, - /// Skill 的简短描述,用于 system prompt 中的索引展示。 - /// - /// 这是两阶段 skill 模型的第一阶段:模型通过描述判断何时调用 `Skill` tool。 - pub description: String, - /// Skill 的完整指南正文。 - /// - /// 在两阶段模型中,只有当模型调用 `Skill` tool 时才加载此内容。 - pub guide: String, - /// Skill 目录的根路径(运行时填充)。 - pub skill_root: Option<String>, - /// Skill 目录中的资产文件列表(如 `references/`、`scripts/` 下的文件)。 - pub asset_files: Vec<String>, - /// 此 skill 允许调用的工具列表。 - /// - /// 用于限制 skill 执行时的能力边界,builtin skill 由 `build.rs` 配置。 - pub allowed_tools: Vec<String>, - /// Skill 的来源,影响覆盖优先级和诊断标签。 - pub source: SkillSource, -} - -impl SkillSpec { - /// 检查此 skill 是否匹配请求的名称。 - /// - /// 比较时进行大小写不敏感和斜杠容忍处理, - /// 使得 `/repo-search` 和 `REPO SEARCH` 都能匹配 `repo-search`。 - pub fn matches_requested_name(&self, requested_name: &str) -> bool { - let requested_name = normalize_skill_name(requested_name); - // `id` is already validated as kebab-case at parse time, so normalize - // is strictly for the caller-provided side — both sides land in the - // same canonical form for comparison. - requested_name == normalize_skill_name(&self.id) - } -} - -/// 检查名称是否为合法的 skill 名称。 -/// -/// 合法名称仅允许小写 ASCII 字母、数字和连字符,且不能为空。 -/// 这是 Claude-style skill 的强制要求。 -pub fn is_valid_skill_name(name: &str) -> bool { - !name.is_empty() - && name - .chars() - .all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-') -} - -/// 规范化 skill 名称。 -/// -/// 将输入转换为小写、去除首尾空白和前导斜杠、将非字母数字字符替换为空格后合并。 -/// 用于用户输入与 skill id 的模糊匹配。 -pub fn normalize_skill_name(value: &str) -> String { - value - .trim() - .trim_start_matches('/') - .chars() - .map(|ch| { - if ch.is_ascii_alphanumeric() || (!ch.is_ascii() && ch.is_alphanumeric()) { - ch.to_ascii_lowercase() - } else { - ' ' - } - }) - .collect::<String>() - .split_whitespace() - .collect::<Vec<_>>() - .join(" ") -} - -#[cfg(test)] -mod tests { - use super::*; - - fn project_skill(id: &str, name: &str, description: &str) -> SkillSpec { - SkillSpec { - id: id.to_string(), - name: name.to_string(), - description: description.to_string(), - guide: "guide".to_string(), - skill_root: None, - asset_files: Vec::new(), - allowed_tools: Vec::new(), - source: SkillSource::Project, - } - } - - #[test] - fn skill_name_matching_is_case_insensitive_and_slash_tolerant() { - let skill = project_skill("repo-search", "repo-search", "Search the repo"); - - assert!(skill.matches_requested_name("repo-search")); - assert!(skill.matches_requested_name("/repo-search")); - assert!(skill.matches_requested_name("REPO SEARCH")); - assert!(!skill.matches_requested_name("edit-file")); - } - - #[test] - fn validates_claude_style_skill_names() { - assert!(is_valid_skill_name("git-commit")); - assert!(is_valid_skill_name("pdf2")); - assert!(!is_valid_skill_name("Git-Commit")); - assert!(!is_valid_skill_name("git_commit")); - assert!(!is_valid_skill_name("")); - } -} +pub use astrcode_core::{SkillSource, SkillSpec, is_valid_skill_name, normalize_skill_name}; diff --git a/crates/adapter-storage/Cargo.toml b/crates/adapter-storage/Cargo.toml index e8892907..e6e22d9e 100644 --- a/crates/adapter-storage/Cargo.toml +++ b/crates/adapter-storage/Cargo.toml @@ -6,7 +6,6 @@ license.workspace = true authors.workspace = true [dependencies] -astrcode-adapter-mcp = { path = "../adapter-mcp" } astrcode-core = { path = "../core" } async-trait.workspace = true chrono.workspace = true diff --git a/crates/adapter-storage/src/mcp_settings_store.rs b/crates/adapter-storage/src/mcp_settings_store.rs index 4e0845c3..0593a701 100644 --- a/crates/adapter-storage/src/mcp_settings_store.rs +++ b/crates/adapter-storage/src/mcp_settings_store.rs @@ -8,7 +8,7 @@ use std::{ path::{Path, PathBuf}, }; -use astrcode_adapter_mcp::config::{McpApprovalData, McpSettingsStore}; +use astrcode_core::{McpApprovalData, McpSettingsStore}; use serde::{Deserialize, Serialize}; /// 基于 JSON 文件的 MCP 审批设置存储。 @@ -116,7 +116,7 @@ fn write_atomic(path: &Path, bytes: &[u8]) -> Result<(), String> { #[cfg(test)] mod tests { - use astrcode_adapter_mcp::config::McpApprovalStatus; + use astrcode_core::McpApprovalStatus; use super::*; diff --git a/crates/adapter-tools/Cargo.toml b/crates/adapter-tools/Cargo.toml index a6b5a7fd..39cbf603 100644 --- a/crates/adapter-tools/Cargo.toml +++ b/crates/adapter-tools/Cargo.toml @@ -6,7 +6,6 @@ license.workspace = true authors.workspace = true [dependencies] -astrcode-adapter-skills = { path = "../adapter-skills" } astrcode-core = { path = "../core" } async-trait.workspace = true base64.workspace = true diff --git a/crates/adapter-tools/src/builtin_tools/skill_tool.rs b/crates/adapter-tools/src/builtin_tools/skill_tool.rs index b9912d35..1a9f243f 100644 --- a/crates/adapter-tools/src/builtin_tools/skill_tool.rs +++ b/crates/adapter-tools/src/builtin_tools/skill_tool.rs @@ -9,20 +9,21 @@ //! //! ## 事实源 //! -//! 唯一事实源为 `SkillCatalog`(adapter-skills), +//! 唯一事实源为 `SkillCatalog` 端口, //! SkillTool 不做独立缓存或发现。 use std::sync::Arc; -use astrcode_adapter_skills::{SKILL_TOOL_NAME, SkillCatalog, SkillSpec, normalize_skill_name}; use astrcode_core::{ - Result, SideEffect, Tool, ToolCapabilityMetadata, ToolContext, ToolDefinition, - ToolExecutionResult, ToolPromptMetadata, + Result, SideEffect, SkillCatalog, SkillSpec, Tool, ToolCapabilityMetadata, ToolContext, + ToolDefinition, ToolExecutionResult, ToolPromptMetadata, normalize_skill_name, }; use async_trait::async_trait; use serde::Deserialize; use serde_json::{Value, json}; +const SKILL_TOOL_NAME: &str = "Skill"; + /// Skill 工具的输入参数。 #[derive(Debug, Deserialize)] struct SkillToolInput { @@ -39,11 +40,11 @@ struct SkillToolInput { /// 每次执行时基于当前 working dir 查询 catalog, /// 确保 surface 替换后不会残留旧 skill。 pub struct SkillTool { - skill_catalog: Arc<SkillCatalog>, + skill_catalog: Arc<dyn SkillCatalog>, } impl SkillTool { - pub fn new(skill_catalog: Arc<SkillCatalog>) -> Self { + pub fn new(skill_catalog: Arc<dyn SkillCatalog>) -> Self { Self { skill_catalog } } } @@ -201,14 +202,43 @@ fn normalize_skill_path(path: &str) -> String { #[cfg(test)] mod tests { - use std::sync::Arc; + use std::sync::{Arc, RwLock}; - use astrcode_adapter_skills::{SkillCatalog, SkillSource, SkillSpec}; - use astrcode_core::{CancelToken, ToolContext}; + use astrcode_core::{CancelToken, SkillCatalog, SkillSource, SkillSpec, ToolContext}; use serde_json::json; use super::*; + #[derive(Default)] + struct FakeSkillCatalog { + skills: RwLock<Vec<SkillSpec>>, + } + + impl FakeSkillCatalog { + fn new(skills: Vec<SkillSpec>) -> Self { + Self { + skills: RwLock::new(skills), + } + } + + fn replace_base_skills(&self, skills: Vec<SkillSpec>) { + let mut guard = self + .skills + .write() + .expect("fake skill catalog lock should not be poisoned"); + *guard = skills; + } + } + + impl SkillCatalog for FakeSkillCatalog { + fn resolve_for_working_dir(&self, _working_dir: &str) -> Vec<SkillSpec> { + self.skills + .read() + .expect("fake skill catalog lock should not be poisoned") + .clone() + } + } + fn tool_context() -> ToolContext { ToolContext::new("session-1".into(), std::env::temp_dir(), CancelToken::new()) } @@ -228,7 +258,7 @@ mod tests { #[tokio::test] async fn loads_and_expands_skill_content() { - let catalog = Arc::new(SkillCatalog::new(vec![sample_skill()])); + let catalog = Arc::new(FakeSkillCatalog::new(vec![sample_skill()])); let tool = SkillTool::new(catalog); let result = tool @@ -252,7 +282,7 @@ mod tests { #[tokio::test] async fn rejects_unknown_skills() { - let catalog = Arc::new(SkillCatalog::new(vec![sample_skill()])); + let catalog = Arc::new(FakeSkillCatalog::new(vec![sample_skill()])); let tool = SkillTool::new(catalog); let result = tool @@ -275,8 +305,8 @@ mod tests { #[tokio::test] async fn reads_latest_skill_catalog_without_stale_cache() { - let catalog = Arc::new(SkillCatalog::new(vec![sample_skill()])); - let tool = SkillTool::new(Arc::clone(&catalog)); + let catalog = Arc::new(FakeSkillCatalog::new(vec![sample_skill()])); + let tool = SkillTool::new(catalog.clone()); catalog.replace_base_skills(vec![SkillSpec { id: "repo-search".to_string(), diff --git a/crates/core/src/config.rs b/crates/core/src/config.rs index d1acc776..a6e56144 100644 --- a/crates/core/src/config.rs +++ b/crates/core/src/config.rs @@ -171,7 +171,6 @@ pub struct RuntimeConfig { pub micro_compact_gap_threshold_secs: Option<u64>, #[serde(skip_serializing_if = "Option::is_none")] pub micro_compact_keep_recent_results: Option<usize>, - #[serde(skip_serializing_if = "Option::is_none")] pub api_session_ttl_hours: Option<i64>, } diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index a9f6f536..55d7deef 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -63,6 +63,7 @@ pub mod home; pub mod hook; pub mod ids; pub mod local_server; +mod mcp; pub mod mode; pub mod observability; pub mod plugin; @@ -76,6 +77,7 @@ pub mod session; mod session_catalog; mod session_plan; mod shell; +mod skill; pub mod store; mod time; // test_support 通过 feature gate "test-support" 守卫。 @@ -150,6 +152,7 @@ pub use hook::{ }; pub use ids::{AgentId, CapabilityName, SessionId, SubRunId, TurnId}; pub use local_server::{LOCAL_SERVER_READY_PREFIX, LocalServerInfo}; +pub use mcp::{McpApprovalData, McpApprovalStatus}; pub use mode::{ ActionPolicies, ActionPolicyEffect, ActionPolicyRule, BUILTIN_MODE_CODE_ID, BUILTIN_MODE_PLAN_ID, BUILTIN_MODE_REVIEW_ID, CapabilitySelector, ChildPolicySpec, @@ -169,12 +172,13 @@ pub use policy::{ }; pub use ports::{ EventStore, LlmEvent, LlmEventSink, LlmFinishReason, LlmOutput, LlmProvider, LlmRequest, - LlmUsage, ModelLimits, PromptAgentProfileSummary, PromptBuildCacheMetrics, PromptBuildOutput, - PromptBuildRequest, PromptCacheHints, PromptDeclaration, PromptDeclarationKind, - PromptDeclarationRenderTarget, PromptDeclarationSource, PromptEntrySummary, PromptFacts, - PromptFactsProvider, PromptFactsRequest, PromptGovernanceContext, PromptLayerFingerprints, - PromptProvider, PromptSkillSummary, RecoveredSessionState, ResourceProvider, - ResourceReadResult, ResourceRequestContext, SessionRecoveryCheckpoint, + LlmUsage, McpSettingsStore, ModelLimits, PromptAgentProfileSummary, PromptBuildCacheMetrics, + PromptBuildOutput, PromptBuildRequest, PromptCacheHints, PromptDeclaration, + PromptDeclarationKind, PromptDeclarationRenderTarget, PromptDeclarationSource, + PromptEntrySummary, PromptFacts, PromptFactsProvider, PromptFactsRequest, + PromptGovernanceContext, PromptLayerFingerprints, PromptProvider, PromptSkillSummary, + RecoveredSessionState, ResourceProvider, ResourceReadResult, ResourceRequestContext, + SessionRecoveryCheckpoint, SkillCatalog, }; pub use projection::{AgentState, AgentStateProjector, project}; pub use registry::{CapabilityContext, CapabilityExecutionResult, CapabilityInvoker}; @@ -189,6 +193,7 @@ pub use session_plan::{SessionPlanState, SessionPlanStatus, session_plan_content pub use shell::{ ResolvedShell, ShellFamily, default_shell_label, detect_shell_family, resolve_shell, }; +pub use skill::{SkillSource, SkillSpec, is_valid_skill_name, normalize_skill_name}; pub use store::{ EventLogWriter, SessionManager, SessionTurnAcquireResult, SessionTurnBusy, SessionTurnLease, StoreError, StoreResult, diff --git a/crates/core/src/mcp.rs b/crates/core/src/mcp.rs new file mode 100644 index 00000000..d00d9d92 --- /dev/null +++ b/crates/core/src/mcp.rs @@ -0,0 +1,21 @@ +//! MCP 审批共享模型。 + +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct McpApprovalData { + pub server_signature: String, + pub status: McpApprovalStatus, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub approved_at: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub approved_by: Option<String>, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum McpApprovalStatus { + Pending, + Approved, + Rejected, +} diff --git a/crates/core/src/ports.rs b/crates/core/src/ports.rs index 2edb2f64..5ae0f5b0 100644 --- a/crates/core/src/ports.rs +++ b/crates/core/src/ports.rs @@ -16,9 +16,10 @@ use serde_json::Value; use crate::{ AgentState, CancelToken, CapabilitySpec, ChildSessionNode, Config, ConfigOverlay, - DeleteProjectResult, InputQueueProjection, LlmMessage, Phase, ReasoningContent, Result, - SessionId, SessionMeta, SessionTurnAcquireResult, StorageEvent, StoredEvent, SystemPromptBlock, - SystemPromptLayer, TaskSnapshot, ToolCallRequest, ToolDefinition, TurnId, + DeleteProjectResult, InputQueueProjection, LlmMessage, McpApprovalData, Phase, + ReasoningContent, Result, SessionId, SessionMeta, SessionTurnAcquireResult, SkillSpec, + StorageEvent, StoredEvent, SystemPromptBlock, SystemPromptLayer, TaskSnapshot, ToolCallRequest, + ToolDefinition, TurnId, }; /// MCP 配置文件作用域。 @@ -493,6 +494,27 @@ pub trait ResourceProvider: Send + Sync { ) -> Result<ResourceReadResult>; } +/// Skill 查询端口。 +pub trait SkillCatalog: Send + Sync { + fn resolve_for_working_dir(&self, working_dir: &str) -> Vec<SkillSpec>; +} + +/// MCP settings 持久化端口。 +pub trait McpSettingsStore: Send + Sync { + fn load_approvals( + &self, + project_path: &str, + ) -> std::result::Result<Vec<McpApprovalData>, String>; + + fn save_approval( + &self, + project_path: &str, + data: &McpApprovalData, + ) -> std::result::Result<(), String>; + + fn clear_approvals(&self, project_path: &str) -> std::result::Result<(), String>; +} + /// 配置存储端口。 /// /// 将配置文件 IO 从 application 层剥离,由 adapter 层实现。 diff --git a/crates/core/src/skill.rs b/crates/core/src/skill.rs new file mode 100644 index 00000000..e6207aa4 --- /dev/null +++ b/crates/core/src/skill.rs @@ -0,0 +1,112 @@ +//! Skill 领域模型与查询端口共享的稳定类型。 + +use serde::{Deserialize, Serialize}; + +/// Skill 的来源。 +/// +/// 用于追踪 skill 是从哪里加载的,影响覆盖优先级和诊断标签。 +/// 优先级顺序:Builtin < Mcp < Plugin < User < Project(后者覆盖前者)。 +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] +#[serde(rename_all = "snake_case")] +pub enum SkillSource { + #[default] + Builtin, + User, + Project, + Plugin, + Mcp, +} + +impl SkillSource { + pub fn as_tag(&self) -> &'static str { + match self { + Self::Builtin => "source:builtin", + Self::User => "source:user", + Self::Project => "source:project", + Self::Plugin => "source:plugin", + Self::Mcp => "source:mcp", + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct SkillSpec { + pub id: String, + pub name: String, + pub description: String, + pub guide: String, + pub skill_root: Option<String>, + pub asset_files: Vec<String>, + pub allowed_tools: Vec<String>, + pub source: SkillSource, +} + +impl SkillSpec { + pub fn matches_requested_name(&self, requested_name: &str) -> bool { + let requested_name = normalize_skill_name(requested_name); + requested_name == normalize_skill_name(&self.id) + } +} + +pub fn is_valid_skill_name(name: &str) -> bool { + !name.is_empty() + && name + .chars() + .all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-') +} + +pub fn normalize_skill_name(value: &str) -> String { + value + .trim() + .trim_start_matches('/') + .chars() + .map(|ch| { + if ch.is_ascii_alphanumeric() || (!ch.is_ascii() && ch.is_alphanumeric()) { + ch.to_ascii_lowercase() + } else { + ' ' + } + }) + .collect::<String>() + .split_whitespace() + .collect::<Vec<_>>() + .join(" ") +} + +#[cfg(test)] +mod tests { + use super::*; + + fn project_skill(id: &str, name: &str, description: &str) -> SkillSpec { + SkillSpec { + id: id.to_string(), + name: name.to_string(), + description: description.to_string(), + guide: "guide".to_string(), + skill_root: None, + asset_files: Vec::new(), + allowed_tools: Vec::new(), + source: SkillSource::Project, + } + } + + #[test] + fn skill_name_matching_is_case_insensitive_and_slash_tolerant() { + let skill = project_skill("repo-search", "repo-search", "Search the repo"); + + assert!(skill.matches_requested_name("repo-search")); + assert!(skill.matches_requested_name("/repo-search")); + assert!(skill.matches_requested_name("REPO SEARCH")); + assert!(!skill.matches_requested_name("edit-file")); + } + + #[test] + fn validates_claude_style_skill_names() { + assert!(is_valid_skill_name("git-commit")); + assert!(is_valid_skill_name("pdf2")); + assert!(!is_valid_skill_name("Git-Commit")); + assert!(!is_valid_skill_name("git_commit")); + assert!(!is_valid_skill_name("")); + } +} diff --git a/crates/server/src/bootstrap/capabilities.rs b/crates/server/src/bootstrap/capabilities.rs index 1f5ae189..0ee3c1e9 100644 --- a/crates/server/src/bootstrap/capabilities.rs +++ b/crates/server/src/bootstrap/capabilities.rs @@ -9,7 +9,7 @@ use std::{path::Path, sync::Arc}; -use astrcode_adapter_skills::{SkillCatalog, SkillSpec, load_builtin_skills}; +use astrcode_adapter_skills::{LayeredSkillCatalog, load_builtin_skills}; use astrcode_adapter_tools::{ agent_tools::{CloseAgentTool, ObserveAgentTool, SendAgentTool, SpawnAgentTool}, builtin_tools::{ @@ -30,6 +30,7 @@ use astrcode_adapter_tools::{ }, }; use astrcode_application::AgentOrchestrationService; +use astrcode_core::{SkillCatalog, SkillSpec}; use super::deps::{ core::{CapabilityInvoker, Result, Tool}, @@ -43,7 +44,7 @@ use super::deps::{ /// 因为它们依赖 `AgentOrchestrationService`,必须在更晚的组合根阶段装配。 pub(crate) fn build_core_tool_invokers( tool_search_index: Arc<ToolSearchIndex>, - skill_catalog: Arc<SkillCatalog>, + skill_catalog: Arc<dyn SkillCatalog>, ) -> Result<Vec<Arc<dyn CapabilityInvoker>>> { let tools: Vec<Arc<dyn Tool>> = vec![ Arc::new(ReadFileTool), @@ -83,10 +84,13 @@ pub(crate) fn build_core_tool_invokers( pub(crate) fn build_skill_catalog( home_dir: &Path, mut external_base_skills: Vec<SkillSpec>, -) -> Arc<SkillCatalog> { +) -> Arc<LayeredSkillCatalog> { let mut base_skills = load_builtin_skills(); base_skills.append(&mut external_base_skills); - Arc::new(SkillCatalog::new_with_home_dir(base_skills, home_dir)) + Arc::new(LayeredSkillCatalog::new_with_home_dir( + base_skills, + home_dir, + )) } /// 让 tool_search 索引与当前外部能力事实源保持同步。 diff --git a/crates/server/src/bootstrap/composer_skills.rs b/crates/server/src/bootstrap/composer_skills.rs index 63bff0be..0d142b03 100644 --- a/crates/server/src/bootstrap/composer_skills.rs +++ b/crates/server/src/bootstrap/composer_skills.rs @@ -1,15 +1,15 @@ use std::{path::Path, sync::Arc}; -use astrcode_adapter_skills::SkillCatalog; use astrcode_application::{ComposerResolvedSkill, ComposerSkillPort, ComposerSkillSummary}; +use astrcode_core::SkillCatalog; #[derive(Clone)] pub(crate) struct RuntimeComposerSkillPort { - skill_catalog: Arc<SkillCatalog>, + skill_catalog: Arc<dyn SkillCatalog>, } impl RuntimeComposerSkillPort { - pub(crate) fn new(skill_catalog: Arc<SkillCatalog>) -> Self { + pub(crate) fn new(skill_catalog: Arc<dyn SkillCatalog>) -> Self { Self { skill_catalog } } } diff --git a/crates/server/src/bootstrap/governance.rs b/crates/server/src/bootstrap/governance.rs index e9295935..94f9592d 100644 --- a/crates/server/src/bootstrap/governance.rs +++ b/crates/server/src/bootstrap/governance.rs @@ -6,7 +6,7 @@ use std::{path::PathBuf, sync::Arc}; use astrcode_adapter_mcp::manager::McpConnectionManager; -use astrcode_adapter_skills::{SkillCatalog, load_builtin_skills}; +use astrcode_adapter_skills::{LayeredSkillCatalog, load_builtin_skills}; use astrcode_application::{ AppGovernance, ApplicationError, ModeCatalog, RuntimeGovernancePort, RuntimeGovernanceSnapshot, RuntimeObservabilityCollector, RuntimeReloader, SessionInfoProvider, config::ConfigService, @@ -33,7 +33,7 @@ pub(crate) struct GovernanceBuildInput { pub observability: Arc<RuntimeObservabilityCollector>, pub mcp_manager: Arc<McpConnectionManager>, pub capability_sync: CapabilitySurfaceSync, - pub skill_catalog: Arc<SkillCatalog>, + pub skill_catalog: Arc<LayeredSkillCatalog>, pub plugin_search_paths: Vec<PathBuf>, pub plugin_skill_root: PathBuf, pub plugin_supervisors: Vec<Arc<Supervisor>>, @@ -162,7 +162,7 @@ struct ServerRuntimeReloader { coordinator: Arc<RuntimeCoordinator>, mcp_manager: Arc<McpConnectionManager>, capability_sync: CapabilitySurfaceSync, - skill_catalog: Arc<SkillCatalog>, + skill_catalog: Arc<LayeredSkillCatalog>, plugin_search_paths: Vec<PathBuf>, plugin_skill_root: PathBuf, working_dir: PathBuf, diff --git a/crates/server/src/bootstrap/plugins.rs b/crates/server/src/bootstrap/plugins.rs index 3cd7c4e7..fe11dc30 100644 --- a/crates/server/src/bootstrap/plugins.rs +++ b/crates/server/src/bootstrap/plugins.rs @@ -15,8 +15,8 @@ use std::{ sync::Arc, }; -use astrcode_adapter_skills::{SkillSource, SkillSpec, collect_asset_files, is_valid_skill_name}; -use astrcode_core::GovernanceModeSpec; +use astrcode_adapter_skills::collect_asset_files; +use astrcode_core::{GovernanceModeSpec, SkillSource, SkillSpec, is_valid_skill_name}; use astrcode_plugin::{PluginLoader, Supervisor, default_initialize_message, default_profiles}; use astrcode_protocol::plugin::{PeerDescriptor, SkillDescriptor}; use log::warn; diff --git a/crates/server/src/bootstrap/prompt_facts.rs b/crates/server/src/bootstrap/prompt_facts.rs index 32d39d50..1863b748 100644 --- a/crates/server/src/bootstrap/prompt_facts.rs +++ b/crates/server/src/bootstrap/prompt_facts.rs @@ -10,8 +10,8 @@ use std::{ use astrcode_adapter_agents::AgentProfileLoader; use astrcode_adapter_mcp::manager::McpConnectionManager; -use astrcode_adapter_skills::SkillCatalog; use astrcode_application::config::{ConfigService, resolve_current_model}; +use astrcode_core::SkillCatalog; use async_trait::async_trait; use super::deps::core::{ @@ -22,7 +22,7 @@ use super::deps::core::{ pub(crate) fn build_prompt_facts_provider( config_service: Arc<ConfigService>, - skill_catalog: Arc<SkillCatalog>, + skill_catalog: Arc<dyn SkillCatalog>, mcp_manager: Arc<McpConnectionManager>, agent_loader: AgentProfileLoader, ) -> Result<Arc<dyn PromptFactsProvider>> { @@ -36,7 +36,7 @@ pub(crate) fn build_prompt_facts_provider( struct RuntimePromptFactsProvider { config_service: Arc<ConfigService>, - skill_catalog: Arc<SkillCatalog>, + skill_catalog: Arc<dyn SkillCatalog>, agent_loader: AgentProfileLoader, mcp_manager: Arc<McpConnectionManager>, } diff --git a/crates/session-runtime/src/context_window/compaction.rs b/crates/session-runtime/src/context_window/compaction.rs index 06f3d649..7170bc77 100644 --- a/crates/session-runtime/src/context_window/compaction.rs +++ b/crates/session-runtime/src/context_window/compaction.rs @@ -13,7 +13,7 @@ //! 如果压缩请求本身超出上下文窗口,会逐步丢弃最旧的 compact unit 并重试, //! 最多重试 3 次。 -use std::sync::OnceLock; +use std::{collections::HashSet, sync::OnceLock}; use astrcode_core::{ AstrError, CancelToken, CompactAppliedMeta, CompactMode, CompactSummaryEnvelope, LlmMessage, @@ -150,6 +150,31 @@ impl CompactContractViolation { } } +#[derive(Debug, Clone, Default, PartialEq, Eq)] +struct CompactRetryState { + salvage_attempts: usize, + contract_retry_count: usize, + contract_repair_feedback: Option<String>, +} + +impl CompactRetryState { + fn schedule_contract_retry(&mut self, detail: String) { + self.contract_retry_count = self.contract_retry_count.saturating_add(1); + self.contract_repair_feedback = Some(detail); + } + + fn note_salvage_attempt(&mut self) { + self.salvage_attempts = self.salvage_attempts.saturating_add(1); + } +} + +#[derive(Debug, Clone)] +struct CompactExecutionResult { + parsed_output: ParsedCompactOutput, + prepared_input: PreparedCompactInput, + retry_state: CompactRetryState, +} + /// 执行自动压缩。 /// /// 通过 `gateway` 调用 LLM 对历史前缀生成摘要,替换为压缩后的消息。 @@ -179,73 +204,22 @@ pub async fn auto_compact( .max_output_tokens .min(gateway.model_limits().max_output_tokens) .max(1); - let mut salvage_attempts = 0usize; - let mut contract_retry_count = 0usize; - let mut contract_repair_feedback: Option<String> = None; - let (parsed_output, prepared_input) = loop { - if !trim_prefix_until_compact_request_fits( - &mut split.prefix, - compact_prompt_context, - gateway.model_limits(), - &config, - &recent_user_context_messages, - ) { - return Err(AstrError::Internal( - "compact request could not fit within summarization window".to_string(), - )); - } - - let prepared_input = prepare_compact_input(&split.prefix); - if prepared_input.messages.is_empty() { - return Ok(None); - } - - let request = LlmRequest::new(prepared_input.messages.clone(), Vec::new(), cancel.clone()) - .with_system(render_compact_system_prompt( - compact_prompt_context, - prepared_input.prompt_mode.clone(), - effective_max_output_tokens, - &recent_user_context_messages, - config.custom_instructions.as_deref(), - contract_repair_feedback.as_deref(), - )) - .with_max_output_tokens_override(effective_max_output_tokens); - match gateway.call_llm(request, None).await { - Ok(output) => match parse_compact_output(&output.content) { - Ok(parsed_output) => { - if let Some(violation) = - CompactContractViolation::from_parsed_output(&parsed_output) - { - if contract_retry_count < config.max_retry_attempts { - contract_retry_count += 1; - contract_repair_feedback = Some(violation.detail); - continue; - } - } - break (parsed_output, prepared_input); - }, - Err(error) if contract_retry_count < config.max_retry_attempts => { - contract_retry_count += 1; - contract_repair_feedback = Some(error.to_string()); - continue; - }, - Err(error) => return Err(error), - }, - Err(error) - if is_prompt_too_long(&error) && salvage_attempts < config.max_retry_attempts => - { - salvage_attempts += 1; - if !drop_oldest_compaction_unit(&mut split.prefix) { - return Err(AstrError::Internal(error.to_string())); - } - split.keep_start = split.prefix.len(); - }, - Err(error) => return Err(AstrError::Internal(error.to_string())), - } + let Some(execution) = execute_compact_request_with_retries( + gateway, + &mut split, + compact_prompt_context, + &config, + &recent_user_context_messages, + effective_max_output_tokens, + cancel, + ) + .await? + else { + return Ok(None); }; let summary = { - let summary = sanitize_compact_summary(&parsed_output.summary); + let summary = sanitize_compact_summary(&execution.parsed_output.summary); if let Some(history_path) = config.history_path.as_deref() { CompactSummaryEnvelope::new(summary) .with_history_path(history_path) @@ -254,12 +228,12 @@ pub async fn auto_compact( summary } }; - let recent_user_context_digest = parsed_output + let recent_user_context_digest = execution + .parsed_output .recent_user_context_digest .as_deref() .map(sanitize_recent_user_context_digest) .filter(|value| !value.is_empty()); - let output_summary_chars = summary.chars().count().min(u32::MAX as usize) as u32; let compacted_messages = compacted_messages( &summary, recent_user_context_digest.as_deref(), @@ -267,33 +241,18 @@ pub async fn auto_compact( split.keep_start, split.suffix, ); - let post_tokens_estimate = estimate_request_tokens(&compacted_messages, compact_prompt_context); - Ok(Some(CompactResult { - messages: compacted_messages, + Ok(Some(build_compact_result( + compacted_messages, summary, recent_user_context_digest, - recent_user_context_messages: recent_user_context_messages - .iter() - .map(|message| message.content.clone()) - .collect(), + recent_user_context_messages, preserved_recent_turns, pre_tokens, - post_tokens_estimate, - messages_removed: split.keep_start, - tokens_freed: pre_tokens.saturating_sub(post_tokens_estimate), - timestamp: Utc::now(), - meta: CompactAppliedMeta { - mode: prepared_input.prompt_mode.compact_mode(salvage_attempts), - instructions_present: config - .custom_instructions - .as_deref() - .is_some_and(|value| !value.trim().is_empty()), - fallback_used: parsed_output.used_fallback || salvage_attempts > 0, - retry_count: salvage_attempts.min(u32::MAX as usize) as u32, - input_units: prepared_input.input_units.min(u32::MAX as usize) as u32, - output_summary_chars, - }, - })) + split.keep_start, + compact_prompt_context, + &config, + execution, + ))) } #[derive(Debug, Clone)] @@ -442,6 +401,155 @@ fn trim_prefix_until_compact_request_fits( } } +async fn execute_compact_request_with_retries( + gateway: &KernelGateway, + split: &mut CompactionSplit, + compact_prompt_context: Option<&str>, + config: &CompactConfig, + recent_user_context_messages: &[RecentUserContextMessage], + effective_max_output_tokens: usize, + cancel: CancelToken, +) -> Result<Option<CompactExecutionResult>> { + let mut retry_state = CompactRetryState::default(); + loop { + if !trim_prefix_until_compact_request_fits( + &mut split.prefix, + compact_prompt_context, + gateway.model_limits(), + config, + recent_user_context_messages, + ) { + return Err(AstrError::Internal( + "compact request could not fit within summarization window".to_string(), + )); + } + + let prepared_input = prepare_compact_input(&split.prefix); + if prepared_input.messages.is_empty() { + return Ok(None); + } + + let request = build_compact_request( + prepared_input.messages.clone(), + compact_prompt_context, + &prepared_input.prompt_mode, + effective_max_output_tokens, + recent_user_context_messages, + config.custom_instructions.as_deref(), + retry_state.contract_repair_feedback.as_deref(), + cancel.clone(), + ); + + match gateway.call_llm(request, None).await { + Ok(output) => match parse_compact_output(&output.content) { + Ok(parsed_output) => { + if let Some(violation) = + CompactContractViolation::from_parsed_output(&parsed_output) + { + if retry_state.contract_retry_count < config.max_retry_attempts { + retry_state.schedule_contract_retry(violation.detail); + continue; + } + } + return Ok(Some(CompactExecutionResult { + parsed_output, + prepared_input, + retry_state, + })); + }, + Err(error) if retry_state.contract_retry_count < config.max_retry_attempts => { + retry_state.schedule_contract_retry(error.to_string()); + continue; + }, + Err(error) => return Err(error), + }, + Err(error) + if is_prompt_too_long(&error) + && retry_state.salvage_attempts < config.max_retry_attempts => + { + retry_state.note_salvage_attempt(); + if !drop_oldest_compaction_unit(&mut split.prefix) { + return Err(AstrError::Internal(error.to_string())); + } + split.keep_start = split.prefix.len(); + }, + Err(error) => return Err(AstrError::Internal(error.to_string())), + } + } +} + +fn build_compact_request( + messages: Vec<LlmMessage>, + compact_prompt_context: Option<&str>, + prompt_mode: &CompactPromptMode, + effective_max_output_tokens: usize, + recent_user_context_messages: &[RecentUserContextMessage], + custom_instructions: Option<&str>, + contract_repair_feedback: Option<&str>, + cancel: CancelToken, +) -> LlmRequest { + LlmRequest::new(messages, Vec::new(), cancel) + .with_system(render_compact_system_prompt( + compact_prompt_context, + prompt_mode.clone(), + effective_max_output_tokens, + recent_user_context_messages, + custom_instructions, + contract_repair_feedback, + )) + .with_max_output_tokens_override(effective_max_output_tokens) +} + +fn build_compact_result( + compacted_messages: Vec<LlmMessage>, + summary: String, + recent_user_context_digest: Option<String>, + recent_user_context_messages: Vec<RecentUserContextMessage>, + preserved_recent_turns: usize, + pre_tokens: usize, + messages_removed: usize, + compact_prompt_context: Option<&str>, + config: &CompactConfig, + execution: CompactExecutionResult, +) -> CompactResult { + let CompactExecutionResult { + parsed_output, + prepared_input, + retry_state, + } = execution; + let post_tokens_estimate = estimate_request_tokens(&compacted_messages, compact_prompt_context); + let output_summary_chars = summary.chars().count().min(u32::MAX as usize) as u32; + + CompactResult { + messages: compacted_messages, + summary, + recent_user_context_digest, + recent_user_context_messages: recent_user_context_messages + .into_iter() + .map(|message| message.content) + .collect(), + preserved_recent_turns, + pre_tokens, + post_tokens_estimate, + messages_removed, + tokens_freed: pre_tokens.saturating_sub(post_tokens_estimate), + timestamp: Utc::now(), + meta: CompactAppliedMeta { + mode: prepared_input + .prompt_mode + .compact_mode(retry_state.salvage_attempts), + instructions_present: config + .custom_instructions + .as_deref() + .is_some_and(|value| !value.trim().is_empty()), + fallback_used: parsed_output.used_fallback || retry_state.salvage_attempts > 0, + retry_count: retry_state.salvage_attempts.min(u32::MAX as usize) as u32, + input_units: prepared_input.input_units.min(u32::MAX as usize) as u32, + output_summary_chars, + }, + } +} + fn compact_request_fits_window( request_messages: &[LlmMessage], system_prompt: &str, @@ -459,6 +567,10 @@ fn compacted_messages( keep_start: usize, suffix: Vec<LlmMessage>, ) -> Vec<LlmMessage> { + let recent_user_context_indices = recent_user_context_messages + .iter() + .map(|message| message.index) + .collect::<HashSet<_>>(); let mut messages = vec![LlmMessage::User { content: format_compact_summary(summary), origin: UserMessageOrigin::CompactSummary, @@ -480,15 +592,15 @@ fn compacted_messages( .into_iter() .enumerate() .filter(|(offset, message)| { - !matches!( + let is_reinjected_real_user_message = matches!( message, LlmMessage::User { origin: UserMessageOrigin::User, .. - } if recent_user_context_messages - .iter() - .any(|recent| recent.index == keep_start + offset) - ) + } + ) && recent_user_context_indices + .contains(&(keep_start + offset)); + !is_reinjected_real_user_message }) .map(|(_, message)| message), ); diff --git a/crates/session-runtime/src/context_window/compaction/protocol.rs b/crates/session-runtime/src/context_window/compaction/protocol.rs index ae3a6f88..d22b2edc 100644 --- a/crates/session-runtime/src/context_window/compaction/protocol.rs +++ b/crates/session-runtime/src/context_window/compaction/protocol.rs @@ -249,21 +249,7 @@ pub(super) fn normalize_compaction_tool_content(content: &str) -> String { if is_persisted_output(&collapsed) { return summarize_persisted_tool_output(&collapsed); } - - const MAX_COMPACTION_TOOL_CHARS: usize = 1_600; - let char_count = collapsed.chars().count(); - if char_count <= MAX_COMPACTION_TOOL_CHARS { - return collapsed; - } - - let preview = collapsed - .chars() - .take(MAX_COMPACTION_TOOL_CHARS) - .collect::<String>(); - format!( - "{preview}\n\n[tool output truncated for compaction; preserve only the conclusion, key \ - errors, important file paths, and referenced IDs]" - ) + collapsed } pub(super) fn sanitize_compact_summary(summary: &str) -> String { diff --git a/crates/session-runtime/src/context_window/compaction/sanitize.rs b/crates/session-runtime/src/context_window/compaction/sanitize.rs index 05913b52..ab011744 100644 --- a/crates/session-runtime/src/context_window/compaction/sanitize.rs +++ b/crates/session-runtime/src/context_window/compaction/sanitize.rs @@ -1,61 +1,106 @@ use super::*; +type RegexAccessor = fn() -> &'static Regex; + +#[derive(Clone, Copy)] +struct ReplacementRule { + regex: RegexAccessor, + replacement: &'static str, +} + +#[derive(Clone, Copy)] +struct RouteKeyRule { + key: &'static str, + replacement: &'static str, +} + +const SANITIZE_REPLACEMENT_RULES: &[ReplacementRule] = &[ + ReplacementRule { + regex: direct_child_validation_regex, + replacement: "direct-child validation rejected a stale child reference; use the live \ + direct-child snapshot or the latest live tool result instead.", + }, + ReplacementRule { + regex: child_agent_reference_block_regex, + replacement: "Child agent reference metadata existed earlier, but compacted history is \ + not an authoritative routing source.", + }, + ReplacementRule { + regex: exact_agent_instruction_regex, + replacement: "Use only the latest live child snapshot or tool result for agent routing.", + }, + ReplacementRule { + regex: raw_root_agent_id_regex, + replacement: "<agent-id>", + }, + ReplacementRule { + regex: raw_agent_id_regex, + replacement: "<agent-id>", + }, + ReplacementRule { + regex: raw_subrun_id_regex, + replacement: "<subrun-id>", + }, + ReplacementRule { + regex: raw_session_id_regex, + replacement: "<session-id>", + }, +]; + +const ROUTE_KEY_RULES: &[RouteKeyRule] = &[ + RouteKeyRule { + key: "agentId", + replacement: "${key}<latest-direct-child-agentId>", + }, + RouteKeyRule { + key: "childAgentId", + replacement: "${key}<latest-direct-child-agentId>", + }, + RouteKeyRule { + key: "parentAgentId", + replacement: "${key}<parent-agentId>", + }, + RouteKeyRule { + key: "subRunId", + replacement: "${key}<direct-child-subRunId>", + }, + RouteKeyRule { + key: "parentSubRunId", + replacement: "${key}<parent-subRunId>", + }, + RouteKeyRule { + key: "sessionId", + replacement: "${key}<session-id>", + }, + RouteKeyRule { + key: "childSessionId", + replacement: "${key}<child-session-id>", + }, + RouteKeyRule { + key: "openSessionId", + replacement: "${key}<child-session-id>", + }, +]; + +struct CompiledRouteKeyRule { + replacement: &'static str, + regex: Regex, +} + pub(super) fn sanitize_compact_summary(summary: &str) -> String { let had_route_sensitive_content = summary_has_route_sensitive_content(summary); let mut sanitized = summary.trim().to_string(); - sanitized = direct_child_validation_regex() - .replace_all( - &sanitized, - "direct-child validation rejected a stale child reference; use the live direct-child \ - snapshot or the latest live tool result instead.", - ) - .into_owned(); - sanitized = child_agent_reference_block_regex() - .replace_all( - &sanitized, - "Child agent reference metadata existed earlier, but compacted history is not an \ - authoritative routing source.", - ) - .into_owned(); - for (regex, replacement) in [ - ( - route_key_regex("agentId"), - "${key}<latest-direct-child-agentId>", - ), - ( - route_key_regex("childAgentId"), - "${key}<latest-direct-child-agentId>", - ), - (route_key_regex("parentAgentId"), "${key}<parent-agentId>"), - (route_key_regex("subRunId"), "${key}<direct-child-subRunId>"), - (route_key_regex("parentSubRunId"), "${key}<parent-subRunId>"), - (route_key_regex("sessionId"), "${key}<session-id>"), - ( - route_key_regex("childSessionId"), - "${key}<child-session-id>", - ), - (route_key_regex("openSessionId"), "${key}<child-session-id>"), - ] { - sanitized = regex.replace_all(&sanitized, replacement).into_owned(); + for rule in SANITIZE_REPLACEMENT_RULES { + sanitized = (rule.regex)() + .replace_all(&sanitized, rule.replacement) + .into_owned(); + } + for rule in route_key_rules() { + sanitized = rule + .regex + .replace_all(&sanitized, rule.replacement) + .into_owned(); } - sanitized = exact_agent_instruction_regex() - .replace_all( - &sanitized, - "Use only the latest live child snapshot or tool result for agent routing.", - ) - .into_owned(); - sanitized = raw_root_agent_id_regex() - .replace_all(&sanitized, "<agent-id>") - .into_owned(); - sanitized = raw_agent_id_regex() - .replace_all(&sanitized, "<agent-id>") - .into_owned(); - sanitized = raw_subrun_id_regex() - .replace_all(&sanitized, "<subrun-id>") - .into_owned(); - sanitized = raw_session_id_regex() - .replace_all(&sanitized, "<session-id>") - .into_owned(); sanitized = collapse_compaction_whitespace(&sanitized); if had_route_sensitive_content { ensure_compact_boundary_section(&sanitized) @@ -81,25 +126,12 @@ fn ensure_compact_boundary_section(summary: &str) -> String { } fn summary_has_route_sensitive_content(summary: &str) -> bool { - direct_child_validation_regex().is_match(summary) - || child_agent_reference_block_regex().is_match(summary) - || exact_agent_instruction_regex().is_match(summary) - || raw_root_agent_id_regex().is_match(summary) - || raw_agent_id_regex().is_match(summary) - || raw_subrun_id_regex().is_match(summary) - || raw_session_id_regex().is_match(summary) - || [ - route_key_regex("agentId"), - route_key_regex("childAgentId"), - route_key_regex("parentAgentId"), - route_key_regex("subRunId"), - route_key_regex("parentSubRunId"), - route_key_regex("sessionId"), - route_key_regex("childSessionId"), - route_key_regex("openSessionId"), - ] - .into_iter() - .any(|regex| regex.is_match(summary)) + SANITIZE_REPLACEMENT_RULES + .iter() + .any(|rule| (rule.regex)().is_match(summary)) + || route_key_rules() + .iter() + .any(|rule| rule.regex.is_match(summary)) } fn child_agent_reference_block_regex() -> &'static Regex { @@ -118,34 +150,26 @@ fn direct_child_validation_regex() -> &'static Regex { }) } -fn route_key_regex(key: &str) -> &'static Regex { - static AGENT_ID: OnceLock<Regex> = OnceLock::new(); - static CHILD_AGENT_ID: OnceLock<Regex> = OnceLock::new(); - static PARENT_AGENT_ID: OnceLock<Regex> = OnceLock::new(); - static SUB_RUN_ID: OnceLock<Regex> = OnceLock::new(); - static PARENT_SUB_RUN_ID: OnceLock<Regex> = OnceLock::new(); - static SESSION_ID: OnceLock<Regex> = OnceLock::new(); - static CHILD_SESSION_ID: OnceLock<Regex> = OnceLock::new(); - static OPEN_SESSION_ID: OnceLock<Regex> = OnceLock::new(); - let slot = match key { - "agentId" => &AGENT_ID, - "childAgentId" => &CHILD_AGENT_ID, - "parentAgentId" => &PARENT_AGENT_ID, - "subRunId" => &SUB_RUN_ID, - "parentSubRunId" => &PARENT_SUB_RUN_ID, - "sessionId" => &SESSION_ID, - "childSessionId" => &CHILD_SESSION_ID, - "openSessionId" => &OPEN_SESSION_ID, - other => panic!("unsupported route key regex: {other}"), - }; - slot.get_or_init(|| { - Regex::new(&format!( - r"(?i)(?P<key>`?{key}`?\s*[:=]\s*`?)[^`\s,;\])]+`?" - )) - .expect("route key regex should compile") +fn route_key_rules() -> &'static [CompiledRouteKeyRule] { + static RULES: OnceLock<Vec<CompiledRouteKeyRule>> = OnceLock::new(); + RULES.get_or_init(|| { + ROUTE_KEY_RULES + .iter() + .map(|rule| CompiledRouteKeyRule { + replacement: rule.replacement, + regex: compile_route_key_regex(rule.key), + }) + .collect() }) } +fn compile_route_key_regex(key: &str) -> Regex { + Regex::new(&format!( + r"(?i)(?P<key>`?{key}`?\s*[:=]\s*`?)[^`\s,;\])]+`?" + )) + .expect("route key regex should compile") +} + fn exact_agent_instruction_regex() -> &'static Regex { static REGEX: OnceLock<Regex> = OnceLock::new(); REGEX.get_or_init(|| { diff --git a/crates/session-runtime/src/context_window/compaction/tests.rs b/crates/session-runtime/src/context_window/compaction/tests.rs index 10a776ad..e5eafc32 100644 --- a/crates/session-runtime/src/context_window/compaction/tests.rs +++ b/crates/session-runtime/src/context_window/compaction/tests.rs @@ -325,6 +325,7 @@ fn normalize_compaction_tool_content_removes_exact_child_identifiers() { assert!(!normalized.contains("session-child")); } + #[test] fn sanitize_compact_summary_replaces_stale_route_identifiers_with_boundary_guidance() { let sanitized = sanitize_compact_summary( diff --git a/docs/architecture/crates-dependency-graph.md b/docs/architecture/crates-dependency-graph.md index eaaa437a..90d2656b 100644 --- a/docs/architecture/crates-dependency-graph.md +++ b/docs/architecture/crates-dependency-graph.md @@ -15,20 +15,32 @@ graph TD astrcode-adapter-prompt[astrcode-adapter-prompt] --> astrcode-core[astrcode-core] astrcode-adapter-skills[astrcode-adapter-skills] --> astrcode-core[astrcode-core] astrcode-adapter-storage[astrcode-adapter-storage] --> astrcode-core[astrcode-core] + astrcode-adapter-tools[astrcode-adapter-tools] --> astrcode-adapter-skills[astrcode-adapter-skills] astrcode-adapter-tools[astrcode-adapter-tools] --> astrcode-core[astrcode-core] astrcode-application[astrcode-application] --> astrcode-core[astrcode-core] astrcode-application[astrcode-application] --> astrcode-kernel[astrcode-kernel] astrcode-application[astrcode-application] --> astrcode-session-runtime[astrcode-session-runtime] + astrcode-cli[astrcode-cli] --> astrcode-client[astrcode-client] + astrcode-cli[astrcode-cli] --> astrcode-core[astrcode-core] + astrcode-client[astrcode-client] --> astrcode-protocol[astrcode-protocol] astrcode-core[astrcode-core] astrcode-kernel[astrcode-kernel] --> astrcode-core[astrcode-core] astrcode-plugin[astrcode-plugin] --> astrcode-core[astrcode-core] astrcode-plugin[astrcode-plugin] --> astrcode-protocol[astrcode-protocol] astrcode-protocol[astrcode-protocol] --> astrcode-core[astrcode-core] + astrcode-sdk[astrcode-sdk] --> astrcode-core[astrcode-core] astrcode-sdk[astrcode-sdk] --> astrcode-protocol[astrcode-protocol] + astrcode-server[astrcode-server] --> astrcode-adapter-agents[astrcode-adapter-agents] + astrcode-server[astrcode-server] --> astrcode-adapter-llm[astrcode-adapter-llm] + astrcode-server[astrcode-server] --> astrcode-adapter-mcp[astrcode-adapter-mcp] + astrcode-server[astrcode-server] --> astrcode-adapter-prompt[astrcode-adapter-prompt] + astrcode-server[astrcode-server] --> astrcode-adapter-skills[astrcode-adapter-skills] astrcode-server[astrcode-server] --> astrcode-adapter-storage[astrcode-adapter-storage] + astrcode-server[astrcode-server] --> astrcode-adapter-tools[astrcode-adapter-tools] astrcode-server[astrcode-server] --> astrcode-application[astrcode-application] astrcode-server[astrcode-server] --> astrcode-core[astrcode-core] astrcode-server[astrcode-server] --> astrcode-kernel[astrcode-kernel] + astrcode-server[astrcode-server] --> astrcode-plugin[astrcode-plugin] astrcode-server[astrcode-server] --> astrcode-protocol[astrcode-protocol] astrcode-server[astrcode-server] --> astrcode-session-runtime[astrcode-session-runtime] astrcode-session-runtime[astrcode-session-runtime] --> astrcode-core[astrcode-core] @@ -45,12 +57,14 @@ graph TD | astrcode-adapter-prompt | crates/adapter-prompt | 1 | astrcode-core | | astrcode-adapter-skills | crates/adapter-skills | 1 | astrcode-core | | astrcode-adapter-storage | crates/adapter-storage | 1 | astrcode-core | -| astrcode-adapter-tools | crates/adapter-tools | 1 | astrcode-core | +| astrcode-adapter-tools | crates/adapter-tools | 2 | astrcode-adapter-skills, astrcode-core | | astrcode-application | crates/application | 3 | astrcode-core, astrcode-kernel, astrcode-session-runtime | +| astrcode-cli | crates/cli | 2 | astrcode-client, astrcode-core | +| astrcode-client | crates/client | 1 | astrcode-protocol | | astrcode-core | crates/core | 0 | - | | astrcode-kernel | crates/kernel | 1 | astrcode-core | | astrcode-plugin | crates/plugin | 2 | astrcode-core, astrcode-protocol | | astrcode-protocol | crates/protocol | 1 | astrcode-core | -| astrcode-sdk | crates/sdk | 1 | astrcode-protocol | -| astrcode-server | crates/server | 6 | astrcode-adapter-storage, astrcode-application, astrcode-core, astrcode-kernel, astrcode-protocol, astrcode-session-runtime | +| astrcode-sdk | crates/sdk | 2 | astrcode-core, astrcode-protocol | +| astrcode-server | crates/server | 13 | astrcode-adapter-agents, astrcode-adapter-llm, astrcode-adapter-mcp, astrcode-adapter-prompt, astrcode-adapter-skills, astrcode-adapter-storage, astrcode-adapter-tools, astrcode-application, astrcode-core, astrcode-kernel, astrcode-plugin, astrcode-protocol, astrcode-session-runtime | | astrcode-session-runtime | crates/session-runtime | 2 | astrcode-core, astrcode-kernel | From 86a6a6f3e018564c319cdbef6302f7c34443bdbc Mon Sep 17 00:00:00 2001 From: whatevertogo <whatevertogo@users.noreply.github.com> Date: Mon, 20 Apr 2026 03:50:20 +0800 Subject: [PATCH 53/53] =?UTF-8?q?fix:=20=E4=BF=AE=E6=AD=A3=20astrcode-adap?= =?UTF-8?q?ter-tools=20=E7=9A=84=E4=BE=9D=E8=B5=96=E5=85=B3=E7=B3=BB?= =?UTF-8?q?=EF=BC=8C=E6=9B=B4=E6=96=B0=E4=BE=9D=E8=B5=96=E8=AE=A1=E6=95=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/architecture/crates-dependency-graph.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/docs/architecture/crates-dependency-graph.md b/docs/architecture/crates-dependency-graph.md index 90d2656b..bf67c6d3 100644 --- a/docs/architecture/crates-dependency-graph.md +++ b/docs/architecture/crates-dependency-graph.md @@ -15,7 +15,6 @@ graph TD astrcode-adapter-prompt[astrcode-adapter-prompt] --> astrcode-core[astrcode-core] astrcode-adapter-skills[astrcode-adapter-skills] --> astrcode-core[astrcode-core] astrcode-adapter-storage[astrcode-adapter-storage] --> astrcode-core[astrcode-core] - astrcode-adapter-tools[astrcode-adapter-tools] --> astrcode-adapter-skills[astrcode-adapter-skills] astrcode-adapter-tools[astrcode-adapter-tools] --> astrcode-core[astrcode-core] astrcode-application[astrcode-application] --> astrcode-core[astrcode-core] astrcode-application[astrcode-application] --> astrcode-kernel[astrcode-kernel] @@ -57,7 +56,7 @@ graph TD | astrcode-adapter-prompt | crates/adapter-prompt | 1 | astrcode-core | | astrcode-adapter-skills | crates/adapter-skills | 1 | astrcode-core | | astrcode-adapter-storage | crates/adapter-storage | 1 | astrcode-core | -| astrcode-adapter-tools | crates/adapter-tools | 2 | astrcode-adapter-skills, astrcode-core | +| astrcode-adapter-tools | crates/adapter-tools | 1 | astrcode-core | | astrcode-application | crates/application | 3 | astrcode-core, astrcode-kernel, astrcode-session-runtime | | astrcode-cli | crates/cli | 2 | astrcode-client, astrcode-core | | astrcode-client | crates/client | 1 | astrcode-protocol |