mirror of
https://github.com/multica-ai/multica.git
synced 2026-08-05 09:30:05 +02:00
* fix(daemon): gate codex session pointer writes on rollout presence (MUL-5305) Codex issue follow-ups on local_directory projects intermittently lost their session: the server sent a prior session whose rollout was not in the task CODEX_HOME, so the daemon dropped the resume and started a fresh thread (gateCodexResumeToRolloutPresence), losing the conversation. Root of the bad pointer: the daemon persists a Codex session id as the resumable pointer at two points -- the mid-flight pin and the terminal report -- before the rollout is guaranteed on disk. A task that exits early (crash / runtime offline / timeout) leaves a pinned/reported session id with no rollout; GetLastTaskSession (which accepts failed rows) then hands it to the next follow-up, which drops it. Enforce the invariant at write time: only record a Codex session as the resumable pointer once its rollout is present in the per-issue store, with a short bounded wait for flush. If it never lands, don't overwrite the last good pointer -- a blanked session_id becomes NULL server-side, so GetLastTaskSession falls back to the most recent session whose rollout is real. Non-Codex providers are unaffected; crash recovery is preserved because a present rollout still pins. - codexSessionResumable: shared write-time presence check (bounded wait) - runTask: gate the terminal session_id before reporting - executeAndDrain: gate the mid-flight pin (thread codexHome through) - tests: helper cases + behavioral pin test Co-authored-by: multica-agent <github@multica.ai> * fix(daemon): address review — don't silently downgrade completed sessions (MUL-5305) Follow-up to review feedback on #5960: - Must-fix 1 (silent downgrade): limit the write-time session withholding to NON-completed terminal states. A missing rollout means no resumable conversation was persisted, so a withheld non-completed attempt loses nothing; a completed session is authoritative and, if its rollout is anomalously absent, is still recorded so the next run's resume gate discloses the loss (PriorSessionResumeUnavailable, MUL-4424) instead of silently falling back to an older session. Extracted resumableTerminalSessionID. - Non-blocking risk: pin the mid-flight resume pointer with a per-status presence check instead of one fixed 2s window, and set sessionPinned only once the rollout is confirmed, so a rollout that lands shortly after the first status is still pinned this run. - Must-fix 2 (regression coverage): pin skipped when rollout absent (no /session call); terminal helper (completed keeps / failed withholds); and a DB-backed GetLastTaskSession test proving the next claim falls back to the older recorded session when the latest was blanked. Co-authored-by: multica-agent <github@multica.ai> * fix(daemon): disclose Codex session continuity gaps end-to-end (MUL-5305) Addresses review feedback on #5960. Must-fix 1 — a completed turn whose rollout is missing is exactly the #5934 case (the reporter waits for each turn to finish), so it can no longer be excluded from withholding. Withhold the session for ANY terminal state, and pair the withhold with a persisted continuity-gap signal so the next claim still discloses the loss even while resuming an older good session: - new agent_task_queue.session_rollout_missing column (migration 224) - daemon sends session_rollout_missing on the terminal report; the handler clears the resume pointer (MarkTaskSessionRolloutMissing, overriding FailAgentTask's COALESCE) and flags the row - claim reads GetLatestTaskRolloutMissing and sets a new prior_session_resume_unavailable response field, which the daemon ORs into the brief's PriorSessionResumeUnavailable disclosure Must-fix 2 — Codex reveals the session id on a single task_started status, so a one-shot presence check missed a rollout that flushed later and lost in-flight crash recovery. Pin via a background waiter bounded by the run's context that pins the moment the rollout lands. Tests: - completed + rollout missing -> next claim withholds the bad session AND flags the continuity gap (cross-layer DB test) - session pinned once its rollout appears after the status (mid-run) - pin skipped while the rollout is absent Co-authored-by: multica-agent <github@multica.ai> * fix(server): make continuity-gap write atomic + disclose on all claim paths (MUL-5305) Addresses review round 3 of #5960. Must-fix 1 — the previous handler-level marker ran AFTER the terminal transaction committed, and FailTask creates + wakes the auto-retry inside that same transaction, so a retry could claim the rollout-missing session before the marker cleared it (and a marker failure was swallowed). Move session_rollout_missing INTO the terminal write: CompleteAgentTask and FailAgentTask now force session_id NULL (overriding Fail's COALESCE that would keep a stale mid-flight pin) and set the flag in the SAME UPDATE, so the withhold + gap flag commit atomically with the retry creation. The flag is threaded through TaskService.CompleteTask/FailTask; the swallowed best-effort MarkTaskSessionRolloutMissing query is removed. Must-fix 2 — the daemon withholds for all Codex tasks, but only the issue non-rerun claim consumed the disclosure. Now every fallback path sets prior_session_resume_unavailable: the manual-rerun branch reads the source task's session_rollout_missing, and the chat branch reads a new GetLatestChatTaskRolloutMissing. Tests (cross-layer DB): - completed + rollout missing via the real CompleteAgentTask terminal write -> session withheld AND gap flagged - failed + rollout missing forces session_id NULL over the COALESCE- preserved mid-flight pin in ONE statement Deploy order: migration + server first, daemon second (new fields are omitempty and ignored by an old peer). Co-authored-by: multica-agent <github@multica.ai> * fix(handler): return 5xx on FailTask error + cover claim-response gap paths (MUL-5305) Addresses review round 4 of #5960. Must-fix 1 — the FailTask handler returned 400 on a service/DB error, but the daemon's terminal callback treats 400 as permanent (postJSONWithRetry / isTransientError bails without retrying). Since the fail transaction is now the sole persistence point for the withheld session + continuity-gap flag + auto-retry, a rolled-back fail must be retried, so return 5xx (an invalid request body still returns 400), mirroring CompleteTask. Regression: client.FailTask retries on a transient 5xx and eventually succeeds. Must-fix 2 — add claim-response-level regressions that drive the two new disclosure branches through buildClaimedTaskResponse: - chat: the latest terminal task on the session withheld -> the next chat claim sets prior_session_resume_unavailable - manual rerun: the source task withheld -> the rerun claim discloses These handler DB tests run under CI's fully-migrated database (the local workspace DB cannot set up the handler fixture). Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: Bohan-J <bohan@devv.ai> 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"}`), "", "", false); 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":""}`), "", "", false); 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":""}`), "", "", false); 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"}`), "", "", false); 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"}`), "", "", false); 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", "", "", "", false); err != nil {
|
|
t.Fatalf("FailTask: %v", err)
|
|
}
|
|
if got := attachmentMessageID(t, attID); got != nil {
|
|
t.Fatalf("FailTask must not bind attachments, got %v", got)
|
|
}
|
|
})
|
|
}
|