Skip to content

Commit 55ca652

Browse files
authored
Merge pull request #277 from syncable-dev/develop
Develop
2 parents 70f73b1 + c877140 commit 55ca652

55 files changed

Lines changed: 7791 additions & 2844 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/ci.yml

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,8 @@ jobs:
2020
fail-fast: false
2121
matrix:
2222
os: [ubuntu-latest, macos-latest, windows-latest]
23-
rust: [stable]
23+
# MSRV 1.88 - AWS SDK requires Rust 1.88
24+
rust: ["1.88"]
2425

2526
steps:
2627
- uses: actions/checkout@v4
@@ -79,5 +80,8 @@ jobs:
7980
- uses: rustsec/audit-check@v2
8081
with:
8182
token: ${{ secrets.GITHUB_TOKEN }}
82-
# Only fail on actual vulnerabilities, not unmaintained warnings
83-
ignore: RUSTSEC-2020-0163,RUSTSEC-2024-0320,RUSTSEC-2025-0057,RUSTSEC-2025-0074,RUSTSEC-2025-0075,RUSTSEC-2025-0080,RUSTSEC-2025-0081,RUSTSEC-2025-0098,RUSTSEC-2025-0104,RUSTSEC-2025-0134
83+
# Ignore advisories in transitive dependencies we cannot control:
84+
# - gix-date (RUSTSEC-2025-0140): via rustsec crate, awaiting upstream fix
85+
# - bincode (RUSTSEC-2025-0141): via syntect, marked "complete" by maintainer
86+
# - Other transitive deps from rustsec, aws-sdk, kube, etc.
87+
ignore: RUSTSEC-2020-0163,RUSTSEC-2024-0320,RUSTSEC-2025-0057,RUSTSEC-2025-0074,RUSTSEC-2025-0075,RUSTSEC-2025-0080,RUSTSEC-2025-0081,RUSTSEC-2025-0098,RUSTSEC-2025-0104,RUSTSEC-2025-0134,RUSTSEC-2025-0140,RUSTSEC-2025-0141

.gitignore

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
# will have compiled files and executables
33
debug/
44
target/
5+
test-results/
6+
tmp/
57

68
node_modules/
79
*.vsix
@@ -14,6 +16,9 @@ node_modules/
1416
.qoder/*
1517
.qoder/**/*
1618

19+
# Planning documents (local only, not shared)
20+
.planning/
21+
1722
# MSVC Windows builds of rustc generate these, which store debugging information
1823
*.pdb
1924
# Ignore docs except specific tracked files

.rustfmt.toml

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,4 @@ remove_nested_parens = true
99
merge_derives = true
1010
use_try_shorthand = true
1111
use_field_init_shorthand = true
12-
force_explicit_abi = true
13-
empty_item_single_line = true
14-
struct_lit_single_line = true
15-
fn_single_line = false
16-
where_single_line = false
17-
imports_layout = "Vertical"
18-
imports_granularity = "Crate"
12+
force_explicit_abi = true

Cargo.toml

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,17 @@
22
name = "syncable-cli"
33
version = "0.32.1"
44
edition = "2024"
5+
rust-version = "1.88" # MSRV - AWS SDK requires 1.88
56
authors = ["Syncable Team"]
67
description = "A Rust-based CLI that analyzes code repositories and generates Infrastructure as Code configurations"
78
license = "GPL-3.0"
89
repository = "https://github.com/syncable-dev/syncable-cli"
910
keywords = [
1011
"cli",
11-
"ai",
12-
"devops",
13-
"iac",
1412
"docker",
13+
"kubernetes",
14+
"terraform",
15+
"devops",
1516
]
1617
categories = ["command-line-utilities", "development-tools"]
1718
readme = "README.md"

src/agent/compact/summary.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,7 @@ impl ContextSummary {
110110
}
111111

112112
/// A summary frame ready to be inserted into context
113-
#[derive(Debug, Clone)]
113+
#[derive(Debug, Clone, Serialize, Deserialize)]
114114
pub struct SummaryFrame {
115115
/// The rendered summary text
116116
pub content: String,

src/agent/history.rs

Lines changed: 208 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ pub struct ToolCallRecord {
4747
}
4848

4949
/// Conversation history manager with forge-style compaction support
50-
#[derive(Debug, Clone)]
50+
#[derive(Debug, Clone, Serialize, Deserialize)]
5151
pub struct ConversationHistory {
5252
/// Full conversation turns
5353
turns: Vec<ConversationTurn>,
@@ -235,6 +235,29 @@ impl ConversationHistory {
235235
self.context_summary = ContextSummary::new();
236236
}
237237

238+
/// Clear turns but preserve the summary frame (for sync with truncated raw_chat_history)
239+
///
240+
/// Use this instead of clear() when raw_chat_history is truncated but we want to
241+
/// preserve the accumulated context from prior compaction.
242+
pub fn clear_turns_preserve_context(&mut self) {
243+
// First compact any remaining turns into the summary
244+
if self.turns.len() > 1 {
245+
let _ = self.compact();
246+
}
247+
248+
// Now clear turns but keep summary_frame and context_summary
249+
self.turns.clear();
250+
251+
// Recalculate tokens (just summary frame now)
252+
self.total_tokens = self
253+
.summary_frame
254+
.as_ref()
255+
.map(|f| f.token_count)
256+
.unwrap_or(0);
257+
258+
// User turn count stays as-is for statistics
259+
}
260+
238261
/// Perform forge-style compaction with smart eviction
239262
/// Returns the summary that was created (for logging/display)
240263
pub fn compact(&mut self) -> Option<String> {
@@ -482,6 +505,16 @@ impl ConversationHistory {
482505
.iter()
483506
.map(|s| s.as_str())
484507
}
508+
509+
/// Serialize to JSON for session persistence
510+
pub fn to_json(&self) -> Result<String, serde_json::Error> {
511+
serde_json::to_string(self)
512+
}
513+
514+
/// Deserialize from JSON (for session restore)
515+
pub fn from_json(json: &str) -> Result<Self, serde_json::Error> {
516+
serde_json::from_str(json)
517+
}
485518
}
486519

487520
/// Helper to truncate text with ellipsis
@@ -622,4 +655,178 @@ mod tests {
622655
let reason = history.compaction_reason();
623656
assert!(reason.is_some());
624657
}
658+
659+
#[test]
660+
fn test_clear_turns_preserve_context() {
661+
// Create history with aggressive compaction to trigger summary
662+
let mut history = ConversationHistory::with_config(CompactConfig {
663+
retention_window: 2,
664+
eviction_window: 0.6,
665+
thresholds: CompactThresholds {
666+
token_threshold: Some(200),
667+
turn_threshold: Some(3),
668+
message_threshold: Some(5),
669+
on_turn_end: None,
670+
},
671+
});
672+
673+
// Add turns to trigger compaction
674+
for i in 0..6 {
675+
history.add_turn(
676+
format!("Question {} with extra text", i),
677+
format!("Answer {} with more detail", i),
678+
vec![],
679+
);
680+
}
681+
682+
// Trigger compaction to build summary
683+
if history.needs_compaction() {
684+
let _ = history.compact();
685+
}
686+
687+
// Verify we have a summary frame now
688+
let had_summary_before = history.summary_frame.is_some();
689+
690+
// Now clear turns while preserving context
691+
history.clear_turns_preserve_context();
692+
693+
// Verify turns are cleared but summary is preserved
694+
assert_eq!(history.turn_count(), 0, "Turns should be cleared");
695+
assert!(
696+
history.summary_frame.is_some() == had_summary_before,
697+
"Summary frame should be preserved"
698+
);
699+
700+
// Token count should only include summary frame
701+
if history.summary_frame.is_some() {
702+
assert!(history.token_count() > 0, "Should have tokens from summary");
703+
}
704+
705+
// to_messages should still work and include summary
706+
let messages = history.to_messages();
707+
if history.summary_frame.is_some() {
708+
assert!(
709+
!messages.is_empty(),
710+
"Should still have summary in messages"
711+
);
712+
}
713+
}
714+
715+
#[test]
716+
fn test_clear_vs_clear_preserve_context() {
717+
let mut history = ConversationHistory::new();
718+
719+
// Add some turns
720+
for i in 0..5 {
721+
history.add_turn(format!("Q{}", i), format!("A{}", i), vec![]);
722+
}
723+
724+
// Force compaction
725+
let _ = history.compact();
726+
let had_summary = history.summary_frame.is_some();
727+
728+
// Test clear_turns_preserve_context
729+
let mut history_preserve = history.clone();
730+
history_preserve.clear_turns_preserve_context();
731+
732+
// Test regular clear
733+
let mut history_clear = history.clone();
734+
history_clear.clear();
735+
736+
// Verify difference
737+
if had_summary {
738+
assert!(
739+
history_preserve.summary_frame.is_some(),
740+
"preserve should keep summary"
741+
);
742+
assert!(
743+
history_clear.summary_frame.is_none(),
744+
"clear removes summary"
745+
);
746+
}
747+
748+
// Both should have no turns
749+
assert_eq!(history_preserve.turn_count(), 0);
750+
assert_eq!(history_clear.turn_count(), 0);
751+
}
752+
753+
#[test]
754+
fn test_history_serialization() {
755+
let mut history = ConversationHistory::new();
756+
757+
// Add some turns
758+
history.add_turn(
759+
"What is this project?".to_string(),
760+
"This is a Rust CLI tool.".to_string(),
761+
vec![ToolCallRecord {
762+
tool_name: "analyze".to_string(),
763+
args_summary: "path: .".to_string(),
764+
result_summary: "Found Rust project".to_string(),
765+
tool_id: Some("tool_1".to_string()),
766+
droppable: false,
767+
}],
768+
);
769+
770+
// Serialize
771+
let json = history.to_json().expect("Should serialize");
772+
assert!(!json.is_empty());
773+
774+
// Deserialize
775+
let restored = ConversationHistory::from_json(&json).expect("Should deserialize");
776+
assert_eq!(restored.turn_count(), 1);
777+
assert_eq!(restored.user_turn_count(), 1);
778+
779+
// Verify tool call preserved
780+
let messages = restored.to_messages();
781+
assert!(!messages.is_empty());
782+
}
783+
784+
#[test]
785+
fn test_history_serialization_with_compaction() {
786+
// Create history with compaction triggered
787+
let mut history = ConversationHistory::with_config(CompactConfig {
788+
retention_window: 2,
789+
eviction_window: 0.6,
790+
thresholds: CompactThresholds {
791+
token_threshold: Some(200),
792+
turn_threshold: Some(3),
793+
message_threshold: Some(5),
794+
on_turn_end: None,
795+
},
796+
});
797+
798+
// Add many turns to trigger compaction
799+
for i in 0..6 {
800+
history.add_turn(
801+
format!("Question {} with some text", i),
802+
format!("Answer {} with more detail", i),
803+
vec![],
804+
);
805+
}
806+
807+
// Trigger compaction
808+
if history.needs_compaction() {
809+
let _ = history.compact();
810+
}
811+
812+
let had_summary = history.summary_frame.is_some();
813+
814+
// Serialize with summary
815+
let json = history.to_json().expect("Should serialize");
816+
817+
// Deserialize and verify summary preserved
818+
let restored = ConversationHistory::from_json(&json).expect("Should deserialize");
819+
assert_eq!(
820+
restored.summary_frame.is_some(),
821+
had_summary,
822+
"Summary frame should be preserved"
823+
);
824+
825+
// to_messages should include summary
826+
let messages = restored.to_messages();
827+
if had_summary {
828+
// Summary adds 2 messages (user + assistant acknowledgment)
829+
assert!(messages.len() >= 2, "Should have summary messages");
830+
}
831+
}
625832
}

0 commit comments

Comments
 (0)