-
Notifications
You must be signed in to change notification settings - Fork 17
Expand file tree
/
Copy pathmain_test.go
More file actions
788 lines (710 loc) · 23.7 KB
/
Copy pathmain_test.go
File metadata and controls
788 lines (710 loc) · 23.7 KB
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
17
10BC0
2
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
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
package main
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
)
// TestGetSessionByTopic tests the getSessionByTopic function
func TestGetSessionByTopic(t *testing.T) {
config := &Config{
Sessions: map[string]*SessionInfo{
"project1": {TopicID: 100, Path: "/home/user/project1"},
"project2": {TopicID: 200, Path: "/home/user/project2"},
"money/shop": {TopicID: 300, Path: "/home/user/money/shop"},
},
}
tests := []struct {
name string
topicID int64
expected string
}{
{"existing topic", 100, "project1"},
{"another existing", 200, "project2"},
{"nested path", 300, "money/shop"},
{"non-existent", 999, ""},
{"zero", 0, ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := getSessionByTopic(config, tt.topicID)
if result != tt.expected {
t.Errorf("getSessionByTopic(config, %d) = %q, want %q", tt.topicID, result, tt.expected)
}
})
}
}
// TestGetSessionByTopicNilSessions tests with nil sessions map
func TestGetSessionByTopicNilSessions(t *testing.T) {
config := &Config{
Sessions: nil,
}
result := getSessionByTopic(config, 100)
if result != "" {
t.Errorf("getSessionByTopic with nil sessions = %q, want empty string", result)
}
}
// TestConfigSaveLoad tests saving and loading config
func TestConfigSaveLoad(t *testing.T) {
// Create temp directory for test
tmpDir, err := os.MkdirTemp("", "ccc-test-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
// Override config path for test
originalHome := os.Getenv("HOME")
os.Setenv("HOME", tmpDir)
defer os.Setenv("HOME", originalHome)
// Test config
config := &Config{
BotToken: "test-token-123",
ChatID: 12345,
GroupID: -67890,
Sessions: map[string]*SessionInfo{
"project1": {TopicID: 100, Path: "/home/user/project1"},
"money/shop": {TopicID: 200, Path: "/home/user/money/shop"},
},
Away: true,
}
// Save config
if err := saveConfig(config); err != nil {
t.Fatalf("saveConfig failed: %v", err)
}
// Verify file exists
configPath := filepath.Join(tmpDir, ".config", "ccc", "config.json")
if _, err := os.Stat(configPath); os.IsNotExist(err) {
t.Fatal("Config file was not created")
}
// Load config
loaded, err := loadConfig()
if err != nil {
t.Fatalf("loadConfig failed: %v", err)
}
// Verify loaded config matches
if loaded.BotToken != config.BotToken {
t.Errorf("BotToken = %q, want %q", loaded.BotToken, config.BotToken)
}
if loaded.ChatID != config.ChatID {
t.Errorf("ChatID = %d, want %d", loaded.ChatID, config.ChatID)
}
if loaded.GroupID != config.GroupID {
t.Errorf("GroupID = %d, want %d", loaded.GroupID, config.GroupID)
}
if loaded.Away != config.Away {
t.Errorf("Away = %v, want %v", loaded.Away, config.Away)
}
if len(loaded.Sessions) != len(config.Sessions) {
t.Errorf("Sessions length = %d, want %d", len(loaded.Sessions), len(config.Sessions))
}
for name, info := range config.Sessions {
loadedInfo := loaded.Sessions[name]
if loadedInfo == nil || loadedInfo.TopicID != info.TopicID {
t.Errorf("Sessions[%q].TopicID mismatch", name)
}
}
}
// TestConfigLoadNonExistent tests loading non-existent config
func TestConfigLoadNonExistent(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "ccc-test-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
originalHome := os.Getenv("HOME")
os.Setenv("HOME", tmpDir)
defer os.Setenv("HOME", originalHome)
_, err = loadConfig()
if err == nil {
t.Error("loadConfig should fail for non-existent file")
}
}
// TestConfigSessionsInitialized tests that Sessions map is initialized on load
func TestConfigSessionsInitialized(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "ccc-test-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
originalHome := os.Getenv("HOME")
os.Setenv("HOME", tmpDir)
defer os.Setenv("HOME", originalHome)
// Write config without sessions field
configPath := filepath.Join(tmpDir, ".ccc.json")
data := []byte(`{"bot_token": "test", "chat_id": 123}`)
if err := os.WriteFile(configPath, data, 0600); err != nil {
t.Fatalf("Failed to write test config: %v", err)
}
loaded, err := loadConfig()
if err != nil {
t.Fatalf("loadConfig failed: %v", err)
}
if loaded.Sessions == nil {
t.Error("Sessions should be initialized to non-nil map")
}
}
// TestExtractRecentAssistantTexts tests parsing transcript JSONL files
func TestExtractRecentAssistantTexts(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "ccc-test-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
tests := []struct {
name string
content string
expected []string // expected texts in order
}{
{
name: "simple response with one text block",
content: `{"type":"assistant","requestId":"req_2","message":{"role":"assistant","content":[{"type":"text","text":"Hello! How can I help?"}]}}`,
expected: []string{"Hello! How can I help?"},
},
{
name: "multiple text blocks in one entry",
content: `{"type":"assistant","requestId":"req_2","message":{"role":"assistant","content":[{"type":"text","text":"First part"},{"type":"text","text":"Second part"}]}}`,
expected: []string{"First part", "Second part"},
},
{
name: "filters thinking and tool_use",
content: `{"type":"assistant","requestId":"req_2","message":{"role":"assistant","content":[{"type":"thinking","thinking":"let me think..."},{"type":"text","text":"Here is my answer"},{"type":"tool_use","name":"Bash","input":{"command":"ls"}}]}}`,
expected: []string{"Here is my answer"},
},
{
name: "streaming dedup same requestId keeps last",
content: `{"type":"assistant","requestId":"req_2","message":{"role":"assistant","content":[{"type":"text","text":"partial response..."}]}}
{"type":"assistant","requestId":"req_2","message":{"role":"assistant","content":[{"type":"text","text":"complete response with more detail"}]}}`,
expected: []string{"complete response with more detail"},
},
{
name: "returns ALL turns (not just last)",
content: `{"type":"user","message":{"role":"user","content":[{"type":"text","text":"first question"}]}}
{"type":"assistant","requestId":"req_2","message":{"role":"assistant","content":[{"type":"text","text":"first answer"}]}}
{"type":"user","message":{"role":"user","content":[{"type":"text","text":"second question"}]}}
{"type":"assistant","requestId":"req_4","message":{"role":"assistant","content":[{"type":"text","text":"second answer"}]}}`,
expected: []string{"first answer", "second answer"},
},
{
name: "empty file returns nil",
content: "",
expected: nil,
},
{
name: "no assistant messages returns nil",
content: `{"type":"user","message":{"role":"user","content":[{"type":"text","text":"hello"}]}}`,
expected: nil,
},
{
name: "filters no content",
content: `{"type":"assistant","requestId":"req_2","message":{"role":"assistant","content":[{"type":"text","text":"(no content)"},{"type":"text","text":"real content"}]}}`,
expected: []string{"real content"},
},
{
name: "skips error entries without requestId",
content: `{"type":"assistant","requestId":"req_2","message":{"role":"assistant","content":[{"type":"text","text":"good"}]}}
{"type":"assistant","isApiErrorMessage":true,"message":{"role":"assistant","content":[{"type":"text","text":"No response requested."}]}}`,
expected: []string{"good"},
},
{
name: "multiple requestIds all returned",
content: `{"type":"assistant","requestId":"req_2","message":{"role":"assistant","content":[{"type":"text","text":"running tool"}]}}
{"type":"assistant","requestId":"req_4","message":{"role":"assistant","content":[{"type":"text","text":"tool completed"}]}}`,
expected: []string{"running tool", "tool completed"},
},
{
name: "tail count limits results",
content: `{"type":"assistant","requestId":"req_1","message":{"role":"assistant","content":[{"type":"text","text":"old message"}]}}
{"type":"assistant","requestId":"req_2","message":{"role":"assistant","content":[{"type":"text","text":"recent message"}]}}`,
expected: []string{"recent message"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
filePath := filepath.Join(tmpDir, tt.name+".jsonl")
if err := os.WriteFile(filePath, []byte(tt.content), 0644); err != nil {
t.Fatalf("Failed to write test file: %v", err)
}
tailCount := 80
if tt.name == "tail count limits results" {
tailCount = 1 // only keep last entry
}
blocks := extractRecentAssistantTexts(filePath, tailCount)
var result []string
for _, b := range blocks {
result = append(result, b.text)
}
if tt.expected == nil {
if result != nil {
t.Errorf("got %v, want nil", result)
}
return
}
if len(result) != len(tt.expected) {
t.Errorf("returned %d blocks, want %d: %v", len(result), len(tt.expected), result)
return
}
for i, exp := range tt.expected {
if result[i] != exp {
t.Errorf("block %d = %q, want %q", i, result[i], exp)
}
}
})
}
}
// TestExtractRecentNonExistent tests with non-existent file
func TestExtractRecentNonExistent(t *testing.T) {
result := extractRecentAssistantTexts("/nonexistent/path/file.jsonl", 80)
if result != nil {
t.Errorf("non-existent file = %v, want nil", result)
}
}
// TestExtractRecentEmptyPath tests with empty path
func TestExtractRecentEmptyPath(t *testing.T) {
result := extractRecentAssistantTexts("", 80)
if result != nil {
t.Errorf("empty path = %v, want nil", result)
}
}
// TestExecuteCommand tests the executeCommand function
func TestExecuteCommand(t *testing.T) {
tests := []struct {
name string
cmd string
wantContain string
wantErr bool
}{
{"echo", "echo hello", "hello", false},
{"pwd", "pwd", "/", false},
{"invalid command", "nonexistentcommand123", "", true},
{"exit code", "exit 1", "", true},
{"stderr output", "echo error >&2", "error", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
output, err := executeCommand(tt.cmd)
if (err != nil) != tt.wantErr {
t.Errorf("executeCommand(%q) error = %v, wantErr %v", tt.cmd, err, tt.wantErr)
}
if tt.wantContain != "" && !contains(output, tt.wantContain) {
t.Errorf("executeCommand(%q) output = %q, want to contain %q", tt.cmd, output, tt.wantContain)
}
})
}
}
// TestConfigJSON tests JSON marshaling/unmarshaling
func TestConfigJSON(t *testing.T) {
config := &Config{
BotToken: "token123",
ChatID: 12345,
GroupID: -67890,
Sessions: map[string]*SessionInfo{
"test": {TopicID: 100, Path: "/home/user/test"},
},
Away: true,
}
data, err := json.Marshal(config)
if err != nil {
t.Fatalf("Marshal failed: %v", err)
}
var loaded Config
if err := json.Unmarshal(data, &loaded); err != nil {
t.Fatalf("Unmarshal failed: %v", err)
}
if loaded.BotToken != config.BotToken {
t.Errorf("BotToken mismatch")
}
}
// TestHookDataJSON tests HookData JSON parsing
func TestHookDataJSON(t *testing.T) {
jsonStr := `{"cwd":"/Users/test/project","transcript_path":"/tmp/transcript.jsonl","session_id":"abc123"}`
var hookData HookData
if err := json.Unmarshal([]byte(jsonStr), &hookData); err != nil {
t.Fatalf("Unmarshal failed: %v", err)
}
if hookData.Cwd != "/Users/test/project" {
t.Errorf("Cwd = %q, want %q", hookData.Cwd, "/Users/test/project")
}
if hookData.TranscriptPath != "/tmp/transcript.jsonl" {
t.Errorf("TranscriptPath = %q, want %q", hookData.TranscriptPath, "/tmp/transcript.jsonl")
}
if hookData.SessionID != "abc123" {
t.Errorf("SessionID = %q, want %q", hookData.SessionID, "abc123")
}
}
// TestTelegramMessageJSON tests TelegramMessage JSON parsing
func TestTelegramMessageJSON(t *testing.T) {
jsonStr := `{
"message_id": 123,
"message_thread_id": 456,
"chat": {"id": 789, "type": "supergroup"},
"from": {"id": 111, "username": "testuser"},
"text": "Hello world"
}`
var msg TelegramMessage
if err := json.Unmarshal([]byte(jsonStr), &msg); err != nil {
t.Fatalf("Unmarshal failed: %v", err)
}
if msg.MessageID != 123 {
t.Errorf("MessageID = %d, want 123", msg.MessageID)
}
if msg.MessageThreadID != 456 {
t.Errorf("MessageThreadID = %d, want 456", msg.MessageThreadID)
}
if msg.Chat.ID != 789 {
t.Errorf("Chat.ID = %d, want 789", msg.Chat.ID)
}
if msg.Chat.Type != "supergroup" {
t.Errorf("Chat.Type = %q, want supergroup", msg.Chat.Type)
}
if msg.From.Username != "testuser" {
t.Errorf("From.Username = %q, want testuser", msg.From.Username)
}
if msg.Text != "Hello world" {
t.Errorf("Text = %q, want 'Hello world'", msg.Text)
}
}
// TestMessageTruncation tests that long messages are truncated
func TestMessageTruncation(t *testing.T) {
// The sendMessage function truncates at 4000 chars
// We test the truncation logic directly
const maxLen = 4000
tests := []struct {
name string
inputLen int
shouldTrim bool
}{
{"short message", 100, false},
{"exactly max", maxLen, false},
{"over max", maxLen + 100, true},
{"way over max", 10000, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Create message of specified length
text := make([]byte, tt.inputLen)
for i := range text {
text[i] = 'a'
}
msg := string(text)
// Apply same truncation logic as sendMessage
if len(msg) > maxLen {
msg = msg[:maxLen] + "\n... (truncated)"
}
if tt.shouldTrim {
if len(msg) <= tt.inputLen {
// Should have been truncated
if len(msg) != maxLen+len("\n... (truncated)") {
t.Errorf("truncated length = %d, want %d", len(msg), maxLen+len("\n... (truncated)"))
}
}
} else {
if len(msg) != tt.inputLen {
t.Errorf("message was unexpectedly modified")
}
}
})
}
}
// TestConfigFilePermissions tests that config is saved with correct permissions
func TestConfigFilePermissions(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "ccc-test-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
originalHome := os.Getenv("HOME")
os.Setenv("HOME", tmpDir)
defer os.Setenv("HOME", originalHome)
config := &Config{
BotToken: "secret-token",
ChatID: 12345,
Sessions: make(map[string]*SessionInfo),
}
if err := saveConfig(config); err != nil {
t.Fatalf("saveConfig failed: %v", err)
}
configPath := filepath.Join(tmpDir, ".config", "ccc", "config.json")
info, err := os.Stat(configPath)
if err != nil {
t.Fatalf("Failed to stat config file: %v", err)
}
// Check permissions are 0600 (owner read/write only)
perm := info.Mode().Perm()
if perm != 0600 {
t.Errorf("Config file permissions = %o, want 0600", perm)
}
}
// TestEmptySessionsMap tests behavior with empty sessions
func TestEmptySessionsMap(t *testing.T) {
config := &Config{
Sessions: make(map[string]*SessionInfo),
}
result := getSessionByTopic(config, 100)
if result != "" {
t.Errorf("getSessionByTopic with empty sessions = %q, want empty", result)
}
}
// TestTopicResultJSON tests TopicResult JSON parsing
func TestTopicResultJSON(t *testing.T) {
jsonStr := `{"message_thread_id": 12345, "name": "test-topic"}`
var topic TopicResult
if err := json.Unmarshal([]byte(jsonStr), &topic); err != nil {
t.Fatalf("Unmarshal failed: %v", err)
}
if topic.MessageThreadID != 12345 {
t.Errorf("MessageThreadID = %d, want 12345", topic.MessageThreadID)
}
if topic.Name != "test-topic" {
t.Errorf("Name = %q, want test-topic", topic.Name)
}
}
// TestTelegramResponseJSON tests TelegramResponse JSON parsing
func TestTelegramResponseJSON(t *testing.T) {
tests := []struct {
name string
json string
wantOK bool
wantErr string
}{
{
name: "success response",
json: `{"ok": true, "result": {}}`,
wantOK: true,
},
{
name: "error response",
json: `{"ok": false, "description": "Bad Request"}`,
wantOK: false,
wantErr: "Bad Request",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var resp TelegramResponse
if err := json.Unmarshal([]byte(tt.json), &resp); err != nil {
t.Fatalf("Unmarshal failed: %v", err)
}
if resp.OK != tt.wantOK {
t.Errorf("OK = %v, want %v", resp.OK, tt.wantOK)
}
if resp.Description != tt.wantErr {
t.Errorf("Description = %q, want %q", resp.Description, tt.wantErr)
}
})
}
}
// TestReplyToMessage tests nested message parsing
func TestReplyToMessage(t *testing.T) {
jsonStr := `{
"message_id": 100,
"text": "Reply text",
"chat": {"id": 123, "type": "private"},
"from": {"id": 456, "username": "user"},
"reply_to_message": {
"message_id": 99,
"text": "Original text",
"chat": {"id": 123, "type": "private"},
"from": {"id": 456, "username": "user"}
}
}`
var msg TelegramMessage
if err := json.Unmarshal([]byte(jsonStr), &msg); err != nil {
t.Fatalf("Unmarshal failed: %v", err)
}
if msg.ReplyToMessage == nil {
t.Fatal("ReplyToMessage should not be nil")
}
if msg.ReplyToMessage.MessageID != 99 {
t.Errorf("ReplyToMessage.MessageID = %d, want 99", msg.ReplyToMessage.MessageID)
}
if msg.ReplyToMessage.Text != "Original text" {
t.Errorf("ReplyToMessage.Text = %q, want 'Original text'", msg.ReplyToMessage.Text)
}
}
// TestLedgerAppendAndRead tests basic ledger operations
func TestLedgerAppendAndRead(t *testing.T) {
// Use a unique session name with temp suffix so the ledger file doesn't collide
session := "test-ledger-" + filepath.Base(t.TempDir())
// Clean up after test
defer os.Remove(ledgerPath(session))
// Append a message
rec := &MessageRecord{
ID: "test:1",
Session: session,
Type: "user_prompt",
Text: "hello world",
Origin: "telegram",
TerminalDelivered: false,
TelegramDelivered: true,
}
if err := appendMessage(rec); err != nil {
t.Fatalf("appendMessage failed: %v", err)
}
// Read back
records := readLedger(session)
if len(records) != 1 {
t.Fatalf("readLedger returned %d records, want 1", len(records))
}
if records[0].ID != "test:1" {
t.Errorf("ID = %q, want test:1", records[0].ID)
}
if records[0].TerminalDelivered {
t.Error("TerminalDelivered should be false")
}
// Update delivery
if err := updateDelivery(session, "test:1", "terminal_delivered", true); err != nil {
t.Fatalf("updateDelivery failed: %v", err)
}
// Read again — should be merged
records = readLedger(session)
if len(records) != 1 {
t.Fatalf("readLedger returned %d records after update, want 1", len(records))
}
if !records[0].TerminalDelivered {
t.Error("TerminalDelivered should be true after update")
}
// Test isDelivered
if !isDelivered(session, "test:1", "terminal") {
t.Error("isDelivered(terminal) should be true")
}
if !isDelivered(session, "test:1", "telegram") {
t.Error("isDelivered(telegram) should be true")
}
// Test findUndelivered
appendMessage(&MessageRecord{
ID: "test:2",
Session: session,
Type: "assistant_text",
Text: "response",
Origin: "claude",
TerminalDelivered: true,
TelegramDelivered: false,
})
undelivered := findUndelivered(session, "telegram")
if len(undelivered) != 1 {
t.Fatalf("findUndelivered(telegram) returned %d, want 1", len(undelivered))
}
if undelivered[0].ID != "test:2" {
t.Errorf("undelivered ID = %q, want test:2", undelivered[0].ID)
}
}
// TestLedgerDedup tests that contentHash produces consistent hashes
func TestLedgerDedup(t *testing.T) {
h1 := contentHash("hello world")
h2 := contentHash("hello world")
h3 := contentHash("different text")
if h1 != h2 {
t.Errorf("same content produced different hashes: %s vs %s", h1, h2)
}
if h1 == h3 {
t.Error("different content produced same hash")
}
}
// Helper function
func contains(s, substr string) bool {
return len(s) >= len(substr) && (s == substr || len(substr) == 0 ||
(len(s) > 0 && len(substr) > 0 && findSubstring(s, substr)))
}
func findSubstring(s, substr string) bool {
for i := 0; i <= len(s)-len(substr); i++ {
if s[i:i+len(substr)] == substr {
return true
}
}
return false
}
// TestTitleFromPrompt tests the topic title derived from a /new prompt
func TestTitleFromPrompt(t *testing.T) {
tests := []struct {
prompt string
want string
}{
{"fix the swipe decoder", "fix the swipe decoder"},
{" fix the\nswipe decoder ", "fix the swipe decoder"},
{"", "session"},
{" ", "session"},
{"arregla el bug del parser de fechas en el backend", "arregla el bug del parser de fechas en e…"},
{"añade soporte para emojis 🎉 en el título de la sesión", "añade soporte para emojis 🎉 en el título…"},
}
for _, tt := range tests {
if got := titleFromPrompt(tt.prompt); got != tt.want {
t.Errorf("titleFromPrompt(%q) = %q, want %q", tt.prompt, got, tt.want)
}
}
}
// TestUniqueSessionName tests collision handling for session names
func TestUniqueSessionName(t *testing.T) {
config := &Config{Sessions: map[string]*SessionInfo{
"deploy": {TopicID: 1},
"deploy (2)": {TopicID: 2},
}}
if got := uniqueSessionName(config, "build"); got != "build" {
t.Errorf("uniqueSessionName free name = %q, want %q", got, "build")
}
if got := uniqueSessionName(config, "deploy"); got != "deploy (3)" {
t.Errorf("uniqueSessionName collision = %q, want %q", got, "deploy (3)")
}
}
// TestAgentDisplayName tests that the fleet name follows the topic title
func TestAgentDisplayName(t *testing.T) {
if got := agentDisplayName(&SessionInfo{Title: "~/ccc: fix decoder"}, "fix decoder"); got != "~/ccc: fix decoder" {
t.Errorf("agentDisplayName with title = %q", got)
}
if got := agentDisplayName(&SessionInfo{}, "fix decoder"); got != "fix decoder" {
t.Errorf("agentDisplayName without title = %q", got)
}
if got := agentDisplayName(nil, "fix decoder"); got != "fix decoder" {
t.Errorf("agentDisplayName nil = %q", got)
}
}
// TestSessionWorkDir tests the $HOME default for /new sessions
func TestSessionWorkDir(t *testing.T) {
home, _ := os.UserHomeDir()
if got := sessionWorkDir(&SessionInfo{Path: "/tmp/x"}); got != "/tmp/x" {
t.Errorf("sessionWorkDir explicit = %q", got)
}
if got := sessionWorkDir(&SessionInfo{}); got != home {
t.Errorf("sessionWorkDir default = %q, want %q", got, home)
}
}
// TestCCCMarker verifies the stable per-session marker encoding and that
// tagPrompt embeds it without dropping the original prompt.
func TestCCCMarker(t *testing.T) {
if got := cccMarker(1224); got != "ccc-session:t1224" {
t.Errorf("cccMarker = %q", got)
}
tagged := tagPrompt("fix the decoder", 42)
if !strings.Contains(tagged, "fix the decoder") {
t.Errorf("tagPrompt dropped the prompt: %q", tagged)
}
if !strings.Contains(tagged, cccMarker(42)) {
t.Errorf("tagPrompt missing marker: %q", tagged)
}
}
// TestTranscriptTopicMarker verifies the marker is recovered from a transcript
// (so a resumed agent re-links to its topic) and that noise/absence yields 0.
func TestTranscriptTopicMarker(t *testing.T) {
dir := t.TempDir()
// A transcript whose message history carries the marker for topic 777.
withMarker := filepath.Join(dir, "with.jsonl")
os.WriteFile(withMarker, []byte(
`{"type":"user","message":{"content":"do a thing\n\n<!-- ccc-session:t777 -->"}}`+"\n"+
`{"type":"assistant","message":{"content":"ok"}}`+"\n"), 0644)
if got := transcriptTopicMarker(withMarker); got != 777 {
t.Errorf("transcriptTopicMarker with marker = %d, want 777", got)
}
// No marker.
without := filepath.Join(dir, "without.jsonl")
os.WriteFile(without, []byte(`{"type":"user","message":{"content":"hello"}}`+"\n"), 0644)
if got := transcriptTopicMarker(without); got != 0 {
t.Errorf("transcriptTopicMarker without marker = %d, want 0", got)
}
// Missing / empty path.
if got := transcriptTopicMarker(filepath.Join(dir, "nope.jsonl")); got != 0 {
t.Errorf("transcriptTopicMarker missing file = %d, want 0", got)
}
if got := transcriptTopicMarker(""); got != 0 {
t.Errorf("transcriptTopicMarker empty path = %d, want 0", got)
}
}