From 28a8189e2f562cb925dea5a7ac5f6ff2cfddf6f7 Mon Sep 17 00:00:00 2001 From: karashiiro <49822414+karashiiro@users.noreply.github.com> Date: Wed, 7 Jan 2026 20:16:49 -0800 Subject: [PATCH] feat(persistence): implement compaction handling for session resumption - Add compact_boundary handler in sdk-adapter.ts with type validation - Add CompactionOccurredMessage IPC type (TypeScript + Rust) - Add SessionLogEntry::CompactionOccurred with timestamp - Update extract_sdk_session_id() to return latest ID after compaction - Add frontend handler with session ID mismatch validation --- backend/packages/adapter/src/sdk-adapter.ts | 25 +++++ backend/packages/ipc-types/src/index.ts | 14 ++- tiny-workshop/game/src/ipc.rs | 31 ++++++ tiny-workshop/game/src/lib.rs | 36 +++++++ .../game/src/persistence/session_log.rs | 95 ++++++++++++++++++- 5 files changed, 196 insertions(+), 5 deletions(-) diff --git a/backend/packages/adapter/src/sdk-adapter.ts b/backend/packages/adapter/src/sdk-adapter.ts index 902a7dd..e958e1d 100644 --- a/backend/packages/adapter/src/sdk-adapter.ts +++ b/backend/packages/adapter/src/sdk-adapter.ts @@ -507,6 +507,31 @@ export class SdkAdapter { type: 'session_started', session_id: this.sessionId, }); + } else if (msg.subtype === 'compact_boundary') { + // SDK compacted the conversation - capture new session ID + const newSessionId = msg.session_id; + if (typeof newSessionId !== 'string' || !newSessionId) { + log.warn('Received compact_boundary without valid session_id', { msg }); + break; + } + + const oldSessionId = this.sessionId; + if (!oldSessionId) { + log.warn('Compaction occurred but no prior session ID exists - unexpected state'); + } + + this.sessionId = newSessionId; + + // Extract trigger from SDK message if available, default to 'auto' + const trigger = typeof msg.trigger === 'string' ? msg.trigger : 'auto'; + + log.info('Session compacted', { oldSessionId, newSessionId, trigger }); + emit({ + type: 'compaction_occurred', + old_session_id: oldSessionId ?? '', + new_session_id: newSessionId, + trigger: trigger as 'manual' | 'auto', + }); } break; } diff --git a/backend/packages/ipc-types/src/index.ts b/backend/packages/ipc-types/src/index.ts index f97cc08..fb32749 100644 --- a/backend/packages/ipc-types/src/index.ts +++ b/backend/packages/ipc-types/src/index.ts @@ -162,6 +162,14 @@ export interface RoomQueryMessage { target?: string; } +/** Conversation was compacted (context pruning) */ +export interface CompactionOccurredMessage { + type: 'compaction_occurred'; + old_session_id: string; + new_session_id: string; + trigger: 'manual' | 'auto'; +} + /** All possible backend → frontend messages */ export type BackendMessage = | SessionStartedMessage @@ -174,7 +182,8 @@ export type BackendMessage = | AuthLoginUrlMessage | AuthStatusMessage | RoomActionMessage - | RoomQueryMessage; + | RoomQueryMessage + | CompactionOccurredMessage; // ============================================================================= // Error Codes @@ -231,6 +240,7 @@ export function isBackendMessage(msg: unknown): msg is BackendMessage { m.type === 'auth_login_url' || m.type === 'auth_status' || m.type === 'room_action' || - m.type === 'room_query' + m.type === 'room_query' || + m.type === 'compaction_occurred' ); } diff --git a/tiny-workshop/game/src/ipc.rs b/tiny-workshop/game/src/ipc.rs index 07274ae..f436fa0 100644 --- a/tiny-workshop/game/src/ipc.rs +++ b/tiny-workshop/game/src/ipc.rs @@ -134,6 +134,12 @@ pub enum BackendMessage { #[serde(default)] target: Option, }, + /// Conversation was compacted (context pruning) + CompactionOccurred { + old_session_id: String, + new_session_id: String, + trigger: String, + }, } // ============================================================================= @@ -324,4 +330,29 @@ mod tests { let _: serde_json::Value = serde_json::from_str(&json).unwrap(); } } + + #[test] + fn test_deserialize_compaction_occurred() { + let json = r#"{ + "type": "compaction_occurred", + "old_session_id": "old-abc123", + "new_session_id": "new-xyz789", + "trigger": "auto" + }"#; + + let msg: BackendMessage = serde_json::from_str(json).unwrap(); + + match msg { + BackendMessage::CompactionOccurred { + old_session_id, + new_session_id, + trigger, + } => { + assert_eq!(old_session_id, "old-abc123"); + assert_eq!(new_session_id, "new-xyz789"); + assert_eq!(trigger, "auto"); + } + _ => panic!("Expected CompactionOccurred variant"), + } + } } diff --git a/tiny-workshop/game/src/lib.rs b/tiny-workshop/game/src/lib.rs index 05194f9..0e931da 100644 --- a/tiny-workshop/game/src/lib.rs +++ b/tiny-workshop/game/src/lib.rs @@ -1393,6 +1393,42 @@ impl Game { } } } + + BackendMessage::CompactionOccurred { + old_session_id, + new_session_id, + trigger, + } => { + // Validate that old_session_id matches our current session + // (protects against misrouted or stale compaction messages) + if let Some(ref current_id) = self.session_id { + if !old_session_id.is_empty() && old_session_id != *current_id { + fyrox::core::log::Log::warn(format!( + "Compaction old_session_id mismatch: expected {current_id}, got {old_session_id}" + )); + // Continue anyway - the SDK knows best, but this is suspicious + } + } else if !old_session_id.is_empty() { + fyrox::core::log::Log::warn(format!( + "Received compaction with old_session_id {old_session_id} but we have no session" + )); + } + + fyrox::core::log::Log::info(format!( + "Session compacted: {old_session_id} -> {new_session_id} (trigger: {trigger})" + )); + + // Update stored session ID to the new compacted one + self.session_id = Some(new_session_id.clone()); + + // Log compaction event to session log + self.log_session_entry(persistence::SessionLogEntry::CompactionOccurred { + old_session_id, + new_session_id, + trigger, + timestamp: Utc::now(), + }); + } } } diff --git a/tiny-workshop/game/src/persistence/session_log.rs b/tiny-workshop/game/src/persistence/session_log.rs index 837e71a..6b29f29 100644 --- a/tiny-workshop/game/src/persistence/session_log.rs +++ b/tiny-workshop/game/src/persistence/session_log.rs @@ -82,6 +82,18 @@ pub enum SessionLogEntry { timestamp: DateTime, }, + /// SDK compacted the conversation (context pruning) + CompactionOccurred { + /// Session ID before compaction + old_session_id: String, + /// Session ID after compaction (use this for future resume) + new_session_id: String, + /// How compaction was triggered ("manual" or "auto") + trigger: String, + /// When compaction occurred + timestamp: DateTime, + }, + // === Messages === /// User sent a message UserMessage { @@ -485,12 +497,25 @@ pub fn load_session_summaries(sessions_dir: &Path) -> Result /// - `Some(String)` - The SDK session ID /// - `None` - No SessionStarted entry found pub fn extract_sdk_session_id(entries: &[SessionLogEntry]) -> Option { + // Track the latest session ID - may be updated by compaction events + let mut sdk_session_id = None; for entry in entries { - if let SessionLogEntry::SessionStarted { sdk_session_id, .. } = entry { - return Some(sdk_session_id.clone()); + match entry { + SessionLogEntry::SessionStarted { + sdk_session_id: id, .. + } => { + sdk_session_id = Some(id.clone()); + } + SessionLogEntry::CompactionOccurred { + new_session_id, .. + } => { + // Compaction replaces the session ID + sdk_session_id = Some(new_session_id.clone()); + } + _ => {} } } - None + sdk_session_id } #[cfg(test)] @@ -1024,4 +1049,68 @@ this is not valid json assert_eq!(summaries[1].sdk_session_id, "sdk-summary-1"); assert_eq!(summaries[1].preview, "First session message"); } + + #[test] + fn test_extract_sdk_session_id_with_compaction() { + // Test that extract_sdk_session_id returns the LATEST session ID after compaction + let entries = vec![ + SessionLogEntry::SessionStarted { + sdk_session_id: "original-session-123".to_string(), + model: None, + tools: vec![], + timestamp: Utc::now(), + }, + SessionLogEntry::UserMessage { + content: "Some messages...".to_string(), + timestamp: Utc::now(), + }, + SessionLogEntry::CompactionOccurred { + old_session_id: "original-session-123".to_string(), + new_session_id: "compacted-session-456".to_string(), + trigger: "auto".to_string(), + timestamp: Utc::now(), + }, + SessionLogEntry::UserMessage { + content: "More messages after compaction".to_string(), + timestamp: Utc::now(), + }, + ]; + + let sdk_id = extract_sdk_session_id(&entries); + + // Should return the compacted session ID, not the original + assert_eq!(sdk_id, Some("compacted-session-456".to_string())); + } + + #[test] + fn test_compaction_occurred_serialization() { + let entry = SessionLogEntry::CompactionOccurred { + old_session_id: "old-abc".to_string(), + new_session_id: "new-xyz".to_string(), + trigger: "auto".to_string(), + timestamp: Utc::now(), + }; + + let json = serde_json::to_string(&entry).unwrap(); + assert!(json.contains("\"type\":\"compaction_occurred\"")); + assert!(json.contains("\"old_session_id\":\"old-abc\"")); + assert!(json.contains("\"new_session_id\":\"new-xyz\"")); + assert!(json.contains("\"trigger\":\"auto\"")); + + // Verify roundtrip + let parsed: SessionLogEntry = serde_json::from_str(&json).unwrap(); + if let SessionLogEntry::CompactionOccurred { + old_session_id, + new_session_id, + trigger, + .. + } = parsed + { + assert_eq!(old_session_id, "old-abc"); + assert_eq!(new_session_id, "new-xyz"); + assert_eq!(trigger, "auto"); + } else { + panic!("Expected CompactionOccurred variant"); + } + } } -- 2.51.2