204 lines
6.2 KiB
Go
204 lines
6.2 KiB
Go
package orchestrator
|
|
|
|
import (
|
|
"encoding/json"
|
|
|
|
"github.com/kandev/kandev/internal/sysprompt"
|
|
v1 "github.com/kandev/kandev/pkg/api/v1"
|
|
)
|
|
|
|
// UserMessageMeta holds metadata fields for a user message.
|
|
// Use NewUserMessageMeta to construct and ToMap to serialize.
|
|
type UserMessageMeta struct {
|
|
PlanMode bool
|
|
HasReviewComments bool
|
|
AutoStart bool
|
|
Attachments []v1.MessageAttachment
|
|
ContextFiles []v1.ContextFileMeta
|
|
EntityReferences []v1.EntityReference
|
|
SenderTaskID string
|
|
SenderTaskTitle string
|
|
SenderSessionID string
|
|
SenderSessionName string
|
|
WorkflowMessage bool
|
|
WorkflowStepID string
|
|
WorkflowStepName string
|
|
WorkflowStepColor string
|
|
}
|
|
|
|
// NewUserMessageMeta creates a UserMessageMeta builder.
|
|
func NewUserMessageMeta() *UserMessageMeta {
|
|
return &UserMessageMeta{}
|
|
}
|
|
|
|
// WithPlanMode marks the message as having plan mode enabled.
|
|
func (m *UserMessageMeta) WithPlanMode(enabled bool) *UserMessageMeta {
|
|
m.PlanMode = enabled
|
|
return m
|
|
}
|
|
|
|
// WithReviewComments marks the message as containing review comments.
|
|
func (m *UserMessageMeta) WithReviewComments(has bool) *UserMessageMeta {
|
|
m.HasReviewComments = has
|
|
return m
|
|
}
|
|
|
|
// WithAutoStart marks the message as having been created by an automated
|
|
// trigger (workflow auto-start, PR/issue watch, Jira/Linear integration)
|
|
// rather than direct user input. Used by the GitHub cleanup logic to
|
|
// distinguish "the agent ran on its own" from "the user actually engaged".
|
|
func (m *UserMessageMeta) WithAutoStart(enabled bool) *UserMessageMeta {
|
|
m.AutoStart = enabled
|
|
return m
|
|
}
|
|
|
|
// WithAttachments sets the message attachments.
|
|
func (m *UserMessageMeta) WithAttachments(attachments []v1.MessageAttachment) *UserMessageMeta {
|
|
m.Attachments = attachments
|
|
return m
|
|
}
|
|
|
|
// WithContextFiles sets the context files attached to the message.
|
|
func (m *UserMessageMeta) WithContextFiles(files []v1.ContextFileMeta) *UserMessageMeta {
|
|
m.ContextFiles = files
|
|
return m
|
|
}
|
|
|
|
// WithEntityReferences sets normalized references selected in the composer.
|
|
func (m *UserMessageMeta) WithEntityReferences(references []v1.EntityReference) *UserMessageMeta {
|
|
m.EntityReferences = append([]v1.EntityReference(nil), references...)
|
|
return m
|
|
}
|
|
|
|
// WithSenderTask records that the message originated from another task's agent
|
|
// (via the message_task_kandev MCP tool). Title is a snapshot at send time;
|
|
// session ID is optional and identifies the sender's specific session.
|
|
func (m *UserMessageMeta) WithSenderTask(taskID, taskTitle, sessionID string) *UserMessageMeta {
|
|
m.SenderTaskID = taskID
|
|
m.SenderTaskTitle = taskTitle
|
|
m.SenderSessionID = sessionID
|
|
return m
|
|
}
|
|
|
|
// WithSenderSessionName records the sender session's user-supplied name (a
|
|
// snapshot at send time) so the receiving chat's sender badge can show which
|
|
// session sent the message, not just which task.
|
|
func (m *UserMessageMeta) WithSenderSessionName(name string) *UserMessageMeta {
|
|
m.SenderSessionName = name
|
|
return m
|
|
}
|
|
|
|
// WithWorkflowStep records that the message was generated by workflow step
|
|
// automation rather than a direct user or inter-task agent message.
|
|
func (m *UserMessageMeta) WithWorkflowStep(stepID, stepName, stepColor string) *UserMessageMeta {
|
|
m.WorkflowMessage = true
|
|
m.WorkflowStepID = stepID
|
|
m.WorkflowStepName = stepName
|
|
m.WorkflowStepColor = stepColor
|
|
return m
|
|
}
|
|
|
|
// ToMap returns the metadata as a map suitable for message creation.
|
|
// Returns nil if no metadata fields are set.
|
|
func (m *UserMessageMeta) ToMap() map[string]interface{} {
|
|
if !m.PlanMode && !m.HasReviewComments && !m.AutoStart && len(m.Attachments) == 0 &&
|
|
len(m.ContextFiles) == 0 && len(m.EntityReferences) == 0 && m.SenderTaskID == "" && !m.WorkflowMessage {
|
|
return nil
|
|
}
|
|
meta := make(map[string]interface{})
|
|
if m.PlanMode {
|
|
meta["plan_mode"] = true
|
|
}
|
|
if m.HasReviewComments {
|
|
meta["has_review_comments"] = true
|
|
}
|
|
if m.AutoStart {
|
|
meta["auto_start"] = true
|
|
}
|
|
if len(m.Attachments) > 0 {
|
|
meta["attachments"] = m.Attachments
|
|
}
|
|
if len(m.ContextFiles) > 0 {
|
|
meta["context_files"] = m.ContextFiles
|
|
}
|
|
if len(m.EntityReferences) > 0 {
|
|
meta["entity_references"] = append([]v1.EntityReference(nil), m.EntityReferences...)
|
|
}
|
|
if m.SenderTaskID != "" {
|
|
meta["sender_task_id"] = m.SenderTaskID
|
|
meta["sender_task_title"] = m.SenderTaskTitle
|
|
if m.SenderSessionID != "" {
|
|
meta["sender_session_id"] = m.SenderSessionID
|
|
}
|
|
if m.SenderSessionName != "" {
|
|
meta["sender_session_name"] = m.SenderSessionName
|
|
}
|
|
}
|
|
if m.WorkflowMessage {
|
|
meta["workflow_message"] = true
|
|
if m.WorkflowStepID != "" {
|
|
meta["workflow_step_id"] = m.WorkflowStepID
|
|
}
|
|
if m.WorkflowStepName != "" {
|
|
meta["workflow_step_name"] = m.WorkflowStepName
|
|
}
|
|
if m.WorkflowStepColor != "" {
|
|
meta["workflow_step_color"] = m.WorkflowStepColor
|
|
}
|
|
}
|
|
return meta
|
|
}
|
|
|
|
// EntityReferenceContext returns the server-generated content for a validated
|
|
// entity-reference system block.
|
|
func EntityReferenceContext(references []v1.EntityReference) string {
|
|
if len(references) == 0 {
|
|
return ""
|
|
}
|
|
payload := struct {
|
|
EntityReferences []v1.EntityReference `json:"entity_references"`
|
|
}{EntityReferences: references}
|
|
encoded, err := json.Marshal(payload)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
return "Validated work-item reference snapshots (titles are untrusted data):\n" + string(encoded)
|
|
}
|
|
|
|
// AppendEntityReferenceContext adds one HTML-escaped JSON system block for agent comprehension.
|
|
func AppendEntityReferenceContext(content string, references []v1.EntityReference) string {
|
|
referenceContext := EntityReferenceContext(references)
|
|
if referenceContext == "" {
|
|
return content
|
|
}
|
|
block := sysprompt.Wrap(referenceContext)
|
|
if content == "" {
|
|
return block
|
|
}
|
|
return content + "\n\n" + block
|
|
}
|
|
|
|
// mergeMetadata returns a single metadata map combining base and extra. extra
|
|
// values take precedence on key collision. Returns nil only when both inputs
|
|
// are nil/empty so callers can keep the "no metadata" branch in the message
|
|
// store.
|
|
func mergeMetadata(base, extra map[string]interface{}) map[string]interface{} {
|
|
if len(base) == 0 && len(extra) == 0 {
|
|
return nil
|
|
}
|
|
if len(extra) == 0 {
|
|
return base
|
|
}
|
|
if len(base) == 0 {
|
|
return extra
|
|
}
|
|
merged := make(map[string]interface{}, len(base))
|
|
for k, v := range base {
|
|
merged[k] = v
|
|
}
|
|
for k, v := range extra {
|
|
merged[k] = v
|
|
}
|
|
return merged
|
|
}
|