feat: show task attachments during workspace preparation (#3594)

This commit is contained in:
Carlos Florêncio
2026-09-12 16:30:15 +01:00
committed by GitHub
parent 61e86b40e7
commit f5c73db63c
21 changed files with 1203 additions and 40 deletions
@@ -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,
});
});
}
+58
View File
@@ -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] });
});
});
+32 -19
View File
@@ -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;