mirror of
https://github.com/chenhg5/cc-connect.git
synced 2026-07-03 12:28:10 +08:00
High priority: - PR #420: fix Telegram reply context lost by alias resolution — resolveAlias now runs on user text before ExtraContent merge - PR #567: fix message queue race — adoptPendingFromPlaceholder copies queued messages when placeholder state is replaced - PR #437: harden /diff command — reject targets starting with '-', use '--' separator to prevent git option injection - PR #560: add run_as_env dangerous variable blacklist (LD_PRELOAD, PATH, HOME, etc.); fix doc claiming PATH is in default allowlist Medium priority: - PR #497: ACP permission params log level Info→Debug to prevent sensitive data leaking; add toolInputByID cache size cap (1000) - PR #559: observe new JSONL files start from EOF instead of offset 0, preventing full session history replay to Slack - PR #510: add idempotent Close() test for claude session - PR #514: bridge ReconstructReplyCtx already has structured JSON + backward compat; added decode helper + tests - PR #440: add instance lock acquire/release unit tests - PR #569: document ContinueSession behavioral change in code comments All changes backward-compatible. Tests added for all fixes. Made-with: Cursor
298 lines
7.4 KiB
Go
298 lines
7.4 KiB
Go
package core
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
)
|
|
|
|
func TestObserverTargetInterface(t *testing.T) {
|
|
// Verify the interface exists and has the right method
|
|
var _ ObserverTarget = (*mockObserverTarget)(nil)
|
|
}
|
|
|
|
type mockObserverTarget struct{}
|
|
|
|
func (m *mockObserverTarget) SendObservation(ctx context.Context, channelID, text string) error {
|
|
return nil
|
|
}
|
|
|
|
func TestParseObservationLine(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
line string
|
|
wantType string
|
|
wantText string
|
|
wantSkip bool
|
|
}{
|
|
{
|
|
name: "user message",
|
|
line: `{"type":"user","message":{"role":"user","content":"hello world"},"entrypoint":"cli"}`,
|
|
wantType: "user",
|
|
wantText: "hello world",
|
|
},
|
|
{
|
|
name: "assistant text",
|
|
line: `{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"hi there"}]},"entrypoint":"cli"}`,
|
|
wantType: "assistant",
|
|
wantText: "hi there",
|
|
},
|
|
{
|
|
name: "sdk-cli session skipped",
|
|
line: `{"type":"user","message":{"role":"user","content":"hello"},"entrypoint":"sdk-cli"}`,
|
|
wantSkip: true,
|
|
},
|
|
{
|
|
name: "tool_use skipped",
|
|
line: `{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","name":"Bash"}]},"entrypoint":"cli"}`,
|
|
wantType: "assistant",
|
|
wantText: "",
|
|
},
|
|
{
|
|
name: "non-message type skipped",
|
|
line: `{"type":"system","sessionId":"abc123"}`,
|
|
wantSkip: true,
|
|
},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
obs := parseObservationLine([]byte(tt.line))
|
|
if tt.wantSkip {
|
|
if obs != nil {
|
|
t.Fatalf("expected nil, got %+v", obs)
|
|
}
|
|
return
|
|
}
|
|
if obs == nil {
|
|
t.Fatal("expected non-nil observation")
|
|
}
|
|
if obs.role != tt.wantType {
|
|
t.Fatalf("role: got %q, want %q", obs.role, tt.wantType)
|
|
}
|
|
if obs.text != tt.wantText {
|
|
t.Fatalf("text: got %q, want %q", obs.text, tt.wantText)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestSessionObserverPoll(t *testing.T) {
|
|
dir := t.TempDir()
|
|
|
|
var received []string
|
|
var mu sync.Mutex
|
|
target := &mockObserverTargetCapture{
|
|
fn: func(ctx context.Context, channelID, text string) error {
|
|
mu.Lock()
|
|
received = append(received, text)
|
|
mu.Unlock()
|
|
return nil
|
|
},
|
|
}
|
|
|
|
obs := newSessionObserver(dir, target, "C123")
|
|
sessionFile := filepath.Join(dir, "test-session.jsonl")
|
|
empty, err := os.Create(sessionFile)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := empty.Close(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
obs.initOffsets()
|
|
|
|
// Append lines incrementally so offsets advance from EOF of the empty file.
|
|
ctx := context.Background()
|
|
appendLine := func(line string) {
|
|
t.Helper()
|
|
f, err := os.OpenFile(sessionFile, os.O_APPEND|os.O_WRONLY, 0o644)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := f.WriteString(line); err != nil {
|
|
f.Close()
|
|
t.Fatal(err)
|
|
}
|
|
if err := f.Close(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
appendLine(`{"type":"user","message":{"role":"user","content":"hello"},"entrypoint":"cli","sessionId":"s1"}` + "\n")
|
|
obs.poll(ctx)
|
|
appendLine(`{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"hi there"}]},"entrypoint":"cli","sessionId":"s1"}` + "\n")
|
|
obs.poll(ctx)
|
|
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
if len(received) != 2 {
|
|
t.Fatalf("expected 2 messages, got %d: %v", len(received), received)
|
|
}
|
|
if !strings.HasPrefix(received[0], "user: hello") {
|
|
t.Fatalf("unexpected first message: %s", received[0])
|
|
}
|
|
if !strings.Contains(received[1], "Claude: hi there") {
|
|
t.Fatalf("unexpected second message: %s", received[1])
|
|
}
|
|
}
|
|
|
|
type mockObserverTargetCapture struct {
|
|
fn func(ctx context.Context, channelID, text string) error
|
|
}
|
|
|
|
func (m *mockObserverTargetCapture) SendObservation(ctx context.Context, channelID, text string) error {
|
|
return m.fn(ctx, channelID, text)
|
|
}
|
|
|
|
func TestSessionObserverNewFileSkipsPreExistingLines(t *testing.T) {
|
|
dir := t.TempDir()
|
|
|
|
var received []string
|
|
var mu sync.Mutex
|
|
target := &mockObserverTargetCapture{
|
|
fn: func(ctx context.Context, channelID, text string) error {
|
|
mu.Lock()
|
|
received = append(received, text)
|
|
mu.Unlock()
|
|
return nil
|
|
},
|
|
}
|
|
|
|
obs := newSessionObserver(dir, target, "C123")
|
|
obs.initOffsets()
|
|
|
|
sessionFile := filepath.Join(dir, "appears-late.jsonl")
|
|
f, err := os.Create(sessionFile)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := f.WriteString(`{"type":"user","message":{"role":"user","content":"stale"},"entrypoint":"cli"}` + "\n"); err != nil {
|
|
f.Close()
|
|
t.Fatal(err)
|
|
}
|
|
if err := f.Close(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
obs.poll(context.Background())
|
|
|
|
mu.Lock()
|
|
if len(received) != 0 {
|
|
mu.Unlock()
|
|
t.Fatalf("expected 0 messages (new file should start at EOF), got %d: %v", len(received), received)
|
|
}
|
|
mu.Unlock()
|
|
|
|
f, err = os.OpenFile(sessionFile, os.O_APPEND|os.O_WRONLY, 0o644)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := f.WriteString(`{"type":"user","message":{"role":"user","content":"fresh"},"entrypoint":"cli"}` + "\n"); err != nil {
|
|
f.Close()
|
|
t.Fatal(err)
|
|
}
|
|
if err := f.Close(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
obs.poll(context.Background())
|
|
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
if len(received) != 1 {
|
|
t.Fatalf("expected 1 new message after append, got %d: %v", len(received), received)
|
|
}
|
|
if !strings.Contains(received[0], "fresh") {
|
|
t.Fatalf("expected appended line only, got %q", received[0])
|
|
}
|
|
}
|
|
|
|
func TestSessionObserverInitOffsetsSkipsExisting(t *testing.T) {
|
|
dir := t.TempDir()
|
|
|
|
// Write a JSONL file BEFORE creating the observer
|
|
sessionFile := filepath.Join(dir, "existing.jsonl")
|
|
f, _ := os.Create(sessionFile)
|
|
f.WriteString(`{"type":"user","message":{"role":"user","content":"old message"},"entrypoint":"cli"}` + "\n")
|
|
f.Close()
|
|
|
|
var received []string
|
|
var mu sync.Mutex
|
|
target := &mockObserverTargetCapture{
|
|
fn: func(ctx context.Context, channelID, text string) error {
|
|
mu.Lock()
|
|
received = append(received, text)
|
|
mu.Unlock()
|
|
return nil
|
|
},
|
|
}
|
|
|
|
obs := newSessionObserver(dir, target, "C123")
|
|
obs.initOffsets() // Should record current EOF
|
|
|
|
// Poll should find nothing new
|
|
obs.poll(context.Background())
|
|
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
if len(received) != 0 {
|
|
t.Fatalf("expected 0 messages (pre-existing), got %d: %v", len(received), received)
|
|
}
|
|
}
|
|
|
|
func TestSessionObserverTruncation(t *testing.T) {
|
|
dir := t.TempDir()
|
|
|
|
var received []string
|
|
var mu sync.Mutex
|
|
target := &mockObserverTargetCapture{
|
|
fn: func(ctx context.Context, channelID, text string) error {
|
|
mu.Lock()
|
|
received = append(received, text)
|
|
mu.Unlock()
|
|
return nil
|
|
},
|
|
}
|
|
|
|
obs := newSessionObserver(dir, target, "C123")
|
|
sessionFile := filepath.Join(dir, "long.jsonl")
|
|
empty, err := os.Create(sessionFile)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := empty.Close(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
obs.initOffsets()
|
|
|
|
longText := strings.Repeat("x", 5000)
|
|
f, err := os.OpenFile(sessionFile, os.O_APPEND|os.O_WRONLY, 0o644)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := f.WriteString(fmt.Sprintf(`{"type":"user","message":{"role":"user","content":"%s"},"entrypoint":"cli"}`, longText) + "\n"); err != nil {
|
|
f.Close()
|
|
t.Fatal(err)
|
|
}
|
|
if err := f.Close(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
obs.poll(context.Background())
|
|
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
if len(received) != 1 {
|
|
t.Fatalf("expected 1 message, got %d", len(received))
|
|
}
|
|
if len(received[0]) > 4000 {
|
|
t.Fatalf("message not truncated: len=%d", len(received[0]))
|
|
}
|
|
if !strings.HasSuffix(received[0], "... (truncated)") {
|
|
t.Fatal("truncated message missing suffix")
|
|
}
|
|
}
|