mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-16 14:49:09 +02:00
* feat(chat): support images/files in agent chat replies (MUL-4287) Agents can now attach images/files to their chat replies, matching how comment attachments already work. The write-side gap was that the assistant chat_message is synthesized server-side from the completion callback's text output and never bound any attachments. Backend: - migration 150: nullable attachment.task_id (+ partial index), the transient handle that ties an agent's in-run upload to the reply it produces. - POST /api/upload-file accepts task_id: gated to the task's own agent, in this workspace, on a chat task; tags the row with task_id + chat_session_id. - CompleteTask (chat branch) binds the task's still-unclaimed attachments to the assistant message via BindChatAttachmentsToMessage (rejects rows already owned by an issue/comment/chat_message). An empty-output reply that produced files still creates a message so the images have an owner. FailTask binds nothing. CLI: - `multica attachment upload <path>` uploads a file for the current chat task (task from MULTICA_TASK_ID or --task) and prints id / markdown_url / a ready-to-paste markdown snippet. Prompt: - web/mobile chat prompt tells the agent how to attach a file to its reply. Mobile: - chat:done handler now always invalidates the messages list so attachments (absent from the event payload) refetch; mirrors web's self-heal. - chat bubbles render standalone attachment cards via the existing CommentAttachmentList (dedup vs inline references), matching web. Web/desktop needed no change — they already render message.attachments inline and via AttachmentList, and self-heal on chat:done. Tests: upload permission/isolation, bind-on-complete, empty-output+attachments, FailTask no-bind, null task_id untouched, already-owned not stolen, CLI output contract, mobile refetch-on-done. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: multica-agent <github@multica.ai> * fix(chat): address review blockers on chat reply attachments (MUL-4287) Two final-review blockers on PR #5164: 1. Mobile inline dedup only checked raw `url`, so an attachment referenced inline via `markdown_url` (exactly what the CLI snippet emits) rendered twice — once inline, once as a standalone card. Reuse the core `contentReferencesAttachment` helper so dedup covers every real reference form (stable /api/attachments/<id>/download path, url, download_url, markdown_url), matching web's AttachmentList. Extracted the filter into a pure `lib/attachment-dedup.ts` so it is unit-testable, and added a regression test covering `content` containing `attachment.markdown_url` (plus the other URL forms and same-identity sibling dedup). 2. CLI `attachment upload` emitted `![...]` image markdown for every file, producing a broken-image snippet for non-images. Emit image markdown only for image/* content types and a plain link otherwise, with a CLI contract test for both. Approved scope otherwise unchanged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: multica-agent <github@multica.ai> * fix(chat): renumber attachment_task_id migration 150 -> 157 after main merge (MUL-4287) Merged latest main; main renumbered its migrations and now occupies 150-156, so 150_attachment_task_id collided with 150_agent_task_coalesced_comments and would fail TestMigrationNumericPrefixesStayUniqueAfterLegacySet. Renamed to the next unique prefix (157). No content change; migrate up applies cleanly. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: multica-agent <github@multica.ai> * fix(chat): render agent-produced files as attachment cards, not raw links The chat upload command handed the agent a bare `[name](url)` markdown snippet. Pasted mid-sentence it renders as a plain text link (not a card), and the referenced URL hides the auto-bound standalone attachment — so a file the agent produced could end up showing as nothing. Return the block-level `!file[name](url)` card syntax instead (images keep `` inline), and markdown-escape the filename so names with `[`/`]` don't truncate the label. The prompt and CLI help now state the file auto-attaches below the reply and the snippet is optional, only for placement. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(chat): soften message-list scroll fade (32px → 16px) The 32px edge fade washed out full-bleed content (HTML / image previews) at the list edges. Halve the fade distance so it barely grazes previews while still hinting at more content above/below. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(chat): renumber attachment_task_id migration 157 -> 158 main landed 157_agent_task_delivered_comments while this branch was open, colliding on prefix 157 and failing TestMigrationNumericPrefixesStayUniqueAfterLegacySet. Bump this PR's migration to the next free prefix (158). Rename only; the migration body (nullable attachment.task_id + partial index) is unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(chat): pin attachment upload to the token's task; build index concurrently Two code-review findings on the chat-attachment path (MUL-4287): - Isolation/privacy: POST /api/upload-file only checked the form task_id belonged to the caller's agent, not that it matched the task-scoped token's authoritative X-Task-ID. A run authorized for task A could tag an attachment onto task B (another chat task of the same agent, possibly another user's session), binding it into that reply on completion. Require the form task_id to equal the server-set X-Task-ID; add a same-agent/other-task 403 regression. - Migration: split the task_id lookup index into its own migration (159) built with CREATE INDEX CONCURRENTLY (repo convention) — it cannot share a multi-command file with the ADD COLUMN in 158. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(chat): enforce task-token source on attachment upload; drop transient task_id FK (MUL-4287) Addresses the two remaining Preflight BLOCKERs on PR #5164. Security (file.go): the task_id upload path compared the form task_id to X-Task-ID but did not require X-Actor-Source=task_token. A normal JWT/mul_ PAT leaves that header empty and the middleware does NOT strip a client-forged X-Task-ID; resolveActor's fallback accepts a valid X-Agent-ID+X-Task-ID pair. So a member who learned a task ID could forge both and inject an attachment onto another chat task's assistant reply (cross-session/privacy leak). Now the branch requires X-Actor-Source=task_token first (mirrors chat_history.go's load-bearing boundary), then pins to the middleware-injected X-Task-ID. Tests now go through the real task-token headers and add a forged-JWT-403 regression. Migration (158): task_id is a transient binding handle (written once at upload against an already-validated task, read only during that task's own completion; durable owner is chat_message_id). There is no app-layer path that hard-deletes agent_task_queue rows, and orphan uploads are already reaped by attachment.chat_session_id's ON DELETE CASCADE — so an FK here would only add a cascade dependency the app never relies on plus write overhead on the hot attachment table. Drop the FK; task_id is now a plain UUID column. Added a regression test that an unbound task-tagged upload is reaped on chat_session delete. Index (159, CONCURRENTLY) unchanged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: multica-agent <github@multica.ai> * fix(mobile): align !file card preprocess with web parser + CLI escaped labels (MUL-4287) Howard final-review blocker: mobile's `!file[...]` preprocess didn't keep up with the CLI's file-card output, so agent-produced non-image files rendered nowhere on mobile. - `FILE_LINE_RE` used `[^\]]+` for the label, so the CLI's escaped-bracket output `!file[a\]b.pdf](url)` (cmd_attachment.go escapeMarkdownLabel) never matched — the line stayed literal AND `standaloneAttachments` still hid the fallback card (the URL is in `content`), so the file showed nowhere. - Align the matcher with web's `packages/ui/markdown/file-cards.ts`: label allows backslash-escaped metacharacters (ReDoS-safe class), and the URL is restricted to the same allowlist (site-relative /uploads + /api/attachments/ <UUID>/download, plus absolute http(s)); disallowed schemes stay plain text. - Unescape the label to the real filename, then re-escape only the chars that would break a markdown LINK label (mobile emits `[📎 name](url)`, re-parsed by the renderer — unlike web's HTML data-filename), so a raw `]` never truncates the link text. No dedup change: once the inline `!file` renders, hiding the standalone card is correct. Added focused unit tests covering the escaped-label case, parens/ backslash unescape, the site-relative URL form, and disallowed-scheme rejection. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: multica-agent <github@multica.ai>
402 lines
16 KiB
Go
402 lines
16 KiB
Go
package handler
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"mime/multipart"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
)
|
|
|
|
// seedRunningChatTask inserts a running chat task (chat_session_id set) for the
|
|
// given agent and returns its id. Mirrors createHandlerTestTaskForAgentOnIssue
|
|
// but binds a chat session instead of an issue.
|
|
func seedRunningChatTask(t *testing.T, agentID, sessionID string) string {
|
|
t.Helper()
|
|
var taskID string
|
|
if err := testPool.QueryRow(context.Background(), `
|
|
INSERT INTO agent_task_queue (agent_id, runtime_id, status, priority, chat_session_id, started_at)
|
|
VALUES ($1, $2, 'running', 0, $3, now())
|
|
RETURNING id
|
|
`, agentID, handlerTestRuntimeID(t), sessionID).Scan(&taskID); err != nil {
|
|
t.Fatalf("seed running chat task: %v", err)
|
|
}
|
|
t.Cleanup(func() {
|
|
testPool.Exec(context.Background(), `DELETE FROM agent_task_queue WHERE id = $1`, taskID)
|
|
})
|
|
return taskID
|
|
}
|
|
|
|
// seedAgentChatAttachment inserts an attachment as if the agent had uploaded it
|
|
// during a chat task: tagged with task_id + chat_session_id, no owner message
|
|
// yet. Returns the attachment id.
|
|
func seedAgentChatAttachment(t *testing.T, agentID, sessionID, taskID string) string {
|
|
t.Helper()
|
|
var id string
|
|
if err := testPool.QueryRow(context.Background(), `
|
|
INSERT INTO attachment (workspace_id, task_id, chat_session_id, uploader_type, uploader_id, filename, url, content_type, size_bytes)
|
|
VALUES ($1, $2, $3, 'agent', $4, 'chart.png', 'https://cdn.example/chart.png', 'image/png', 123)
|
|
RETURNING id
|
|
`, testWorkspaceID, taskID, sessionID, agentID).Scan(&id); err != nil {
|
|
t.Fatalf("seed agent chat attachment: %v", err)
|
|
}
|
|
t.Cleanup(func() {
|
|
testPool.Exec(context.Background(), `DELETE FROM attachment WHERE id = $1`, id)
|
|
})
|
|
return id
|
|
}
|
|
|
|
func attachmentMessageID(t *testing.T, attachmentID string) *string {
|
|
t.Helper()
|
|
var msg *string
|
|
if err := testPool.QueryRow(context.Background(),
|
|
`SELECT chat_message_id::text FROM attachment WHERE id = $1`, attachmentID).Scan(&msg); err != nil {
|
|
t.Fatalf("query attachment chat_message_id: %v", err)
|
|
}
|
|
return msg
|
|
}
|
|
|
|
func assistantMessageForTask(t *testing.T, taskID string) (id, content string, ok bool) {
|
|
t.Helper()
|
|
err := testPool.QueryRow(context.Background(),
|
|
`SELECT id::text, content FROM chat_message WHERE task_id = $1 AND role = 'assistant'`, taskID).Scan(&id, &content)
|
|
if err != nil {
|
|
return "", "", false
|
|
}
|
|
return id, content, true
|
|
}
|
|
|
|
// doUpload performs a multipart upload of a task_id form field with the given
|
|
// request headers, and returns the recorder.
|
|
func doUpload(t *testing.T, formTaskID string, headers map[string]string) *httptest.ResponseRecorder {
|
|
t.Helper()
|
|
var body bytes.Buffer
|
|
writer := multipart.NewWriter(&body)
|
|
part, err := writer.CreateFormFile("file", "reply.png")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
part.Write([]byte("\x89PNG\r\n\x1a\nrest-of-bytes"))
|
|
if err := writer.WriteField("task_id", formTaskID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
writer.Close()
|
|
|
|
req := httptest.NewRequest("POST", "/api/upload-file", &body)
|
|
req.Header.Set("Content-Type", writer.FormDataContentType())
|
|
for k, v := range headers {
|
|
req.Header.Set(k, v)
|
|
}
|
|
w := httptest.NewRecorder()
|
|
testHandler.UploadFile(w, req)
|
|
return w
|
|
}
|
|
|
|
// uploadWithTaskID performs a multipart upload as a genuine task-token agent
|
|
// request: it stamps the server-set X-Actor-Source=task_token + X-Task-ID pair
|
|
// exactly as the auth middleware would for a `mat_` token (the boundary the
|
|
// handler trusts). actorTaskID is what goes on X-Task-ID (the token's task);
|
|
// formTaskID is the upload's task_id form field. They match in the happy path.
|
|
// When agentID is empty the caller is a plain member (no task-token headers).
|
|
func uploadWithTaskID(t *testing.T, agentID, actorTaskID, formTaskID string) *httptest.ResponseRecorder {
|
|
t.Helper()
|
|
headers := map[string]string{
|
|
"X-User-ID": testUserID,
|
|
"X-Workspace-ID": testWorkspaceID,
|
|
}
|
|
if agentID != "" {
|
|
// Mirror the middleware: a task token stamps the actor source and task.
|
|
headers["X-Actor-Source"] = "task_token"
|
|
headers["X-Agent-ID"] = agentID
|
|
}
|
|
if actorTaskID != "" {
|
|
headers["X-Task-ID"] = actorTaskID
|
|
}
|
|
return doUpload(t, formTaskID, headers)
|
|
}
|
|
|
|
// TestUploadFile_TaskScopedChatAttachment covers the write side: an agent
|
|
// uploading a file for its chat reply gets a row tagged with task_id +
|
|
// chat_session_id, and the permission/isolation gates reject the bad cases.
|
|
func TestUploadFile_TaskScopedChatAttachment(t *testing.T) {
|
|
if testPool == nil {
|
|
t.Skip("test database not available")
|
|
}
|
|
origStorage := testHandler.Storage
|
|
testHandler.Storage = &mockStorage{}
|
|
defer func() { testHandler.Storage = origStorage }()
|
|
|
|
agentID := createHandlerTestAgent(t, "ChatReplyAgent", []byte("[]"))
|
|
sessionID := createHandlerTestChatSession(t, agentID)
|
|
taskID := seedRunningChatTask(t, agentID, sessionID)
|
|
|
|
t.Run("agent uploads for own chat task", func(t *testing.T) {
|
|
w := uploadWithTaskID(t, agentID, taskID, taskID)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
var resp AttachmentResponse
|
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
|
t.Fatalf("decode: %v; body: %s", err, w.Body.String())
|
|
}
|
|
t.Cleanup(func() {
|
|
testPool.Exec(context.Background(), `DELETE FROM attachment WHERE id = $1`, resp.ID)
|
|
})
|
|
if resp.ChatSessionID == nil || *resp.ChatSessionID != sessionID {
|
|
t.Fatalf("chat_session_id: want %s, got %v", sessionID, resp.ChatSessionID)
|
|
}
|
|
if resp.ChatMessageID != nil {
|
|
t.Fatalf("chat_message_id must be NULL before completion, got %v", resp.ChatMessageID)
|
|
}
|
|
if resp.UploaderType != "agent" {
|
|
t.Fatalf("uploader_type: want agent, got %s", resp.UploaderType)
|
|
}
|
|
// task_id is set on the row (not exposed in the DTO — verify via DB).
|
|
var dbTask *string
|
|
if err := testPool.QueryRow(context.Background(),
|
|
`SELECT task_id::text FROM attachment WHERE id = $1`, resp.ID).Scan(&dbTask); err != nil {
|
|
t.Fatalf("query task_id: %v", err)
|
|
}
|
|
if dbTask == nil || *dbTask != taskID {
|
|
t.Fatalf("task_id: want %s, got %v", taskID, dbTask)
|
|
}
|
|
})
|
|
|
|
t.Run("member actor rejected", func(t *testing.T) {
|
|
// A plain member request (no task-token headers) is rejected by the
|
|
// task-token boundary before any task lookup.
|
|
w := uploadWithTaskID(t, "", "", taskID)
|
|
if w.Code != http.StatusForbidden {
|
|
t.Fatalf("member upload: expected 403, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
})
|
|
|
|
t.Run("forged agent headers without task token rejected", func(t *testing.T) {
|
|
// The real forgery vector: a normal JWT / mul_ PAT request that the auth
|
|
// middleware did NOT stamp with X-Actor-Source=task_token, but which
|
|
// forges a valid X-Agent-ID + X-Task-ID pair (resolveActor's fallback
|
|
// would otherwise accept it). Even with the form task_id equal to the
|
|
// forged X-Task-ID, the missing task-token source must make it 403 —
|
|
// otherwise a member who learns a task ID could inject an attachment
|
|
// into that task's chat reply.
|
|
w := doUpload(t, taskID, map[string]string{
|
|
"X-User-ID": testUserID,
|
|
"X-Workspace-ID": testWorkspaceID,
|
|
"X-Agent-ID": agentID,
|
|
"X-Task-ID": taskID,
|
|
// deliberately NO X-Actor-Source
|
|
})
|
|
if w.Code != http.StatusForbidden {
|
|
t.Fatalf("forged non-task-token upload: expected 403, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
})
|
|
|
|
t.Run("different agent's task rejected", func(t *testing.T) {
|
|
otherAgent := createHandlerTestAgent(t, "OtherReplyAgent", []byte("[]"))
|
|
otherTask := createHandlerTestTaskForAgent(t, otherAgent)
|
|
// Actor is agentID (valid X-Agent-ID/X-Task-ID pair), but the upload
|
|
// targets a task owned by a different agent.
|
|
w := uploadWithTaskID(t, agentID, taskID, otherTask)
|
|
if w.Code != http.StatusForbidden {
|
|
t.Fatalf("foreign task upload: expected 403, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
})
|
|
|
|
t.Run("same agent's other chat task rejected", func(t *testing.T) {
|
|
// X-Task-ID is this run's own task, but the form targets a DIFFERENT
|
|
// chat task of the SAME agent (another session, possibly another user).
|
|
// Without pinning the form task_id to the token's bound X-Task-ID this
|
|
// is a cross-session attachment-injection vector.
|
|
otherSession := createHandlerTestChatSession(t, agentID)
|
|
otherChatTask := seedRunningChatTask(t, agentID, otherSession)
|
|
w := uploadWithTaskID(t, agentID, taskID, otherChatTask)
|
|
if w.Code != http.StatusForbidden {
|
|
t.Fatalf("cross-task upload: expected 403, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
})
|
|
|
|
t.Run("non-chat task rejected", func(t *testing.T) {
|
|
issueTask := createHandlerTestTaskForAgent(t, agentID) // no chat_session_id
|
|
w := uploadWithTaskID(t, agentID, issueTask, issueTask)
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Fatalf("non-chat task upload: expected 400, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
})
|
|
|
|
t.Run("malformed task_id rejected", func(t *testing.T) {
|
|
w := uploadWithTaskID(t, agentID, taskID, "not-a-uuid")
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Fatalf("bad task_id: expected 400, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestChatAttachment_UnboundOrphanReapedOnSessionDelete locks the cleanup
|
|
// guarantee that lets us keep task_id as a plain (FK-less) transient column: an
|
|
// unbound task-tagged upload (task_id set, chat_message_id NULL — e.g. the turn
|
|
// failed before binding) is reaped when its chat_session is deleted, via
|
|
// attachment.chat_session_id's ON DELETE CASCADE. Cleanup does not depend on the
|
|
// task relationship, so no attachment.task_id foreign key / cascade is needed.
|
|
func TestChatAttachment_UnboundOrphanReapedOnSessionDelete(t *testing.T) {
|
|
if testPool == nil {
|
|
t.Skip("test database not available")
|
|
}
|
|
agentID := createHandlerTestAgent(t, "OrphanCleanupAgent", []byte("[]"))
|
|
sessionID := createHandlerTestChatSession(t, agentID)
|
|
taskID := seedRunningChatTask(t, agentID, sessionID)
|
|
attID := seedAgentChatAttachment(t, agentID, sessionID, taskID)
|
|
|
|
if _, err := testPool.Exec(context.Background(),
|
|
`DELETE FROM chat_session WHERE id = $1`, sessionID); err != nil {
|
|
t.Fatalf("delete chat session: %v", err)
|
|
}
|
|
|
|
var exists bool
|
|
if err := testPool.QueryRow(context.Background(),
|
|
`SELECT EXISTS(SELECT 1 FROM attachment WHERE id = $1)`, attID).Scan(&exists); err != nil {
|
|
t.Fatalf("check attachment: %v", err)
|
|
}
|
|
if exists {
|
|
t.Fatal("unbound task-tagged attachment must be reaped when its chat_session is deleted")
|
|
}
|
|
}
|
|
|
|
// TestCompleteTask_BindsChatAttachments covers the read/bind side: on chat task
|
|
// completion the agent's task-scoped attachments bind to the assistant reply.
|
|
func TestCompleteTask_BindsChatAttachments(t *testing.T) {
|
|
if testPool == nil {
|
|
t.Skip("test database not available")
|
|
}
|
|
agentID := createHandlerTestAgent(t, "BindReplyAgent", []byte("[]"))
|
|
sessionID := createHandlerTestChatSession(t, agentID)
|
|
|
|
t.Run("output + attachment binds to reply", func(t *testing.T) {
|
|
taskID := seedRunningChatTask(t, agentID, sessionID)
|
|
attID := seedAgentChatAttachment(t, agentID, sessionID, taskID)
|
|
|
|
if _, err := testHandler.TaskService.CompleteTask(context.Background(),
|
|
parseUUID(taskID), []byte(`{"output":"here is the chart"}`), "", ""); err != nil {
|
|
t.Fatalf("CompleteTask: %v", err)
|
|
}
|
|
msgID, content, ok := assistantMessageForTask(t, taskID)
|
|
if !ok {
|
|
t.Fatal("expected an assistant message to be created")
|
|
}
|
|
if content != "here is the chart" {
|
|
t.Fatalf("content: want %q, got %q", "here is the chart", content)
|
|
}
|
|
got := attachmentMessageID(t, attID)
|
|
if got == nil || *got != msgID {
|
|
t.Fatalf("attachment not bound: want message %s, got %v", msgID, got)
|
|
}
|
|
})
|
|
|
|
t.Run("empty output + attachment still creates message and binds", func(t *testing.T) {
|
|
taskID := seedRunningChatTask(t, agentID, sessionID)
|
|
attID := seedAgentChatAttachment(t, agentID, sessionID, taskID)
|
|
|
|
if _, err := testHandler.TaskService.CompleteTask(context.Background(),
|
|
parseUUID(taskID), []byte(`{"output":""}`), "", ""); err != nil {
|
|
t.Fatalf("CompleteTask: %v", err)
|
|
}
|
|
msgID, content, ok := assistantMessageForTask(t, taskID)
|
|
if !ok {
|
|
t.Fatal("image-only reply must still create an assistant message")
|
|
}
|
|
if content != "" {
|
|
t.Fatalf("content should be empty for image-only reply, got %q", content)
|
|
}
|
|
got := attachmentMessageID(t, attID)
|
|
if got == nil || *got != msgID {
|
|
t.Fatalf("attachment not bound: want message %s, got %v", msgID, got)
|
|
}
|
|
})
|
|
|
|
t.Run("empty output + no attachment creates no message", func(t *testing.T) {
|
|
taskID := seedRunningChatTask(t, agentID, sessionID)
|
|
if _, err := testHandler.TaskService.CompleteTask(context.Background(),
|
|
parseUUID(taskID), []byte(`{"output":""}`), "", ""); err != nil {
|
|
t.Fatalf("CompleteTask: %v", err)
|
|
}
|
|
if _, _, ok := assistantMessageForTask(t, taskID); ok {
|
|
t.Fatal("no output and no attachments must not create an assistant message")
|
|
}
|
|
})
|
|
|
|
t.Run("null task_id attachment in same session is not bound", func(t *testing.T) {
|
|
taskID := seedRunningChatTask(t, agentID, sessionID)
|
|
// A loose session attachment with NO task_id (e.g. legacy row) must be
|
|
// left alone — binding is scoped to the producing task.
|
|
var looseID string
|
|
if err := testPool.QueryRow(context.Background(), `
|
|
INSERT INTO attachment (workspace_id, chat_session_id, uploader_type, uploader_id, filename, url, content_type, size_bytes)
|
|
VALUES ($1, $2, 'agent', $3, 'loose.png', 'https://cdn.example/loose.png', 'image/png', 10)
|
|
RETURNING id
|
|
`, testWorkspaceID, sessionID, agentID).Scan(&looseID); err != nil {
|
|
t.Fatalf("seed loose attachment: %v", err)
|
|
}
|
|
t.Cleanup(func() { testPool.Exec(context.Background(), `DELETE FROM attachment WHERE id = $1`, looseID) })
|
|
|
|
if _, err := testHandler.TaskService.CompleteTask(context.Background(),
|
|
parseUUID(taskID), []byte(`{"output":"done"}`), "", ""); err != nil {
|
|
t.Fatalf("CompleteTask: %v", err)
|
|
}
|
|
if got := attachmentMessageID(t, looseID); got != nil {
|
|
t.Fatalf("null-task_id attachment must not be bound, got %v", got)
|
|
}
|
|
})
|
|
|
|
t.Run("already-owned attachment is not stolen", func(t *testing.T) {
|
|
// The bind's WHERE guards are symmetric: comment_id IS NULL AND
|
|
// issue_id IS NULL AND chat_message_id IS NULL. We exercise the guard
|
|
// via an already-bound (chat_message_id set) row with this task's
|
|
// task_id — proving an attachment already claimed by another owner is
|
|
// never re-pointed at the new reply.
|
|
taskID := seedRunningChatTask(t, agentID, sessionID)
|
|
var claimedID string
|
|
if err := testPool.QueryRow(context.Background(), `
|
|
INSERT INTO attachment (workspace_id, task_id, chat_session_id, uploader_type, uploader_id, filename, url, content_type, size_bytes)
|
|
VALUES ($1, $2, $3, 'agent', $4, 'claimed.png', 'https://cdn.example/claimed.png', 'image/png', 10)
|
|
RETURNING id
|
|
`, testWorkspaceID, taskID, sessionID, agentID).Scan(&claimedID); err != nil {
|
|
t.Fatalf("seed attachment: %v", err)
|
|
}
|
|
t.Cleanup(func() { testPool.Exec(context.Background(), `DELETE FROM attachment WHERE id = $1`, claimedID) })
|
|
var priorMsgID string
|
|
if err := testPool.QueryRow(context.Background(), `
|
|
INSERT INTO chat_message (chat_session_id, role, content)
|
|
VALUES ($1, 'assistant', 'prior') RETURNING id
|
|
`, sessionID).Scan(&priorMsgID); err != nil {
|
|
t.Fatalf("seed prior message: %v", err)
|
|
}
|
|
t.Cleanup(func() { testPool.Exec(context.Background(), `DELETE FROM chat_message WHERE id = $1`, priorMsgID) })
|
|
if _, err := testPool.Exec(context.Background(),
|
|
`UPDATE attachment SET chat_message_id = $1 WHERE id = $2`, priorMsgID, claimedID); err != nil {
|
|
t.Fatalf("pre-bind attachment: %v", err)
|
|
}
|
|
|
|
if _, err := testHandler.TaskService.CompleteTask(context.Background(),
|
|
parseUUID(taskID), []byte(`{"output":"done"}`), "", ""); err != nil {
|
|
t.Fatalf("CompleteTask: %v", err)
|
|
}
|
|
got := attachmentMessageID(t, claimedID)
|
|
if got == nil || *got != priorMsgID {
|
|
t.Fatalf("already-bound attachment must keep its owner %s, got %v", priorMsgID, got)
|
|
}
|
|
})
|
|
|
|
t.Run("FailTask does not bind attachments", func(t *testing.T) {
|
|
taskID := seedRunningChatTask(t, agentID, sessionID)
|
|
attID := seedAgentChatAttachment(t, agentID, sessionID, taskID)
|
|
if _, err := testHandler.TaskService.FailTask(context.Background(),
|
|
parseUUID(taskID), "agent crashed", "", "", ""); err != nil {
|
|
t.Fatalf("FailTask: %v", err)
|
|
}
|
|
if got := attachmentMessageID(t, attID); got != nil {
|
|
t.Fatalf("FailTask must not bind attachments, got %v", got)
|
|
}
|
|
})
|
|
}
|