feat: show task attachments during workspace preparation (#3594)
This commit is contained in:
@@ -0,0 +1,145 @@
|
||||
package orchestrator
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/kandev/kandev/internal/task/models"
|
||||
v1 "github.com/kandev/kandev/pkg/api/v1"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// @covers AC-TASKS-PROMPT-ATTACHMENTS-001.8
|
||||
// @covers AC-TASKS-PROMPT-ATTACHMENTS-001.9
|
||||
// @covers AC-TASKS-PROMPT-ATTACHMENTS-001.10
|
||||
func TestInitialPromptPreviewPersistsOnlyInPreparedSession(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repo := setupTestRepo(t)
|
||||
taskRepo := newMockTaskRepo()
|
||||
require.NoError(t, repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws1", Name: "Test"}))
|
||||
require.NoError(t, repo.CreateTask(ctx, &models.Task{ID: "task1", WorkspaceID: "ws1", Title: "Task"}))
|
||||
taskRepo.tasks["task1"] = &v1.Task{ID: "task1", WorkspaceID: "ws1", Title: "Task"}
|
||||
svc := createTestServiceWithScheduler(repo, newMockStepGetter(), taskRepo, &mockAgentManager{})
|
||||
eventBus := &mockEventBus{}
|
||||
svc.eventBus = eventBus
|
||||
preview := models.NewInitialPromptPreview("submitted text", []v1.MessageAttachment{
|
||||
{AttachmentID: "image-1", Type: "image", Name: "screen.png", MimeType: "image/png", SizeBytes: 32},
|
||||
})
|
||||
response, err := svc.LaunchSession(ctx, &LaunchSessionRequest{
|
||||
TaskID: "task1", AgentProfileID: "profile1", Intent: IntentPrepare,
|
||||
DeferredStart: true, InitialPromptPreview: preview,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
session, err := repo.GetTaskSession(ctx, response.SessionID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, models.TaskSessionStateCreated, session.State)
|
||||
encoded, err := json.Marshal(session.Metadata[models.SessionMetaKeyInitialPromptPreview])
|
||||
require.NoError(t, err)
|
||||
expected, err := json.Marshal(preview)
|
||||
require.NoError(t, err)
|
||||
require.JSONEq(t, string(expected), string(encoded))
|
||||
published := eventBus.published()
|
||||
require.NotEmpty(t, published)
|
||||
foundPreview := false
|
||||
for _, event := range published {
|
||||
data, ok := event.Event.Data.(map[string]interface{})
|
||||
if !ok || data["new_state"] != string(models.TaskSessionStateCreated) {
|
||||
continue
|
||||
}
|
||||
metadata, ok := data["session_metadata"].(map[string]interface{})
|
||||
require.True(t, ok)
|
||||
require.Equal(t, session.Metadata[models.SessionMetaKeyInitialPromptPreview], metadata[models.SessionMetaKeyInitialPromptPreview])
|
||||
foundPreview = true
|
||||
}
|
||||
require.True(t, foundPreview, "created-session publication must already contain the preview")
|
||||
|
||||
// A later session on the same task must not inherit submission display data.
|
||||
environment, err := repo.GetTaskEnvironment(ctx, session.TaskEnvironmentID)
|
||||
require.NoError(t, err)
|
||||
environment.Status = models.TaskEnvironmentStatusReady
|
||||
environment.MaterializationSessionID = ""
|
||||
require.NoError(t, repo.UpdateTaskEnvironment(ctx, environment))
|
||||
siblingID, err := svc.PrepareTaskSession(ctx, "task1", "profile1", "", "", "", false)
|
||||
require.NoError(t, err)
|
||||
sibling, err := repo.GetTaskSession(ctx, siblingID)
|
||||
require.NoError(t, err)
|
||||
require.NotEqual(t, session.ID, sibling.ID)
|
||||
require.NotContains(t, sibling.Metadata, models.SessionMetaKeyInitialPromptPreview)
|
||||
|
||||
require.NoError(t, repo.SetSessionMetadataKey(ctx, session.ID, "unrelated", "kept"))
|
||||
session.State = models.TaskSessionStateFailed
|
||||
// Fetch the current metadata before persisting the state, as production does.
|
||||
failed, err := repo.GetTaskSession(ctx, session.ID)
|
||||
require.NoError(t, err)
|
||||
failed.State = session.State
|
||||
require.NoError(t, repo.UpdateTaskSession(ctx, failed))
|
||||
reloaded, err := repo.GetTaskSession(ctx, session.ID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, session.Metadata[models.SessionMetaKeyInitialPromptPreview], reloaded.Metadata[models.SessionMetaKeyInitialPromptPreview])
|
||||
require.Equal(t, "kept", reloaded.Metadata["unrelated"])
|
||||
}
|
||||
|
||||
// @covers AC-TASKS-PROMPT-ATTACHMENTS-001.8
|
||||
// @covers AC-TASKS-PROMPT-ATTACHMENTS-001.10
|
||||
func TestInitialPromptPreviewPersistsThroughPassthroughPrepareUpgrade(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repo := setupTestRepo(t)
|
||||
taskRepo := newMockTaskRepo()
|
||||
require.NoError(t, repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws1", Name: "Test"}))
|
||||
require.NoError(t, repo.CreateTask(ctx, &models.Task{ID: "task1", WorkspaceID: "ws1", Title: "Task"}))
|
||||
taskRepo.tasks["task1"] = &v1.Task{ID: "task1", WorkspaceID: "ws1", Title: "Task"}
|
||||
manager := &mockAgentManager{isPassthrough: true}
|
||||
svc := createTestServiceWithScheduler(repo, newMockStepGetter(), taskRepo, manager)
|
||||
|
||||
preview := models.NewInitialPromptPreview("submitted text", []v1.MessageAttachment{
|
||||
{AttachmentID: "image-1", Type: "image", Name: "screen.png", MimeType: "image/png", SizeBytes: 32},
|
||||
})
|
||||
response, err := svc.LaunchSession(ctx, &LaunchSessionRequest{
|
||||
TaskID: "task1", AgentProfileID: "profile1", Intent: IntentPrepare,
|
||||
InitialPromptPreview: preview,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, response.SessionID)
|
||||
session, err := repo.GetTaskSession(ctx, response.SessionID)
|
||||
require.NoError(t, err)
|
||||
encoded, err := json.Marshal(session.Metadata[models.SessionMetaKeyInitialPromptPreview])
|
||||
require.NoError(t, err)
|
||||
expected, err := json.Marshal(preview)
|
||||
require.NoError(t, err)
|
||||
require.JSONEq(t, string(expected), string(encoded))
|
||||
}
|
||||
|
||||
type initialPreviewFailureStore struct {
|
||||
sessionExecutorStore
|
||||
sessionID string
|
||||
}
|
||||
|
||||
func (s *initialPreviewFailureStore) SetSessionMetadataKey(ctx context.Context, sessionID, key string, value interface{}) error {
|
||||
if key == models.SessionMetaKeyInitialPromptPreview {
|
||||
s.sessionID = sessionID
|
||||
return errors.New("preview storage unavailable")
|
||||
}
|
||||
return s.sessionExecutorStore.SetSessionMetadataKey(ctx, sessionID, key, value)
|
||||
}
|
||||
|
||||
func TestInitialPromptPreviewWriteFailureSettlesSession(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repo := setupTestRepo(t)
|
||||
taskRepo := newMockTaskRepo()
|
||||
require.NoError(t, repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws1", Name: "Test"}))
|
||||
require.NoError(t, repo.CreateTask(ctx, &models.Task{ID: "task1", WorkspaceID: "ws1", Title: "Task"}))
|
||||
taskRepo.tasks["task1"] = &v1.Task{ID: "task1", WorkspaceID: "ws1", Title: "Task"}
|
||||
svc := createTestServiceWithScheduler(repo, newMockStepGetter(), taskRepo, &mockAgentManager{})
|
||||
failing := &initialPreviewFailureStore{sessionExecutorStore: svc.repo}
|
||||
svc.repo = failing
|
||||
_, err := svc.LaunchSession(ctx, &LaunchSessionRequest{
|
||||
TaskID: "task1", AgentProfileID: "profile1", Intent: IntentPrepare,
|
||||
DeferredStart: true, InitialPromptPreview: models.NewInitialPromptPreview("submitted text", nil),
|
||||
})
|
||||
require.ErrorContains(t, err, "preview storage unavailable")
|
||||
session, err := repo.GetTaskSession(ctx, failing.sessionID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, models.TaskSessionStateFailed, session.State)
|
||||
}
|
||||
@@ -70,8 +70,10 @@ type LaunchSessionRequest struct {
|
||||
// server-side coordination flag set by the deferred-start handlers, so it is
|
||||
// kept off the wire protocol (`json:"-"`) — a client must not be able to
|
||||
// suppress the upgrade and strand a passthrough session without a PTY.
|
||||
DeferredStart bool `json:"-"`
|
||||
Attachments []v1.MessageAttachment `json:"attachments,omitempty"`
|
||||
DeferredStart bool `json:"-"`
|
||||
// InitialPromptPreview is supplied only by task creation after attachment claim.
|
||||
InitialPromptPreview *models.InitialPromptPreview `json:"-"`
|
||||
Attachments []v1.MessageAttachment `json:"attachments,omitempty"`
|
||||
// SpawnOrigin identifies the agent session that requested this launch via
|
||||
// spawn_session_kandev, so the new session's first turn can carry spawner
|
||||
// attribution and reply instructions. Like DeferredStart it is kept off the
|
||||
@@ -236,11 +238,12 @@ func (s *Service) claimLaunchAttachments(ctx context.Context, req *LaunchSession
|
||||
// the prompt — eagerly launching here would spawn a promptless PTY and the
|
||||
// later start would be rejected against the now-running session.
|
||||
func (s *Service) launchPrepare(ctx context.Context, req *LaunchSessionRequest) (*LaunchSessionResponse, error) {
|
||||
prepareCtx := withInitialPromptPreview(ctx, req.InitialPromptPreview)
|
||||
if s.shouldUpgradePassthroughPrepare(ctx, req) {
|
||||
return s.launchStart(ctx, req)
|
||||
return s.launchStart(prepareCtx, req)
|
||||
}
|
||||
sessionID, err := s.PrepareTaskSession(
|
||||
ctx, req.TaskID, req.AgentProfileID, req.ExecutorID,
|
||||
prepareCtx, req.TaskID, req.AgentProfileID, req.ExecutorID,
|
||||
req.ExecutorProfileID, req.WorkflowStepID, req.LaunchWorkspace,
|
||||
)
|
||||
if err != nil {
|
||||
|
||||
@@ -448,6 +448,26 @@ func (s *Service) PrepareTaskSession(ctx context.Context, taskID string, agentPr
|
||||
return sessionID, nil
|
||||
}
|
||||
|
||||
type initialPromptPreviewContextKey struct{}
|
||||
|
||||
func withInitialPromptPreview(ctx context.Context, preview *models.InitialPromptPreview) context.Context {
|
||||
if preview == nil {
|
||||
return ctx
|
||||
}
|
||||
return context.WithValue(ctx, initialPromptPreviewContextKey{}, preview)
|
||||
}
|
||||
|
||||
func (s *Service) persistInitialPromptPreviewFromContext(ctx context.Context, taskID, sessionID string) error {
|
||||
preview, ok := ctx.Value(initialPromptPreviewContextKey{}).(*models.InitialPromptPreview)
|
||||
if !ok || preview == nil {
|
||||
return nil
|
||||
}
|
||||
if err := s.repo.SetSessionMetadataKey(ctx, sessionID, models.SessionMetaKeyInitialPromptPreview, preview); err != nil {
|
||||
return s.handleSessionLaunchFailure(ctx, taskID, sessionID, fmt.Errorf("persist initial prompt preview: %w", err))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func isInheritParentWorkspace(task *v1.Task) bool {
|
||||
if task == nil {
|
||||
return false
|
||||
@@ -1775,6 +1795,11 @@ func (s *Service) prepareSessionForStartWithWorkflowRoute(
|
||||
}
|
||||
return "", false, err
|
||||
}
|
||||
if created {
|
||||
if err := s.persistInitialPromptPreviewFromContext(ctx, task.ID, sessionID); err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
}
|
||||
return sessionID, created, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -1442,13 +1442,14 @@ func (h *TaskHandlers) prepareTaskSession(
|
||||
// upgraded to a full launch here so the terminal has a PTY to attach to.
|
||||
// (Contrast the start_agent branch below, which sets DeferredStart=true.)
|
||||
resp, err := h.orchestrator.LaunchSession(c.Request.Context(), &orchestrator.LaunchSessionRequest{
|
||||
TaskID: taskID,
|
||||
Intent: orchestrator.IntentPrepare,
|
||||
AgentProfileID: body.AgentProfileID,
|
||||
ExecutorID: body.ExecutorID,
|
||||
ExecutorProfileID: body.ExecutorProfileID,
|
||||
WorkflowStepID: resolvedStepID,
|
||||
LaunchWorkspace: true,
|
||||
InitialPromptPreview: models.NewInitialPromptPreview(strings.TrimSpace(body.Description), body.Attachments),
|
||||
TaskID: taskID,
|
||||
Intent: orchestrator.IntentPrepare,
|
||||
AgentProfileID: body.AgentProfileID,
|
||||
ExecutorID: body.ExecutorID,
|
||||
ExecutorProfileID: body.ExecutorProfileID,
|
||||
WorkflowStepID: resolvedStepID,
|
||||
LaunchWorkspace: true,
|
||||
})
|
||||
if err != nil {
|
||||
h.logger.Error("failed to prepare session for task", zap.Error(err), zap.String("task_id", taskID))
|
||||
@@ -1476,12 +1477,13 @@ func (h *TaskHandlers) prepareStartAgentSession(
|
||||
resolvedStepID string,
|
||||
) *startAgentDispatch {
|
||||
prepResp, err := h.orchestrator.LaunchSession(ctx, &orchestrator.LaunchSessionRequest{
|
||||
TaskID: taskID,
|
||||
Intent: orchestrator.IntentPrepare,
|
||||
AgentProfileID: body.AgentProfileID,
|
||||
ExecutorID: body.ExecutorID,
|
||||
ExecutorProfileID: body.ExecutorProfileID,
|
||||
WorkflowStepID: resolvedStepID,
|
||||
InitialPromptPreview: models.NewInitialPromptPreview(strings.TrimSpace(body.Description), body.Attachments),
|
||||
TaskID: taskID,
|
||||
Intent: orchestrator.IntentPrepare,
|
||||
AgentProfileID: body.AgentProfileID,
|
||||
ExecutorID: body.ExecutorID,
|
||||
ExecutorProfileID: body.ExecutorProfileID,
|
||||
WorkflowStepID: resolvedStepID,
|
||||
// The async IntentStartCreated dispatch below carries the prompt. Mark
|
||||
// this as a deferred start so a passthrough profile is not eagerly
|
||||
// launched here with an empty prompt (which would pre-empt that
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
v1 "github.com/kandev/kandev/pkg/api/v1"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// @covers AC-TASKS-PROMPT-ATTACHMENTS-001.8
|
||||
func TestTaskCreateInitialPreviewReachesPreparation(t *testing.T) {
|
||||
orch := &captureOrchestrator{prepErr: errors.New("stop before async dispatch")}
|
||||
h := &TaskHandlers{orchestrator: orch, logger: newTestLogger(t)}
|
||||
body := httpCreateTaskRequest{
|
||||
StartAgent: true, AgentProfileID: "profile-1", Description: "submitted text",
|
||||
Attachments: []v1.MessageAttachment{{AttachmentID: "image-1", Type: "image", MimeType: "image/png", Name: "screen.png"}},
|
||||
}
|
||||
h.prepareStartAgentSession(context.Background(), &createTaskResponse{}, "task-1", body, "step-1")
|
||||
require.Len(t, orch.requests, 1)
|
||||
preview := orch.requests[0].InitialPromptPreview
|
||||
require.NotNil(t, preview)
|
||||
require.Equal(t, body.Description, preview.Content)
|
||||
require.Equal(t, body.Attachments, preview.Attachments)
|
||||
require.Empty(t, orch.requests[0].Prompt, "display text must not dispatch an agent prompt")
|
||||
require.Empty(t, orch.requests[0].Attachments)
|
||||
}
|
||||
|
||||
func TestTaskCreateInitialPreviewPrepareOnly(t *testing.T) {
|
||||
orch := &captureOrchestrator{prepErr: errors.New("stop before workspace launch")}
|
||||
h := &TaskHandlers{orchestrator: orch, logger: newTestLogger(t)}
|
||||
c, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
c.Request = httptest.NewRequest("POST", "/tasks", nil)
|
||||
body := httpCreateTaskRequest{
|
||||
PrepareSession: true, AgentProfileID: "profile-1", Description: "submitted text",
|
||||
Attachments: []v1.MessageAttachment{{AttachmentID: "file-1", Type: "resource", Name: "notes.txt", MimeType: "text/plain"}},
|
||||
}
|
||||
h.prepareTaskSession(c, &createTaskResponse{}, "task-1", body, "step-1", true)
|
||||
require.Len(t, orch.requests, 1)
|
||||
require.Equal(t, body.Attachments, orch.requests[0].InitialPromptPreview.Attachments)
|
||||
require.True(t, orch.requests[0].LaunchWorkspace)
|
||||
require.False(t, orch.requests[0].DeferredStart)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package models
|
||||
|
||||
import v1 "github.com/kandev/kandev/pkg/api/v1"
|
||||
|
||||
const SessionMetaKeyInitialPromptPreview = "initial_prompt_preview"
|
||||
|
||||
// InitialPromptPreview is display-only submission data, never an agent prompt.
|
||||
type InitialPromptPreview struct {
|
||||
Content string `json:"content"`
|
||||
Attachments []v1.MessageAttachment `json:"attachments"`
|
||||
}
|
||||
|
||||
// NewInitialPromptPreview copies file-backed descriptors without inline bytes.
|
||||
// Callers must complete attachment claim admission before preparing the session.
|
||||
func NewInitialPromptPreview(content string, attachments []v1.MessageAttachment) *InitialPromptPreview {
|
||||
preview := &InitialPromptPreview{Content: content, Attachments: []v1.MessageAttachment{}}
|
||||
for _, attachment := range attachments {
|
||||
if attachment.AttachmentID == "" {
|
||||
continue
|
||||
}
|
||||
preview.Attachments = append(preview.Attachments, v1.MessageAttachment{
|
||||
AttachmentID: attachment.AttachmentID, Type: attachment.Type,
|
||||
Name: attachment.Name, MimeType: attachment.MimeType,
|
||||
SizeBytes: attachment.SizeBytes, DeliveryMode: attachment.DeliveryMode,
|
||||
})
|
||||
}
|
||||
return preview
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
v1 "github.com/kandev/kandev/pkg/api/v1"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestInitialPromptPreviewOmitsInlineBytes(t *testing.T) {
|
||||
attachments := []v1.MessageAttachment{
|
||||
{Type: "image", Data: "legacy-bytes"},
|
||||
{AttachmentID: "image-1", Type: "image", Name: "screen.png", MimeType: "image/png", Data: "private-bytes", SizeBytes: 32},
|
||||
}
|
||||
preview := NewInitialPromptPreview("prompt", attachments)
|
||||
require.Len(t, preview.Attachments, 1)
|
||||
require.Empty(t, preview.Attachments[0].Data)
|
||||
encoded, err := json.Marshal(preview)
|
||||
require.NoError(t, err)
|
||||
require.NotContains(t, string(encoded), "legacy-bytes")
|
||||
require.NotContains(t, string(encoded), "private-bytes")
|
||||
attachments[1].Name = "changed"
|
||||
require.Equal(t, "screen.png", preview.Attachments[0].Name)
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { IconWand, IconMessageDots, IconFile, IconFolder } from "@tabler/icons-r
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { Message } from "@/lib/types/http";
|
||||
import { TASK_DESCRIPTION_SYNTHETIC_ID } from "@/hooks/initial-prompt-preview";
|
||||
import { MessageActions } from "@/components/task/chat/messages/message-actions";
|
||||
import { useMessageFavorite } from "@/hooks/domains/session/use-message-favorite";
|
||||
import { useUserMessageNavigation } from "@/hooks/use-message-navigation";
|
||||
@@ -93,7 +94,7 @@ type UserMessageProps = {
|
||||
};
|
||||
|
||||
type UserMessageMetadata = WorkflowMessageMetadata & {
|
||||
attachments?: Array<{ type: string; data: string; mime_type: string; name?: string }>;
|
||||
attachments?: UserMessageAttachment[];
|
||||
plan_mode?: boolean;
|
||||
has_review_comments?: boolean;
|
||||
has_hidden_prompts?: boolean;
|
||||
@@ -335,7 +336,9 @@ function UserMessageContent({
|
||||
isRawView={showRaw}
|
||||
onToggleRaw={onToggleRaw}
|
||||
isFavorite={isFavorite}
|
||||
onToggleFavorite={toggleFavorite}
|
||||
onToggleFavorite={
|
||||
comment.id === TASK_DESCRIPTION_SYNTHETIC_ID ? undefined : toggleFavorite
|
||||
}
|
||||
onNavigatePrev={() => {
|
||||
if (userNavigation.previousId && onScrollToMessage)
|
||||
onScrollToMessage(userNavigation.previousId);
|
||||
|
||||
@@ -464,6 +464,7 @@ function useSessionData(
|
||||
);
|
||||
const lastAgentError = useMemo(() => readLastAgentError(session?.metadata), [session?.metadata]);
|
||||
const processed = useProcessedMessages(messages, taskId, resolvedSessionId, taskDescription, {
|
||||
initialPromptPreview: session?.metadata?.initial_prompt_preview,
|
||||
historyInitialized,
|
||||
hasOlderMessages,
|
||||
lastAgentError,
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { expect, type Page, type TestInfo } from "@playwright/test";
|
||||
import type { BackendContext } from "../fixtures/backend";
|
||||
import type { SeedData } from "../fixtures/test-base";
|
||||
import type { ApiClient } from "./api-client";
|
||||
import { makeGitEnv } from "./git-helper";
|
||||
import { KanbanPage } from "../pages/kanban-page";
|
||||
import { MobileKanbanPage } from "../pages/mobile-kanban-page";
|
||||
|
||||
type Options = {
|
||||
testPage: Page;
|
||||
apiClient: ApiClient;
|
||||
backend: BackendContext;
|
||||
seedData: SeedData;
|
||||
testInfo: TestInfo;
|
||||
mobile?: boolean;
|
||||
failPreparation?: boolean;
|
||||
};
|
||||
|
||||
export async function assertPreparationAttachments(options: Options) {
|
||||
const {
|
||||
testPage: page,
|
||||
apiClient,
|
||||
backend,
|
||||
seedData,
|
||||
testInfo,
|
||||
mobile,
|
||||
failPreparation,
|
||||
} = options;
|
||||
const fixture = fs.mkdtempSync(path.join(backend.tmpDir, "attachment-preview-"));
|
||||
const repoDir = path.join(fixture, "repo");
|
||||
const entered = path.join(fixture, "entered");
|
||||
const release = path.join(fixture, "release");
|
||||
const quote = (value: string) => `'${value.replaceAll("'", "'\\''")}'`;
|
||||
const { settings } = await apiClient.getUserSettings();
|
||||
let taskId: string | undefined;
|
||||
let repositoryId: string | undefined;
|
||||
const cleanupErrors: unknown[] = [];
|
||||
const runCleanup = async (cleanup: () => void | Promise<void>) => {
|
||||
try {
|
||||
await cleanup();
|
||||
} catch (error) {
|
||||
cleanupErrors.push(error);
|
||||
}
|
||||
};
|
||||
try {
|
||||
execFileSync("git", ["clone", "--local", seedData.repositoryPath, repoDir], {
|
||||
env: makeGitEnv(backend.tmpDir),
|
||||
stdio: "ignore",
|
||||
});
|
||||
const repository = await apiClient.createRepository(seedData.workspaceId, repoDir, "main", {
|
||||
name: "Preparation preview repository",
|
||||
pull_before_worktree: false,
|
||||
});
|
||||
repositoryId = repository.id;
|
||||
await apiClient.updateRepository(repository.id, {
|
||||
setup_script: `touch ${quote(entered)}\nwhile [ ! -f ${quote(release)} ]; do sleep 0.1; done\n${failPreparation ? "exit 1" : "true"}`,
|
||||
});
|
||||
await apiClient.saveUserSettings({
|
||||
task_create_last_used: {
|
||||
repository_id: repository.id,
|
||||
branch: "main",
|
||||
agent_profile_id: seedData.agentProfileId,
|
||||
executor_profile_id: seedData.worktreeExecutorProfileId,
|
||||
workflow_ids_by_workspace: { [seedData.workspaceId]: seedData.workflowId },
|
||||
},
|
||||
});
|
||||
if (mobile) {
|
||||
const kanban = new MobileKanbanPage(page);
|
||||
await kanban.goto();
|
||||
await kanban.mobileFab.tap();
|
||||
} else {
|
||||
const kanban = new KanbanPage(page);
|
||||
await kanban.goto();
|
||||
await kanban.createTaskButton.first().click();
|
||||
}
|
||||
const dialog = page.getByTestId("create-task-dialog");
|
||||
await expect(dialog).toBeVisible();
|
||||
await dialog.getByTestId("task-title-input").fill("Preparation attachment previews");
|
||||
await dialog.getByTestId("task-description-input").fill("/e2e:simple-message");
|
||||
await expect(dialog.getByTestId("repo-chip-trigger").first()).toContainText(
|
||||
"Preparation preview repository",
|
||||
);
|
||||
const imageBytes = fs.readFileSync(
|
||||
path.join(process.cwd(), "public/web-app-manifest-192x192.png"),
|
||||
);
|
||||
await dialog.locator('input[type="file"]').setInputFiles([
|
||||
{ name: "screen-one.png", mimeType: "image/png", buffer: imageBytes },
|
||||
{ name: "screen-two.png", mimeType: "image/png", buffer: imageBytes },
|
||||
{ name: "notes.txt", mimeType: "text/plain", buffer: Buffer.from("preview file contents") },
|
||||
]);
|
||||
const submit = dialog.getByTestId("submit-start-agent");
|
||||
await expect(submit).toBeEnabled();
|
||||
const responsePromise = page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().endsWith("/api/v1/tasks") && response.request().method() === "POST",
|
||||
);
|
||||
await submit.click();
|
||||
const response = await responsePromise;
|
||||
expect(response.ok()).toBeTruthy();
|
||||
const created = (await response.json()) as { id: string; session_id: string };
|
||||
taskId = created.id;
|
||||
expect(created.session_id).toBeTruthy();
|
||||
await expect.poll(() => fs.existsSync(entered), { timeout: 60_000 }).toBe(true);
|
||||
await page.goto(`/t/${taskId}`);
|
||||
const bubble = page.getByTestId("user-message-bubble");
|
||||
await expect(bubble).toHaveCount(1);
|
||||
await expect(bubble).toContainText("/e2e:simple-message");
|
||||
await expect(
|
||||
bubble.getByRole("button", { name: "Open Attachment 1", exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
bubble.getByRole("button", { name: "Open Attachment 2", exact: true }),
|
||||
).toBeVisible();
|
||||
for (const index of [1, 2]) {
|
||||
const trigger = bubble.getByRole("button", { name: `Open Attachment ${index}`, exact: true });
|
||||
if (mobile) await trigger.tap();
|
||||
else await trigger.click();
|
||||
await expect(page.getByRole("dialog", { name: "Image preview" })).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
}
|
||||
const file = bubble.getByTestId("message-file-attachment");
|
||||
await expect(file).toContainText("notes.txt");
|
||||
await page.reload();
|
||||
await expect(
|
||||
bubble.getByRole("button", { name: "Open Attachment 2", exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(file).toContainText("notes.txt");
|
||||
const before = await apiClient.listSessionMessages(created.session_id);
|
||||
expect(before.messages.filter((message) => message.author_type === "user")).toHaveLength(0);
|
||||
expect(
|
||||
await page.evaluate(
|
||||
() => document.documentElement.scrollWidth <= document.documentElement.clientWidth,
|
||||
),
|
||||
).toBe(true);
|
||||
await page.screenshot({ path: testInfo.outputPath("preparation-attachments.png") });
|
||||
|
||||
// An unavailable image must leave the text and remaining attachments usable.
|
||||
const firstSource = await bubble
|
||||
.getByRole("button", { name: "Open Attachment 1", exact: true })
|
||||
.locator("img")
|
||||
.getAttribute("src");
|
||||
expect(firstSource).toBeTruthy();
|
||||
await page.route(firstSource!, (route) => route.fulfill({ status: 404, body: "not found" }));
|
||||
await page.reload();
|
||||
await expect(bubble).toContainText("/e2e:simple-message");
|
||||
await expect(file).toBeVisible();
|
||||
await page.unroute(firstSource!);
|
||||
|
||||
fs.writeFileSync(release, "release");
|
||||
if (failPreparation) {
|
||||
const panel = page.getByTestId("prepare-progress-panel");
|
||||
await expect(panel).toHaveAttribute("data-status", "completed_with_warnings", {
|
||||
timeout: 60_000,
|
||||
});
|
||||
await page.reload();
|
||||
await expect(
|
||||
bubble.getByRole("button", { name: "Open Attachment 2", exact: true }),
|
||||
).toBeVisible();
|
||||
} else {
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const { messages } = await apiClient.listSessionMessages(created.session_id);
|
||||
return messages.filter((message) => message.author_type === "user").length;
|
||||
},
|
||||
{ timeout: 60_000 },
|
||||
)
|
||||
.toBe(1);
|
||||
await expect(bubble).toHaveCount(1);
|
||||
await page.reload();
|
||||
await expect(bubble).toHaveCount(1);
|
||||
await expect(
|
||||
bubble.getByRole("button", { name: "Open Attachment 2", exact: true }),
|
||||
).toBeVisible();
|
||||
}
|
||||
} finally {
|
||||
await runCleanup(() => fs.writeFileSync(release, "release"));
|
||||
await runCleanup(async () => {
|
||||
if (!taskId) return;
|
||||
const response = await apiClient.rawRequest(
|
||||
"DELETE",
|
||||
`/api/v1/tasks/${taskId}?discard_worktree_changes=true`,
|
||||
);
|
||||
expect(response.ok).toBeTruthy();
|
||||
});
|
||||
await runCleanup(async () => {
|
||||
if (!repositoryId) return;
|
||||
const response = await apiClient.rawRequest("DELETE", `/api/v1/repositories/${repositoryId}`);
|
||||
expect(response.ok).toBeTruthy();
|
||||
});
|
||||
await runCleanup(() =>
|
||||
apiClient.saveUserSettings({
|
||||
task_create_last_used: settings.task_create_last_used as Parameters<
|
||||
ApiClient["saveUserSettings"]
|
||||
>[0]["task_create_last_used"],
|
||||
}),
|
||||
);
|
||||
await runCleanup(() => fs.rmSync(fixture, { recursive: true, force: true }));
|
||||
if (cleanupErrors.length > 0) throw cleanupErrors[0];
|
||||
}
|
||||
}
|
||||
@@ -33,7 +33,12 @@ function visibleEditor(scope: Locator): Locator {
|
||||
}
|
||||
|
||||
async function typeMention(editor: Locator, query: string): Promise<Locator> {
|
||||
await editor.fill("");
|
||||
await expect(editor).toBeEditable();
|
||||
await editor.click();
|
||||
await editor.press("ControlOrMeta+A");
|
||||
await editor.press("Backspace");
|
||||
await expect.poll(() => editor.textContent()).toBe("");
|
||||
await editor.click();
|
||||
await editor.pressSequentially(`@${query}`);
|
||||
const menu = editor.page().getByRole("listbox", { name: /Mention tasks, files, prompts/i });
|
||||
await expect(menu).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { test } from "../../fixtures/test-base";
|
||||
import { useRegularMode } from "../../helpers/regular-mode";
|
||||
import { assertPreparationAttachments } from "../../helpers/preparation-attachments";
|
||||
|
||||
useRegularMode();
|
||||
|
||||
test("mobile initial attachments survive preparation and reload", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
backend,
|
||||
seedData,
|
||||
}, testInfo) => {
|
||||
test.setTimeout(180_000);
|
||||
await assertPreparationAttachments({
|
||||
testPage,
|
||||
apiClient,
|
||||
backend,
|
||||
seedData,
|
||||
testInfo,
|
||||
mobile: true,
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import { test } from "../../fixtures/test-base";
|
||||
import { useRegularMode } from "../../helpers/regular-mode";
|
||||
import { assertPreparationAttachments } from "../../helpers/preparation-attachments";
|
||||
|
||||
useRegularMode();
|
||||
|
||||
for (const failPreparation of [false, true]) {
|
||||
test(`initial attachments survive preparation ${failPreparation ? "setup error" : "completion"}`, async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
backend,
|
||||
seedData,
|
||||
}, testInfo) => {
|
||||
test.setTimeout(180_000);
|
||||
await assertPreparationAttachments({
|
||||
testPage,
|
||||
apiClient,
|
||||
backend,
|
||||
seedData,
|
||||
testInfo,
|
||||
failPreparation,
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
export const TASK_DESCRIPTION_SYNTHETIC_ID = "task-description";
|
||||
|
||||
type PreviewAttachment = {
|
||||
attachment_id: string;
|
||||
type: "image" | "resource";
|
||||
mime_type: string;
|
||||
name: string;
|
||||
size_bytes?: number;
|
||||
delivery_mode?: "prompt" | "path";
|
||||
};
|
||||
|
||||
export type InitialPromptPreview = {
|
||||
content: string;
|
||||
attachments: PreviewAttachment[];
|
||||
};
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function readAttachment(value: unknown): PreviewAttachment | null {
|
||||
if (
|
||||
!isRecord(value) ||
|
||||
typeof value.attachment_id !== "string" ||
|
||||
!value.attachment_id.trim() ||
|
||||
!["image", "resource"].includes(value.type as string) ||
|
||||
typeof value.mime_type !== "string" ||
|
||||
typeof value.name !== "string"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
attachment_id: value.attachment_id,
|
||||
type: value.type as PreviewAttachment["type"],
|
||||
mime_type: value.mime_type,
|
||||
name: value.name,
|
||||
...(typeof value.size_bytes === "number" &&
|
||||
Number.isFinite(value.size_bytes) &&
|
||||
value.size_bytes > 0
|
||||
? { size_bytes: value.size_bytes }
|
||||
: {}),
|
||||
...(value.delivery_mode === "prompt" || value.delivery_mode === "path"
|
||||
? { delivery_mode: value.delivery_mode }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function readInitialPromptPreview(value: unknown): InitialPromptPreview | null {
|
||||
if (!isRecord(value)) return null;
|
||||
const content = typeof value.content === "string" ? value.content : "";
|
||||
const attachments = Array.isArray(value.attachments)
|
||||
? value.attachments
|
||||
.slice(0, 10)
|
||||
.map(readAttachment)
|
||||
.filter((item) => item !== null)
|
||||
: [];
|
||||
return content.trim() || attachments.length ? { content, attachments } : null;
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import { renderHook } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { useProcessedMessages } from "./use-processed-messages";
|
||||
import {
|
||||
sessionId as toSessionId,
|
||||
taskId as toTaskId,
|
||||
type Message,
|
||||
type MessageType,
|
||||
} from "@/lib/types/http";
|
||||
|
||||
const TASK_DESCRIPTION = "task description";
|
||||
const SUBMITTED_TEXT = "submitted text";
|
||||
|
||||
function makeMessage(id: string, authorType: "agent" | "user", content: string): Message {
|
||||
return {
|
||||
id,
|
||||
session_id: toSessionId("s1"),
|
||||
task_id: toTaskId("t1"),
|
||||
author_type: authorType,
|
||||
content,
|
||||
type: "message" satisfies MessageType,
|
||||
created_at: "",
|
||||
};
|
||||
}
|
||||
|
||||
describe("initial prompt preview rendering", () => {
|
||||
it("shows submitted attachments before the stored initial prompt arrives", () => {
|
||||
const attachments = [
|
||||
{
|
||||
attachment_id: "image-1",
|
||||
type: "image",
|
||||
mime_type: "image/png",
|
||||
name: "screen.png",
|
||||
size_bytes: 32,
|
||||
},
|
||||
{
|
||||
attachment_id: "file-1",
|
||||
type: "resource",
|
||||
mime_type: "text/plain",
|
||||
name: "notes.txt",
|
||||
size_bytes: 12,
|
||||
},
|
||||
];
|
||||
const { result } = renderHook(() =>
|
||||
useProcessedMessages([], "t1", "s1", TASK_DESCRIPTION, {
|
||||
historyInitialized: true,
|
||||
hasOlderMessages: false,
|
||||
initialPromptPreview: { content: SUBMITTED_TEXT, attachments },
|
||||
}),
|
||||
);
|
||||
expect(result.current.allMessages[0]).toMatchObject({
|
||||
content: SUBMITTED_TEXT,
|
||||
metadata: { attachments },
|
||||
});
|
||||
});
|
||||
|
||||
it("hydrates an attachment-only preview and replaces it with stored history", () => {
|
||||
const attachment = {
|
||||
attachment_id: "image-1",
|
||||
type: "image",
|
||||
mime_type: "image/png",
|
||||
name: "screen.png",
|
||||
};
|
||||
const { result, rerender } = renderHook(
|
||||
({
|
||||
preview,
|
||||
messages,
|
||||
sessionId,
|
||||
}: {
|
||||
preview: unknown;
|
||||
messages: Message[];
|
||||
sessionId: string;
|
||||
}) =>
|
||||
useProcessedMessages(messages, "t1", sessionId, "", {
|
||||
historyInitialized: true,
|
||||
hasOlderMessages: false,
|
||||
initialPromptPreview: preview,
|
||||
}),
|
||||
{
|
||||
initialProps: { preview: undefined as unknown, messages: [] as Message[], sessionId: "s1" },
|
||||
},
|
||||
);
|
||||
expect(result.current.allMessages).toEqual([]);
|
||||
const preview = { content: "", attachments: [attachment] };
|
||||
rerender({ preview, messages: [], sessionId: "s1" });
|
||||
expect(result.current.allMessages[0]).toMatchObject({
|
||||
content: "",
|
||||
metadata: { attachments: [attachment] },
|
||||
});
|
||||
const stored = {
|
||||
...makeMessage("stored", "user", ""),
|
||||
metadata: { attachments: [attachment] },
|
||||
};
|
||||
rerender({ preview, messages: [stored], sessionId: "s1" });
|
||||
expect(result.current.allMessages).toEqual([stored]);
|
||||
rerender({ preview: undefined, messages: [], sessionId: "s2" });
|
||||
expect(result.current.allMessages).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("initial prompt preview guards", () => {
|
||||
it.each([
|
||||
{ historyInitialized: false, hasOlderMessages: false },
|
||||
{ historyInitialized: true, hasOlderMessages: true },
|
||||
])("does not bypass history guards with preview metadata: %j", (history) => {
|
||||
const { result } = renderHook(() =>
|
||||
useProcessedMessages([], "t1", "s1", TASK_DESCRIPTION, {
|
||||
...history,
|
||||
initialPromptPreview: { content: SUBMITTED_TEXT, attachments: [] },
|
||||
}),
|
||||
);
|
||||
expect(result.current.allMessages).toEqual([]);
|
||||
});
|
||||
|
||||
it("discards malformed preview entries independently and strips non-display data", () => {
|
||||
const attachment = {
|
||||
attachment_id: "image-1",
|
||||
type: "image",
|
||||
mime_type: "image/png",
|
||||
name: "screen.png",
|
||||
};
|
||||
const { result } = renderHook(() =>
|
||||
useProcessedMessages([], "t1", "s1", TASK_DESCRIPTION, {
|
||||
historyInitialized: true,
|
||||
hasOlderMessages: false,
|
||||
initialPromptPreview: {
|
||||
content: SUBMITTED_TEXT,
|
||||
attachments: [
|
||||
null,
|
||||
"invalid",
|
||||
{},
|
||||
{ ...attachment, data: "private", storage_key: "private/path" },
|
||||
{ ...attachment, type: "audio" },
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(result.current.allMessages[0].metadata).toEqual({ attachments: [attachment] });
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { readInitialPromptPreview, TASK_DESCRIPTION_SYNTHETIC_ID } from "./initial-prompt-preview";
|
||||
import {
|
||||
sessionId as toSessionId,
|
||||
taskId as toTaskId,
|
||||
@@ -144,6 +145,7 @@ export type GroupedRenderOptions = {
|
||||
};
|
||||
|
||||
export type ProcessedMessagesOptions = {
|
||||
initialPromptPreview?: unknown;
|
||||
historyInitialized?: boolean;
|
||||
hasOlderMessages?: boolean;
|
||||
lastAgentError?: LastAgentError | null;
|
||||
@@ -481,30 +483,38 @@ export function shouldShowTaskDescriptionFallback(
|
||||
taskDescription: string | null,
|
||||
visibleMessages: Message[],
|
||||
options: Pick<ProcessedMessagesOptions, "historyInitialized" | "hasOlderMessages"> = {},
|
||||
hasInitialPreview = false,
|
||||
): boolean {
|
||||
return (
|
||||
Boolean(taskDescription) &&
|
||||
(Boolean(taskDescription) || hasInitialPreview) &&
|
||||
options.historyInitialized === true &&
|
||||
options.hasOlderMessages === false &&
|
||||
!visibleMessages.some((message) => message.author_type === "user")
|
||||
);
|
||||
}
|
||||
|
||||
export const TASK_DESCRIPTION_SYNTHETIC_ID = "task-description";
|
||||
export { TASK_DESCRIPTION_SYNTHETIC_ID } from "./initial-prompt-preview";
|
||||
|
||||
function buildTaskDescriptionMessage(
|
||||
showFallback: boolean,
|
||||
visibleMessages: Message[],
|
||||
taskDescription: string | null,
|
||||
taskId: string | null,
|
||||
resolvedSessionId: string | null,
|
||||
options: ProcessedMessagesOptions,
|
||||
): Message | null {
|
||||
if (!showFallback) return null;
|
||||
const preview = readInitialPromptPreview(options.initialPromptPreview);
|
||||
if (
|
||||
!shouldShowTaskDescriptionFallback(taskDescription, visibleMessages, options, preview !== null)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
id: TASK_DESCRIPTION_SYNTHETIC_ID,
|
||||
task_id: toTaskId(taskId ?? ""),
|
||||
session_id: toSessionId(resolvedSessionId ?? ""),
|
||||
author_type: "user",
|
||||
content: taskDescription ?? "",
|
||||
content: preview?.content ?? taskDescription ?? "",
|
||||
...(preview ? { metadata: { attachments: preview.attachments } } : {}),
|
||||
type: "message",
|
||||
created_at: "",
|
||||
};
|
||||
@@ -537,20 +547,23 @@ export function useProcessedMessages(
|
||||
|
||||
const visibleMessages = useVisibleMessages(messages, toolCallIds, subagentChildIds, scope);
|
||||
|
||||
const showTaskDescriptionFallback = useMemo(
|
||||
() => shouldShowTaskDescriptionFallback(taskDescription, visibleMessages, options),
|
||||
[taskDescription, visibleMessages, options.historyInitialized, options.hasOlderMessages],
|
||||
);
|
||||
const taskDescriptionMessage: Message | null = useMemo(
|
||||
() =>
|
||||
buildTaskDescriptionMessage(
|
||||
showTaskDescriptionFallback,
|
||||
taskDescription,
|
||||
taskId,
|
||||
resolvedSessionId,
|
||||
),
|
||||
[showTaskDescriptionFallback, taskDescription, taskId, resolvedSessionId],
|
||||
);
|
||||
const taskDescriptionMessage = useMemo(() => {
|
||||
return buildTaskDescriptionMessage(
|
||||
visibleMessages,
|
||||
taskDescription,
|
||||
taskId,
|
||||
resolvedSessionId,
|
||||
options,
|
||||
);
|
||||
}, [
|
||||
taskId,
|
||||
resolvedSessionId,
|
||||
options.initialPromptPreview,
|
||||
taskDescription,
|
||||
visibleMessages,
|
||||
options.historyInitialized,
|
||||
options.hasOlderMessages,
|
||||
]);
|
||||
|
||||
const allMessages = useMemo(() => {
|
||||
return taskDescriptionMessage ? [taskDescriptionMessage, ...visibleMessages] : visibleMessages;
|
||||
|
||||
Reference in New Issue
Block a user