conversation.rs 26 KB

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