Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import PeekabooFoundation
import Tachikoma
import TauTUI

/// Temporary session info struct until PeekabooAgentService implements session management
struct AgentSessionInfo: Codable {
let id: String
let task: String
Expand Down
332 changes: 58 additions & 274 deletions Apps/CLI/Tests/CLIAutomationTests/AgentResumeCLITests.swift
Original file line number Diff line number Diff line change
@@ -1,301 +1,85 @@
import Foundation
import PeekabooCore
import Testing
@testable import PeekabooCLI

@Suite(.tags(.safe))
struct AgentResumeCLITests {
// MARK: - Command Line Argument Tests

@Test
func `AgentCommand has resume option`() throws {
// Verify that the AgentCommand struct has the resume property
// This is a compile-time test to ensure the property exists
func `Agent defaults to no task or resume selection`() throws {
let command = try AgentCommand.parse([])

// The resume property should be optional and default to nil
#expect(command.resume == false)
}

@Test
func `AgentCommand task is optional for resume functionality`() throws {
// Verify that task is now optional to support resume without initial task
let command = try AgentCommand.parse([])

// Task should be optional
#expect(command.task == nil)
#expect(!command.resume)
#expect(command.resumeSession == nil)
}

// MARK: - Resume Command Validation Tests

@Test
func `Resume validation handles empty session ID`() {
let resumeSessionId = ""
let shouldShowRecentSessions = resumeSessionId.isEmpty
#expect(shouldShowRecentSessions == true)
}

@Test
func `Resume validation handles valid session ID`() {
let resumeSessionId = "valid-session-123"
let shouldShowRecentSessions = resumeSessionId.isEmpty
#expect(shouldShowRecentSessions == false)
}

// MARK: - Error Message Tests

@Test
func `Error messages are properly formatted`() throws {
// Test JSON error format
let jsonError = ["success": false, "error": "Session not found"] as [String: Any]
#expect(jsonError["success"] as? Bool == false)
#expect(jsonError["error"] as? String == "Session not found")

// Test that error can be serialized to JSON
do {
let jsonData = try JSONSerialization.data(withJSONObject: jsonError, options: .prettyPrinted)
let jsonString = String(data: jsonData, encoding: .utf8)
#expect(jsonString != nil)
#expect(try #require(jsonString).contains("\"success\" : false"))
} catch {
#expect(Bool(false), "JSON serialization should not fail")
}
}

// TODO: Rewrite these tests.
/*
@Test("Session data formats correctly for JSON output")
func sessionDataFormatsCorrectlyForJSON() async {
let manager = SessionManager.shared
let session = try! await manager.createSession(task: "JSON test task")

await manager.addMessageToSession(sessionId: session.id, message: .init(role: .user, content: "JSON step"))
//await manager.setLastQuestion(sessionId: session.id, question: "JSON question?")

let updatedSession = try! await manager.getSession(id: session.id)!

// Format session data as it would be for JSON output
let sessionData: [String: Any] = [
"id": updatedSession.id,
"task": updatedSession.summary,
"steps": updatedSession.messages.count,
"lastQuestion": "" as Any,
"createdAt": ISO8601DateFormatter().string(from: updatedSession.createdAt),
"lastActivityAt": ISO8601DateFormatter().string(from: updatedSession.updatedAt)
]

#expect(sessionData["id"] as? String == session.id)
#expect(sessionData["task"] as? String == "JSON test task")
#expect(sessionData["steps"] as? Int == 1)
#expect(sessionData["lastQuestion"] as? String == "")

// Test serialization
do {
let jsonData = try JSONSerialization.data(withJSONObject: sessionData, options: .prettyPrinted)
let jsonString = String(data: jsonData, encoding: .utf8)
#expect(jsonString != nil)
#expect(jsonString!.contains("JSON test task"))
} catch {
#expect(Bool(false), "Session data should serialize to JSON")
}

// Clean up
await manager.deleteSession(id: session.id)
}
*/

// MARK: - Time Formatting Tests

@Test
func `Time ago formatting works correctly`() {
let now = Date()

// Test recent time (less than 1 minute)
let recent = now.addingTimeInterval(-30) // 30 seconds ago
let recentFormatted = self.formatTimeAgoForTest(recent, from: now)
#expect(recentFormatted == "just now")

// Test minutes ago
let minutesAgo = now.addingTimeInterval(-90) // 1.5 minutes ago
let minutesFormatted = self.formatTimeAgoForTest(minutesAgo, from: now)
#expect(minutesFormatted == "1 minute ago")

let multipleMinutesAgo = now.addingTimeInterval(-300) // 5 minutes ago
let multipleMinutesFormatted = self.formatTimeAgoForTest(multipleMinutesAgo, from: now)
#expect(multipleMinutesFormatted == "5 minutes ago")

// Test hours ago
let hoursAgo = now.addingTimeInterval(-3900) // 1.08 hours ago
let hoursFormatted = self.formatTimeAgoForTest(hoursAgo, from: now)
#expect(hoursFormatted == "1 hour ago")

let multipleHoursAgo = now.addingTimeInterval(-7200) // 2 hours ago
let multipleHoursFormatted = self.formatTimeAgoForTest(multipleHoursAgo, from: now)
#expect(multipleHoursFormatted == "2 hours ago")

// Test days ago
let daysAgo = now.addingTimeInterval(-86500) // Just over 1 day ago
let daysFormatted = self.formatTimeAgoForTest(daysAgo, from: now)
#expect(daysFormatted == "1 day ago")

let multipleDaysAgo = now.addingTimeInterval(-172_800) // 2 days ago
let multipleDaysFormatted = self.formatTimeAgoForTest(multipleDaysAgo, from: now)
#expect(multipleDaysFormatted == "2 days ago")
}

/// Helper function to test time formatting logic
private func formatTimeAgoForTest(_ date: Date, from now: Date = Date()) -> String {
let interval = now.timeIntervalSince(date)

if interval < 60 {
return "just now"
} else if interval < 3600 {
let minutes = Int(interval / 60)
return "\(minutes) minute\(minutes == 1 ? "" : "s") ago"
} else if interval < 86400 {
let hours = Int(interval / 3600)
return "\(hours) hour\(hours == 1 ? "" : "s") ago"
} else {
let days = Int(interval / 86400)
return "\(days) day\(days == 1 ? "" : "s") ago"
}
}

// MARK: - Session Display Tests

// TODO: Rewrite these tests.
/*
@Test("Session list formatting includes all required fields")
func sessionListFormattingIncludesAllRequiredFields() async {
let manager = SessionManager.shared

// Create test sessions with different characteristics
let session1 = try! await manager.createSession(task: "Simple task")
let session2 = try! await manager.createSession(task: "Complex task with multiple steps")
let session3 = try! await manager.createSession(task: "Task with question")

// Add different amounts of content
await manager.addMessageToSession(sessionId: session2.id, message: .init(role: .user, content: "Step 1"))
await manager.addMessageToSession(sessionId: session2.id, message: .init(role: .user, content: "Step 2"))
await manager.addMessageToSession(sessionId: session2.id, message: .init(role: .user, content: "Step 3"))

let sessions = try! await manager.listSessions()
let testSessions = sessions.filter { [session1.id, session2.id, session3.id].contains($0.id) }

#expect(testSessions.count == 3)

// Verify each session has the required fields for display
for session in testSessions {
#expect(!session.id.isEmpty)
#expect(session.summary != nil)
#expect(session.createdAt <= Date())
#expect(session.lastAccessedAt <= Date())
}

// Verify specific session characteristics
let simpleSession = testSessions.first { $0.id == session1.id }
#expect(simpleSession?.messageCount == 0)

let complexSession = testSessions.first { $0.id == session2.id }
#expect(complexSession?.messageCount == 3)

// Clean up
await manager.deleteSession(id: session1.id)
await manager.deleteSession(id: session2.id)
await manager.deleteSession(id: session3.id)
}
*/

// MARK: - Resume Prompt Construction Tests

@Test
func `Resume prompt is constructed correctly`() {
_ = "Open TextEdit" // Original task
let continuationTask = "Now save the document"

let expectedPrompt = "Continue with the original task. The user's response: \(continuationTask)"
func `Resume subcommand selects latest session without an ID`() throws {
let command = try AgentResumeSubcommand.parse([])

#expect(expectedPrompt == "Continue with the original task. The user's response: Now save the document")

// Test with different continuation tasks
let longContinuation = [
"This is a very long continuation task",
"that includes multiple instructions",
"and complex requirements",
].joined(separator: " ")
let longPrompt = "Continue with the original task. The user's response: \(longContinuation)"

#expect(longPrompt.contains("very long continuation task"))
#expect(command.sessionId == nil)
#expect(!command.options.allowForeground)
#expect(command.options.model == nil)
}

// MARK: - Configuration Integration Tests

@Test
func `Resume respects configuration settings`() {
// Test that resume functionality respects the same configuration as regular commands
let defaultModel = "gpt-5.6"
let defaultMaxSteps = 20

// These would be the defaults used in resume
#expect(defaultModel == "gpt-5.6")
#expect(defaultMaxSteps == 20)

// Test that configuration override logic works
let configModel = "claude-opus-4-7"
let configMaxSteps = 30

let effectiveModel = configModel // Would be from config if available
let effectiveMaxSteps = configMaxSteps // Would be from config if available

#expect(effectiveModel == "claude-opus-4-7")
#expect(effectiveMaxSteps == 30)
func `Resume subcommand retains exact ID and execution options`() throws {
let id = "12345678-1234-1234-1234-123456789abc"
let command = try AgentResumeSubcommand.parse([
id, "--model", "ollama/test-model", "--max-steps", "12", "--allow-foreground",
])

#expect(command.sessionId == id)
#expect(command.options.model == "ollama/test-model")
#expect(command.options.maxSteps == 12)
#expect(command.options.allowForeground)
}

// MARK: - Edge Case Tests

@Test
func `Resume handles special characters in task`() {
_ = "Task with \"quotes\" and 'apostrophes' and {brackets} and <tags>" // Special task
let continuationTask = "Continue with émojis 👻 and unicode ∆∇∫"

let resumePrompt = "Continue with the original task. The user's response: \(continuationTask)"

#expect(resumePrompt.contains("émojis 👻"))
#expect(resumePrompt.contains("unicode ∆∇∫"))
@Test(arguments: [
"Continue with émojis 👻 and unicode ∆∇∫",
"Task with \"quotes\" and 'apostrophes' and {brackets} and <tags>",
String(repeating: "Long continuation. ", count: 100),
])
func `Resume parsing preserves continuation text`(_ task: String) throws {
let command = try AgentCommand.parse([task, "--resume-session", "saved-session"])

#expect(command.task == task)
#expect(command.resumeSession == "saved-session")
#expect(!command.resume)
}

@Test
func `Resume handles very long tasks`() {
_ = String(repeating: "Very long task description. ", count: 100) // Long task
let longContinuation = String(repeating: "Long continuation. ", count: 50)

let resumePrompt = "Continue with the original task. The user's response: \(longContinuation)"

#expect(resumePrompt.count > 1000) // Should handle long text
#expect(resumePrompt.contains("Long continuation."))
func `Session JSON uses the production projection and complete timestamps`() throws {
let command = AgentCommand()
let session = AgentSessionInfo(
id: "saved-session",
task: "Continue \"document\" 👻",
created: Date(timeIntervalSince1970: 1_700_000_000),
lastModified: Date(timeIntervalSince1970: 1_700_000_060),
messageCount: 4,
status: "active",
toolExecutionPolicy: "background_only"
)
let data = try JSONSerialization.data(withJSONObject: command.sessionJSONObject(session))
let json = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any])

#expect(json["id"] as? String == session.id)
#expect(json["task"] as? String == session.task)
#expect(json["createdAt"] as? String == "2023-11-14T22:13:20Z")
#expect(json["updatedAt"] as? String == "2023-11-14T22:14:20Z")
#expect(json["messageCount"] as? Int == 4)
#expect(json["status"] as? String == "active")
#expect(json["toolExecutionPolicy"] as? String == "background_only")
}

// MARK: - Session ID Validation Tests

@Test
func `Session ID validation works correctly`() {
// Test valid UUID format
let validUUID = UUID().uuidString
#expect(validUUID.count == 36)
#expect(validUUID.contains("-"))

// Test short ID display (prefix 8 characters)
let shortID = String(validUUID.prefix(8))
#expect(shortID.count == 8)
#expect(!shortID.contains("-"))

// Test invalid session IDs
let emptyID = ""
let shortInvalidID = "abc"
let longInvalidID = "this-is-not-a-valid-uuid-format-at-all"
@Test(arguments: [
(30.0, "just now"), (90.0, "1 minute ago"), (300.0, "5 minutes ago"),
(3900.0, "1 hour ago"), (7200.0, "2 hours ago"),
(86500.0, "1 day ago"), (172_800.0, "2 days ago"),
])
func `Session age uses the production formatter`(_ interval: TimeInterval, _ expected: String) {
let now = Date(timeIntervalSince1970: 1_700_000_000)

#expect(emptyID.isEmpty)
#expect(shortInvalidID.count < 36)
#expect(longInvalidID.count > 36) // Not a valid UUID format
#expect(PeekabooCLI.formatTimeAgo(now.addingTimeInterval(-interval), from: now) == expected)
}
}
Loading