mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-30 07:10:49 +02:00
* docs(plans): chat attachment & image support implementation plan Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> Co-authored-by: multica-agent <github@multica.ai> * feat(db): add chat_session_id/chat_message_id to attachment Co-authored-by: multica-agent <github@multica.ai> * feat(db): sqlc — chat_session_id on CreateAttachment + LinkAttachmentsToChatMessage Co-authored-by: multica-agent <github@multica.ai> * feat(file): upload-file accepts chat_session_id form field Co-authored-by: multica-agent <github@multica.ai> * feat(chat): SendChatMessage links uploaded attachments to the new message Co-authored-by: multica-agent <github@multica.ai> * feat(api): uploadFile accepts chatSessionId; sendChatMessage accepts attachmentIds Co-authored-by: multica-agent <github@multica.ai> * feat(core): useFileUpload supports chatSessionId context Co-authored-by: multica-agent <github@multica.ai> * feat(chat): support paste/drag/upload attachments in chat input Co-authored-by: multica-agent <github@multica.ai> * test(e2e): chat input attachment upload + send round-trip Co-authored-by: multica-agent <github@multica.ai> * chore(chat): keep lazy-created session title empty so untitled fallback localizes Co-authored-by: multica-agent <github@multica.ai> * fix(chat): address review — dedupe ensureSession + parse upload response - chat-window: cache in-flight createSession promise in a ref so a file drop followed by a quick send no longer spawns two sessions (and orphans the attachment on the losing one). - Attachment type + EMPTY_ATTACHMENT + AttachmentResponseSchema: include the new chat_session_id / chat_message_id fields the server now returns. - uploadFile: route the response through parseWithFallback so a malformed body returns EMPTY_ATTACHMENT instead of an undefined-keyed Attachment, matching the API boundary rule. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> Co-authored-by: multica-agent <github@multica.ai> * fix(chat): address PR #2445 review — test ctx, send gating, attachment surface 1. Backend test was 400ing because the handler reads workspace from middleware-injected ctx, and `newRequest` only sets the header. Helper `withChatTestWorkspaceCtx` mirrors the agent-access-test pattern and loads the member row + SetMemberContext before invoking the handler. 2. Attachment metadata now flows end-to-end: - new sqlc `ListAttachmentsByChatMessageIDs` (batch lookup, mirrors the comment-side query) - `chatMessageToResponse` takes `attachments` and `ChatMessageResponse` surfaces them — same shape as CommentResponse - `ListChatMessages` loads them via a new `groupChatMessageAttachments` helper so the chat bubble can render file cards - daemon claim path pulls `ListAttachmentsByChatMessage` for the latest user message and ships `ChatMessageAttachments` to the daemon - `buildChatPrompt` lists id+filename+content_type and instructs the agent to `multica attachment download <id>` — fixes the private-CDN expiring-URL problem where the markdown URL would have expired by the time the agent acts - TS `ChatMessage` gains an optional `attachments` field 3. Chat composer now blocks send while uploads are in flight: - `pendingUploads` counter increments in handleUpload, SubmitButton uses it to disable - handleSend also gates on `editorRef.current.hasActiveUploads()` to catch the Mod+Enter path that bypasses the button - new vitest covers the "drop large file → immediate send" scenario where attachment id would otherwise be silently dropped Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> Co-authored-by: multica-agent <github@multica.ai> * chore: drop implementation plan doc Process artefact, not something the repo needs to keep. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com> Co-authored-by: multica-agent <github@multica.ai>
200 lines
14 KiB
Go
200 lines
14 KiB
Go
package daemon
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
|
|
"github.com/multica-ai/multica/server/internal/daemon/execenv"
|
|
)
|
|
|
|
// BuildPrompt constructs the task prompt for an agent CLI.
|
|
// Keep this minimal — detailed instructions live in CLAUDE.md / AGENTS.md
|
|
// injected by execenv.InjectRuntimeConfig. The provider string is used by
|
|
// comment-triggered tasks: Codex's per-turn reply template needs the
|
|
// platform-aware "stdin or file" variant, every other provider gets a
|
|
// lightweight inline template (or Windows file for any provider on
|
|
// Windows).
|
|
func BuildPrompt(task Task, provider string) string {
|
|
if task.ChatSessionID != "" {
|
|
return buildChatPrompt(task)
|
|
}
|
|
if task.TriggerCommentID != "" {
|
|
return buildCommentPrompt(task, provider)
|
|
}
|
|
if task.AutopilotRunID != "" {
|
|
return buildAutopilotPrompt(task)
|
|
}
|
|
if task.QuickCreatePrompt != "" {
|
|
return buildQuickCreatePrompt(task)
|
|
}
|
|
var b strings.Builder
|
|
b.WriteString("You are running as a local coding agent for a Multica workspace.\n\n")
|
|
fmt.Fprintf(&b, "Your assigned issue ID is: %s\n\n", task.IssueID)
|
|
fmt.Fprintf(&b, "Start by running `multica issue get %s --output json` to understand your task, then complete it.\n", task.IssueID)
|
|
fmt.Fprintf(&b, "If you need comment history, `multica issue comment list %s --output json` returns all comments for the issue (server caps at 2000). Pass `--since <RFC3339>` to fetch only comments newer than a known cursor.\n", task.IssueID)
|
|
return b.String()
|
|
}
|
|
|
|
// buildQuickCreatePrompt constructs a prompt for quick-create tasks. The
|
|
// user typed a single natural-language sentence in the create-issue modal;
|
|
// the agent's job is to translate it into one `multica issue create` CLI
|
|
// invocation, using its judgment to decide whether fetching referenced URLs
|
|
// would produce a better issue. No issue exists yet, so the agent must NOT
|
|
// call `multica issue get` or attempt to comment — there's nothing to read
|
|
// or reply to.
|
|
func buildQuickCreatePrompt(task Task) string {
|
|
var b strings.Builder
|
|
b.WriteString("You are running as a quick-create assistant for a Multica workspace.\n\n")
|
|
b.WriteString("A user captured the following input via the quick-create modal. There is NO existing issue. Your job is to create a well-formed issue from this input with a single `multica issue create` command.\n\n")
|
|
fmt.Fprintf(&b, "User input:\n> %s\n\n", task.QuickCreatePrompt)
|
|
|
|
b.WriteString("Field rules:\n\n")
|
|
|
|
// title
|
|
b.WriteString("- **title**: required. A concise but semantically rich summary. If the input references external resources (PRs, issues, URLs), use your judgment on whether fetching the resource would produce a meaningfully better title — e.g. \"review PR #123\" → \"Review PR #123: Refactor auth module to OAuth2\". Strip filler words but preserve key semantic information.\n\n")
|
|
|
|
// description — the core optimization
|
|
b.WriteString("- **description**: The description is the executing agent's primary context. Aim for high fidelity — they should grasp the user's intent as if they had read the raw input themselves. Use a two-section structure:\n\n")
|
|
b.WriteString(" 1. **User request** — Faithfully restate what the user wants in their own words. Preserve specific names, identifiers, file paths, code snippets, and technical terms verbatim. Strip non-spec material before writing it (this is removal, not paraphrasing): verbal routing wrappers about creating the issue (e.g. \"create an issue\", \"分配给 X\") and pure conversational fillers (e.g. \"对吧?\"). When in doubt, keep it.\n\n")
|
|
b.WriteString(" CC exception: `multica issue create` has no `--subscriber` flag, and the platform auto-subscribes members whose `[@Name](mention://member/<uuid>)` link appears in the description. When the user wrote \"cc @Y\", strip the verbal \"cc\" wrapper from the User request body and append a final `CC: <mention link(s)>` line to the description so the cc routing still fires.\n\n")
|
|
b.WriteString(" 2. **Context** — include ONLY when the input cited external resources AND you successfully fetched them AND they produced verifiable facts worth recording. Summarize facts only (e.g. \"PR #45 changes auth to JWT\"), not interpretation or unsolicited reference implementations. If you have nothing factual to add, omit the section entirely — never use it as an apology log for resources you could not fetch.\n\n")
|
|
b.WriteString(" Hard rules: never invent requirements, implementation details, or acceptance criteria the user did not express; never reduce multi-sentence input to a single vague sentence; never echo the title.\n\n")
|
|
|
|
// priority
|
|
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")
|
|
b.WriteString(" - When the user names someone (\"assign to X\" / \"@X\"), call `multica workspace members --output json` (and `multica agent list --output json` if it might be an agent) and find the matching entity by display name. On a clean unambiguous match, prefer `--assignee-id <uuid>` using the `user_id` (member) or `id` (agent) from that JSON — UUID matching is exact and robust to name collisions in workspaces with overlapping names. `--assignee <name>` (fuzzy) is acceptable as a fallback when names are unambiguous. On no match or ambiguous match, do NOT pass either flag — instead append a final line to the description: `Unrecognized assignee: X`.\n")
|
|
agentID := ""
|
|
agentName := ""
|
|
if task.Agent != nil {
|
|
agentID = task.Agent.ID
|
|
agentName = task.Agent.Name
|
|
}
|
|
if agentID != "" {
|
|
fmt.Fprintf(&b, " - When the user did NOT name an assignee, default to YOURSELF: pass `--assignee-id %q` (your agent UUID). The picker agent is the expected owner because the user opened quick-create with you selected — never leave the issue unassigned. Use the UUID flag, not `--assignee <name>`, so the assignment is unambiguous even when other agents share part of your name.\n\n", agentID)
|
|
} else if agentName != "" {
|
|
fmt.Fprintf(&b, " - When the user did NOT name an assignee, default to YOURSELF: pass `--assignee %q`. The picker agent is the expected owner because the user opened quick-create with you selected — never leave the issue unassigned.\n\n", agentName)
|
|
} else {
|
|
b.WriteString(" - When the user did NOT name an assignee, default to YOURSELF (the picker agent): pass `--assignee-id <your agent UUID>` (preferred) or `--assignee <your agent name>`. Never leave the issue unassigned.\n\n")
|
|
}
|
|
|
|
// project — pinned by the modal when the user picked one, otherwise
|
|
// omitted so the platform routes to the workspace default. Always pass
|
|
// the UUID (never a name) so the issue lands in the right project even
|
|
// when several share a title.
|
|
if task.ProjectID != "" {
|
|
if task.ProjectTitle != "" {
|
|
fmt.Fprintf(&b, "- **project**: required for this run. Pass `--project %q` so the new issue lands in project %q (the user picked it in the quick-create modal). Do not infer a different project from the prompt text — the modal selection is authoritative.\n", task.ProjectID, task.ProjectTitle)
|
|
} else {
|
|
fmt.Fprintf(&b, "- **project**: required for this run. Pass `--project %q` so the new issue lands in the project the user picked in the quick-create modal. Do not infer a different project from the prompt text — the modal selection is authoritative.\n", task.ProjectID)
|
|
}
|
|
} else {
|
|
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")
|
|
|
|
// output format
|
|
b.WriteString("Output format:\n")
|
|
b.WriteString("- Run exactly one `multica issue create` invocation. Do not retry for any reason — even on non-zero exit. The issue may already exist; another attempt would create a duplicate.\n")
|
|
b.WriteString("- After success, print exactly one line: `Created MUL-<n>: <title>` and exit. No commentary, no follow-up tool calls.\n")
|
|
b.WriteString("- Do NOT call `multica issue get` or `multica issue comment add` — there is no issue to query or comment on.\n")
|
|
b.WriteString("- On CLI error, exit with the error as the only output. The platform writes a failure notification automatically.\n")
|
|
return b.String()
|
|
}
|
|
|
|
// buildCommentPrompt constructs a prompt for comment-triggered tasks.
|
|
// The triggering comment content is embedded directly so the agent cannot
|
|
// miss it, even when stale output files exist in a reused workdir.
|
|
// The reply instructions (including the current TriggerCommentID as --parent)
|
|
// are re-emitted on every turn so resumed sessions cannot carry forward a
|
|
// previous turn's --parent UUID.
|
|
func buildCommentPrompt(task Task, provider string) string {
|
|
var b strings.Builder
|
|
b.WriteString("You are running as a local coding agent for a Multica workspace.\n\n")
|
|
fmt.Fprintf(&b, "Your assigned issue ID is: %s\n\n", task.IssueID)
|
|
if task.TriggerCommentContent != "" {
|
|
authorLabel := "A user"
|
|
if task.TriggerAuthorType == "agent" {
|
|
name := task.TriggerAuthorName
|
|
if name == "" {
|
|
name = "another agent"
|
|
}
|
|
authorLabel = fmt.Sprintf("Another agent (%s)", name)
|
|
}
|
|
fmt.Fprintf(&b, "[NEW COMMENT] %s just left a new comment. Focus on THIS comment — do not confuse it with previous ones:\n\n", authorLabel)
|
|
fmt.Fprintf(&b, "> %s\n\n", task.TriggerCommentContent)
|
|
if task.TriggerAuthorType == "agent" {
|
|
b.WriteString("⚠️ The triggering comment was posted by another agent. Decide whether a reply is warranted. If you produced actual work this turn (investigated, fixed something, answered a real question), post the result as a normal reply — that is NOT a noise comment, and the standard rule that final results must be delivered via comment still applies. If the triggering comment was a pure acknowledgment, thanks, or sign-off AND you produced no work this turn, do NOT reply — and do NOT post a comment saying 'No reply needed' or similar. Simply exit with no output. Silence is the preferred way to end agent-to-agent threads. If you do reply, do not @mention the other agent as a sign-off (that re-triggers them and starts a loop).\n\n")
|
|
}
|
|
}
|
|
fmt.Fprintf(&b, "Start by running `multica issue get %s --output json` to understand your task, then decide how to proceed.\n\n", task.IssueID)
|
|
fmt.Fprintf(&b, "If you need comment history, `multica issue comment list %s --output json` returns all comments for the issue (server caps at 2000). Pass `--since <RFC3339>` to fetch only comments newer than a known cursor.\n\n", task.IssueID)
|
|
b.WriteString(execenv.BuildCommentReplyInstructions(provider, task.IssueID, task.TriggerCommentID))
|
|
return b.String()
|
|
}
|
|
|
|
// buildChatPrompt constructs a prompt for interactive chat tasks.
|
|
func buildChatPrompt(task Task) string {
|
|
var b strings.Builder
|
|
b.WriteString("You are running as a chat assistant for a Multica workspace.\n")
|
|
b.WriteString("A user is chatting with you directly. Respond to their message.\n\n")
|
|
fmt.Fprintf(&b, "User message:\n%s\n", task.ChatMessage)
|
|
// List attachments by id + filename so the agent can fetch them via
|
|
// the CLI. We deliberately do NOT inline the URL: chat attachments
|
|
// live behind a signed CDN with a short TTL, so by the time the agent
|
|
// has finished thinking the URL embedded in the markdown body may
|
|
// have expired. `multica attachment download <id>` re-signs at click
|
|
// time and is the only reliable path.
|
|
if len(task.ChatMessageAttachments) > 0 {
|
|
b.WriteString("\nAttachments on this message:\n")
|
|
for _, a := range task.ChatMessageAttachments {
|
|
if a.ContentType != "" {
|
|
fmt.Fprintf(&b, "- id=%s filename=%q content_type=%s\n", a.ID, a.Filename, a.ContentType)
|
|
} else {
|
|
fmt.Fprintf(&b, "- id=%s filename=%q\n", a.ID, a.Filename)
|
|
}
|
|
}
|
|
b.WriteString("Use `multica attachment download <id>` to fetch each file locally before referring to it.\n")
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
// buildAutopilotPrompt constructs a prompt for run_only autopilot tasks.
|
|
func buildAutopilotPrompt(task Task) string {
|
|
var b strings.Builder
|
|
b.WriteString("You are running as a local coding agent for a Multica workspace.\n\n")
|
|
b.WriteString("This task was triggered by an Autopilot in run-only mode. There is no assigned Multica issue for this run.\n\n")
|
|
fmt.Fprintf(&b, "Autopilot run ID: %s\n", task.AutopilotRunID)
|
|
if task.AutopilotID != "" {
|
|
fmt.Fprintf(&b, "Autopilot ID: %s\n", task.AutopilotID)
|
|
}
|
|
if task.AutopilotTitle != "" {
|
|
fmt.Fprintf(&b, "Autopilot title: %s\n", task.AutopilotTitle)
|
|
}
|
|
if task.AutopilotSource != "" {
|
|
fmt.Fprintf(&b, "Trigger source: %s\n", task.AutopilotSource)
|
|
}
|
|
if strings.TrimSpace(string(task.AutopilotTriggerPayload)) != "" {
|
|
fmt.Fprintf(&b, "Trigger payload:\n%s\n", strings.TrimSpace(string(task.AutopilotTriggerPayload)))
|
|
}
|
|
b.WriteString("\nAutopilot instructions:\n")
|
|
if strings.TrimSpace(task.AutopilotDescription) != "" {
|
|
b.WriteString(task.AutopilotDescription)
|
|
b.WriteString("\n\n")
|
|
} else if task.AutopilotTitle != "" {
|
|
fmt.Fprintf(&b, "%s\n\n", task.AutopilotTitle)
|
|
} else {
|
|
b.WriteString("No additional autopilot instructions were provided. Inspect the autopilot configuration before proceeding.\n\n")
|
|
}
|
|
if task.AutopilotID != "" {
|
|
fmt.Fprintf(&b, "Start by running `multica autopilot get %s --output json` if you need the full autopilot configuration, then complete the instructions above.\n", task.AutopilotID)
|
|
} else {
|
|
b.WriteString("Complete the instructions above.\n")
|
|
}
|
|
b.WriteString("Do not run `multica issue get`; this run does not have an issue ID.\n")
|
|
return b.String()
|
|
}
|