//! 全链路集成:agent 跑对话 → harness 压缩规划 → provider 生成摘要 → //! 摘要写回 → agent 继续(对应未来 TUI 的自动压缩流程)。 //! Full-chain integration: agent runs a conversation → harness plans //! compaction → the provider summarizes → the summary is written back → the //! agent continues (mirroring the TUI's future auto-compaction flow). mod common; use focus_core::event::VecSink; use focus_core::model::*; use focus_core::provider::{ProviderRequest, StreamEvent}; use focus_core::tool::{ToolEffects, ToolRegistry, ToolResult}; use focus_core::{Agent, AgentConfig, CoreError}; use focus_harness::compaction::{ apply_summary, plan_compaction, DEFAULT_KEEP_RATIO, DEFAULT_THRESHOLD_RATIO, }; use focus_harness::prompt::{default_context, ContextUsage, SystemPromptTemplate}; use focus_json::JsonValue; use focus_providers::anthropic::AnthropicProvider; use focus_providers::config::{resolve_context_window, ProviderConfig}; use std::sync::Arc; /// 简单无副作用工具。 /// A trivial side-effect-free tool. #[derive(Debug)] struct NoopTool; impl focus_core::Tool for NoopTool { fn name(&self) -> &str { "noop" } fn description(&self) -> &str { "does nothing" } fn parameters(&self) -> JsonValue { let mut o = JsonValue::obj(); o.insert("type", "object".into()).ok(); o } fn effects(&self) -> ToolEffects { ToolEffects::NONE } fn execute( &self, _id: &str, _args: &JsonValue, _u: Option<&focus_core::tool::ToolUpdateSink>, ) -> Result { Ok(ToolResult::text("ok")) } } /// 构造一条「只输出文本」的 Anthropic SSE 回复。 /// Build an Anthropic SSE reply that only outputs text. fn text_sse(text: &str) -> String { format!( "event: message_start\ndata: {{\"type\":\"message_start\",\"message\":{{\"usage\":{{\"input_tokens\":10}}}}}}\n\n\ event: content_block_start\ndata: {{\"type\":\"content_block_start\",\"index\":0,\"content_block\":{{\"type\":\"text\",\"text\":\"\"}}}}\n\n\ event: content_block_delta\ndata: {{\"type\":\"content_block_delta\",\"index\":0,\"delta\":{{\"type\":\"text_delta\",\"text\":\"{}\"}}}}\n\n\ event: content_block_stop\ndata: {{\"type\":\"content_block_stop\",\"index\":0}}\n\n\ event: message_delta\ndata: {{\"type\":\"message_delta\",\"delta\":{{\"stop_reason\":\"end_turn\"}},\"usage\":{{\"output_tokens\":5}}}}\n\n\ event: message_stop\ndata: {{\"type\":\"message_stop\"}}\n\n", text ) } fn agent_with(mock: common::MockTransport) -> Agent { let provider = AnthropicProvider::with_transport(ProviderConfig::new("sk-test"), Arc::new(mock)); let config = AgentConfig { model: "claude-sonnet-4".into(), system_prompt: "You are helpful.".into(), max_tokens: Some(256), ..Default::default() }; Agent::new( config, Box::new(provider), ToolRegistry::with(Box::new(NoopTool)), ) } #[test] fn full_conversation_compaction_and_continue() { // ---- 阶段 1:跑几轮对话(mock 每次都回同样的话)。---- // ---- Phase 1: run a few turns (the mock replies the same text). ---- let mut mock = common::MockTransport::new(); for _ in 0..6 { mock.push_body(text_sse("some answer")); } let mut agent = agent_with(mock); let mut sink = VecSink::new(); for i in 0..6 { agent .prompt(format!("question {}", i), &mut sink) .expect("run failed"); } let messages = agent.messages().to_vec(); assert_eq!(messages.len(), 12); // 6 user + 6 assistant / 6 user + 6 assistant // ---- 阶段 2:上下文窗口未知时,按三层解析取窗口。---- // ---- Phase 2: unknown window → resolve via the three-layer lookup. ---- let window = resolve_context_window(None, "claude-sonnet-4"); assert_eq!(window, 200_000); // ---- 阶段 3:估算并规划压缩。---- // ---- Phase 3: estimate and plan compaction. ---- let estimated = focus_harness::estimate_messages(&messages); assert!(estimated > 0); // 小窗口强制触发压缩。 // Force a trigger with a tiny window. let plan = plan_compaction(&messages, 64, DEFAULT_THRESHOLD_RATIO, DEFAULT_KEEP_RATIO) .expect("compaction plan"); assert!(!plan.summarize.is_empty()); assert!(!plan.keep.is_empty()); // ---- 阶段 4:用 provider「生成」摘要(同样走 mock 流)。---- // ---- Phase 4: "generate" the summary via the provider (mock stream). ---- let mut mock2 = common::MockTransport::new(); mock2.push_body(text_sse( "earlier talk- f1", )); let provider = AnthropicProvider::with_transport(ProviderConfig::new("sk-test"), Arc::new(mock2)); let summary_request = ProviderRequest { model: "claude-sonnet-4".into(), system_prompt: plan.summary_instruction.clone(), messages: plan.summarize.clone(), tools: JsonValue::arr(), max_tokens: Some(512), temperature: None, }; let events = common::collect(&provider, &summary_request); let summary = match events.last().unwrap() { StreamEvent::Done { message } => message .content .iter() .find_map(|c| c.as_text()) .map(|t| t.text.clone()) .unwrap_or_default(), other => panic!("expected done, got {:?}", other), }; assert!(summary.contains(""), "got: {}", summary); // ---- 阶段 5:写回摘要并注入动态系统提示(方案 E)。---- // ---- Phase 5: write the summary back and inject a dynamic system // prompt (scheme E). ---- let compacted = apply_summary(&summary, &plan); agent.replace_messages(compacted); let mut ctx = default_context(); ctx.context_usage = Some(ContextUsage { estimated_tokens: focus_harness::estimate_messages(agent.messages()), context_window: window, window_known: true, }); let tpl = SystemPromptTemplate::default(); agent.set_system_prompt(tpl.render(&ctx)); assert!(agent.config().system_prompt.contains("Context usage")); // ---- 阶段 6:压缩后继续对话。---- // ---- Phase 6: continue the conversation after compaction. ---- let mut mock3 = common::MockTransport::new(); mock3.push_body(text_sse("post-compaction answer")); // 重新注入 provider(mock 是一次性的)。 // Re-inject a provider (the mock is single-use). let provider = AnthropicProvider::with_transport(ProviderConfig::new("sk-test"), Arc::new(mock3)); agent.replace_messages(agent.messages().to_vec()); // 通过内部字段替换 provider:直接重建 agent 以保留消息。 // Rebuild the agent keeping the transcript, since the provider is not // swappable on an existing agent. let mut agent2 = Agent::new( AgentConfig { model: "claude-sonnet-4".into(), system_prompt: agent.config().system_prompt.clone(), max_tokens: Some(256), ..Default::default() }, Box::new(provider), ToolRegistry::with(Box::new(NoopTool)), ); agent2.replace_messages(agent.messages().to_vec()); let mut sink2 = VecSink::new(); agent2.prompt("what now?", &mut sink2).expect("run failed"); let msgs = agent2.messages(); // 摘要消息 + 保留消息 + 新 user + 新 assistant。 // Summary message + kept messages + new user + new assistant. assert_eq!( msgs.len(), plan.keep.len() + 3, "transcript: summary + keep + user + assistant" ); let first = &msgs[0]; assert!(matches!(first, Message::User(_))); let last = msgs.last().unwrap(); assert!(matches!(last, Message::Assistant(_))); }