初始化仓库
This commit is contained in:
@@ -0,0 +1,257 @@
|
||||
package config_matrix
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/chenhg5/cc-connect/config"
|
||||
)
|
||||
|
||||
func writeConfig(t *testing.T, body string) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "config.toml")
|
||||
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
|
||||
t.Fatalf("write config: %v", err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func baseProjectTOML(extra string) string {
|
||||
return `
|
||||
data_dir = "` + filepath.ToSlash(os.TempDir()) + `/cc-connect-release-test"
|
||||
` + extra + `
|
||||
|
||||
[[projects]]
|
||||
name = "release"
|
||||
|
||||
[projects.agent]
|
||||
type = "claudecode"
|
||||
work_dir = "/tmp/cc-connect-release-work"
|
||||
|
||||
[[projects.platforms]]
|
||||
type = "feishu"
|
||||
app_id = "cli_release"
|
||||
app_secret = "secret"
|
||||
`
|
||||
}
|
||||
|
||||
func TestReleaseConfig_ProjectDisplayOverridesGlobalFromLoadedConfig(t *testing.T) {
|
||||
path := writeConfig(t, `
|
||||
attachment_send = "off"
|
||||
|
||||
[display]
|
||||
mode = "quiet"
|
||||
card_mode = "rich"
|
||||
thinking_messages = true
|
||||
tool_messages = false
|
||||
|
||||
[[projects]]
|
||||
name = "release"
|
||||
reset_on_idle_mins = 0
|
||||
|
||||
[projects.display]
|
||||
mode = "full"
|
||||
card_mode = "legacy"
|
||||
thinking_messages = false
|
||||
tool_messages = true
|
||||
thinking_max_len = 111
|
||||
tool_max_len = 222
|
||||
|
||||
[projects.agent]
|
||||
type = "claudecode"
|
||||
work_dir = "/tmp/cc-connect-release-work"
|
||||
|
||||
[[projects.platforms]]
|
||||
type = "feishu"
|
||||
app_id = "cli_release"
|
||||
app_secret = "secret"
|
||||
`)
|
||||
|
||||
cfg, err := config.Load(path)
|
||||
if err != nil {
|
||||
t.Fatalf("Load() error = %v", err)
|
||||
}
|
||||
if cfg.AttachmentSend != "off" {
|
||||
t.Fatalf("AttachmentSend = %q, want off", cfg.AttachmentSend)
|
||||
}
|
||||
if len(cfg.Projects) != 1 {
|
||||
t.Fatalf("projects = %d, want 1", len(cfg.Projects))
|
||||
}
|
||||
proj := &cfg.Projects[0]
|
||||
if proj.ResetOnIdleMins == nil || *proj.ResetOnIdleMins != 0 {
|
||||
t.Fatalf("ResetOnIdleMins = %#v, want explicit 0", proj.ResetOnIdleMins)
|
||||
}
|
||||
|
||||
mode, thinking, tools, thinkingMax, toolMax, _, _ := config.EffectiveDisplay(cfg, proj)
|
||||
if mode != config.DisplayModeFull {
|
||||
t.Fatalf("mode = %q, want project full override", mode)
|
||||
}
|
||||
if thinking {
|
||||
t.Fatal("thinking_messages = true, want project override false")
|
||||
}
|
||||
if !tools {
|
||||
t.Fatal("tool_messages = false, want project override true")
|
||||
}
|
||||
if thinkingMax != 111 || toolMax != 222 {
|
||||
t.Fatalf("max lens = %d/%d, want 111/222", thinkingMax, toolMax)
|
||||
}
|
||||
if got := config.EffectiveCardMode(cfg, proj); got != "legacy" {
|
||||
t.Fatalf("card mode = %q, want project legacy override", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseConfig_DefaultsKeepAttachmentsAndFullDisplayEnabled(t *testing.T) {
|
||||
path := writeConfig(t, baseProjectTOML(""))
|
||||
cfg, err := config.Load(path)
|
||||
if err != nil {
|
||||
t.Fatalf("Load() error = %v", err)
|
||||
}
|
||||
if cfg.AttachmentSend != "on" {
|
||||
t.Fatalf("AttachmentSend = %q, want default on", cfg.AttachmentSend)
|
||||
}
|
||||
mode, thinking, tools, _, _, _, _ := config.EffectiveDisplay(cfg, &cfg.Projects[0])
|
||||
if mode != config.DisplayModeFull || !thinking || !tools {
|
||||
t.Fatalf("display = mode:%s thinking:%v tools:%v, want full/true/true", mode, thinking, tools)
|
||||
}
|
||||
if got := config.EffectiveCardMode(cfg, &cfg.Projects[0]); got != "legacy" {
|
||||
t.Fatalf("card mode = %q, want default legacy", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseConfig_BehaviorControlSwitchesParseFromLoadedConfig(t *testing.T) {
|
||||
path := writeConfig(t, `
|
||||
[stream_preview]
|
||||
enabled = false
|
||||
disabled_platforms = ["feishu", "telegram"]
|
||||
interval_ms = 250
|
||||
min_delta_chars = 12
|
||||
max_chars = 777
|
||||
|
||||
[[projects]]
|
||||
name = "release"
|
||||
show_context_indicator = false
|
||||
reply_footer = false
|
||||
disabled_commands = ["restart", "shell"]
|
||||
|
||||
[projects.display]
|
||||
mode = "quiet"
|
||||
card_mode = "rich"
|
||||
thinking_messages = false
|
||||
tool_messages = false
|
||||
|
||||
[projects.agent]
|
||||
type = "claudecode"
|
||||
work_dir = "/tmp/cc-connect-release-work"
|
||||
|
||||
[[projects.platforms]]
|
||||
type = "feishu"
|
||||
app_id = "cli_release"
|
||||
app_secret = "secret"
|
||||
`)
|
||||
|
||||
cfg, err := config.Load(path)
|
||||
if err != nil {
|
||||
t.Fatalf("Load() error = %v", err)
|
||||
}
|
||||
if cfg.StreamPreview.Enabled == nil || *cfg.StreamPreview.Enabled {
|
||||
t.Fatalf("stream_preview.enabled = %#v, want false", cfg.StreamPreview.Enabled)
|
||||
}
|
||||
if got := strings.Join(cfg.StreamPreview.DisabledPlatforms, ","); got != "feishu,telegram" {
|
||||
t.Fatalf("stream_preview.disabled_platforms = %#v", cfg.StreamPreview.DisabledPlatforms)
|
||||
}
|
||||
if cfg.StreamPreview.IntervalMs == nil || *cfg.StreamPreview.IntervalMs != 250 {
|
||||
t.Fatalf("stream_preview.interval_ms = %#v, want 250", cfg.StreamPreview.IntervalMs)
|
||||
}
|
||||
if cfg.StreamPreview.MinDeltaChars == nil || *cfg.StreamPreview.MinDeltaChars != 12 {
|
||||
t.Fatalf("stream_preview.min_delta_chars = %#v, want 12", cfg.StreamPreview.MinDeltaChars)
|
||||
}
|
||||
if cfg.StreamPreview.MaxChars == nil || *cfg.StreamPreview.MaxChars != 777 {
|
||||
t.Fatalf("stream_preview.max_chars = %#v, want 777", cfg.StreamPreview.MaxChars)
|
||||
}
|
||||
|
||||
proj := &cfg.Projects[0]
|
||||
if proj.ShowContextIndicator == nil || *proj.ShowContextIndicator {
|
||||
t.Fatalf("show_context_indicator = %#v, want false", proj.ShowContextIndicator)
|
||||
}
|
||||
if proj.ReplyFooter == nil || *proj.ReplyFooter {
|
||||
t.Fatalf("reply_footer = %#v, want false", proj.ReplyFooter)
|
||||
}
|
||||
if strings.Join(proj.DisabledCommands, ",") != "restart,shell" {
|
||||
t.Fatalf("disabled_commands = %#v", proj.DisabledCommands)
|
||||
}
|
||||
mode, thinking, tools, _, _, _, _ := config.EffectiveDisplay(cfg, proj)
|
||||
if mode != config.DisplayModeQuiet || thinking || tools {
|
||||
t.Fatalf("display = mode:%s thinking:%v tools:%v, want quiet/false/false", mode, thinking, tools)
|
||||
}
|
||||
if got := config.EffectiveCardMode(cfg, proj); got != "rich" {
|
||||
t.Fatalf("card mode = %q, want rich", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseConfig_InvalidCriticalOptionsFailFast(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
toml string
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "invalid attachment send",
|
||||
toml: baseProjectTOML(`
|
||||
attachment_send = "maybe"
|
||||
`),
|
||||
wantErr: `attachment_send must be "on" or "off"`,
|
||||
},
|
||||
{
|
||||
name: "invalid project display mode",
|
||||
toml: `
|
||||
[[projects]]
|
||||
name = "release"
|
||||
|
||||
[projects.display]
|
||||
mode = "verbose"
|
||||
|
||||
[projects.agent]
|
||||
type = "claudecode"
|
||||
work_dir = "/tmp/cc-connect-release-work"
|
||||
|
||||
[[projects.platforms]]
|
||||
type = "feishu"
|
||||
app_id = "cli_release"
|
||||
app_secret = "secret"
|
||||
`,
|
||||
wantErr: `projects[0].display.mode must be "full", "compact", or "quiet"`,
|
||||
},
|
||||
{
|
||||
name: "negative reset on idle",
|
||||
toml: `
|
||||
[[projects]]
|
||||
name = "release"
|
||||
reset_on_idle_mins = -1
|
||||
|
||||
[projects.agent]
|
||||
type = "claudecode"
|
||||
work_dir = "/tmp/cc-connect-release-work"
|
||||
|
||||
[[projects.platforms]]
|
||||
type = "feishu"
|
||||
app_id = "cli_release"
|
||||
app_secret = "secret"
|
||||
`,
|
||||
wantErr: "reset_on_idle_mins must be >= 0",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
_, err := config.Load(writeConfig(t, tt.toml))
|
||||
if err == nil {
|
||||
t.Fatal("Load() error = nil, want validation error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), tt.wantErr) {
|
||||
t.Fatalf("Load() error = %q, want contains %q", err.Error(), tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
package engine_matrix
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/chenhg5/cc-connect/core"
|
||||
)
|
||||
|
||||
type promptRecord struct {
|
||||
sessionID string
|
||||
prompt string
|
||||
}
|
||||
|
||||
type matrixAgent struct {
|
||||
mu sync.Mutex
|
||||
sessions []*matrixSession
|
||||
list []core.AgentSessionInfo
|
||||
records []promptRecord
|
||||
}
|
||||
|
||||
func newMatrixAgent() *matrixAgent {
|
||||
return &matrixAgent{}
|
||||
}
|
||||
|
||||
func (a *matrixAgent) Name() string { return "matrix-agent" }
|
||||
|
||||
func (a *matrixAgent) StartSession(_ context.Context, sessionID string) (core.AgentSession, error) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
if sessionID == "" {
|
||||
sessionID = fmt.Sprintf("agent-session-%d", len(a.sessions)+1)
|
||||
}
|
||||
session := &matrixSession{agent: a, id: sessionID, alive: true, events: make(chan core.Event, 32)}
|
||||
a.sessions = append(a.sessions, session)
|
||||
a.list = append(a.list, core.AgentSessionInfo{
|
||||
ID: sessionID,
|
||||
Summary: "release matrix session",
|
||||
MessageCount: 1,
|
||||
ModifiedAt: time.Now(),
|
||||
})
|
||||
return session, nil
|
||||
}
|
||||
|
||||
func (a *matrixAgent) ListSessions(_ context.Context) ([]core.AgentSessionInfo, error) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
return append([]core.AgentSessionInfo(nil), a.list...), nil
|
||||
}
|
||||
|
||||
func (a *matrixAgent) Stop() error {
|
||||
a.mu.Lock()
|
||||
sessions := append([]*matrixSession(nil), a.sessions...)
|
||||
a.mu.Unlock()
|
||||
for _, session := range sessions {
|
||||
_ = session.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *matrixAgent) addRecord(sessionID, prompt string) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
a.records = append(a.records, promptRecord{sessionID: sessionID, prompt: prompt})
|
||||
}
|
||||
|
||||
func (a *matrixAgent) waitRecords(t *testing.T, n int) []promptRecord {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
a.mu.Lock()
|
||||
if len(a.records) >= n {
|
||||
out := append([]promptRecord(nil), a.records...)
|
||||
a.mu.Unlock()
|
||||
return out
|
||||
}
|
||||
a.mu.Unlock()
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
t.Fatalf("timeout waiting for %d prompts, got %d: %#v", n, len(a.records), a.records)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *matrixAgent) recordCount() int {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
return len(a.records)
|
||||
}
|
||||
|
||||
type matrixSession struct {
|
||||
mu sync.Mutex
|
||||
agent *matrixAgent
|
||||
id string
|
||||
alive bool
|
||||
events chan core.Event
|
||||
counter int
|
||||
}
|
||||
|
||||
func (s *matrixSession) Send(prompt string, _ []core.ImageAttachment, _ []core.FileAttachment) error {
|
||||
s.mu.Lock()
|
||||
id := s.id
|
||||
s.counter++
|
||||
count := s.counter
|
||||
s.mu.Unlock()
|
||||
|
||||
s.agent.addRecord(id, prompt)
|
||||
s.events <- core.Event{Type: core.EventResult, Content: "matrix response " + id + " #" + strconv.Itoa(count), Done: true}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *matrixSession) Events() <-chan core.Event { return s.events }
|
||||
func (s *matrixSession) RespondPermission(string, core.PermissionResult) error {
|
||||
return nil
|
||||
}
|
||||
func (s *matrixSession) CurrentSessionID() string {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.id
|
||||
}
|
||||
func (s *matrixSession) Alive() bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.alive
|
||||
}
|
||||
func (s *matrixSession) Close() error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if !s.alive {
|
||||
return nil
|
||||
}
|
||||
s.alive = false
|
||||
close(s.events)
|
||||
return nil
|
||||
}
|
||||
|
||||
type matrixPlatform struct {
|
||||
mu sync.Mutex
|
||||
texts []string
|
||||
}
|
||||
|
||||
func (p *matrixPlatform) Name() string { return "matrix" }
|
||||
func (p *matrixPlatform) Start(core.MessageHandler) error {
|
||||
return nil
|
||||
}
|
||||
func (p *matrixPlatform) Stop() error { return nil }
|
||||
func (p *matrixPlatform) Reply(_ context.Context, replyCtx any, content string) error {
|
||||
return p.Send(context.Background(), replyCtx, content)
|
||||
}
|
||||
func (p *matrixPlatform) Send(_ context.Context, _ any, content string) error {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
p.texts = append(p.texts, content)
|
||||
return nil
|
||||
}
|
||||
func (p *matrixPlatform) clear() {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
p.texts = nil
|
||||
}
|
||||
func (p *matrixPlatform) snapshot() []string {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
return append([]string(nil), p.texts...)
|
||||
}
|
||||
func (p *matrixPlatform) waitTextContaining(t *testing.T, substr string) string {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
for _, text := range p.snapshot() {
|
||||
if strings.Contains(strings.ToLower(text), strings.ToLower(substr)) {
|
||||
return text
|
||||
}
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("timeout waiting for %q, got %#v", substr, p.snapshot())
|
||||
return ""
|
||||
}
|
||||
|
||||
func newMatrixEngine(t *testing.T) (*core.Engine, *matrixAgent, *matrixPlatform) {
|
||||
t.Helper()
|
||||
agent := newMatrixAgent()
|
||||
platform := &matrixPlatform{}
|
||||
engine := core.NewEngine("release-core", agent, []core.Platform{platform}, t.TempDir()+"/sessions.json", core.LangEnglish)
|
||||
t.Cleanup(func() {
|
||||
engine.Stop()
|
||||
_ = agent.Stop()
|
||||
})
|
||||
return engine, agent, platform
|
||||
}
|
||||
|
||||
func matrixMessage(content string) *core.Message {
|
||||
return &core.Message{
|
||||
SessionKey: "matrix:chat-1:user-1",
|
||||
Platform: "matrix",
|
||||
UserID: "user-1",
|
||||
UserName: "Release Tester",
|
||||
ChatName: "Release Room",
|
||||
Content: content,
|
||||
ReplyCtx: "reply-ctx",
|
||||
}
|
||||
}
|
||||
|
||||
func receive(engine *core.Engine, platform *matrixPlatform, content string) {
|
||||
engine.ReceiveMessage(platform, matrixMessage(content))
|
||||
}
|
||||
|
||||
func TestSessionLifecycleCommandsThroughReceiveMessage(t *testing.T) {
|
||||
engine, agent, platform := newMatrixEngine(t)
|
||||
|
||||
receive(engine, platform, "first user turn")
|
||||
agent.waitRecords(t, 1)
|
||||
platform.waitTextContaining(t, "matrix response")
|
||||
platform.clear()
|
||||
|
||||
receive(engine, platform, "/new release-named")
|
||||
platform.waitTextContaining(t, "release-named")
|
||||
platform.clear()
|
||||
|
||||
receive(engine, platform, "second user turn")
|
||||
agent.waitRecords(t, 2)
|
||||
platform.waitTextContaining(t, "matrix response")
|
||||
platform.clear()
|
||||
|
||||
receive(engine, platform, "/list")
|
||||
platform.waitTextContaining(t, "release-named")
|
||||
platform.clear()
|
||||
|
||||
receive(engine, platform, "/name renamed-release")
|
||||
platform.waitTextContaining(t, "renamed-release")
|
||||
platform.clear()
|
||||
|
||||
receive(engine, platform, "/list")
|
||||
platform.waitTextContaining(t, "renamed-release")
|
||||
platform.clear()
|
||||
|
||||
receive(engine, platform, "/current")
|
||||
platform.waitTextContaining(t, "session")
|
||||
platform.clear()
|
||||
|
||||
receive(engine, platform, "/status")
|
||||
platform.waitTextContaining(t, "user-1")
|
||||
}
|
||||
|
||||
func TestAliasDisabledCommandAndBannedWordsThroughReceiveMessage(t *testing.T) {
|
||||
engine, agent, platform := newMatrixEngine(t)
|
||||
engine.AddAlias("帮助", "/whoami")
|
||||
|
||||
receive(engine, platform, "帮助")
|
||||
platform.waitTextContaining(t, "user-1")
|
||||
if got := agent.recordCount(); got != 0 {
|
||||
t.Fatalf("alias to command should not reach agent, got %d prompts", got)
|
||||
}
|
||||
platform.clear()
|
||||
|
||||
engine.SetDisabledCommands([]string{"whoami"})
|
||||
receive(engine, platform, "/whoami")
|
||||
platform.waitTextContaining(t, "disabled")
|
||||
if got := agent.recordCount(); got != 0 {
|
||||
t.Fatalf("disabled command should not reach agent, got %d prompts", got)
|
||||
}
|
||||
platform.clear()
|
||||
|
||||
engine.SetBannedWords([]string{"forbidden"})
|
||||
receive(engine, platform, "this contains forbidden content")
|
||||
platform.waitTextContaining(t, "blocked")
|
||||
if got := agent.recordCount(); got != 0 {
|
||||
t.Fatalf("banned message should not reach agent, got %d prompts", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCustomPromptCommandThroughReceiveMessage(t *testing.T) {
|
||||
engine, agent, platform := newMatrixEngine(t)
|
||||
engine.AddCommand("daily", "Daily summary", "Summarize release status for {{1}}", "", "", "release-test")
|
||||
|
||||
receive(engine, platform, "/daily beta")
|
||||
|
||||
records := agent.waitRecords(t, 1)
|
||||
if !strings.Contains(records[0].prompt, "Summarize release status for beta") {
|
||||
t.Fatalf("custom command prompt = %q", records[0].prompt)
|
||||
}
|
||||
platform.waitTextContaining(t, "matrix response")
|
||||
}
|
||||
|
||||
func TestUnknownSlashCommandNotifiesThenFallsThroughToAgent(t *testing.T) {
|
||||
engine, agent, platform := newMatrixEngine(t)
|
||||
|
||||
receive(engine, platform, "/not-a-command keep this request")
|
||||
|
||||
platform.waitTextContaining(t, "forwarding")
|
||||
records := agent.waitRecords(t, 1)
|
||||
if !strings.Contains(records[0].prompt, "/not-a-command keep this request") {
|
||||
t.Fatalf("unknown slash command should fall through to agent, got prompt %q", records[0].prompt)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,448 @@
|
||||
package media_pipeline
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/chenhg5/cc-connect/core"
|
||||
)
|
||||
|
||||
type sendRecord struct {
|
||||
prompt string
|
||||
images []core.ImageAttachment
|
||||
files []core.FileAttachment
|
||||
}
|
||||
|
||||
type recordingAgent struct {
|
||||
session *recordingSession
|
||||
}
|
||||
|
||||
func newRecordingAgent() *recordingAgent {
|
||||
return &recordingAgent{session: newRecordingSession()}
|
||||
}
|
||||
|
||||
func (a *recordingAgent) Name() string { return "recording-agent" }
|
||||
|
||||
func (a *recordingAgent) StartSession(_ context.Context, sessionID string) (core.AgentSession, error) {
|
||||
a.session.setID(sessionID)
|
||||
return a.session, nil
|
||||
}
|
||||
|
||||
func (a *recordingAgent) ListSessions(_ context.Context) ([]core.AgentSessionInfo, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (a *recordingAgent) Stop() error {
|
||||
return a.session.Close()
|
||||
}
|
||||
|
||||
type recordingSession struct {
|
||||
mu sync.Mutex
|
||||
id string
|
||||
alive bool
|
||||
records []sendRecord
|
||||
events chan core.Event
|
||||
blockFirst bool
|
||||
blocked bool
|
||||
}
|
||||
|
||||
func newRecordingSession() *recordingSession {
|
||||
return &recordingSession{alive: true, events: make(chan core.Event, 16)}
|
||||
}
|
||||
|
||||
func (s *recordingSession) setID(id string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.id = id
|
||||
}
|
||||
|
||||
func (s *recordingSession) blockFirstResult() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.blockFirst = true
|
||||
}
|
||||
|
||||
func (s *recordingSession) Send(prompt string, images []core.ImageAttachment, files []core.FileAttachment) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if !s.alive {
|
||||
return errors.New("session closed")
|
||||
}
|
||||
rec := sendRecord{
|
||||
prompt: prompt,
|
||||
images: append([]core.ImageAttachment(nil), images...),
|
||||
files: append([]core.FileAttachment(nil), files...),
|
||||
}
|
||||
s.records = append(s.records, rec)
|
||||
if !(s.blockFirst && len(s.records) == 1) {
|
||||
s.events <- core.Event{Type: core.EventResult, Content: "media ok", Done: true}
|
||||
} else {
|
||||
s.blocked = true
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *recordingSession) Events() <-chan core.Event {
|
||||
return s.events
|
||||
}
|
||||
|
||||
func (s *recordingSession) RespondPermission(string, core.PermissionResult) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *recordingSession) CurrentSessionID() string {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.id
|
||||
}
|
||||
|
||||
func (s *recordingSession) Alive() bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.alive
|
||||
}
|
||||
|
||||
func (s *recordingSession) Close() error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if !s.alive {
|
||||
return nil
|
||||
}
|
||||
s.alive = false
|
||||
close(s.events)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *recordingSession) releaseFirstResult(content string) {
|
||||
s.releaseFirstEvent(core.Event{Type: core.EventResult, Content: content, Done: true})
|
||||
}
|
||||
|
||||
func (s *recordingSession) releaseFirstEvent(event core.Event) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if !s.blocked {
|
||||
return
|
||||
}
|
||||
s.events <- event
|
||||
s.blocked = false
|
||||
}
|
||||
|
||||
func (s *recordingSession) waitRecords(t *testing.T, n int) []sendRecord {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
s.mu.Lock()
|
||||
if len(s.records) >= n {
|
||||
out := append([]sendRecord(nil), s.records...)
|
||||
s.mu.Unlock()
|
||||
return out
|
||||
}
|
||||
s.mu.Unlock()
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
t.Fatalf("timeout waiting for %d Send calls, got %d: %#v", n, len(s.records), s.records)
|
||||
return nil
|
||||
}
|
||||
|
||||
type mediaPlatform struct {
|
||||
mu sync.Mutex
|
||||
texts []string
|
||||
images []core.ImageAttachment
|
||||
files []core.FileAttachment
|
||||
replyCtx []any
|
||||
}
|
||||
|
||||
func (p *mediaPlatform) Name() string { return "media" }
|
||||
func (p *mediaPlatform) Start(core.MessageHandler) error {
|
||||
return nil
|
||||
}
|
||||
func (p *mediaPlatform) Stop() error { return nil }
|
||||
func (p *mediaPlatform) Reply(_ context.Context, replyCtx any, content string) error {
|
||||
return p.Send(context.Background(), replyCtx, content)
|
||||
}
|
||||
func (p *mediaPlatform) Send(_ context.Context, replyCtx any, content string) error {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
p.texts = append(p.texts, content)
|
||||
p.replyCtx = append(p.replyCtx, replyCtx)
|
||||
return nil
|
||||
}
|
||||
func (p *mediaPlatform) SendImage(_ context.Context, replyCtx any, img core.ImageAttachment) error {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
p.images = append(p.images, img)
|
||||
p.replyCtx = append(p.replyCtx, replyCtx)
|
||||
return nil
|
||||
}
|
||||
func (p *mediaPlatform) SendFile(_ context.Context, replyCtx any, file core.FileAttachment) error {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
p.files = append(p.files, file)
|
||||
p.replyCtx = append(p.replyCtx, replyCtx)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *mediaPlatform) snapshot() (texts []string, images []core.ImageAttachment, files []core.FileAttachment, replyCtx []any) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
return append([]string(nil), p.texts...),
|
||||
append([]core.ImageAttachment(nil), p.images...),
|
||||
append([]core.FileAttachment(nil), p.files...),
|
||||
append([]any(nil), p.replyCtx...)
|
||||
}
|
||||
|
||||
func (p *mediaPlatform) waitTextContaining(t *testing.T, substr string) string {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
texts, _, _, _ := p.snapshot()
|
||||
for _, text := range texts {
|
||||
if strings.Contains(strings.ToLower(text), strings.ToLower(substr)) {
|
||||
return text
|
||||
}
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
texts, _, _, _ := p.snapshot()
|
||||
t.Fatalf("timeout waiting for text containing %q, got %#v", substr, texts)
|
||||
return ""
|
||||
}
|
||||
|
||||
func newMediaEngine(t *testing.T) (*core.Engine, *recordingAgent, *mediaPlatform) {
|
||||
t.Helper()
|
||||
agent := newRecordingAgent()
|
||||
platform := &mediaPlatform{}
|
||||
engine := core.NewEngine("release-media", agent, []core.Platform{platform}, t.TempDir()+"/sessions.json", core.LangEnglish)
|
||||
t.Cleanup(func() {
|
||||
engine.Stop()
|
||||
_ = agent.Stop()
|
||||
})
|
||||
return engine, agent, platform
|
||||
}
|
||||
|
||||
func mediaMessage(content string) *core.Message {
|
||||
return &core.Message{
|
||||
SessionKey: "media:chat-1:user-1",
|
||||
Platform: "media",
|
||||
UserID: "user-1",
|
||||
UserName: "tester",
|
||||
Content: content,
|
||||
ReplyCtx: "reply-ctx-1",
|
||||
}
|
||||
}
|
||||
|
||||
func TestInboundImagesAndFilesReachAgentThroughEngine(t *testing.T) {
|
||||
engine, agent, platform := newMediaEngine(t)
|
||||
msg := mediaMessage("analyze these attachments")
|
||||
msg.Images = []core.ImageAttachment{{MimeType: "image/png", FileName: "screenshot.png", Data: []byte("png-bytes")}}
|
||||
msg.Files = []core.FileAttachment{{MimeType: "application/pdf", FileName: "spec.pdf", Data: []byte("%PDF")}}
|
||||
|
||||
engine.ReceiveMessage(platform, msg)
|
||||
|
||||
records := agent.session.waitRecords(t, 1)
|
||||
if !strings.Contains(records[0].prompt, "analyze these attachments") {
|
||||
t.Fatalf("prompt = %q, want user content", records[0].prompt)
|
||||
}
|
||||
if len(records[0].images) != 1 || records[0].images[0].FileName != "screenshot.png" || string(records[0].images[0].Data) != "png-bytes" {
|
||||
t.Fatalf("images not preserved: %#v", records[0].images)
|
||||
}
|
||||
if len(records[0].files) != 1 || records[0].files[0].FileName != "spec.pdf" || string(records[0].files[0].Data) != "%PDF" {
|
||||
t.Fatalf("files not preserved: %#v", records[0].files)
|
||||
}
|
||||
platform.waitTextContaining(t, "media ok")
|
||||
}
|
||||
|
||||
func TestAttachmentOnlyMessageReachesAgent(t *testing.T) {
|
||||
engine, agent, platform := newMediaEngine(t)
|
||||
msg := mediaMessage("")
|
||||
msg.Images = []core.ImageAttachment{{MimeType: "image/jpeg", FileName: "photo.jpg", Data: []byte("jpeg-bytes")}}
|
||||
|
||||
engine.ReceiveMessage(platform, msg)
|
||||
|
||||
records := agent.session.waitRecords(t, 1)
|
||||
if len(records[0].images) != 1 || records[0].images[0].MimeType != "image/jpeg" {
|
||||
t.Fatalf("attachment-only image not delivered: %#v", records[0].images)
|
||||
}
|
||||
platform.waitTextContaining(t, "media ok")
|
||||
}
|
||||
|
||||
func TestQueuedMessagePreservesFiles(t *testing.T) {
|
||||
engine, agent, platform := newMediaEngine(t)
|
||||
agent.session.blockFirstResult()
|
||||
|
||||
first := mediaMessage("start long task")
|
||||
engine.ReceiveMessage(platform, first)
|
||||
agent.session.waitRecords(t, 1)
|
||||
|
||||
queued := mediaMessage("please also inspect this file")
|
||||
queued.MessageID = "queued-msg"
|
||||
queued.Files = []core.FileAttachment{{MimeType: "text/plain", FileName: "queued.txt", Data: []byte("queued-file")}}
|
||||
engine.ReceiveMessage(platform, queued)
|
||||
|
||||
platform.waitTextContaining(t, "process after")
|
||||
agent.session.releaseFirstResult("first done")
|
||||
|
||||
records := agent.session.waitRecords(t, 2)
|
||||
if !strings.Contains(records[1].prompt, "please also inspect this file") {
|
||||
t.Fatalf("queued prompt = %q", records[1].prompt)
|
||||
}
|
||||
if len(records[1].files) != 1 || records[1].files[0].FileName != "queued.txt" || string(records[1].files[0].Data) != "queued-file" {
|
||||
t.Fatalf("queued file not preserved: %#v", records[1].files)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendToSessionWithAttachmentsDeliversTextImagesAndFiles(t *testing.T) {
|
||||
engine, agent, platform := newMediaEngine(t)
|
||||
msg := mediaMessage("establish active session")
|
||||
engine.ReceiveMessage(platform, msg)
|
||||
agent.session.waitRecords(t, 1)
|
||||
platform.waitTextContaining(t, "media ok")
|
||||
|
||||
err := engine.SendToSessionWithAttachments(
|
||||
msg.SessionKey,
|
||||
"delivery ready",
|
||||
[]core.ImageAttachment{{MimeType: "image/png", FileName: "chart.png", Data: []byte("chart")}},
|
||||
[]core.FileAttachment{{MimeType: "text/plain", FileName: "report.txt", Data: []byte("report")}},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("SendToSessionWithAttachments() error = %v", err)
|
||||
}
|
||||
|
||||
texts, images, files, replyCtx := platform.snapshot()
|
||||
if !containsText(texts, "delivery ready") {
|
||||
t.Fatalf("texts = %#v, want delivery message", texts)
|
||||
}
|
||||
if len(images) != 1 || images[0].FileName != "chart.png" || string(images[0].Data) != "chart" {
|
||||
t.Fatalf("images = %#v", images)
|
||||
}
|
||||
if len(files) != 1 || files[0].FileName != "report.txt" || string(files[0].Data) != "report" {
|
||||
t.Fatalf("files = %#v", files)
|
||||
}
|
||||
for _, ctx := range replyCtx {
|
||||
if ctx != "reply-ctx-1" {
|
||||
t.Fatalf("reply context = %#v, want original reply context", replyCtx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendToSessionWithAttachmentsDoesNotDuplicateEchoedFinalTextWithContextIndicator(t *testing.T) {
|
||||
engine, agent, platform := newMediaEngine(t)
|
||||
agent.session.blockFirstResult()
|
||||
|
||||
msg := mediaMessage("start long task")
|
||||
engine.ReceiveMessage(platform, msg)
|
||||
agent.session.waitRecords(t, 1)
|
||||
|
||||
sideText := "delivery ready"
|
||||
err := engine.SendToSessionWithAttachments(
|
||||
msg.SessionKey,
|
||||
sideText,
|
||||
nil,
|
||||
[]core.FileAttachment{{MimeType: "text/plain", FileName: "report.txt", Data: []byte("report")}},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("SendToSessionWithAttachments() error = %v", err)
|
||||
}
|
||||
|
||||
agent.session.releaseFirstEvent(core.Event{
|
||||
Type: core.EventResult,
|
||||
Content: sideText,
|
||||
InputTokens: 52000,
|
||||
Done: true,
|
||||
})
|
||||
|
||||
deadline := time.Now().Add(300 * time.Millisecond)
|
||||
var lastTexts []string
|
||||
for time.Now().Before(deadline) {
|
||||
texts, _, _, _ := platform.snapshot()
|
||||
lastTexts = texts
|
||||
count := 0
|
||||
for _, text := range texts {
|
||||
if strings.Contains(text, sideText) {
|
||||
count++
|
||||
}
|
||||
if strings.Contains(text, "[ctx:") {
|
||||
t.Fatalf("unexpected duplicate context indicator reply: %#v", texts)
|
||||
}
|
||||
}
|
||||
if count > 1 {
|
||||
t.Fatalf("texts = %#v, want no duplicate delivery message", texts)
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
count := 0
|
||||
for _, text := range lastTexts {
|
||||
if strings.Contains(text, sideText) {
|
||||
count++
|
||||
}
|
||||
}
|
||||
if count != 1 {
|
||||
t.Fatalf("texts = %#v, want exactly one side-channel delivery message", lastTexts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendToSessionWithAttachmentsRespectsDisabledAttachmentSend(t *testing.T) {
|
||||
engine, agent, platform := newMediaEngine(t)
|
||||
msg := mediaMessage("establish active session")
|
||||
engine.ReceiveMessage(platform, msg)
|
||||
agent.session.waitRecords(t, 1)
|
||||
platform.waitTextContaining(t, "media ok")
|
||||
|
||||
engine.SetAttachmentSendEnabled(false)
|
||||
err := engine.SendToSessionWithAttachments(
|
||||
msg.SessionKey,
|
||||
"should not send",
|
||||
nil,
|
||||
[]core.FileAttachment{{MimeType: "text/plain", FileName: "blocked.txt", Data: []byte("blocked")}},
|
||||
)
|
||||
if !errors.Is(err, core.ErrAttachmentSendDisabled) {
|
||||
t.Fatalf("err = %v, want ErrAttachmentSendDisabled", err)
|
||||
}
|
||||
texts, images, files, _ := platform.snapshot()
|
||||
if containsText(texts, "should not send") || len(images) != 0 || len(files) != 0 {
|
||||
t.Fatalf("disabled attachment send leaked output: texts=%#v images=%#v files=%#v", texts, images, files)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendToSessionWithAttachmentsRequiresSessionWhenMultipleSessionsHaveAttachments(t *testing.T) {
|
||||
engine, agent, platform := newMediaEngine(t)
|
||||
first := mediaMessage("first")
|
||||
first.SessionKey = "media:chat-1:user-1"
|
||||
second := mediaMessage("second")
|
||||
second.SessionKey = "media:chat-1:user-2"
|
||||
second.ReplyCtx = "reply-ctx-2"
|
||||
|
||||
engine.ReceiveMessage(platform, first)
|
||||
engine.ReceiveMessage(platform, second)
|
||||
agent.session.waitRecords(t, 2)
|
||||
platform.waitTextContaining(t, "media ok")
|
||||
|
||||
err := engine.SendToSessionWithAttachments(
|
||||
"",
|
||||
"ambiguous",
|
||||
[]core.ImageAttachment{{MimeType: "image/png", FileName: "ambiguous.png", Data: []byte("img")}},
|
||||
nil,
|
||||
)
|
||||
if err == nil || !strings.Contains(err.Error(), "multiple active sessions") {
|
||||
t.Fatalf("err = %v, want multiple active sessions error", err)
|
||||
}
|
||||
texts, images, files, _ := platform.snapshot()
|
||||
if containsText(texts, "ambiguous") || len(images) != 0 || len(files) != 0 {
|
||||
t.Fatalf("ambiguous attachment send leaked output: texts=%#v images=%#v files=%#v", texts, images, files)
|
||||
}
|
||||
}
|
||||
|
||||
func containsText(texts []string, want string) bool {
|
||||
for _, text := range texts {
|
||||
if strings.Contains(text, want) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user