diff --git a/internal/chat/service_conversation.go b/internal/chat/service_conversation.go new file mode 100644 index 0000000..dbe7d51 --- /dev/null +++ b/internal/chat/service_conversation.go @@ -0,0 +1,203 @@ +package chat + +import ( + "context" + "log/slog" + "strings" + "time" + + "github.com/google/uuid" + + "github.com/yan1h/agent-coding-workflow/internal/audit" + "github.com/yan1h/agent-coding-workflow/internal/infra/errs" + "github.com/yan1h/agent-coding-workflow/internal/infra/llm" +) + +// compile-time assertion +var _ ConversationService = (*conversationService)(nil) + +type conversationService struct { + repo Repository + registry *llm.Registry + auditor audit.Recorder + log *slog.Logger +} + +// NewConversationService constructs a ConversationService. +func NewConversationService(repo Repository, reg *llm.Registry, a audit.Recorder, log *slog.Logger) ConversationService { + return &conversationService{repo: repo, registry: reg, auditor: a, log: log} +} + +// Create validates the model and optional template, then inserts a new conversation. +func (s *conversationService) Create(ctx context.Context, userID uuid.UUID, in CreateConversationInput) (*Conversation, error) { + // Validate model exists and is enabled. + model, err := s.repo.GetLLMModel(ctx, in.ModelID) + if err != nil { + return nil, err + } + if !model.Enabled { + return nil, errs.New(errs.CodeChatModelNotFound, "model is disabled") + } + + // Resolve system prompt: custom override > template content > empty string. + var systemPrompt string + if in.SystemPrompt != nil { + systemPrompt = *in.SystemPrompt + } else if in.TemplateID != nil { + tpl, err := s.repo.GetPromptTemplate(ctx, *in.TemplateID) + if err != nil { + return nil, err + } + // Check user-scoped template ownership. + if tpl.Scope == TemplateScopeUser && (tpl.OwnerID == nil || *tpl.OwnerID != userID) { + return nil, errs.New(errs.CodeForbidden, "template not accessible") + } + systemPrompt = tpl.Content + } + + id, err := uuid.NewV7() + if err != nil { + return nil, errs.Wrap(err, errs.CodeInternal, "generate conversation id") + } + + c, err := s.repo.InsertConversation(ctx, InsertConversationParams{ + ID: id, + UserID: userID, + ProjectID: in.ProjectID, + WorkspaceID: in.WorkspaceID, + IssueID: in.IssueID, + ModelID: in.ModelID, + SystemPrompt: systemPrompt, + Title: nil, + }) + if err != nil { + return nil, err + } + + s.tryAudit(ctx, audit.Entry{ + UserID: &userID, + Action: "chat.conversation.created", + TargetType: "conversation", + TargetID: c.ID.String(), + }) + return c, nil +} + +// Get returns a conversation and its most recent messages, enforcing ownership. +func (s *conversationService) Get(ctx context.Context, userID, id uuid.UUID) (*Conversation, []Message, error) { + c, err := s.repo.GetConversation(ctx, id) + if err != nil { + return nil, nil, err + } + if c.UserID != userID { + return nil, nil, errs.New(errs.CodeForbidden, "not conversation owner") + } + msgs, err := s.repo.ListMessagesByConversation(ctx, id, nil, 50) + if err != nil { + return nil, nil, err + } + return c, msgs, nil +} + +// List returns conversations for the given user, defaulting limit to 50. +func (s *conversationService) List(ctx context.Context, userID uuid.UUID, f ConversationFilter) ([]Conversation, error) { + if f.Limit == 0 { + f.Limit = 50 + } + return s.repo.ListConversationsByUser(ctx, userID, f) +} + +// UpdateTitle manually sets a conversation title, enforcing ownership. +func (s *conversationService) UpdateTitle(ctx context.Context, userID, id uuid.UUID, title string) error { + c, err := s.repo.GetConversation(ctx, id) + if err != nil { + return err + } + if c.UserID != userID { + return errs.New(errs.CodeForbidden, "not conversation owner") + } + return s.repo.SetConversationTitleManual(ctx, id, &title) +} + +// SoftDelete marks a conversation as deleted, enforcing ownership. +func (s *conversationService) SoftDelete(ctx context.Context, userID, id uuid.UUID) error { + c, err := s.repo.GetConversation(ctx, id) + if err != nil { + return err + } + if c.UserID != userID { + return errs.New(errs.CodeForbidden, "not conversation owner") + } + if err := s.repo.SoftDeleteConversation(ctx, id); err != nil { + return err + } + s.tryAudit(ctx, audit.Entry{ + UserID: &userID, + Action: "chat.conversation.deleted", + TargetType: "conversation", + TargetID: id.String(), + }) + return nil +} + +// GenerateTitle is NOT on the ConversationService interface. +// It is called by the streamer (T14) via type assertion on the concrete type. +// It runs in a fire-and-forget goroutine; errors are silently swallowed. +func (s *conversationService) GenerateTitle(ctx context.Context, conversationID uuid.UUID) { + tCtx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + + titleModel, err := s.repo.GetTitleGeneratorModel(tCtx) + if err != nil { + s.log.Warn("GenerateTitle: no title model", "err", err) + return + } + + first, err := s.repo.GetFirstUserMessage(tCtx, conversationID) + if err != nil { + s.log.Warn("GenerateTitle: no first user message", "conversation_id", conversationID, "err", err) + return + } + + client, err := s.registry.GetClient(tCtx, titleModel.EndpointID) + if err != nil { + s.log.Warn("GenerateTitle: get client failed", "endpoint_id", titleModel.EndpointID, "err", err) + return + } + + req := llm.Request{ + SystemPrompt: "用 ≤20 字概括下面对话主题,仅输出标题:", + Messages: []llm.Message{{ + Role: llm.RoleUser, + Blocks: []llm.ContentBlock{{Type: "text", Text: first.Content}}, + }}, + MaxOutputTokens: 64, + } + + ch, err := client.Stream(tCtx, titleModel.ModelID, req) + if err != nil { + s.log.Warn("GenerateTitle: stream failed", "err", err) + return + } + + var title string + for ev := range ch { + if ev.Type == llm.EventTextDelta { + title += ev.Text + } + } + title = strings.TrimSpace(title) + if title == "" { + return + } + + if err := s.repo.UpdateConversationTitle(tCtx, conversationID, &title); err != nil { + s.log.Warn("GenerateTitle: update title failed", "conversation_id", conversationID, "err", err) + } +} + +func (s *conversationService) tryAudit(ctx context.Context, e audit.Entry) { + if err := s.auditor.Record(ctx, e); err != nil { + s.log.Warn("audit record failed", "action", e.Action, "err", err) + } +} diff --git a/internal/chat/service_conversation_test.go b/internal/chat/service_conversation_test.go new file mode 100644 index 0000000..764d6cc --- /dev/null +++ b/internal/chat/service_conversation_test.go @@ -0,0 +1,433 @@ +package chat + +import ( + "context" + "sync" + "testing" + "time" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/yan1h/agent-coding-workflow/internal/audit" + "github.com/yan1h/agent-coding-workflow/internal/infra/errs" +) + +// ===== conversation fake repo extension ===== +// convFakeRepo embeds fakeRepo and overrides conversation/template/model methods. +type convFakeRepo struct { + *fakeRepo + mu sync.Mutex + conversations map[uuid.UUID]*Conversation + messages map[uuid.UUID][]Message + templates map[uuid.UUID]*PromptTemplate + titleUpdated map[uuid.UUID]*string + softDeleted []uuid.UUID +} + +func newConvFakeRepo() *convFakeRepo { + return &convFakeRepo{ + fakeRepo: newFakeRepo(), + conversations: make(map[uuid.UUID]*Conversation), + messages: make(map[uuid.UUID][]Message), + templates: make(map[uuid.UUID]*PromptTemplate), + titleUpdated: make(map[uuid.UUID]*string), + } +} + +func (r *convFakeRepo) InsertConversation(_ context.Context, p InsertConversationParams) (*Conversation, error) { + r.mu.Lock() + defer r.mu.Unlock() + c := &Conversation{ + ID: p.ID, + UserID: p.UserID, + ProjectID: p.ProjectID, + WorkspaceID: p.WorkspaceID, + IssueID: p.IssueID, + ModelID: p.ModelID, + SystemPrompt: p.SystemPrompt, + Title: p.Title, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + r.conversations[p.ID] = c + return c, nil +} + +func (r *convFakeRepo) GetConversation(_ context.Context, id uuid.UUID) (*Conversation, error) { + r.mu.Lock() + defer r.mu.Unlock() + c, ok := r.conversations[id] + if !ok { + return nil, errs.New(errs.CodeChatConversationNotFound, "conversation not found") + } + return c, nil +} + +func (r *convFakeRepo) ListConversationsByUser(_ context.Context, userID uuid.UUID, f ConversationFilter) ([]Conversation, error) { + r.mu.Lock() + defer r.mu.Unlock() + var out []Conversation + for _, c := range r.conversations { + if c.UserID == userID && c.DeletedAt == nil { + out = append(out, *c) + } + } + limit := f.Limit + if limit > 0 && len(out) > limit { + out = out[:limit] + } + return out, nil +} + +func (r *convFakeRepo) SetConversationTitleManual(_ context.Context, id uuid.UUID, title *string) error { + r.mu.Lock() + defer r.mu.Unlock() + c, ok := r.conversations[id] + if !ok { + return errs.New(errs.CodeChatConversationNotFound, "conversation not found") + } + c.Title = title + r.titleUpdated[id] = title + return nil +} + +func (r *convFakeRepo) UpdateConversationTitle(_ context.Context, id uuid.UUID, title *string) error { + r.mu.Lock() + defer r.mu.Unlock() + c, ok := r.conversations[id] + if !ok { + return errs.New(errs.CodeChatConversationNotFound, "conversation not found") + } + c.Title = title + r.titleUpdated[id] = title + return nil +} + +func (r *convFakeRepo) SoftDeleteConversation(_ context.Context, id uuid.UUID) error { + r.mu.Lock() + defer r.mu.Unlock() + c, ok := r.conversations[id] + if !ok { + return errs.New(errs.CodeChatConversationNotFound, "conversation not found") + } + now := time.Now() + c.DeletedAt = &now + r.softDeleted = append(r.softDeleted, id) + return nil +} + +func (r *convFakeRepo) ListMessagesByConversation(_ context.Context, convID uuid.UUID, _ *int64, limit int) ([]Message, error) { + r.mu.Lock() + defer r.mu.Unlock() + msgs := r.messages[convID] + if limit > 0 && len(msgs) > limit { + msgs = msgs[:limit] + } + return msgs, nil +} + +func (r *convFakeRepo) GetLLMModel(_ context.Context, id uuid.UUID) (*LLMModel, error) { + for _, m := range r.fakeRepo.models { + if m.ID == id { + cp := m + return &cp, nil + } + } + return nil, errs.New(errs.CodeChatModelNotFound, "model not found") +} + +func (r *convFakeRepo) GetPromptTemplate(_ context.Context, id uuid.UUID) (*PromptTemplate, error) { + t, ok := r.templates[id] + if !ok { + return nil, errs.New(errs.CodeChatTemplateNotFound, "template not found") + } + return t, nil +} + +// ===== recording auditor ===== + +type recordingAuditor struct { + mu sync.Mutex + entries []audit.Entry +} + +func (a *recordingAuditor) Record(_ context.Context, e audit.Entry) error { + a.mu.Lock() + defer a.mu.Unlock() + a.entries = append(a.entries, e) + return nil +} + +func (a *recordingAuditor) lastEntry() (audit.Entry, bool) { + a.mu.Lock() + defer a.mu.Unlock() + if len(a.entries) == 0 { + return audit.Entry{}, false + } + return a.entries[len(a.entries)-1], true +} + +// ===== helpers ===== + +func newConvSvc(repo *convFakeRepo, aud audit.Recorder) ConversationService { + return NewConversationService(repo, nil, aud, discardLogger()) +} + +func addModel(repo *convFakeRepo, enabled bool) LLMModel { + m := LLMModel{ + ID: uuid.Must(uuid.NewV7()), + ModelID: "test-model", + Enabled: enabled, + } + repo.fakeRepo.models = append(repo.fakeRepo.models, m) + return m +} + +func addTemplate(repo *convFakeRepo, scope TemplateScope, ownerID *uuid.UUID) *PromptTemplate { + t := &PromptTemplate{ + ID: uuid.Must(uuid.NewV7()), + Name: "test-tpl", + Content: "system prompt from template", + Scope: scope, + OwnerID: ownerID, + } + repo.templates[t.ID] = t + return t +} + +func addConversation(repo *convFakeRepo, userID uuid.UUID) *Conversation { + c := &Conversation{ + ID: uuid.Must(uuid.NewV7()), + UserID: userID, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + repo.conversations[c.ID] = c + return c +} + +// ===== tests ===== + +func TestConversationService_Create_HappyPath(t *testing.T) { + t.Helper() + repo := newConvFakeRepo() + aud := &recordingAuditor{} + svc := newConvSvc(repo, aud) + userID := uuid.Must(uuid.NewV7()) + m := addModel(repo, true) + + c, err := svc.Create(context.Background(), userID, CreateConversationInput{ModelID: m.ID}) + require.NoError(t, err) + require.NotNil(t, c) + assert.Equal(t, userID, c.UserID) + assert.Equal(t, m.ID, c.ModelID) + assert.Empty(t, c.SystemPrompt) + + entry, ok := aud.lastEntry() + require.True(t, ok) + assert.Equal(t, "chat.conversation.created", entry.Action) + assert.Equal(t, &userID, entry.UserID) +} + +func TestConversationService_Create_WithSystemTemplate(t *testing.T) { + repo := newConvFakeRepo() + svc := newConvSvc(repo, noopAuditor{}) + userID := uuid.Must(uuid.NewV7()) + m := addModel(repo, true) + tpl := addTemplate(repo, TemplateScopeSystem, nil) + + c, err := svc.Create(context.Background(), userID, CreateConversationInput{ + ModelID: m.ID, + TemplateID: &tpl.ID, + }) + require.NoError(t, err) + assert.Equal(t, "system prompt from template", c.SystemPrompt) +} + +func TestConversationService_Create_TemplateUserScopeOwnerMismatch(t *testing.T) { + repo := newConvFakeRepo() + svc := newConvSvc(repo, noopAuditor{}) + userID := uuid.Must(uuid.NewV7()) + otherID := uuid.Must(uuid.NewV7()) + m := addModel(repo, true) + tpl := addTemplate(repo, TemplateScopeUser, &otherID) + + _, err := svc.Create(context.Background(), userID, CreateConversationInput{ + ModelID: m.ID, + TemplateID: &tpl.ID, + }) + require.Error(t, err) + var appErr *errs.AppError + require.ErrorAs(t, err, &appErr) + assert.Equal(t, errs.CodeForbidden, appErr.Code) +} + +func TestConversationService_Create_WithSystemPromptOverride(t *testing.T) { + repo := newConvFakeRepo() + svc := newConvSvc(repo, noopAuditor{}) + userID := uuid.Must(uuid.NewV7()) + m := addModel(repo, true) + tpl := addTemplate(repo, TemplateScopeSystem, nil) + override := "custom system prompt" + + c, err := svc.Create(context.Background(), userID, CreateConversationInput{ + ModelID: m.ID, + TemplateID: &tpl.ID, + SystemPrompt: &override, + }) + require.NoError(t, err) + // override takes precedence; template content ignored + assert.Equal(t, override, c.SystemPrompt) +} + +func TestConversationService_Create_ModelNotFound(t *testing.T) { + repo := newConvFakeRepo() + svc := newConvSvc(repo, noopAuditor{}) + userID := uuid.Must(uuid.NewV7()) + + _, err := svc.Create(context.Background(), userID, CreateConversationInput{ + ModelID: uuid.Must(uuid.NewV7()), + }) + require.Error(t, err) + var appErr *errs.AppError + require.ErrorAs(t, err, &appErr) + assert.Equal(t, errs.CodeChatModelNotFound, appErr.Code) +} + +func TestConversationService_Create_ModelDisabled(t *testing.T) { + repo := newConvFakeRepo() + svc := newConvSvc(repo, noopAuditor{}) + userID := uuid.Must(uuid.NewV7()) + m := addModel(repo, false) + + _, err := svc.Create(context.Background(), userID, CreateConversationInput{ModelID: m.ID}) + require.Error(t, err) + var appErr *errs.AppError + require.ErrorAs(t, err, &appErr) + assert.Equal(t, errs.CodeChatModelNotFound, appErr.Code) +} + +func TestConversationService_Create_TemplateNotFound(t *testing.T) { + repo := newConvFakeRepo() + svc := newConvSvc(repo, noopAuditor{}) + userID := uuid.Must(uuid.NewV7()) + m := addModel(repo, true) + missingID := uuid.Must(uuid.NewV7()) + + _, err := svc.Create(context.Background(), userID, CreateConversationInput{ + ModelID: m.ID, + TemplateID: &missingID, + }) + require.Error(t, err) + var appErr *errs.AppError + require.ErrorAs(t, err, &appErr) + assert.Equal(t, errs.CodeChatTemplateNotFound, appErr.Code) +} + +func TestConversationService_Get_HappyPath(t *testing.T) { + repo := newConvFakeRepo() + svc := newConvSvc(repo, noopAuditor{}) + userID := uuid.Must(uuid.NewV7()) + conv := addConversation(repo, userID) + repo.messages[conv.ID] = []Message{ + {ID: 1, ConversationID: conv.ID, Role: RoleUser, Content: "hello"}, + } + + got, msgs, err := svc.Get(context.Background(), userID, conv.ID) + require.NoError(t, err) + require.NotNil(t, got) + assert.Equal(t, conv.ID, got.ID) + require.Len(t, msgs, 1) + assert.Equal(t, "hello", msgs[0].Content) +} + +func TestConversationService_Get_NotOwner(t *testing.T) { + repo := newConvFakeRepo() + svc := newConvSvc(repo, noopAuditor{}) + ownerID := uuid.Must(uuid.NewV7()) + callerID := uuid.Must(uuid.NewV7()) + conv := addConversation(repo, ownerID) + + _, _, err := svc.Get(context.Background(), callerID, conv.ID) + require.Error(t, err) + var appErr *errs.AppError + require.ErrorAs(t, err, &appErr) + assert.Equal(t, errs.CodeForbidden, appErr.Code) +} + +func TestConversationService_List_DefaultLimit(t *testing.T) { + repo := newConvFakeRepo() + svc := newConvSvc(repo, noopAuditor{}) + userID := uuid.Must(uuid.NewV7()) + // Add 3 conversations + for i := 0; i < 3; i++ { + addConversation(repo, userID) + } + + // Pass Limit=0 — should default to 50 internally (all 3 returned) + convs, err := svc.List(context.Background(), userID, ConversationFilter{Limit: 0}) + require.NoError(t, err) + assert.Len(t, convs, 3) +} + +func TestConversationService_UpdateTitle_HappyPath(t *testing.T) { + repo := newConvFakeRepo() + svc := newConvSvc(repo, noopAuditor{}) + userID := uuid.Must(uuid.NewV7()) + conv := addConversation(repo, userID) + + err := svc.UpdateTitle(context.Background(), userID, conv.ID, "new title") + require.NoError(t, err) + // Verify title was written + title, ok := repo.titleUpdated[conv.ID] + require.True(t, ok) + require.NotNil(t, title) + assert.Equal(t, "new title", *title) +} + +func TestConversationService_UpdateTitle_NotOwner(t *testing.T) { + repo := newConvFakeRepo() + svc := newConvSvc(repo, noopAuditor{}) + ownerID := uuid.Must(uuid.NewV7()) + callerID := uuid.Must(uuid.NewV7()) + conv := addConversation(repo, ownerID) + + err := svc.UpdateTitle(context.Background(), callerID, conv.ID, "hack") + require.Error(t, err) + var appErr *errs.AppError + require.ErrorAs(t, err, &appErr) + assert.Equal(t, errs.CodeForbidden, appErr.Code) +} + +func TestConversationService_SoftDelete_HappyPath(t *testing.T) { + repo := newConvFakeRepo() + aud := &recordingAuditor{} + svc := newConvSvc(repo, aud) + userID := uuid.Must(uuid.NewV7()) + conv := addConversation(repo, userID) + + err := svc.SoftDelete(context.Background(), userID, conv.ID) + require.NoError(t, err) + require.Contains(t, repo.softDeleted, conv.ID) + + entry, ok := aud.lastEntry() + require.True(t, ok) + assert.Equal(t, "chat.conversation.deleted", entry.Action) + assert.Equal(t, &userID, entry.UserID) +} + +func TestConversationService_SoftDelete_NotOwner(t *testing.T) { + repo := newConvFakeRepo() + svc := newConvSvc(repo, noopAuditor{}) + ownerID := uuid.Must(uuid.NewV7()) + callerID := uuid.Must(uuid.NewV7()) + conv := addConversation(repo, ownerID) + + err := svc.SoftDelete(context.Background(), callerID, conv.ID) + require.Error(t, err) + var appErr *errs.AppError + require.ErrorAs(t, err, &appErr) + assert.Equal(t, errs.CodeForbidden, appErr.Code) +}