mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-17 11:48:42 +02:00
Compare commits
1 Commits
feat/templ
...
agent/lamb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8260e11c8a |
@@ -426,7 +426,7 @@ export class ApiClient {
|
||||
});
|
||||
}
|
||||
|
||||
async quickCreateIssue(data: { agent_id: string; prompt: string; priority?: string; due_date?: string; project_id?: string }): Promise<{ task_id: string }> {
|
||||
async quickCreateIssue(data: { agent_id: string; prompt: string }): Promise<{ task_id: string }> {
|
||||
return this.fetch("/api/issues/quick-create", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(data),
|
||||
|
||||
@@ -16,7 +16,7 @@ import { Switch } from "@multica/ui/components/ui/switch";
|
||||
import { api, ApiError } from "@multica/core/api";
|
||||
import { useWorkspaceId } from "@multica/core/hooks";
|
||||
import { useCurrentWorkspace } from "@multica/core/paths";
|
||||
import { agentListOptions, memberListOptions } from "@multica/core/workspace/queries";
|
||||
import { agentListOptions } from "@multica/core/workspace/queries";
|
||||
import { useQuickCreateStore } from "@multica/core/issues/stores/quick-create-store";
|
||||
import { useIssueDraftStore } from "@multica/core/issues/stores/draft-store";
|
||||
import { useCreateModeStore } from "@multica/core/issues/stores/create-mode-store";
|
||||
@@ -27,12 +27,11 @@ import {
|
||||
MIN_QUICK_CREATE_CLI_VERSION,
|
||||
} from "@multica/core/runtimes";
|
||||
import { useFileUpload } from "@multica/core/hooks/use-file-upload";
|
||||
import type { Agent, IssuePriority } from "@multica/core/types";
|
||||
import type { Agent } from "@multica/core/types";
|
||||
import { ActorAvatar } from "../common/actor-avatar";
|
||||
import { canAssignAgent } from "../issues/components/pickers/assignee-picker";
|
||||
import { PriorityPicker, DueDatePicker } from "../issues/components";
|
||||
import { ProjectPicker } from "../projects/components/project-picker";
|
||||
import { useAuthStore } from "@multica/core/auth";
|
||||
import { memberListOptions } from "@multica/core/workspace/queries";
|
||||
import {
|
||||
ContentEditor,
|
||||
type ContentEditorRef,
|
||||
@@ -40,7 +39,6 @@ import {
|
||||
FileDropOverlay,
|
||||
} from "../editor";
|
||||
import { FileUploadButton } from "@multica/ui/components/common/file-upload-button";
|
||||
import { PillButton } from "../common/pill-button";
|
||||
|
||||
// AgentCreatePanel — agent-mode body of the create-issue dialog. Renders
|
||||
// only the inner content; the surrounding `<Dialog>` AND `<DialogContent>`
|
||||
@@ -139,9 +137,6 @@ export function AgentCreatePanel({
|
||||
const [justSent, setJustSent] = useState(false);
|
||||
const [sentCount, setSentCount] = useState(0);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [priority, setPriority] = useState<IssuePriority>("none");
|
||||
const [dueDate, setDueDate] = useState<string | null>(null);
|
||||
const [projectId, setProjectId] = useState<string | null>(null);
|
||||
|
||||
// Image paste/drop support: route uploads through the same helper Advanced
|
||||
// uses, so users can paste screenshots straight into the prompt and the
|
||||
@@ -169,13 +164,7 @@ export function AgentCreatePanel({
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
try {
|
||||
await api.quickCreateIssue({
|
||||
agent_id: agentId,
|
||||
prompt: md,
|
||||
...(priority !== "none" ? { priority } : {}),
|
||||
...(dueDate ? { due_date: dueDate } : {}),
|
||||
...(projectId ? { project_id: projectId } : {}),
|
||||
});
|
||||
await api.quickCreateIssue({ agent_id: agentId, prompt: md });
|
||||
setLastAgentId(agentId);
|
||||
setLastMode("agent");
|
||||
toast.success("Sent to agent — you'll get an inbox notification when it's done", {
|
||||
@@ -338,27 +327,6 @@ export function AgentCreatePanel({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-1.5 px-5 py-1.5 shrink-0 flex-wrap border-b">
|
||||
<PriorityPicker
|
||||
priority={priority}
|
||||
onUpdate={(u) => u.priority && setPriority(u.priority)}
|
||||
triggerRender={<PillButton />}
|
||||
align="start"
|
||||
/>
|
||||
<DueDatePicker
|
||||
dueDate={dueDate}
|
||||
onUpdate={(u) => setDueDate(u.due_date ?? null)}
|
||||
triggerRender={<PillButton />}
|
||||
align="start"
|
||||
/>
|
||||
<ProjectPicker
|
||||
projectId={projectId}
|
||||
onUpdate={(u) => setProjectId(u.project_id ?? null)}
|
||||
triggerRender={<PillButton />}
|
||||
align="start"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Prompt — same rich editor Advanced uses, so paste/drop images,
|
||||
mentions, and formatting all work. The dropZone wrapper enables
|
||||
drag-and-drop file uploads alongside paste. */}
|
||||
|
||||
@@ -61,11 +61,7 @@ func buildQuickCreatePrompt(task Task) string {
|
||||
b.WriteString(" - Never echo the title in the description.\n\n")
|
||||
|
||||
// priority
|
||||
if task.QuickCreatePriority != "" {
|
||||
fmt.Fprintf(&b, "- **priority**: pass `--priority %s`.\n\n", task.QuickCreatePriority)
|
||||
} else {
|
||||
b.WriteString("- **priority**: one of `urgent`, `high`, `medium`, `low`, or omit. Map P0/P1 → urgent/high; \"asap\" → urgent. If unspecified, omit.\n\n")
|
||||
}
|
||||
b.WriteString("- **priority**: one of `urgent`, `high`, `medium`, `low`, or omit. Map P0/P1 → urgent/high; \"asap\" → urgent. If unspecified, omit.\n\n")
|
||||
|
||||
// assignee
|
||||
b.WriteString("- **assignee**:\n")
|
||||
@@ -80,17 +76,8 @@ func buildQuickCreatePrompt(task Task) string {
|
||||
b.WriteString(" - When the user did NOT name an assignee, default to YOURSELF (the picker agent): pass `--assignee <your agent name>`. Never leave the issue unassigned.\n\n")
|
||||
}
|
||||
|
||||
// due date
|
||||
if task.QuickCreateDueDate != "" {
|
||||
fmt.Fprintf(&b, "- **due-date**: pass `--due-date %s`.\n\n", task.QuickCreateDueDate)
|
||||
}
|
||||
|
||||
// fields to omit
|
||||
if task.QuickCreateProjectID != "" {
|
||||
fmt.Fprintf(&b, "- **project**: pass `--project %s`.\n\n", task.QuickCreateProjectID)
|
||||
} else {
|
||||
b.WriteString("- **project**: omit unless the platform UI shows a specific project context. The platform will route the issue to the workspace default.\n")
|
||||
}
|
||||
b.WriteString("- **project**: omit. The platform will route the issue to the workspace default.\n")
|
||||
b.WriteString("- **status**: omit (defaults to `todo`).\n")
|
||||
b.WriteString("- **attachments**: do NOT pass `--attachment`. The flag only accepts LOCAL file paths. Any image URL in the user input is already markdown — keep it inline in `--description` instead.\n\n")
|
||||
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestBuildQuickCreatePrompt_AllExplicitFields(t *testing.T) {
|
||||
task := Task{
|
||||
QuickCreatePrompt: "Fix the login button",
|
||||
QuickCreatePriority: "high",
|
||||
QuickCreateDueDate: "2025-06-01T00:00:00Z",
|
||||
QuickCreateProjectID: "123e4567-e89b-12d3-a456-426614174000",
|
||||
}
|
||||
got := buildQuickCreatePrompt(task)
|
||||
|
||||
for _, want := range []string{
|
||||
"`--priority high`",
|
||||
"`--due-date 2025-06-01T00:00:00Z`",
|
||||
"`--project 123e4567-e89b-12d3-a456-426614174000`",
|
||||
"`--priority high`.\n\n",
|
||||
"`--due-date 2025-06-01T00:00:00Z`.\n\n",
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("prompt missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
|
||||
if strings.Contains(got, `\n`) {
|
||||
t.Fatalf("prompt should contain real newlines, got literal \\n:\n%s", got)
|
||||
}
|
||||
if strings.Contains(got, "Map P0/P1") {
|
||||
t.Fatalf("prompt should not include fallback priority guidance when explicit priority is set:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildQuickCreatePrompt_PriorityOnly(t *testing.T) {
|
||||
task := Task{
|
||||
QuickCreatePrompt: "Urgent: server is down",
|
||||
QuickCreatePriority: "urgent",
|
||||
}
|
||||
got := buildQuickCreatePrompt(task)
|
||||
|
||||
if !strings.Contains(got, "`--priority urgent`") {
|
||||
t.Fatalf("prompt missing explicit priority flag:\n%s", got)
|
||||
}
|
||||
if strings.Contains(got, "Map P0/P1") {
|
||||
t.Fatalf("prompt should not include fallback priority guidance when explicit priority is set:\n%s", got)
|
||||
}
|
||||
if strings.Contains(got, "`--due-date") || strings.Contains(got, "`--project") {
|
||||
t.Fatalf("prompt should not inject unset quick-create flags:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildQuickCreatePrompt_NoneSet(t *testing.T) {
|
||||
task := Task{QuickCreatePrompt: "Something came up"}
|
||||
got := buildQuickCreatePrompt(task)
|
||||
|
||||
if !strings.Contains(got, "Map P0/P1") {
|
||||
t.Fatalf("prompt should include fallback priority guidance when no explicit priority is set:\n%s", got)
|
||||
}
|
||||
if strings.Contains(got, `\n`) {
|
||||
t.Fatalf("prompt should contain real newlines, got literal \\n:\n%s", got)
|
||||
}
|
||||
}
|
||||
@@ -33,34 +33,31 @@ type ProjectResourceData struct {
|
||||
// Task represents a claimed task from the server.
|
||||
// Agent data (name, skills) is populated by the claim endpoint.
|
||||
type Task struct {
|
||||
ID string `json:"id"`
|
||||
AgentID string `json:"agent_id"`
|
||||
RuntimeID string `json:"runtime_id"`
|
||||
IssueID string `json:"issue_id"`
|
||||
WorkspaceID string `json:"workspace_id"`
|
||||
Agent *AgentData `json:"agent,omitempty"`
|
||||
ID string `json:"id"`
|
||||
AgentID string `json:"agent_id"`
|
||||
RuntimeID string `json:"runtime_id"`
|
||||
IssueID string `json:"issue_id"`
|
||||
WorkspaceID string `json:"workspace_id"`
|
||||
Agent *AgentData `json:"agent,omitempty"`
|
||||
Repos []RepoData `json:"repos,omitempty"`
|
||||
ProjectID string `json:"project_id,omitempty"` // issue's project, when present
|
||||
ProjectTitle string `json:"project_title,omitempty"` // human-readable project title for context injection
|
||||
ProjectResources []ProjectResourceData `json:"project_resources,omitempty"` // project-scoped resources to expose to the agent
|
||||
PriorSessionID string `json:"prior_session_id,omitempty"` // Claude session ID from a previous task on this issue
|
||||
PriorWorkDir string `json:"prior_work_dir,omitempty"` // work_dir from a previous task on this issue
|
||||
TriggerCommentID string `json:"trigger_comment_id,omitempty"` // comment that triggered this task
|
||||
TriggerCommentContent string `json:"trigger_comment_content,omitempty"` // content of the triggering comment
|
||||
TriggerAuthorType string `json:"trigger_author_type,omitempty"` // "agent" or "member" — author kind for the triggering comment
|
||||
TriggerAuthorName string `json:"trigger_author_name,omitempty"` // display name of the triggering comment author
|
||||
ChatSessionID string `json:"chat_session_id,omitempty"` // non-empty for chat tasks
|
||||
ChatMessage string `json:"chat_message,omitempty"` // user message content for chat tasks
|
||||
AutopilotRunID string `json:"autopilot_run_id,omitempty"` // non-empty for autopilot run_only tasks
|
||||
AutopilotID string `json:"autopilot_id,omitempty"` // autopilot that spawned this run
|
||||
AutopilotTitle string `json:"autopilot_title,omitempty"` // autopilot title used as task context
|
||||
AutopilotDescription string `json:"autopilot_description,omitempty"` // autopilot description used as task prompt
|
||||
AutopilotSource string `json:"autopilot_source,omitempty"` // manual, schedule, webhook, or api
|
||||
AutopilotTriggerPayload json.RawMessage `json:"autopilot_trigger_payload,omitempty"` // optional trigger payload for webhook/api runs
|
||||
QuickCreatePrompt string `json:"quick_create_prompt,omitempty"` // user's natural-language input for quick-create tasks
|
||||
QuickCreatePriority string `json:"quick_create_priority,omitempty"` // priority explicitly selected in quick-create UI
|
||||
QuickCreateDueDate string `json:"quick_create_due_date,omitempty"` // due date explicitly selected in quick-create UI
|
||||
QuickCreateProjectID string `json:"quick_create_project_id,omitempty"` // project explicitly selected in quick-create UI
|
||||
ProjectID string `json:"project_id,omitempty"` // issue's project, when present
|
||||
ProjectTitle string `json:"project_title,omitempty"` // human-readable project title for context injection
|
||||
ProjectResources []ProjectResourceData `json:"project_resources,omitempty"` // project-scoped resources to expose to the agent
|
||||
PriorSessionID string `json:"prior_session_id,omitempty"` // Claude session ID from a previous task on this issue
|
||||
PriorWorkDir string `json:"prior_work_dir,omitempty"` // work_dir from a previous task on this issue
|
||||
TriggerCommentID string `json:"trigger_comment_id,omitempty"` // comment that triggered this task
|
||||
TriggerCommentContent string `json:"trigger_comment_content,omitempty"` // content of the triggering comment
|
||||
TriggerAuthorType string `json:"trigger_author_type,omitempty"` // "agent" or "member" — author kind for the triggering comment
|
||||
TriggerAuthorName string `json:"trigger_author_name,omitempty"` // display name of the triggering comment author
|
||||
ChatSessionID string `json:"chat_session_id,omitempty"` // non-empty for chat tasks
|
||||
ChatMessage string `json:"chat_message,omitempty"` // user message content for chat tasks
|
||||
AutopilotRunID string `json:"autopilot_run_id,omitempty"` // non-empty for autopilot run_only tasks
|
||||
AutopilotID string `json:"autopilot_id,omitempty"` // autopilot that spawned this run
|
||||
AutopilotTitle string `json:"autopilot_title,omitempty"` // autopilot title used as task context
|
||||
AutopilotDescription string `json:"autopilot_description,omitempty"` // autopilot description used as task prompt
|
||||
AutopilotSource string `json:"autopilot_source,omitempty"` // manual, schedule, webhook, or api
|
||||
AutopilotTriggerPayload json.RawMessage `json:"autopilot_trigger_payload,omitempty"` // optional trigger payload for webhook/api runs
|
||||
QuickCreatePrompt string `json:"quick_create_prompt,omitempty"` // user's natural-language input for quick-create tasks
|
||||
}
|
||||
|
||||
// AgentData holds agent details returned by the claim endpoint.
|
||||
|
||||
@@ -134,48 +134,45 @@ type ProjectResourceData struct {
|
||||
}
|
||||
|
||||
type AgentTaskResponse struct {
|
||||
ID string `json:"id"`
|
||||
AgentID string `json:"agent_id"`
|
||||
RuntimeID string `json:"runtime_id"`
|
||||
IssueID string `json:"issue_id"`
|
||||
WorkspaceID string `json:"workspace_id"`
|
||||
Status string `json:"status"`
|
||||
Priority int32 `json:"priority"`
|
||||
DispatchedAt *string `json:"dispatched_at"`
|
||||
StartedAt *string `json:"started_at"`
|
||||
CompletedAt *string `json:"completed_at"`
|
||||
Result any `json:"result"`
|
||||
Error *string `json:"error"`
|
||||
FailureReason string `json:"failure_reason,omitempty"` // see TaskService.MaybeRetryFailedTask
|
||||
Attempt int32 `json:"attempt"`
|
||||
MaxAttempts int32 `json:"max_attempts"`
|
||||
ParentTaskID *string `json:"parent_task_id,omitempty"`
|
||||
Agent *TaskAgentData `json:"agent,omitempty"`
|
||||
ID string `json:"id"`
|
||||
AgentID string `json:"agent_id"`
|
||||
RuntimeID string `json:"runtime_id"`
|
||||
IssueID string `json:"issue_id"`
|
||||
WorkspaceID string `json:"workspace_id"`
|
||||
Status string `json:"status"`
|
||||
Priority int32 `json:"priority"`
|
||||
DispatchedAt *string `json:"dispatched_at"`
|
||||
StartedAt *string `json:"started_at"`
|
||||
CompletedAt *string `json:"completed_at"`
|
||||
Result any `json:"result"`
|
||||
Error *string `json:"error"`
|
||||
FailureReason string `json:"failure_reason,omitempty"` // see TaskService.MaybeRetryFailedTask
|
||||
Attempt int32 `json:"attempt"`
|
||||
MaxAttempts int32 `json:"max_attempts"`
|
||||
ParentTaskID *string `json:"parent_task_id,omitempty"`
|
||||
Agent *TaskAgentData `json:"agent,omitempty"`
|
||||
Repos []RepoData `json:"repos,omitempty"`
|
||||
ProjectID string `json:"project_id,omitempty"` // issue's project, when present
|
||||
ProjectTitle string `json:"project_title,omitempty"` // for surfacing in agent context
|
||||
ProjectResources []ProjectResourceData `json:"project_resources,omitempty"` // resources attached to the project
|
||||
CreatedAt string `json:"created_at"`
|
||||
PriorSessionID string `json:"prior_session_id,omitempty"` // session ID from a previous task on same issue
|
||||
PriorWorkDir string `json:"prior_work_dir,omitempty"` // work_dir from a previous task on same issue
|
||||
TriggerCommentID *string `json:"trigger_comment_id,omitempty"` // comment that triggered this task
|
||||
TriggerCommentContent string `json:"trigger_comment_content,omitempty"` // content of the triggering comment
|
||||
TriggerSummary *string `json:"trigger_summary,omitempty"` // canonical short description snapshot — comment text / autopilot title — taken at task creation; survives source edits/deletes
|
||||
TriggerAuthorType string `json:"trigger_author_type,omitempty"` // "agent" or "member" — author kind of the triggering comment
|
||||
TriggerAuthorName string `json:"trigger_author_name,omitempty"` // display name of the triggering comment author
|
||||
ChatSessionID string `json:"chat_session_id,omitempty"` // non-empty for chat tasks
|
||||
ChatMessage string `json:"chat_message,omitempty"` // user message for chat tasks
|
||||
AutopilotRunID string `json:"autopilot_run_id,omitempty"` // non-empty for autopilot-spawned tasks
|
||||
AutopilotID string `json:"autopilot_id,omitempty"` // autopilot that spawned this task
|
||||
AutopilotTitle string `json:"autopilot_title,omitempty"` // autopilot title used as task context
|
||||
AutopilotDescription string `json:"autopilot_description,omitempty"` // autopilot description used as task prompt
|
||||
AutopilotSource string `json:"autopilot_source,omitempty"` // manual, schedule, webhook, or api
|
||||
AutopilotTriggerPayload json.RawMessage `json:"autopilot_trigger_payload,omitempty"` // optional trigger payload for webhook/api runs
|
||||
QuickCreatePrompt string `json:"quick_create_prompt,omitempty"` // user's natural-language input for quick-create tasks
|
||||
QuickCreatePriority string `json:"quick_create_priority,omitempty"` // priority explicitly selected in quick-create UI
|
||||
QuickCreateDueDate string `json:"quick_create_due_date,omitempty"` // due date explicitly selected in quick-create UI
|
||||
QuickCreateProjectID string `json:"quick_create_project_id,omitempty"` // project explicitly selected in quick-create UI
|
||||
Kind string `json:"kind"` // discriminator: "comment" | "autopilot" | "chat" | "quick_create" | "direct" — used by the activity row to label tasks that have no linked issue
|
||||
ProjectID string `json:"project_id,omitempty"` // issue's project, when present
|
||||
ProjectTitle string `json:"project_title,omitempty"` // for surfacing in agent context
|
||||
ProjectResources []ProjectResourceData `json:"project_resources,omitempty"` // resources attached to the project
|
||||
CreatedAt string `json:"created_at"`
|
||||
PriorSessionID string `json:"prior_session_id,omitempty"` // session ID from a previous task on same issue
|
||||
PriorWorkDir string `json:"prior_work_dir,omitempty"` // work_dir from a previous task on same issue
|
||||
TriggerCommentID *string `json:"trigger_comment_id,omitempty"` // comment that triggered this task
|
||||
TriggerCommentContent string `json:"trigger_comment_content,omitempty"` // content of the triggering comment
|
||||
TriggerSummary *string `json:"trigger_summary,omitempty"` // canonical short description snapshot — comment text / autopilot title — taken at task creation; survives source edits/deletes
|
||||
TriggerAuthorType string `json:"trigger_author_type,omitempty"` // "agent" or "member" — author kind of the triggering comment
|
||||
TriggerAuthorName string `json:"trigger_author_name,omitempty"` // display name of the triggering comment author
|
||||
ChatSessionID string `json:"chat_session_id,omitempty"` // non-empty for chat tasks
|
||||
ChatMessage string `json:"chat_message,omitempty"` // user message for chat tasks
|
||||
AutopilotRunID string `json:"autopilot_run_id,omitempty"` // non-empty for autopilot-spawned tasks
|
||||
AutopilotID string `json:"autopilot_id,omitempty"` // autopilot that spawned this task
|
||||
AutopilotTitle string `json:"autopilot_title,omitempty"` // autopilot title used as task context
|
||||
AutopilotDescription string `json:"autopilot_description,omitempty"` // autopilot description used as task prompt
|
||||
AutopilotSource string `json:"autopilot_source,omitempty"` // manual, schedule, webhook, or api
|
||||
AutopilotTriggerPayload json.RawMessage `json:"autopilot_trigger_payload,omitempty"` // optional trigger payload for webhook/api runs
|
||||
QuickCreatePrompt string `json:"quick_create_prompt,omitempty"` // user's natural-language input for quick-create tasks
|
||||
Kind string `json:"kind"` // discriminator: "comment" | "autopilot" | "chat" | "quick_create" | "direct" — used by the activity row to label tasks that have no linked issue
|
||||
}
|
||||
|
||||
// TaskAgentData holds agent info included in claim responses so the daemon
|
||||
|
||||
@@ -1095,15 +1095,6 @@ func (h *Handler) ClaimTaskByRuntime(w http.ResponseWriter, r *http.Request) {
|
||||
if json.Unmarshal(task.Context, &qc) == nil && qc.Type == service.QuickCreateContextType {
|
||||
hasQuickCreate = true
|
||||
resp.QuickCreatePrompt = qc.Prompt
|
||||
if qc.Priority != nil {
|
||||
resp.QuickCreatePriority = *qc.Priority
|
||||
}
|
||||
if qc.DueDate != nil {
|
||||
resp.QuickCreateDueDate = *qc.DueDate
|
||||
}
|
||||
if qc.ProjectID != nil {
|
||||
resp.QuickCreateProjectID = *qc.ProjectID
|
||||
}
|
||||
resp.WorkspaceID = qc.WorkspaceID
|
||||
if ws, err := h.Queries.GetWorkspace(r.Context(), parseUUID(qc.WorkspaceID)); err == nil && ws.Repos != nil {
|
||||
var repos []RepoData
|
||||
|
||||
@@ -18,7 +18,6 @@ import (
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
"github.com/multica-ai/multica/server/internal/logger"
|
||||
"github.com/multica-ai/multica/server/internal/service"
|
||||
"github.com/multica-ai/multica/server/internal/util"
|
||||
"github.com/multica-ai/multica/server/pkg/agent"
|
||||
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
||||
@@ -27,33 +26,33 @@ import (
|
||||
|
||||
// IssueResponse is the JSON response for an issue.
|
||||
type IssueResponse struct {
|
||||
ID string `json:"id"`
|
||||
WorkspaceID string `json:"workspace_id"`
|
||||
Number int32 `json:"number"`
|
||||
Identifier string `json:"identifier"`
|
||||
Title string `json:"title"`
|
||||
Description *string `json:"description"`
|
||||
Status string `json:"status"`
|
||||
Priority string `json:"priority"`
|
||||
AssigneeType *string `json:"assignee_type"`
|
||||
AssigneeID *string `json:"assignee_id"`
|
||||
CreatorType string `json:"creator_type"`
|
||||
CreatorID string `json:"creator_id"`
|
||||
ParentIssueID *string `json:"parent_issue_id"`
|
||||
ProjectID *string `json:"project_id"`
|
||||
Position float64 `json:"position"`
|
||||
DueDate *string `json:"due_date"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
Reactions []IssueReactionResponse `json:"reactions,omitempty"`
|
||||
Attachments []AttachmentResponse `json:"attachments,omitempty"`
|
||||
ID string `json:"id"`
|
||||
WorkspaceID string `json:"workspace_id"`
|
||||
Number int32 `json:"number"`
|
||||
Identifier string `json:"identifier"`
|
||||
Title string `json:"title"`
|
||||
Description *string `json:"description"`
|
||||
Status string `json:"status"`
|
||||
Priority string `json:"priority"`
|
||||
AssigneeType *string `json:"assignee_type"`
|
||||
AssigneeID *string `json:"assignee_id"`
|
||||
CreatorType string `json:"creator_type"`
|
||||
CreatorID string `json:"creator_id"`
|
||||
ParentIssueID *string `json:"parent_issue_id"`
|
||||
ProjectID *string `json:"project_id"`
|
||||
Position float64 `json:"position"`
|
||||
DueDate *string `json:"due_date"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
Reactions []IssueReactionResponse `json:"reactions,omitempty"`
|
||||
Attachments []AttachmentResponse `json:"attachments,omitempty"`
|
||||
// Labels are bulk-attached by list/detail endpoints so the client can render
|
||||
// chips without an N+1 round-trip per row. Pointer + omitempty so paths that
|
||||
// don't load labels (e.g. UpdateIssue, batch UpdateIssues, the issue:updated
|
||||
// WS broadcast) emit no `labels` field at all — the client merge then
|
||||
// preserves whatever labels are already in cache. nil pointer = "field
|
||||
// absent, do not touch"; non-nil (incl. empty slice) = authoritative list.
|
||||
Labels *[]LabelResponse `json:"labels,omitempty"`
|
||||
Labels *[]LabelResponse `json:"labels,omitempty"`
|
||||
}
|
||||
|
||||
func issueToResponse(i db.Issue, issuePrefix string) IssueResponse {
|
||||
@@ -287,7 +286,7 @@ func buildSearchQuery(phrase string, terms []string, queryNum int, hasNum bool,
|
||||
}
|
||||
|
||||
escapedPhrase := escapeLike(phrase)
|
||||
phraseParam := nextArg(escapedPhrase) // $1
|
||||
phraseParam := nextArg(escapedPhrase) // $1
|
||||
phraseContains := "'%' || " + phraseParam + " || '%'"
|
||||
phraseStartsWith := phraseParam + " || '%'"
|
||||
|
||||
@@ -859,11 +858,8 @@ func (h *Handler) ChildIssueProgress(w http.ResponseWriter, r *http.Request) {
|
||||
// into a `multica issue create` invocation in the background; success and
|
||||
// failure both surface as inbox notifications to the requester.
|
||||
type QuickCreateIssueRequest struct {
|
||||
AgentID string `json:"agent_id"`
|
||||
Prompt string `json:"prompt"`
|
||||
Priority *string `json:"priority,omitempty"`
|
||||
DueDate *string `json:"due_date,omitempty"`
|
||||
ProjectID *string `json:"project_id,omitempty"`
|
||||
AgentID string `json:"agent_id"`
|
||||
Prompt string `json:"prompt"`
|
||||
}
|
||||
|
||||
// QuickCreateIssueResponse echoes the queued task id so the frontend can
|
||||
@@ -887,43 +883,6 @@ func (h *Handler) QuickCreateIssue(w http.ResponseWriter, r *http.Request) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if req.Priority != nil {
|
||||
priority := strings.ToLower(strings.TrimSpace(*req.Priority))
|
||||
if priority == "" {
|
||||
req.Priority = nil
|
||||
} else {
|
||||
switch priority {
|
||||
case "urgent", "high", "medium", "low":
|
||||
req.Priority = &priority
|
||||
default:
|
||||
writeError(w, http.StatusBadRequest, "priority must be one of: urgent, high, medium, low")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
if req.DueDate != nil {
|
||||
dueDate := strings.TrimSpace(*req.DueDate)
|
||||
if dueDate == "" {
|
||||
req.DueDate = nil
|
||||
} else {
|
||||
if _, err := time.Parse(time.RFC3339, dueDate); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid due_date format, expected RFC3339")
|
||||
return
|
||||
}
|
||||
req.DueDate = &dueDate
|
||||
}
|
||||
}
|
||||
if req.ProjectID != nil {
|
||||
projectID := strings.TrimSpace(*req.ProjectID)
|
||||
if projectID == "" {
|
||||
req.ProjectID = nil
|
||||
} else {
|
||||
if _, ok := parseUUIDOrBadRequest(w, projectID, "project_id"); !ok {
|
||||
return
|
||||
}
|
||||
req.ProjectID = &projectID
|
||||
}
|
||||
}
|
||||
|
||||
workspaceID := h.resolveWorkspaceID(r)
|
||||
wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace_id")
|
||||
@@ -984,11 +943,7 @@ func (h *Handler) QuickCreateIssue(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
task, err := h.TaskService.EnqueueQuickCreateTask(r.Context(), wsUUID, requesterUUID, agentUUID, prompt, func(qc *service.QuickCreateContext) {
|
||||
qc.Priority = req.Priority
|
||||
qc.DueDate = req.DueDate
|
||||
qc.ProjectID = req.ProjectID
|
||||
})
|
||||
task, err := h.TaskService.EnqueueQuickCreateTask(r.Context(), wsUUID, requesterUUID, agentUUID, prompt)
|
||||
if err != nil {
|
||||
slog.Warn("quick-create enqueue failed", append(logger.RequestAttrs(r), "error", err)...)
|
||||
writeError(w, http.StatusInternalServerError, "failed to enqueue quick-create task")
|
||||
@@ -1087,16 +1042,16 @@ func readRuntimeCLIVersion(metadata []byte) string {
|
||||
}
|
||||
|
||||
type CreateIssueRequest struct {
|
||||
Title string `json:"title"`
|
||||
Description *string `json:"description"`
|
||||
Status string `json:"status"`
|
||||
Priority string `json:"priority"`
|
||||
AssigneeType *string `json:"assignee_type"`
|
||||
AssigneeID *string `json:"assignee_id"`
|
||||
ParentIssueID *string `json:"parent_issue_id"`
|
||||
ProjectID *string `json:"project_id"`
|
||||
DueDate *string `json:"due_date"`
|
||||
AttachmentIDs []string `json:"attachment_ids,omitempty"`
|
||||
Title string `json:"title"`
|
||||
Description *string `json:"description"`
|
||||
Status string `json:"status"`
|
||||
Priority string `json:"priority"`
|
||||
AssigneeType *string `json:"assignee_type"`
|
||||
AssigneeID *string `json:"assignee_id"`
|
||||
ParentIssueID *string `json:"parent_issue_id"`
|
||||
ProjectID *string `json:"project_id"`
|
||||
DueDate *string `json:"due_date"`
|
||||
AttachmentIDs []string `json:"attachment_ids,omitempty"`
|
||||
// OriginType / OriginID stamp the new issue with its provenance so
|
||||
// platform-internal flows can deterministically locate it later. Only
|
||||
// trusted callers should set these — currently the daemon CLI passes
|
||||
@@ -1332,16 +1287,16 @@ func (h *Handler) CreateIssue(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
type UpdateIssueRequest struct {
|
||||
Title *string `json:"title"`
|
||||
Description *string `json:"description"`
|
||||
Status *string `json:"status"`
|
||||
Priority *string `json:"priority"`
|
||||
AssigneeType *string `json:"assignee_type"`
|
||||
AssigneeID *string `json:"assignee_id"`
|
||||
Position *float64 `json:"position"`
|
||||
DueDate *string `json:"due_date"`
|
||||
ParentIssueID *string `json:"parent_issue_id"`
|
||||
ProjectID *string `json:"project_id"`
|
||||
Title *string `json:"title"`
|
||||
Description *string `json:"description"`
|
||||
Status *string `json:"status"`
|
||||
Priority *string `json:"priority"`
|
||||
AssigneeType *string `json:"assignee_type"`
|
||||
AssigneeID *string `json:"assignee_id"`
|
||||
Position *float64 `json:"position"`
|
||||
DueDate *string `json:"due_date"`
|
||||
ParentIssueID *string `json:"parent_issue_id"`
|
||||
ProjectID *string `json:"project_id"`
|
||||
}
|
||||
|
||||
func (h *Handler) UpdateIssue(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -1,134 +0,0 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/multica-ai/multica/server/internal/service"
|
||||
)
|
||||
|
||||
func setHandlerTestRuntimeCLIVersion(t *testing.T, version string) {
|
||||
t.Helper()
|
||||
if _, err := testPool.Exec(context.Background(), `
|
||||
UPDATE agent_runtime
|
||||
SET metadata = jsonb_set(COALESCE(metadata, '{}'::jsonb), '{cli_version}', to_jsonb($2::text), true)
|
||||
WHERE id = $1
|
||||
`, handlerTestRuntimeID(t), version); err != nil {
|
||||
t.Fatalf("set runtime cli_version: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuickCreateIssue_StoresExplicitFields(t *testing.T) {
|
||||
if testHandler == nil {
|
||||
t.Skip("database not available")
|
||||
}
|
||||
ctx := context.Background()
|
||||
setHandlerTestRuntimeCLIVersion(t, "0.2.24")
|
||||
agentID := createHandlerTestAgent(t, "Quick Create Test Agent", nil)
|
||||
|
||||
const dueDate = "2025-06-01T00:00:00Z"
|
||||
const projectID = "123e4567-e89b-12d3-a456-426614174000"
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := newRequest(http.MethodPost, "/api/issues/quick-create", map[string]any{
|
||||
"agent_id": agentID,
|
||||
"prompt": "Create a follow-up issue",
|
||||
"priority": " HIGH ",
|
||||
"due_date": " " + dueDate + " ",
|
||||
"project_id": " " + projectID + " ",
|
||||
})
|
||||
testHandler.QuickCreateIssue(w, req)
|
||||
if w.Code != http.StatusAccepted {
|
||||
t.Fatalf("QuickCreateIssue: expected 202, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var resp QuickCreateIssueResponse
|
||||
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
|
||||
var contextJSON []byte
|
||||
if err := testPool.QueryRow(ctx, `SELECT context FROM agent_task_queue WHERE id = $1`, resp.TaskID).Scan(&contextJSON); err != nil {
|
||||
t.Fatalf("load queued task context: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
testPool.Exec(context.Background(), `DELETE FROM agent_task_queue WHERE id = $1`, resp.TaskID)
|
||||
})
|
||||
|
||||
var qc service.QuickCreateContext
|
||||
if err := json.Unmarshal(contextJSON, &qc); err != nil {
|
||||
t.Fatalf("unmarshal quick-create context: %v", err)
|
||||
}
|
||||
if qc.Type != service.QuickCreateContextType {
|
||||
t.Fatalf("context type = %q, want %q", qc.Type, service.QuickCreateContextType)
|
||||
}
|
||||
if qc.Priority == nil || *qc.Priority != "high" {
|
||||
t.Fatalf("context priority = %v, want high", qc.Priority)
|
||||
}
|
||||
if qc.DueDate == nil || *qc.DueDate != dueDate {
|
||||
t.Fatalf("context due_date = %v, want %s", qc.DueDate, dueDate)
|
||||
}
|
||||
if qc.ProjectID == nil || *qc.ProjectID != projectID {
|
||||
t.Fatalf("context project_id = %v, want %s", qc.ProjectID, projectID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuickCreateIssue_RejectsInvalidOptionalFields(t *testing.T) {
|
||||
if testHandler == nil {
|
||||
t.Skip("database not available")
|
||||
}
|
||||
setHandlerTestRuntimeCLIVersion(t, "0.2.24")
|
||||
agentID := createHandlerTestAgent(t, "Quick Create Validation Agent", nil)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
body map[string]any
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "invalid priority",
|
||||
body: map[string]any{
|
||||
"agent_id": agentID,
|
||||
"prompt": "Create something",
|
||||
"priority": "none",
|
||||
},
|
||||
want: "priority must be one of: urgent, high, medium, low",
|
||||
},
|
||||
{
|
||||
name: "invalid due date",
|
||||
body: map[string]any{
|
||||
"agent_id": agentID,
|
||||
"prompt": "Create something",
|
||||
"due_date": "tomorrow",
|
||||
},
|
||||
want: "invalid due_date format, expected RFC3339",
|
||||
},
|
||||
{
|
||||
name: "invalid project id",
|
||||
body: map[string]any{
|
||||
"agent_id": agentID,
|
||||
"prompt": "Create something",
|
||||
"project_id": "not-a-uuid",
|
||||
},
|
||||
want: "invalid project_id",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
req := newRequest(http.MethodPost, "/api/issues/quick-create", tc.body)
|
||||
testHandler.QuickCreateIssue(w, req)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("QuickCreateIssue: expected 400, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if body := w.Body.String(); body == "" || !strings.Contains(body, tc.want) {
|
||||
t.Fatalf("response body %q does not contain %q", body, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -208,13 +208,10 @@ func (s *TaskService) EnqueueTaskForMention(ctx context.Context, issue db.Issue,
|
||||
// and switches to the quick-create prompt template; the completion path
|
||||
// uses RequesterID + WorkspaceID to write the inbox notification.
|
||||
type QuickCreateContext struct {
|
||||
Type string `json:"type"`
|
||||
Prompt string `json:"prompt"`
|
||||
RequesterID string `json:"requester_id"`
|
||||
WorkspaceID string `json:"workspace_id"`
|
||||
Priority *string `json:"priority,omitempty"`
|
||||
DueDate *string `json:"due_date,omitempty"`
|
||||
ProjectID *string `json:"project_id,omitempty"`
|
||||
Type string `json:"type"`
|
||||
Prompt string `json:"prompt"`
|
||||
RequesterID string `json:"requester_id"`
|
||||
WorkspaceID string `json:"workspace_id"`
|
||||
}
|
||||
|
||||
// QuickCreateContextType marks a task as a quick-create job.
|
||||
@@ -226,7 +223,7 @@ const QuickCreateContextType = "quick_create"
|
||||
// `multica issue create` call. Pre-validates that the agent is reachable
|
||||
// (not archived, has a runtime) so the API can reject up-front rather than
|
||||
// queue a task no one will ever claim.
|
||||
func (s *TaskService) EnqueueQuickCreateTask(ctx context.Context, workspaceID, requesterID pgtype.UUID, agentID pgtype.UUID, prompt string, opts ...func(*QuickCreateContext)) (db.AgentTaskQueue, error) {
|
||||
func (s *TaskService) EnqueueQuickCreateTask(ctx context.Context, workspaceID, requesterID pgtype.UUID, agentID pgtype.UUID, prompt string) (db.AgentTaskQueue, error) {
|
||||
agent, err := s.Queries.GetAgent(ctx, agentID)
|
||||
if err != nil {
|
||||
return db.AgentTaskQueue{}, fmt.Errorf("load agent: %w", err)
|
||||
@@ -244,9 +241,6 @@ func (s *TaskService) EnqueueQuickCreateTask(ctx context.Context, workspaceID, r
|
||||
RequesterID: util.UUIDToString(requesterID),
|
||||
WorkspaceID: util.UUIDToString(workspaceID),
|
||||
}
|
||||
for _, opt := range opts {
|
||||
opt(&payload)
|
||||
}
|
||||
contextJSON, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return db.AgentTaskQueue{}, fmt.Errorf("marshal quick-create context: %w", err)
|
||||
|
||||
Reference in New Issue
Block a user