conversation.rs 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802
  1. use std::collections::BTreeMap;
  2. use std::fmt::{Display, Formatter};
  3. use crate::compact::{
  4. compact_session, estimate_session_tokens, CompactionConfig, CompactionResult,
  5. };
  6. use crate::config::RuntimeFeatureConfig;
  7. use crate::hooks::{HookRunResult, HookRunner};
  8. use crate::permissions::{PermissionOutcome, PermissionPolicy, PermissionPrompter};
  9. use crate::session::{ContentBlock, ConversationMessage, Session};
  10. use crate::usage::{TokenUsage, UsageTracker};
  11. #[derive(Debug, Clone, PartialEq, Eq)]
  12. pub struct ApiRequest {
  13. pub system_prompt: Vec<String>,
  14. pub messages: Vec<ConversationMessage>,
  15. }
  16. #[derive(Debug, Clone, PartialEq, Eq)]
  17. pub enum AssistantEvent {
  18. TextDelta(String),
  19. ToolUse {
  20. id: String,
  21. name: String,
  22. input: String,
  23. },
  24. Usage(TokenUsage),
  25. MessageStop,
  26. }
  27. pub trait ApiClient {
  28. fn stream(&mut self, request: ApiRequest) -> Result<Vec<AssistantEvent>, RuntimeError>;
  29. }
  30. pub trait ToolExecutor {
  31. fn execute(&mut self, tool_name: &str, input: &str) -> Result<String, ToolError>;
  32. }
  33. #[derive(Debug, Clone, PartialEq, Eq)]
  34. pub struct ToolError {
  35. message: String,
  36. }
  37. impl ToolError {
  38. #[must_use]
  39. pub fn new(message: impl Into<String>) -> Self {
  40. Self {
  41. message: message.into(),
  42. }
  43. }
  44. }
  45. impl Display for ToolError {
  46. fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
  47. write!(f, "{}", self.message)
  48. }
  49. }
  50. impl std::error::Error for ToolError {}
  51. #[derive(Debug, Clone, PartialEq, Eq)]
  52. pub struct RuntimeError {
  53. message: String,
  54. }
  55. impl RuntimeError {
  56. #[must_use]
  57. pub fn new(message: impl Into<String>) -> Self {
  58. Self {
  59. message: message.into(),
  60. }
  61. }
  62. }
  63. impl Display for RuntimeError {
  64. fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
  65. write!(f, "{}", self.message)
  66. }
  67. }
  68. impl std::error::Error for RuntimeError {}
  69. #[derive(Debug, Clone, PartialEq, Eq)]
  70. pub struct TurnSummary {
  71. pub assistant_messages: Vec<ConversationMessage>,
  72. pub tool_results: Vec<ConversationMessage>,
  73. pub iterations: usize,
  74. pub usage: TokenUsage,
  75. }
  76. pub struct ConversationRuntime<C, T> {
  77. session: Session,
  78. api_client: C,
  79. tool_executor: T,
  80. permission_policy: PermissionPolicy,
  81. system_prompt: Vec<String>,
  82. max_iterations: usize,
  83. usage_tracker: UsageTracker,
  84. hook_runner: HookRunner,
  85. }
  86. impl<C, T> ConversationRuntime<C, T>
  87. where
  88. C: ApiClient,
  89. T: ToolExecutor,
  90. {
  91. #[must_use]
  92. pub fn new(
  93. session: Session,
  94. api_client: C,
  95. tool_executor: T,
  96. permission_policy: PermissionPolicy,
  97. system_prompt: Vec<String>,
  98. ) -> Self {
  99. Self::new_with_features(
  100. session,
  101. api_client,
  102. tool_executor,
  103. permission_policy,
  104. system_prompt,
  105. RuntimeFeatureConfig::default(),
  106. )
  107. }
  108. #[must_use]
  109. #[allow(clippy::needless_pass_by_value)]
  110. pub fn new_with_features(
  111. session: Session,
  112. api_client: C,
  113. tool_executor: T,
  114. permission_policy: PermissionPolicy,
  115. system_prompt: Vec<String>,
  116. feature_config: RuntimeFeatureConfig,
  117. ) -> Self {
  118. let usage_tracker = UsageTracker::from_session(&session);
  119. Self {
  120. session,
  121. api_client,
  122. tool_executor,
  123. permission_policy,
  124. system_prompt,
  125. max_iterations: usize::MAX,
  126. usage_tracker,
  127. hook_runner: HookRunner::from_feature_config(&feature_config),
  128. }
  129. }
  130. #[must_use]
  131. pub fn with_max_iterations(mut self, max_iterations: usize) -> Self {
  132. self.max_iterations = max_iterations;
  133. self
  134. }
  135. pub fn run_turn(
  136. &mut self,
  137. user_input: impl Into<String>,
  138. mut prompter: Option<&mut dyn PermissionPrompter>,
  139. ) -> Result<TurnSummary, RuntimeError> {
  140. self.session
  141. .messages
  142. .push(ConversationMessage::user_text(user_input.into()));
  143. let mut assistant_messages = Vec::new();
  144. let mut tool_results = Vec::new();
  145. let mut iterations = 0;
  146. loop {
  147. iterations += 1;
  148. if iterations > self.max_iterations {
  149. return Err(RuntimeError::new(
  150. "conversation loop exceeded the maximum number of iterations",
  151. ));
  152. }
  153. let request = ApiRequest {
  154. system_prompt: self.system_prompt.clone(),
  155. messages: self.session.messages.clone(),
  156. };
  157. let events = self.api_client.stream(request)?;
  158. let (assistant_message, usage) = build_assistant_message(events)?;
  159. if let Some(usage) = usage {
  160. self.usage_tracker.record(usage);
  161. }
  162. let pending_tool_uses = assistant_message
  163. .blocks
  164. .iter()
  165. .filter_map(|block| match block {
  166. ContentBlock::ToolUse { id, name, input } => {
  167. Some((id.clone(), name.clone(), input.clone()))
  168. }
  169. _ => None,
  170. })
  171. .collect::<Vec<_>>();
  172. self.session.messages.push(assistant_message.clone());
  173. assistant_messages.push(assistant_message);
  174. if pending_tool_uses.is_empty() {
  175. break;
  176. }
  177. for (tool_use_id, tool_name, input) in pending_tool_uses {
  178. let permission_outcome = if let Some(prompt) = prompter.as_mut() {
  179. self.permission_policy
  180. .authorize(&tool_name, &input, Some(*prompt))
  181. } else {
  182. self.permission_policy.authorize(&tool_name, &input, None)
  183. };
  184. let result_message = match permission_outcome {
  185. PermissionOutcome::Allow => {
  186. let pre_hook_result = self.hook_runner.run_pre_tool_use(&tool_name, &input);
  187. if pre_hook_result.is_denied() {
  188. let deny_message = format!("PreToolUse hook denied tool `{tool_name}`");
  189. ConversationMessage::tool_result(
  190. tool_use_id,
  191. tool_name,
  192. format_hook_message(&pre_hook_result, &deny_message),
  193. true,
  194. )
  195. } else {
  196. let (mut output, mut is_error) =
  197. match self.tool_executor.execute(&tool_name, &input) {
  198. Ok(output) => (output, false),
  199. Err(error) => (error.to_string(), true),
  200. };
  201. output = merge_hook_feedback(pre_hook_result.messages(), output, false);
  202. let post_hook_result = self
  203. .hook_runner
  204. .run_post_tool_use(&tool_name, &input, &output, is_error);
  205. if post_hook_result.is_denied() {
  206. is_error = true;
  207. }
  208. output = merge_hook_feedback(
  209. post_hook_result.messages(),
  210. output,
  211. post_hook_result.is_denied(),
  212. );
  213. ConversationMessage::tool_result(
  214. tool_use_id,
  215. tool_name,
  216. output,
  217. is_error,
  218. )
  219. }
  220. }
  221. PermissionOutcome::Deny { reason } => {
  222. ConversationMessage::tool_result(tool_use_id, tool_name, reason, true)
  223. }
  224. };
  225. self.session.messages.push(result_message.clone());
  226. tool_results.push(result_message);
  227. }
  228. }
  229. Ok(TurnSummary {
  230. assistant_messages,
  231. tool_results,
  232. iterations,
  233. usage: self.usage_tracker.cumulative_usage(),
  234. })
  235. }
  236. #[must_use]
  237. pub fn compact(&self, config: CompactionConfig) -> CompactionResult {
  238. compact_session(&self.session, config)
  239. }
  240. #[must_use]
  241. pub fn estimated_tokens(&self) -> usize {
  242. estimate_session_tokens(&self.session)
  243. }
  244. #[must_use]
  245. pub fn usage(&self) -> &UsageTracker {
  246. &self.usage_tracker
  247. }
  248. #[must_use]
  249. pub fn session(&self) -> &Session {
  250. &self.session
  251. }
  252. #[must_use]
  253. pub fn into_session(self) -> Session {
  254. self.session
  255. }
  256. }
  257. fn build_assistant_message(
  258. events: Vec<AssistantEvent>,
  259. ) -> Result<(ConversationMessage, Option<TokenUsage>), RuntimeError> {
  260. let mut text = String::new();
  261. let mut blocks = Vec::new();
  262. let mut finished = false;
  263. let mut usage = None;
  264. for event in events {
  265. match event {
  266. AssistantEvent::TextDelta(delta) => text.push_str(&delta),
  267. AssistantEvent::ToolUse { id, name, input } => {
  268. flush_text_block(&mut text, &mut blocks);
  269. blocks.push(ContentBlock::ToolUse { id, name, input });
  270. }
  271. AssistantEvent::Usage(value) => usage = Some(value),
  272. AssistantEvent::MessageStop => {
  273. finished = true;
  274. }
  275. }
  276. }
  277. flush_text_block(&mut text, &mut blocks);
  278. if !finished {
  279. return Err(RuntimeError::new(
  280. "assistant stream ended without a message stop event",
  281. ));
  282. }
  283. if blocks.is_empty() {
  284. return Err(RuntimeError::new("assistant stream produced no content"));
  285. }
  286. Ok((
  287. ConversationMessage::assistant_with_usage(blocks, usage),
  288. usage,
  289. ))
  290. }
  291. fn flush_text_block(text: &mut String, blocks: &mut Vec<ContentBlock>) {
  292. if !text.is_empty() {
  293. blocks.push(ContentBlock::Text {
  294. text: std::mem::take(text),
  295. });
  296. }
  297. }
  298. fn format_hook_message(result: &HookRunResult, fallback: &str) -> String {
  299. if result.messages().is_empty() {
  300. fallback.to_string()
  301. } else {
  302. result.messages().join("\n")
  303. }
  304. }
  305. fn merge_hook_feedback(messages: &[String], output: String, denied: bool) -> String {
  306. if messages.is_empty() {
  307. return output;
  308. }
  309. let mut sections = Vec::new();
  310. if !output.trim().is_empty() {
  311. sections.push(output);
  312. }
  313. let label = if denied {
  314. "Hook feedback (denied)"
  315. } else {
  316. "Hook feedback"
  317. };
  318. sections.push(format!("{label}:\n{}", messages.join("\n")));
  319. sections.join("\n\n")
  320. }
  321. type ToolHandler = Box<dyn FnMut(&str) -> Result<String, ToolError>>;
  322. #[derive(Default)]
  323. pub struct StaticToolExecutor {
  324. handlers: BTreeMap<String, ToolHandler>,
  325. }
  326. impl StaticToolExecutor {
  327. #[must_use]
  328. pub fn new() -> Self {
  329. Self::default()
  330. }
  331. #[must_use]
  332. pub fn register(
  333. mut self,
  334. tool_name: impl Into<String>,
  335. handler: impl FnMut(&str) -> Result<String, ToolError> + 'static,
  336. ) -> Self {
  337. self.handlers.insert(tool_name.into(), Box::new(handler));
  338. self
  339. }
  340. }
  341. impl ToolExecutor for StaticToolExecutor {
  342. fn execute(&mut self, tool_name: &str, input: &str) -> Result<String, ToolError> {
  343. self.handlers
  344. .get_mut(tool_name)
  345. .ok_or_else(|| ToolError::new(format!("unknown tool: {tool_name}")))?(input)
  346. }
  347. }
  348. #[cfg(test)]
  349. mod tests {
  350. use super::{
  351. ApiClient, ApiRequest, AssistantEvent, ConversationRuntime, RuntimeError,
  352. StaticToolExecutor,
  353. };
  354. use crate::compact::CompactionConfig;
  355. use crate::config::{RuntimeFeatureConfig, RuntimeHookConfig};
  356. use crate::permissions::{
  357. PermissionMode, PermissionPolicy, PermissionPromptDecision, PermissionPrompter,
  358. PermissionRequest,
  359. };
  360. use crate::prompt::{ProjectContext, SystemPromptBuilder};
  361. use crate::session::{ContentBlock, MessageRole, Session};
  362. use crate::usage::TokenUsage;
  363. use std::path::PathBuf;
  364. struct ScriptedApiClient {
  365. call_count: usize,
  366. }
  367. impl ApiClient for ScriptedApiClient {
  368. fn stream(&mut self, request: ApiRequest) -> Result<Vec<AssistantEvent>, RuntimeError> {
  369. self.call_count += 1;
  370. match self.call_count {
  371. 1 => {
  372. assert!(request
  373. .messages
  374. .iter()
  375. .any(|message| message.role == MessageRole::User));
  376. Ok(vec![
  377. AssistantEvent::TextDelta("Let me calculate that.".to_string()),
  378. AssistantEvent::ToolUse {
  379. id: "tool-1".to_string(),
  380. name: "add".to_string(),
  381. input: "2,2".to_string(),
  382. },
  383. AssistantEvent::Usage(TokenUsage {
  384. input_tokens: 20,
  385. output_tokens: 6,
  386. cache_creation_input_tokens: 1,
  387. cache_read_input_tokens: 2,
  388. }),
  389. AssistantEvent::MessageStop,
  390. ])
  391. }
  392. 2 => {
  393. let last_message = request
  394. .messages
  395. .last()
  396. .expect("tool result should be present");
  397. assert_eq!(last_message.role, MessageRole::Tool);
  398. Ok(vec![
  399. AssistantEvent::TextDelta("The answer is 4.".to_string()),
  400. AssistantEvent::Usage(TokenUsage {
  401. input_tokens: 24,
  402. output_tokens: 4,
  403. cache_creation_input_tokens: 1,
  404. cache_read_input_tokens: 3,
  405. }),
  406. AssistantEvent::MessageStop,
  407. ])
  408. }
  409. _ => Err(RuntimeError::new("unexpected extra API call")),
  410. }
  411. }
  412. }
  413. struct PromptAllowOnce;
  414. impl PermissionPrompter for PromptAllowOnce {
  415. fn decide(&mut self, request: &PermissionRequest) -> PermissionPromptDecision {
  416. assert_eq!(request.tool_name, "add");
  417. PermissionPromptDecision::Allow
  418. }
  419. }
  420. #[test]
  421. fn runs_user_to_tool_to_result_loop_end_to_end_and_tracks_usage() {
  422. let api_client = ScriptedApiClient { call_count: 0 };
  423. let tool_executor = StaticToolExecutor::new().register("add", |input| {
  424. let total = input
  425. .split(',')
  426. .map(|part| part.parse::<i32>().expect("input must be valid integer"))
  427. .sum::<i32>();
  428. Ok(total.to_string())
  429. });
  430. let permission_policy = PermissionPolicy::new(PermissionMode::WorkspaceWrite);
  431. let system_prompt = SystemPromptBuilder::new()
  432. .with_project_context(ProjectContext {
  433. cwd: PathBuf::from("/tmp/project"),
  434. current_date: "2026-03-31".to_string(),
  435. git_status: None,
  436. git_diff: None,
  437. instruction_files: Vec::new(),
  438. })
  439. .with_os("linux", "6.8")
  440. .build();
  441. let mut runtime = ConversationRuntime::new(
  442. Session::new(),
  443. api_client,
  444. tool_executor,
  445. permission_policy,
  446. system_prompt,
  447. );
  448. let summary = runtime
  449. .run_turn("what is 2 + 2?", Some(&mut PromptAllowOnce))
  450. .expect("conversation loop should succeed");
  451. assert_eq!(summary.iterations, 2);
  452. assert_eq!(summary.assistant_messages.len(), 2);
  453. assert_eq!(summary.tool_results.len(), 1);
  454. assert_eq!(runtime.session().messages.len(), 4);
  455. assert_eq!(summary.usage.output_tokens, 10);
  456. assert!(matches!(
  457. runtime.session().messages[1].blocks[1],
  458. ContentBlock::ToolUse { .. }
  459. ));
  460. assert!(matches!(
  461. runtime.session().messages[2].blocks[0],
  462. ContentBlock::ToolResult {
  463. is_error: false,
  464. ..
  465. }
  466. ));
  467. }
  468. #[test]
  469. fn records_denied_tool_results_when_prompt_rejects() {
  470. struct RejectPrompter;
  471. impl PermissionPrompter for RejectPrompter {
  472. fn decide(&mut self, _request: &PermissionRequest) -> PermissionPromptDecision {
  473. PermissionPromptDecision::Deny {
  474. reason: "not now".to_string(),
  475. }
  476. }
  477. }
  478. struct SingleCallApiClient;
  479. impl ApiClient for SingleCallApiClient {
  480. fn stream(&mut self, request: ApiRequest) -> Result<Vec<AssistantEvent>, RuntimeError> {
  481. if request
  482. .messages
  483. .iter()
  484. .any(|message| message.role == MessageRole::Tool)
  485. {
  486. return Ok(vec![
  487. AssistantEvent::TextDelta("I could not use the tool.".to_string()),
  488. AssistantEvent::MessageStop,
  489. ]);
  490. }
  491. Ok(vec![
  492. AssistantEvent::ToolUse {
  493. id: "tool-1".to_string(),
  494. name: "blocked".to_string(),
  495. input: "secret".to_string(),
  496. },
  497. AssistantEvent::MessageStop,
  498. ])
  499. }
  500. }
  501. let mut runtime = ConversationRuntime::new(
  502. Session::new(),
  503. SingleCallApiClient,
  504. StaticToolExecutor::new(),
  505. PermissionPolicy::new(PermissionMode::WorkspaceWrite),
  506. vec!["system".to_string()],
  507. );
  508. let summary = runtime
  509. .run_turn("use the tool", Some(&mut RejectPrompter))
  510. .expect("conversation should continue after denied tool");
  511. assert_eq!(summary.tool_results.len(), 1);
  512. assert!(matches!(
  513. &summary.tool_results[0].blocks[0],
  514. ContentBlock::ToolResult { is_error: true, output, .. } if output == "not now"
  515. ));
  516. }
  517. #[test]
  518. fn denies_tool_use_when_pre_tool_hook_blocks() {
  519. struct SingleCallApiClient;
  520. impl ApiClient for SingleCallApiClient {
  521. fn stream(&mut self, request: ApiRequest) -> Result<Vec<AssistantEvent>, RuntimeError> {
  522. if request
  523. .messages
  524. .iter()
  525. .any(|message| message.role == MessageRole::Tool)
  526. {
  527. return Ok(vec![
  528. AssistantEvent::TextDelta("blocked".to_string()),
  529. AssistantEvent::MessageStop,
  530. ]);
  531. }
  532. Ok(vec![
  533. AssistantEvent::ToolUse {
  534. id: "tool-1".to_string(),
  535. name: "blocked".to_string(),
  536. input: r#"{"path":"secret.txt"}"#.to_string(),
  537. },
  538. AssistantEvent::MessageStop,
  539. ])
  540. }
  541. }
  542. let mut runtime = ConversationRuntime::new_with_features(
  543. Session::new(),
  544. SingleCallApiClient,
  545. StaticToolExecutor::new().register("blocked", |_input| {
  546. panic!("tool should not execute when hook denies")
  547. }),
  548. PermissionPolicy::new(PermissionMode::DangerFullAccess),
  549. vec!["system".to_string()],
  550. RuntimeFeatureConfig::default().with_hooks(RuntimeHookConfig::new(
  551. vec![shell_snippet("printf 'blocked by hook'; exit 2")],
  552. Vec::new(),
  553. )),
  554. );
  555. let summary = runtime
  556. .run_turn("use the tool", None)
  557. .expect("conversation should continue after hook denial");
  558. assert_eq!(summary.tool_results.len(), 1);
  559. let ContentBlock::ToolResult {
  560. is_error, output, ..
  561. } = &summary.tool_results[0].blocks[0]
  562. else {
  563. panic!("expected tool result block");
  564. };
  565. assert!(
  566. *is_error,
  567. "hook denial should produce an error result: {output}"
  568. );
  569. assert!(
  570. output.contains("denied tool") || output.contains("blocked by hook"),
  571. "unexpected hook denial output: {output:?}"
  572. );
  573. }
  574. #[test]
  575. fn appends_post_tool_hook_feedback_to_tool_result() {
  576. struct TwoCallApiClient {
  577. calls: usize,
  578. }
  579. impl ApiClient for TwoCallApiClient {
  580. fn stream(&mut self, request: ApiRequest) -> Result<Vec<AssistantEvent>, RuntimeError> {
  581. self.calls += 1;
  582. match self.calls {
  583. 1 => Ok(vec![
  584. AssistantEvent::ToolUse {
  585. id: "tool-1".to_string(),
  586. name: "add".to_string(),
  587. input: r#"{"lhs":2,"rhs":2}"#.to_string(),
  588. },
  589. AssistantEvent::MessageStop,
  590. ]),
  591. 2 => {
  592. assert!(request
  593. .messages
  594. .iter()
  595. .any(|message| message.role == MessageRole::Tool));
  596. Ok(vec![
  597. AssistantEvent::TextDelta("done".to_string()),
  598. AssistantEvent::MessageStop,
  599. ])
  600. }
  601. _ => Err(RuntimeError::new("unexpected extra API call")),
  602. }
  603. }
  604. }
  605. let mut runtime = ConversationRuntime::new_with_features(
  606. Session::new(),
  607. TwoCallApiClient { calls: 0 },
  608. StaticToolExecutor::new().register("add", |_input| Ok("4".to_string())),
  609. PermissionPolicy::new(PermissionMode::DangerFullAccess),
  610. vec!["system".to_string()],
  611. RuntimeFeatureConfig::default().with_hooks(RuntimeHookConfig::new(
  612. vec![shell_snippet("printf 'pre hook ran'")],
  613. vec![shell_snippet("printf 'post hook ran'")],
  614. )),
  615. );
  616. let summary = runtime
  617. .run_turn("use add", None)
  618. .expect("tool loop succeeds");
  619. assert_eq!(summary.tool_results.len(), 1);
  620. let ContentBlock::ToolResult {
  621. is_error, output, ..
  622. } = &summary.tool_results[0].blocks[0]
  623. else {
  624. panic!("expected tool result block");
  625. };
  626. assert!(
  627. !*is_error,
  628. "post hook should preserve non-error result: {output:?}"
  629. );
  630. assert!(
  631. output.contains('4'),
  632. "tool output missing value: {output:?}"
  633. );
  634. assert!(
  635. output.contains("pre hook ran"),
  636. "tool output missing pre hook feedback: {output:?}"
  637. );
  638. assert!(
  639. output.contains("post hook ran"),
  640. "tool output missing post hook feedback: {output:?}"
  641. );
  642. }
  643. #[test]
  644. fn reconstructs_usage_tracker_from_restored_session() {
  645. struct SimpleApi;
  646. impl ApiClient for SimpleApi {
  647. fn stream(
  648. &mut self,
  649. _request: ApiRequest,
  650. ) -> Result<Vec<AssistantEvent>, RuntimeError> {
  651. Ok(vec![
  652. AssistantEvent::TextDelta("done".to_string()),
  653. AssistantEvent::MessageStop,
  654. ])
  655. }
  656. }
  657. let mut session = Session::new();
  658. session
  659. .messages
  660. .push(crate::session::ConversationMessage::assistant_with_usage(
  661. vec![ContentBlock::Text {
  662. text: "earlier".to_string(),
  663. }],
  664. Some(TokenUsage {
  665. input_tokens: 11,
  666. output_tokens: 7,
  667. cache_creation_input_tokens: 2,
  668. cache_read_input_tokens: 1,
  669. }),
  670. ));
  671. let runtime = ConversationRuntime::new(
  672. session,
  673. SimpleApi,
  674. StaticToolExecutor::new(),
  675. PermissionPolicy::new(PermissionMode::DangerFullAccess),
  676. vec!["system".to_string()],
  677. );
  678. assert_eq!(runtime.usage().turns(), 1);
  679. assert_eq!(runtime.usage().cumulative_usage().total_tokens(), 21);
  680. }
  681. #[test]
  682. fn compacts_session_after_turns() {
  683. struct SimpleApi;
  684. impl ApiClient for SimpleApi {
  685. fn stream(
  686. &mut self,
  687. _request: ApiRequest,
  688. ) -> Result<Vec<AssistantEvent>, RuntimeError> {
  689. Ok(vec![
  690. AssistantEvent::TextDelta("done".to_string()),
  691. AssistantEvent::MessageStop,
  692. ])
  693. }
  694. }
  695. let mut runtime = ConversationRuntime::new(
  696. Session::new(),
  697. SimpleApi,
  698. StaticToolExecutor::new(),
  699. PermissionPolicy::new(PermissionMode::DangerFullAccess),
  700. vec!["system".to_string()],
  701. );
  702. runtime.run_turn("a", None).expect("turn a");
  703. runtime.run_turn("b", None).expect("turn b");
  704. runtime.run_turn("c", None).expect("turn c");
  705. let result = runtime.compact(CompactionConfig {
  706. preserve_recent_messages: 2,
  707. max_estimated_tokens: 1,
  708. });
  709. assert!(result.summary.contains("Conversation summary"));
  710. assert_eq!(
  711. result.compacted_session.messages[0].role,
  712. MessageRole::System
  713. );
  714. }
  715. #[cfg(windows)]
  716. fn shell_snippet(script: &str) -> String {
  717. script.replace('\'', "\"")
  718. }
  719. #[cfg(not(windows))]
  720. fn shell_snippet(script: &str) -> String {
  721. script.to_string()
  722. }
  723. }
备用站点 当前处于降级运行的备用站点,仅供应急访问,数据和功能可能不是最新。