terraphim_agent 1.16.34

Terraphim AI Agent CLI - Command-line interface with interactive REPL and ASCII graph visualization
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
use std::process::Command;
use std::time::Duration;

use anyhow::Result;
use serial_test::serial;
use terraphim_agent::client::{ApiClient, ChatResponse, ConfigResponse, SearchResponse};
use terraphim_types::{Layer, NormalizedTermValue, RoleName, SearchQuery};

const TEST_SERVER_URL: &str = "http://localhost:8000";
#[allow(dead_code)]
const TEST_TIMEOUT: Duration = Duration::from_secs(10);

/// Test helper to check if server is running
async fn is_server_running() -> bool {
    let client = ApiClient::new(TEST_SERVER_URL);
    client.health().await.is_ok()
}

/// Test helper to wait for server startup
#[allow(dead_code)]
async fn wait_for_server() -> Result<()> {
    let max_attempts = 30;
    for _ in 0..max_attempts {
        if is_server_running().await {
            return Ok(());
        }
        tokio::time::sleep(Duration::from_millis(1000)).await;
    }
    anyhow::bail!("Server did not start within timeout")
}

#[tokio::test]
#[serial]
async fn test_api_client_health_check() {
    if !is_server_running().await {
        println!("Server not running, skipping test");
        return;
    }

    let client = ApiClient::new(TEST_SERVER_URL);
    let result = client.health().await;
    assert!(result.is_ok(), "Health check should succeed");
}

#[tokio::test]
#[serial]
async fn test_api_client_search() {
    if !is_server_running().await {
        println!("Server not running, skipping test");
        return;
    }

    let client = ApiClient::new(TEST_SERVER_URL);
    let query = SearchQuery {
        search_term: NormalizedTermValue::from("test"),
        search_terms: None,
        operator: None,
        skip: Some(0),
        limit: Some(5),
        role: Some(RoleName::new("Terraphim Engineer")),
        layer: Layer::default(),
        include_pinned: false,
    };

    let result = client.search(&query).await;
    assert!(result.is_ok(), "Search should succeed");

    let response: SearchResponse = result.unwrap();
    assert_eq!(response.status, "success");
    assert!(response.results.len() <= 5);
}

#[tokio::test]
#[serial]
async fn test_api_client_get_config() {
    if !is_server_running().await {
        println!("Server not running, skipping test");
        return;
    }

    let client = ApiClient::new(TEST_SERVER_URL);
    let result = client.get_config().await;
    assert!(result.is_ok(), "Get config should succeed");

    let response: ConfigResponse = result.unwrap();
    assert_eq!(response.status, "success");
    assert!(!response.config.roles.is_empty());
}

#[tokio::test]
#[serial]
async fn test_api_client_update_selected_role() {
    if !is_server_running().await {
        println!("Server not running, skipping test");
        return;
    }

    let client = ApiClient::new(TEST_SERVER_URL);

    // Get current config to find available roles
    let config_result = client.get_config().await;
    assert!(config_result.is_ok());
    let config = config_result.unwrap();

    // Get first available role
    let role_names: Vec<String> = config.config.roles.keys().map(|k| k.to_string()).collect();

    if role_names.is_empty() {
        println!("No roles available, skipping role update test");
        return;
    }

    let test_role = &role_names[0];
    let result = client.update_selected_role(test_role).await;
    assert!(result.is_ok(), "Update selected role should succeed");

    let response: ConfigResponse = result.unwrap();
    assert_eq!(response.status, "success");
    assert_eq!(response.config.selected_role.to_string(), *test_role);
}

#[tokio::test]
#[serial]
async fn test_api_client_get_rolegraph() {
    if !is_server_running().await {
        println!("Server not running, skipping test");
        return;
    }

    let client = ApiClient::new(TEST_SERVER_URL);

    // Use "Terraphim Engineer" role which has a knowledge graph loaded.
    // Calling with None uses the server's currently selected role which may
    // not have a rolegraph, causing a 500 error.
    let result = client.get_rolegraph_edges(Some("Terraphim Engineer")).await;
    assert!(
        result.is_ok(),
        "Get rolegraph should succeed for Terraphim Engineer role"
    );

    let response = result.unwrap();
    assert_eq!(response.status, "success");
    // Nodes and edges can be empty, that's valid
}

#[tokio::test]
#[serial]
async fn test_api_client_chat() {
    if !is_server_running().await {
        println!("Server not running, skipping test");
        return;
    }

    let client = ApiClient::new(TEST_SERVER_URL);
    let result = client.chat("Default", "Hello, this is a test", None).await;
    assert!(result.is_ok(), "Chat should succeed");

    let response: ChatResponse = result.unwrap();
    // Chat might succeed or fail depending on LLM availability
    // We just check that the response structure is correct
    assert!(!response.status.is_empty());
}

#[tokio::test]
#[serial]
async fn test_api_client_network_timeout() {
    // Test with invalid URL to ensure timeout behavior
    let client = ApiClient::new("http://invalid-server:9999");
    let result = client.health().await;
    assert!(result.is_err(), "Should fail with network error");
}

#[tokio::test]
#[serial]
async fn test_search_with_different_roles() {
    if !is_server_running().await {
        println!("Server not running, skipping test");
        return;
    }

    let client = ApiClient::new(TEST_SERVER_URL);

    // Get available roles
    let config_result = client.get_config().await;
    assert!(config_result.is_ok());
    let config = config_result.unwrap();

    let role_names: Vec<String> = config.config.roles.keys().map(|k| k.to_string()).collect();

    if role_names.is_empty() {
        println!("No roles available, skipping multi-role search test");
        return;
    }

    // Test search with each available role.
    // Some roles may return errors (e.g. if their name contains spaces that
    // are normalised differently on the server, or if their haystack is not
    // configured).  We require at least one role to succeed.
    let mut any_succeeded = false;
    for role_name in &role_names {
        let query = SearchQuery {
            search_term: NormalizedTermValue::from("test"),
            search_terms: None,
            operator: None,
            skip: Some(0),
            limit: Some(3),
            role: Some(RoleName::new(role_name)),
            layer: Layer::default(),
            include_pinned: false,
        };

        let result = client.search(&query).await;
        match result {
            Ok(response) => {
                assert_eq!(
                    response.status, "success",
                    "Search with role {} returned unexpected status",
                    role_name
                );
                any_succeeded = true;
            }
            Err(e) => {
                println!(
                    "Search with role {} returned error (may be expected): {:?}",
                    role_name, e
                );
            }
        }
    }
    assert!(any_succeeded, "At least one role should support search");
}

#[tokio::test]
#[serial]
async fn test_search_pagination() {
    if !is_server_running().await {
        println!("Server not running, skipping test");
        return;
    }

    let client = ApiClient::new(TEST_SERVER_URL);

    // Search first page
    let query1 = SearchQuery {
        search_term: NormalizedTermValue::from("test"),
        search_terms: None,
        operator: None,
        skip: Some(0),
        limit: Some(2),
        role: Some(RoleName::new("Default")),
        layer: Layer::default(),
        include_pinned: false,
    };

    let result1 = client.search(&query1).await;
    assert!(result1.is_ok());

    // Search second page
    let query2 = SearchQuery {
        search_term: NormalizedTermValue::from("test"),
        search_terms: None,
        operator: None,
        skip: Some(2),
        limit: Some(2),
        role: Some(RoleName::new("Default")),
        layer: Layer::default(),
        include_pinned: false,
    };

    let result2 = client.search(&query2).await;
    assert!(result2.is_ok());

    let response1: SearchResponse = result1.unwrap();
    let response2: SearchResponse = result2.unwrap();

    // If there are enough results, pages should be different
    if response1.results.len() == 2 && !response2.results.is_empty() {
        // Results should be different (assuming different documents)
        let ids1: Vec<String> = response1.results.iter().map(|d| d.id.clone()).collect();
        let ids2: Vec<String> = response2.results.iter().map(|d| d.id.clone()).collect();

        // Should have different document IDs (no overlap)
        for id1 in &ids1 {
            assert!(!ids2.contains(id1), "Pages should have different documents");
        }
    }
}

#[test]
#[serial]
fn test_tui_cli_search_command() {
    if !std::process::Command::new("cargo")
        .args(["build", "--bin", "terraphim-agent"])
        .status()
        .map(|s| s.success())
        .unwrap_or(false)
    {
        println!("Could not build TUI binary, skipping CLI test");
        return;
    }

    let output = Command::new("cargo")
        .args([
            "run",
            "--bin",
            "terraphim-agent",
            "--",
            "search",
            "test",
            "--limit",
            "3",
        ])
        .env("TERRAPHIM_SERVER", TEST_SERVER_URL)
        .output();

    if let Ok(output) = output {
        if output.status.success() {
            let stdout = String::from_utf8_lossy(&output.stdout);
            println!("CLI search output: {}", stdout);
            // Should contain some search results or handle gracefully
            assert!(!stdout.contains("error"), "CLI should not show errors");
        } else {
            let stderr = String::from_utf8_lossy(&output.stderr);
            println!("CLI search failed: {}", stderr);
            // This might fail if server is not running, which is okay for testing
        }
    }
}

#[test]
#[serial]
fn test_tui_cli_roles_list_command() {
    if !std::process::Command::new("cargo")
        .args(["build", "--bin", "terraphim-agent"])
        .status()
        .map(|s| s.success())
        .unwrap_or(false)
    {
        println!("Could not build TUI binary, skipping CLI test");
        return;
    }

    let output = Command::new("cargo")
        .args(["run", "--bin", "terraphim-agent", "--", "roles", "list"])
        .env("TERRAPHIM_SERVER", TEST_SERVER_URL)
        .output();

    if let Ok(output) = output {
        if output.status.success() {
            let stdout = String::from_utf8_lossy(&output.stdout);
            println!("CLI roles list output: {}", stdout);
            // Should contain role names or handle gracefully
        } else {
            let stderr = String::from_utf8_lossy(&output.stderr);
            println!("CLI roles list failed: {}", stderr);
        }
    }
}

#[test]
#[serial]
fn test_tui_cli_config_show_command() {
    if !std::process::Command::new("cargo")
        .args(["build", "--bin", "terraphim-agent"])
        .status()
        .map(|s| s.success())
        .unwrap_or(false)
    {
        println!("Could not build TUI binary, skipping CLI test");
        return;
    }

    let output = Command::new("cargo")
        .args(["run", "--bin", "terraphim-agent", "--", "config", "show"])
        .env("TERRAPHIM_SERVER", TEST_SERVER_URL)
        .output();

    if let Ok(output) = output {
        if output.status.success() {
            let stdout = String::from_utf8_lossy(&output.stdout);
            println!("CLI config show output: {}", stdout);
            // Should contain JSON config or handle gracefully
            if !stdout.is_empty() {
                // Try to parse as JSON if we got content
                if stdout.starts_with('{') {
                    assert!(
                        serde_json::from_str::<serde_json::Value>(&stdout).is_ok(),
                        "Config output should be valid JSON"
                    );
                }
            }
        } else {
            let stderr = String::from_utf8_lossy(&output.stderr);
            println!("CLI config show failed: {}", stderr);
        }
    }
}

#[test]
#[serial]
fn test_tui_cli_graph_command() {
    if !std::process::Command::new("cargo")
        .args(["build", "--bin", "terraphim-agent"])
        .status()
        .map(|s| s.success())
        .unwrap_or(false)
    {
        println!("Could not build TUI binary, skipping CLI test");
        return;
    }

    let output = Command::new("cargo")
        .args([
            "run",
            "--bin",
            "terraphim-agent",
            "--",
            "graph",
            "--top-k",
            "5",
        ])
        .env("TERRAPHIM_SERVER", TEST_SERVER_URL)
        .output();

    if let Ok(output) = output {
        if output.status.success() {
            let stdout = String::from_utf8_lossy(&output.stdout);
            println!("CLI graph output: {}", stdout);
            // Graph command outputs top-k concept names, one per line
            // An empty role graph is valid (no documents indexed yet)
            // Just verify the command ran successfully
            let lines: Vec<_> = stdout.lines().collect();
            println!("Graph returned {} concepts", lines.len());
            // Command succeeded - this is the important check
        } else {
            let stderr = String::from_utf8_lossy(&output.stderr);
            println!("CLI graph failed: {}", stderr);
        }
    }
}

#[tokio::test]
#[serial]
async fn test_api_error_handling() {
    if !is_server_running().await {
        println!("Server not running, skipping test");
        return;
    }

    let client = ApiClient::new(TEST_SERVER_URL);

    // Test search with invalid parameters
    let query = SearchQuery {
        search_term: NormalizedTermValue::from(""), // Empty search
        search_terms: None,
        operator: None,
        skip: Some(0),
        limit: Some(0), // Invalid limit
        role: Some(RoleName::new("NonExistentRole")),
        layer: Layer::default(),
        include_pinned: false,
    };

    let result = client.search(&query).await;
    // This might succeed or fail depending on server implementation
    // We just ensure it doesn't panic
    match result {
        Ok(response) => {
            println!("Empty search response: {:?}", response);
        }
        Err(e) => {
            println!("Empty search error (expected): {:?}", e);
        }
    }
}