From d8436dfd8fc2afef258a18fe371a63f88936dc23 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?= Date: Wed, 17 Jun 2026 19:01:42 +0100 Subject: [PATCH] feat: improve passthrough composer and session recovery (#1411) --- .../executor/executor_interaction.go | 104 +++++- .../executor_passthrough_prompt_test.go | 247 ++++++++++++- .../internal/task/service/service_messages.go | 77 +++- .../internal/task/service/service_test.go | 54 +++ .../components/task/chat/chat-input-area.tsx | 2 +- .../components/task/chat/chat-input-body.tsx | 5 +- .../task/chat/chat-input-container.tsx | 2 + .../task/chat/chat-input-toolbar.tsx | 7 + .../components/task/chat/use-tiptap-editor.ts | 1 + .../task/new-session-form-actions.test.ts | 16 +- .../task/new-session-form-actions.ts | 31 +- .../components/task/new-subtask-dialog.tsx | 16 +- .../task/passthrough-chat-composer.test.ts | 228 ++++++++++++ .../task/passthrough-chat-composer.tsx | 315 ++++++++++++++++ .../components/task/passthrough-composer.tsx | 124 ------- .../task/passthrough-toolbar.test.tsx | 240 +++++++++++- .../components/task/passthrough-toolbar.tsx | 188 +++++----- .../task/preview-session-tabs.test.tsx | 101 ----- .../task/session-context-summary.test.ts | 66 ++++ .../task/session-context-summary.ts | 47 +++ apps/web/e2e/helpers/api-client.ts | 25 +- apps/web/e2e/helpers/session-store.ts | 24 ++ apps/web/e2e/helpers/ssh.ts | 12 +- .../cli-mode/comment-run-passthrough.spec.ts | 6 +- .../mobile-passthrough-composer.spec.ts | 139 +++++++ .../cli-mode/passthrough-toolbar.spec.ts | 347 +++++++++++++++++- .../e2e/tests/docker/docker-launch.spec.ts | 17 +- .../tests/settings/keyboard-shortcuts.spec.ts | 2 + apps/web/hooks/use-keyboard-shortcut.ts | 9 +- apps/web/hooks/use-message-handler.ts | 5 +- apps/web/hooks/use-summarize-session.test.ts | 51 +++ apps/web/hooks/use-summarize-session.ts | 23 +- apps/web/lib/keyboard/constants.ts | 4 + .../lib/keyboard/shortcut-overrides.test.ts | 8 +- apps/web/lib/keyboard/shortcut-overrides.ts | 5 + docs/specs/cli-mode-parity/spec.md | 40 +- 36 files changed, 2139 insertions(+), 449 deletions(-) create mode 100644 apps/web/components/task/passthrough-chat-composer.test.ts create mode 100644 apps/web/components/task/passthrough-chat-composer.tsx delete mode 100644 apps/web/components/task/passthrough-composer.tsx delete mode 100644 apps/web/components/task/preview-session-tabs.test.tsx create mode 100644 apps/web/components/task/session-context-summary.test.ts create mode 100644 apps/web/components/task/session-context-summary.ts create mode 100644 apps/web/e2e/tests/cli-mode/mobile-passthrough-composer.spec.ts create mode 100644 apps/web/hooks/use-summarize-session.test.ts diff --git a/apps/backend/internal/orchestrator/executor/executor_interaction.go b/apps/backend/internal/orchestrator/executor/executor_interaction.go index a500b5f448..c5a74c7fe3 100644 --- a/apps/backend/internal/orchestrator/executor/executor_interaction.go +++ b/apps/backend/internal/orchestrator/executor/executor_interaction.go @@ -4,10 +4,13 @@ import ( "context" "errors" "fmt" + "path/filepath" + "strings" "time" "github.com/kandev/kandev/internal/agent/agents" "github.com/kandev/kandev/internal/agent/runtime/lifecycle" + agentctlshared "github.com/kandev/kandev/internal/agentctl/server/adapter/transport/shared" "github.com/kandev/kandev/internal/task/models" v1 "github.com/kandev/kandev/pkg/api/v1" "go.uber.org/zap" @@ -161,7 +164,7 @@ func (e *Executor) Prompt(ctx context.Context, taskID, sessionID string, prompt // dispatchOnly is intentionally not forwarded: PTY writes are inherently // fire-and-forget, so the flag has no analogue in passthrough mode. if e.agentManager.IsPassthroughSession(ctx, sessionID) { - return e.promptPassthrough(ctx, taskID, sessionID, prompt, attachments) + return e.promptPassthrough(ctx, taskID, session, prompt, attachments) } result, err := e.agentManager.PromptAgent(ctx, executionID, prompt, attachments, dispatchOnly) @@ -175,9 +178,9 @@ func (e *Executor) Prompt(ctx context.Context, taskID, sessionID string, prompt } // promptPassthrough delivers a user prompt to a passthrough (PTY) agent session. -// Passthrough mode has no protocol channel for attachments: when the caller -// supplies any, we log a warning so an operator can see they were dropped -// (image-only messages are rejected outright since there is nothing to write). +// Passthrough mode has no structured protocol channel for attachments, so we +// save them into the session workspace and append path instructions to the +// stdin prompt. // // The submit sequence is resolved per-agent via ResolvePassthroughConfig — most // TUI CLIs use "\r" but the config field lets a future agent override it @@ -187,15 +190,14 @@ func (e *Executor) Prompt(ctx context.Context, taskID, sessionID string, prompt // as an error so Service.handlePromptError can revert session state and surface // the failure to the user. A MarkPassthroughRunning failure is non-fatal — the // data is already in the PTY; only the AgentRunning event is missed. -func (e *Executor) promptPassthrough(ctx context.Context, taskID, sessionID, prompt string, attachments []v1.MessageAttachment) (*PromptResult, error) { - if prompt == "" { - return nil, fmt.Errorf("passthrough prompt cannot be empty (attachments are not supported in CLI passthrough mode)") +func (e *Executor) promptPassthrough(ctx context.Context, taskID string, session *models.TaskSession, prompt string, attachments []v1.MessageAttachment) (*PromptResult, error) { + sessionID := session.ID + promptWithAttachments, err := e.buildPassthroughPromptWithAttachments(ctx, session, prompt, attachments) + if err != nil { + return nil, err } - if len(attachments) > 0 { - e.logger.Warn("dropping attachments on passthrough prompt; CLI passthrough mode has no attachment channel", - zap.String("task_id", taskID), - zap.String("session_id", sessionID), - zap.Int("attachments_count", len(attachments))) + if strings.TrimSpace(promptWithAttachments) == "" { + return nil, fmt.Errorf("passthrough prompt cannot be empty") } pt, err := e.agentManager.ResolvePassthroughConfig(ctx, sessionID) if err != nil { @@ -214,7 +216,7 @@ func (e *Executor) promptPassthrough(ctx context.Context, taskID, sessionID, pro zap.String("session_id", sessionID), zap.Error(err)) } - for _, chunk := range agents.PlanPassthroughStdinChunks(prompt, pt) { + for _, chunk := range agents.PlanPassthroughStdinChunks(promptWithAttachments, pt) { if chunk.DelayBefore > 0 { time.Sleep(chunk.DelayBefore) } @@ -225,6 +227,82 @@ func (e *Executor) promptPassthrough(ctx context.Context, taskID, sessionID, pro return &PromptResult{StopReason: stopReasonPassthrough}, nil } +func (e *Executor) buildPassthroughPromptWithAttachments(ctx context.Context, session *models.TaskSession, prompt string, attachments []v1.MessageAttachment) (string, error) { + if len(attachments) == 0 { + return prompt, nil + } + workDir := e.passthroughAttachmentWorkspace(ctx, session) + if workDir == "" { + return "", fmt.Errorf("passthrough attachments require a session workspace path") + } + attachMgr := agentctlshared.NewAttachmentManager(workDir, e.logger.Zap()) + attachMgr.SetSessionID(session.ID) + saved, err := attachMgr.SaveAttachments(attachments) + if err != nil { + return "", fmt.Errorf("save passthrough attachments: %w", err) + } + if len(saved) == 0 { + if strings.TrimSpace(prompt) != "" { + e.logger.Warn("no attachments were saved for passthrough prompt; delivering text-only", + zap.String("session_id", session.ID), + zap.Int("attachments_submitted", len(attachments))) + return prompt, nil + } + return "", fmt.Errorf("passthrough prompt has no usable attachments") + } + attachmentPrompt := strings.TrimSpace(agentctlshared.BuildAttachmentPrompt(saved, true)) + if strings.TrimSpace(prompt) == "" { + return attachmentPrompt, nil + } + return prompt + "\n\n" + attachmentPrompt, nil +} + +func (e *Executor) passthroughAttachmentWorkspace(ctx context.Context, session *models.TaskSession) string { + if workDir := strings.TrimSpace(session.WorkspacePath); workDir != "" { + return workDir + } + if workDir := workspaceFromSessionWorktrees(session); workDir != "" { + return workDir + } + if session.TaskEnvironmentID != "" { + if env, err := e.repo.GetTaskEnvironment(ctx, session.TaskEnvironmentID); err == nil { + if workDir := workspaceFromTaskEnvironment(env); workDir != "" { + return workDir + } + } + } + if env, err := e.repo.GetTaskEnvironmentByTaskID(ctx, session.TaskID); err == nil { + if workDir := workspaceFromTaskEnvironment(env); workDir != "" { + return workDir + } + } + return "" +} + +func workspaceFromSessionWorktrees(session *models.TaskSession) string { + if len(session.Worktrees) == 0 { + return "" + } + first := strings.TrimSpace(session.Worktrees[0].WorktreePath) + if first == "" { + return "" + } + if len(session.Worktrees) == 1 { + return first + } + return filepath.Dir(first) +} + +func workspaceFromTaskEnvironment(env *models.TaskEnvironment) string { + if env == nil { + return "" + } + if workDir := strings.TrimSpace(env.WorkspacePath); workDir != "" { + return workDir + } + return strings.TrimSpace(env.WorktreePath) +} + // SwitchModel switches the model for a running session. It first attempts an // in-place switch via ACP model selection (instant, no process restart). If // the agent doesn't support in-place switching, it falls back to stopping and diff --git a/apps/backend/internal/orchestrator/executor/executor_passthrough_prompt_test.go b/apps/backend/internal/orchestrator/executor/executor_passthrough_prompt_test.go index 4a0b2ffadf..b4fc971aa5 100644 --- a/apps/backend/internal/orchestrator/executor/executor_passthrough_prompt_test.go +++ b/apps/backend/internal/orchestrator/executor/executor_passthrough_prompt_test.go @@ -2,7 +2,10 @@ package executor import ( "context" + "encoding/base64" "errors" + "os" + "path/filepath" "strings" "testing" "time" @@ -127,21 +130,25 @@ func TestExecutor_Prompt_PassthroughMarkRunningErrorIsNonFatal(t *testing.T) { } } -func TestExecutor_Prompt_PassthroughWithAttachmentsDropsAttachmentsAndSucceeds(t *testing.T) { - // Attachments have no place in passthrough mode (no ACP channel for binary - // payloads). When prompt text is present we still deliver the text; the - // caller (logs) records that the attachments were dropped. +func TestExecutor_Prompt_PassthroughWithAttachmentsSavesFilesAndReferencesPaths(t *testing.T) { repo := newMockRepository() agentManager := &mockAgentManager{ isPassthroughSessionFunc: func(_ context.Context, _ string) bool { return true }, } seedPassthroughSession(t, repo, agentManager, "task-1", "sess-1", "exec-1") + workDir := t.TempDir() + repo.sessions["sess-1"].WorkspacePath = workDir exec := newTestExecutor(t, agentManager, repo) - atts := []v1.MessageAttachment{{Type: "image", MimeType: "image/png", Name: "screenshot.png"}} + atts := []v1.MessageAttachment{{ + Type: "resource", + MimeType: "text/plain", + Name: "../notes.txt", + Data: base64.StdEncoding.EncodeToString([]byte("passthrough attachment body")), + }} result, err := exec.Prompt(context.Background(), "task-1", "sess-1", "look at this", atts, false) if err != nil { - t.Fatalf("Prompt with attachments should still succeed (attachments dropped); got error: %v", err) + t.Fatalf("Prompt with attachments should succeed: %v", err) } if result == nil || result.StopReason != "passthrough_dispatched" { t.Fatalf("expected passthrough_dispatched result, got %+v", result) @@ -149,8 +156,215 @@ func TestExecutor_Prompt_PassthroughWithAttachmentsDropsAttachmentsAndSucceeds(t if got := len(agentManager.writePassthroughStdinCalls); got != 1 { t.Fatalf("expected 1 WritePassthroughStdin call, got %d", got) } - if !strings.HasPrefix(agentManager.writePassthroughStdinCalls[0].Data, "look at this") { - t.Errorf("PTY write should carry the typed text, got %q", agentManager.writePassthroughStdinCalls[0].Data) + data := agentManager.writePassthroughStdinCalls[0].Data + relPath := filepath.Join(".kandev", "attachments", "sess-1", "notes.txt") + if !strings.Contains(data, "look at this") { + t.Errorf("PTY write should carry the typed text, got %q", data) + } + if !strings.Contains(data, relPath) { + t.Errorf("PTY write should reference saved attachment path %q, got %q", relPath, data) + } + contents, err := os.ReadFile(filepath.Join(workDir, relPath)) + if err != nil { + t.Fatalf("expected saved attachment file: %v", err) + } + if string(contents) != "passthrough attachment body" { + t.Fatalf("saved attachment contents = %q", string(contents)) + } +} + +func TestExecutor_Prompt_PassthroughAttachmentOnlyMessageSendsPathPrompt(t *testing.T) { + repo := newMockRepository() + agentManager := &mockAgentManager{ + isPassthroughSessionFunc: func(_ context.Context, _ string) bool { return true }, + } + seedPassthroughSession(t, repo, agentManager, "task-1", "sess-1", "exec-1") + workDir := t.TempDir() + repo.sessions["sess-1"].WorkspacePath = workDir + exec := newTestExecutor(t, agentManager, repo) + + atts := []v1.MessageAttachment{{ + Type: "resource", + MimeType: "text/plain", + Name: "only.txt", + Data: base64.StdEncoding.EncodeToString([]byte("attachment-only")), + }} + if _, err := exec.Prompt(context.Background(), "task-1", "sess-1", "", atts, false); err != nil { + t.Fatalf("attachment-only passthrough prompt should send path instructions: %v", err) + } + if got := len(agentManager.writePassthroughStdinCalls); got != 1 { + t.Fatalf("expected 1 WritePassthroughStdin call, got %d", got) + } + if !strings.Contains(agentManager.writePassthroughStdinCalls[0].Data, filepath.Join(".kandev", "attachments", "sess-1", "only.txt")) { + t.Errorf("PTY write should reference only attachment, got %q", agentManager.writePassthroughStdinCalls[0].Data) + } +} + +func TestExecutor_Prompt_PassthroughInvalidAttachmentsDeliverTextOnlyPrompt(t *testing.T) { + repo := newMockRepository() + agentManager := &mockAgentManager{ + isPassthroughSessionFunc: func(_ context.Context, _ string) bool { return true }, + } + seedPassthroughSession(t, repo, agentManager, "task-1", "sess-1", "exec-1") + repo.sessions["sess-1"].WorkspacePath = t.TempDir() + exec := newTestExecutor(t, agentManager, repo) + + atts := []v1.MessageAttachment{{ + Type: "resource", + MimeType: "text/plain", + Name: "broken.txt", + Data: "not-base64!!!", + }} + if _, err := exec.Prompt(context.Background(), "task-1", "sess-1", "keep this text", atts, false); err != nil { + t.Fatalf("text prompt should still send when attachments are unusable: %v", err) + } + if got := len(agentManager.writePassthroughStdinCalls); got != 1 { + t.Fatalf("expected 1 WritePassthroughStdin call, got %d", got) + } + want := "keep this text\r" + if agentManager.writePassthroughStdinCalls[0].Data != want { + t.Fatalf("PTY write = %q, want text-only payload %q", agentManager.writePassthroughStdinCalls[0].Data, want) + } +} + +func TestExecutor_Prompt_PassthroughAttachmentsUseSessionWorktreeFallback(t *testing.T) { + repo := newMockRepository() + agentManager := &mockAgentManager{ + isPassthroughSessionFunc: func(_ context.Context, _ string) bool { return true }, + } + seedPassthroughSession(t, repo, agentManager, "task-1", "sess-1", "exec-1") + workDir := t.TempDir() + repo.sessions["sess-1"].Worktrees = []*models.TaskSessionWorktree{{ + SessionID: "sess-1", + RepositoryID: "repo-1", + WorktreePath: workDir, + }} + exec := newTestExecutor(t, agentManager, repo) + + atts := []v1.MessageAttachment{{ + Type: "resource", + MimeType: "text/plain", + Name: "fallback.txt", + Data: base64.StdEncoding.EncodeToString([]byte("worktree-fallback")), + }} + if _, err := exec.Prompt(context.Background(), "task-1", "sess-1", "with fallback", atts, false); err != nil { + t.Fatalf("Prompt with worktree fallback should succeed: %v", err) + } + relPath := filepath.Join(".kandev", "attachments", "sess-1", "fallback.txt") + if !strings.Contains(agentManager.writePassthroughStdinCalls[0].Data, relPath) { + t.Errorf("PTY write should reference saved attachment path %q, got %q", relPath, agentManager.writePassthroughStdinCalls[0].Data) + } + contents, err := os.ReadFile(filepath.Join(workDir, relPath)) + if err != nil { + t.Fatalf("expected saved attachment file: %v", err) + } + if string(contents) != "worktree-fallback" { + t.Fatalf("saved attachment contents = %q", string(contents)) + } +} + +func TestExecutor_Prompt_PassthroughAttachmentsUseMultiWorktreeRootFallback(t *testing.T) { + repo := newMockRepository() + agentManager := &mockAgentManager{ + isPassthroughSessionFunc: func(_ context.Context, _ string) bool { return true }, + } + seedPassthroughSession(t, repo, agentManager, "task-1", "sess-1", "exec-1") + workDir := filepath.Join(t.TempDir(), "repo-one") + if err := os.MkdirAll(workDir, 0o755); err != nil { + t.Fatalf("create worktree dir: %v", err) + } + repo.sessions["sess-1"].Worktrees = []*models.TaskSessionWorktree{ + {SessionID: "sess-1", RepositoryID: "repo-1", WorktreePath: workDir}, + {SessionID: "sess-1", RepositoryID: "repo-2", WorktreePath: filepath.Join(t.TempDir(), "repo-two")}, + } + exec := newTestExecutor(t, agentManager, repo) + + atts := []v1.MessageAttachment{{ + Type: "resource", + MimeType: "text/plain", + Name: "multi.txt", + Data: base64.StdEncoding.EncodeToString([]byte("multi-worktree-root")), + }} + if _, err := exec.Prompt(context.Background(), "task-1", "sess-1", "with multi", atts, false); err != nil { + t.Fatalf("Prompt with multi-worktree fallback should succeed: %v", err) + } + relPath := filepath.Join(".kandev", "attachments", "sess-1", "multi.txt") + contents, err := os.ReadFile(filepath.Join(filepath.Dir(workDir), relPath)) + if err != nil { + t.Fatalf("expected saved attachment under shared worktree root: %v", err) + } + if string(contents) != "multi-worktree-root" { + t.Fatalf("saved attachment contents = %q", string(contents)) + } +} + +func TestExecutor_Prompt_PassthroughAttachmentsUseTaskEnvironmentFallback(t *testing.T) { + repo := newMockRepository() + agentManager := &mockAgentManager{ + isPassthroughSessionFunc: func(_ context.Context, _ string) bool { return true }, + } + seedPassthroughSession(t, repo, agentManager, "task-1", "sess-1", "exec-1") + workDir := t.TempDir() + repo.sessions["sess-1"].TaskEnvironmentID = "env-1" + repo.taskEnvironments["env-1"] = &models.TaskEnvironment{ + ID: "env-1", + TaskID: "task-1", + WorkspacePath: workDir, + } + exec := newTestExecutor(t, agentManager, repo) + + atts := []v1.MessageAttachment{{ + Type: "resource", + MimeType: "text/plain", + Name: "env.txt", + Data: base64.StdEncoding.EncodeToString([]byte("env-fallback")), + }} + if _, err := exec.Prompt(context.Background(), "task-1", "sess-1", "with env", atts, false); err != nil { + t.Fatalf("Prompt with task environment fallback should succeed: %v", err) + } + relPath := filepath.Join(".kandev", "attachments", "sess-1", "env.txt") + if !strings.Contains(agentManager.writePassthroughStdinCalls[0].Data, relPath) { + t.Errorf("PTY write should reference saved attachment path %q, got %q", relPath, agentManager.writePassthroughStdinCalls[0].Data) + } + contents, err := os.ReadFile(filepath.Join(workDir, relPath)) + if err != nil { + t.Fatalf("expected saved attachment file: %v", err) + } + if string(contents) != "env-fallback" { + t.Fatalf("saved attachment contents = %q", string(contents)) + } +} + +func TestExecutor_Prompt_PassthroughAttachmentsUseTaskEnvironmentByTaskIDFallback(t *testing.T) { + repo := newMockRepository() + agentManager := &mockAgentManager{ + isPassthroughSessionFunc: func(_ context.Context, _ string) bool { return true }, + } + seedPassthroughSession(t, repo, agentManager, "task-1", "sess-1", "exec-1") + workDir := t.TempDir() + repo.taskEnvironments["env-1"] = &models.TaskEnvironment{ + ID: "env-1", + TaskID: "task-1", + WorkspacePath: workDir, + } + exec := newTestExecutor(t, agentManager, repo) + + atts := []v1.MessageAttachment{{ + Type: "resource", + MimeType: "text/plain", + Name: "env-by-task.txt", + Data: base64.StdEncoding.EncodeToString([]byte("env-by-task-fallback")), + }} + if _, err := exec.Prompt(context.Background(), "task-1", "sess-1", "with env by task", atts, false); err != nil { + t.Fatalf("Prompt with task environment by task fallback should succeed: %v", err) + } + relPath := filepath.Join(".kandev", "attachments", "sess-1", "env-by-task.txt") + contents, err := os.ReadFile(filepath.Join(workDir, relPath)) + if err != nil { + t.Fatalf("expected saved attachment file: %v", err) + } + if string(contents) != "env-by-task-fallback" { + t.Fatalf("saved attachment contents = %q", string(contents)) } } @@ -171,6 +385,23 @@ func TestExecutor_Prompt_PassthroughEmptyPromptReturnsError(t *testing.T) { } } +func TestExecutor_Prompt_PassthroughWhitespaceOnlyPromptReturnsError(t *testing.T) { + repo := newMockRepository() + agentManager := &mockAgentManager{ + isPassthroughSessionFunc: func(_ context.Context, _ string) bool { return true }, + } + seedPassthroughSession(t, repo, agentManager, "task-1", "sess-1", "exec-1") + exec := newTestExecutor(t, agentManager, repo) + + _, err := exec.Prompt(context.Background(), "task-1", "sess-1", " \n\t ", nil, false) + if err == nil { + t.Fatal("expected error for whitespace-only passthrough prompt, got nil") + } + if got := len(agentManager.writePassthroughStdinCalls); got != 0 { + t.Errorf("WritePassthroughStdin must not be called for whitespace-only prompts; got %d call(s)", got) + } +} + // TestExecutor_Prompt_PassthroughPreservesUnicodeAndMultiline verifies the PTY // write payload preserves UTF-8 and embedded newlines verbatim. The submit // suffix is appended at the END so the agent's TUI sees one logical "submit" diff --git a/apps/backend/internal/task/service/service_messages.go b/apps/backend/internal/task/service/service_messages.go index 5616c21f71..ea3b4ca7af 100644 --- a/apps/backend/internal/task/service/service_messages.go +++ b/apps/backend/internal/task/service/service_messages.go @@ -2,6 +2,7 @@ package service import ( "context" + "fmt" "time" "github.com/google/uuid" @@ -12,9 +13,21 @@ import ( "github.com/kandev/kandev/internal/task/models" ) +const ( + messageCreateMaxRetries = 5 + messageCreateRetryDelay = 50 * time.Millisecond +) + // CreateMessage creates a new message on an agent session func (s *Service) CreateMessage(ctx context.Context, req *CreateMessageRequest) (*models.Message, error) { - session, err := s.sessions.GetTaskSession(ctx, req.TaskSessionID) + messageID := uuid.New().String() + session, err := s.getSessionWithRetry( + ctx, + req.TaskSessionID, + messageID, + messageCreateMaxRetries, + messageCreateRetryDelay, + ) if err != nil { return nil, err } @@ -37,19 +50,25 @@ func (s *Service) CreateMessage(ctx context.Context, req *CreateMessageRequest) // Ensure we have a turn ID - get active turn or start a new one turnID := req.TurnID if turnID == "" { - turn, err := s.getOrStartTurn(ctx, req.TaskSessionID) + turn, err := s.getOrStartTurnWithRetry( + ctx, + req.TaskSessionID, + messageID, + messageCreateMaxRetries, + messageCreateRetryDelay, + ) if err != nil { s.logger.Warn("failed to get or start turn for message", zap.String("session_id", req.TaskSessionID), zap.Error(err)) - // Continue with empty turn ID - will fail on foreign key if turn is required + return nil, fmt.Errorf("failed to get or start turn: %w", err) } else if turn != nil { turnID = turn.ID } } message := &models.Message{ - ID: uuid.New().String(), + ID: messageID, TaskSessionID: req.TaskSessionID, TaskID: taskID, TurnID: turnID, @@ -83,17 +102,17 @@ func (s *Service) CreateMessage(ctx context.Context, req *CreateMessageRequest) // It includes retry logic to handle transient database errors and ensure // message chunks are not lost during streaming. func (s *Service) CreateMessageWithID(ctx context.Context, id string, req *CreateMessageRequest) (*models.Message, error) { - const maxRetries = 5 - const retryDelay = 50 * time.Millisecond - - session, err := s.getSessionWithRetry(ctx, req.TaskSessionID, id, maxRetries, retryDelay) + session, err := s.getSessionWithRetry(ctx, req.TaskSessionID, id, messageCreateMaxRetries, messageCreateRetryDelay) if err != nil { return nil, err } - message := s.buildMessage(ctx, id, req, session) + message, err := s.buildMessage(ctx, id, req, session) + if err != nil { + return nil, err + } - if err := s.createMessageWithRetry(ctx, message, maxRetries, retryDelay); err != nil { + if err := s.createMessageWithRetry(ctx, message, messageCreateMaxRetries, messageCreateRetryDelay); err != nil { return nil, err } @@ -137,8 +156,33 @@ func (s *Service) getSessionWithRetry(ctx context.Context, sessionID, messageID return nil, err } +// getOrStartTurnWithRetry fetches or starts a turn, retrying short-lived FK/session visibility races. +func (s *Service) getOrStartTurnWithRetry(ctx context.Context, sessionID, messageID string, maxRetries int, retryDelay time.Duration) (*models.Turn, error) { + var turn *models.Turn + var err error + for attempt := 0; attempt < maxRetries; attempt++ { + turn, err = s.getOrStartTurn(ctx, sessionID) + if err == nil { + return turn, nil + } + if ctx.Err() != nil { + return nil, ctx.Err() + } + if attempt < maxRetries-1 { + s.logger.Debug("failed to get or start turn for message, retrying", + zap.String("session_id", sessionID), + zap.String("message_id", messageID), + zap.Int("attempt", attempt+1), + zap.Int("max_retries", maxRetries), + zap.Error(err)) + time.Sleep(retryDelay) + } + } + return nil, err +} + // buildMessage constructs a Message model from a CreateMessageRequest and resolved session. -func (s *Service) buildMessage(ctx context.Context, id string, req *CreateMessageRequest, session *models.TaskSession) *models.Message { +func (s *Service) buildMessage(ctx context.Context, id string, req *CreateMessageRequest, session *models.TaskSession) (*models.Message, error) { authorType := models.MessageAuthorUser if req.AuthorType == "agent" { authorType = models.MessageAuthorAgent @@ -156,10 +200,17 @@ func (s *Service) buildMessage(ctx context.Context, id string, req *CreateMessag turnID := req.TurnID if turnID == "" { - if turn, err := s.getOrStartTurn(ctx, req.TaskSessionID); err != nil { + if turn, err := s.getOrStartTurnWithRetry( + ctx, + req.TaskSessionID, + id, + messageCreateMaxRetries, + messageCreateRetryDelay, + ); err != nil { s.logger.Warn("failed to get or start turn for streaming message", zap.String("session_id", req.TaskSessionID), zap.Error(err)) + return nil, fmt.Errorf("failed to get or start turn: %w", err) } else if turn != nil { turnID = turn.ID } @@ -177,7 +228,7 @@ func (s *Service) buildMessage(ctx context.Context, id string, req *CreateMessag Metadata: req.Metadata, RequestsInput: req.RequestsInput, CreatedAt: time.Now().UTC(), - } + }, nil } // createMessageWithRetry persists a message with retry logic for transient DB errors. diff --git a/apps/backend/internal/task/service/service_test.go b/apps/backend/internal/task/service/service_test.go index 05f97e95e1..bdc3135a54 100644 --- a/apps/backend/internal/task/service/service_test.go +++ b/apps/backend/internal/task/service/service_test.go @@ -2,6 +2,7 @@ package service import ( "context" + "errors" "path/filepath" "strings" "sync" @@ -807,6 +808,59 @@ func TestService_CreateAgentMessage(t *testing.T) { } } +type failingTurnCreator struct { + repository.TurnRepository + err error +} + +func (f failingTurnCreator) CreateTurn(context.Context, *models.Turn) error { + return f.err +} + +func TestService_CreateMessageReturnsTurnStartError(t *testing.T) { + svc, _, repo := createTestService(t) + ctx := context.Background() + setupTestTask(t, repo) + sessionID := setupTestSession(t, repo) + turnErr := errors.New("turn table unavailable") + svc.turns = failingTurnCreator{TurnRepository: repo, err: turnErr} + + _, err := svc.CreateMessage(ctx, &CreateMessageRequest{ + TaskSessionID: sessionID, + Content: "new prompt", + AuthorType: "user", + }) + + if !errors.Is(err, turnErr) { + t.Fatalf("CreateMessage error = %v, want wrapped turn error", err) + } + if strings.Contains(strings.ToLower(err.Error()), "foreign key") { + t.Fatalf("CreateMessage returned masked FK error: %v", err) + } +} + +func TestService_CreateMessageWithIDReturnsTurnStartError(t *testing.T) { + svc, _, repo := createTestService(t) + ctx := context.Background() + setupTestTask(t, repo) + sessionID := setupTestSession(t, repo) + turnErr := errors.New("turn table unavailable") + svc.turns = failingTurnCreator{TurnRepository: repo, err: turnErr} + + _, err := svc.CreateMessageWithID(ctx, "message-123", &CreateMessageRequest{ + TaskSessionID: sessionID, + Content: "streamed output", + AuthorType: "agent", + }) + + if !errors.Is(err, turnErr) { + t.Fatalf("CreateMessageWithID error = %v, want wrapped turn error", err) + } + if strings.Contains(strings.ToLower(err.Error()), "foreign key") { + t.Fatalf("CreateMessageWithID returned masked FK error: %v", err) + } +} + func TestService_CreateMessageSessionNotFound(t *testing.T) { svc, _, _ := createTestService(t) ctx := context.Background() diff --git a/apps/web/components/task/chat/chat-input-area.tsx b/apps/web/components/task/chat/chat-input-area.tsx index 1f3ece69f3..106ae3e481 100644 --- a/apps/web/components/task/chat/chat-input-area.tsx +++ b/apps/web/components/task/chat/chat-input-area.tsx @@ -41,7 +41,7 @@ import type { useChatPanelState } from "./use-chat-panel-state"; const PLAN_CONTEXT_PATH = "plan:context"; -function buildSubmitMessage( +export function buildSubmitMessage( message: string, reviewComments: DiffComment[] | undefined, pendingPRFeedback: import("@/lib/state/slices/comments").PRFeedbackComment[], diff --git a/apps/web/components/task/chat/chat-input-body.tsx b/apps/web/components/task/chat/chat-input-body.tsx index d7c034c9e4..2d74be7925 100644 --- a/apps/web/components/task/chat/chat-input-body.tsx +++ b/apps/web/components/task/chat/chat-input-body.tsx @@ -37,6 +37,7 @@ export type ChatInputEditorAreaProps = { showRequestChangesTooltip: boolean; hideSessionsDropdown?: boolean; minimalToolbar?: boolean; + hideAgentControls?: boolean; hidePlanMode?: boolean; isAgentBusy: boolean; onPlanModeChange: (enabled: boolean) => void; @@ -126,7 +127,8 @@ export function ChatInputEditorArea(p: ChatInputEditorAreaProps) { p; const { isSending, onCancel, contextCount, contextPopoverOpen, setContextPopoverOpen } = p; const { contextFiles, onImplementPlan, onEnhancePrompt, isEnhancingPrompt } = p; - const { isUtilityConfigured, hideSessionsDropdown, minimalToolbar, hidePlanMode } = p; + const { isUtilityConfigured, hideSessionsDropdown, minimalToolbar, hideAgentControls } = p; + const { hidePlanMode } = p; const { onVoiceTranscript, onVoiceAutoSend } = p; // Exclude auto-added plan context from the count — it's always present in plan mode // and shouldn't by itself enable the send button. @@ -195,6 +197,7 @@ export function ChatInputEditorArea(p: ChatInputEditorAreaProps) { onVoiceAutoSend={onVoiceAutoSend} hideSessionsDropdown={hideSessionsDropdown} minimalToolbar={minimalToolbar} + hideAgentControls={hideAgentControls} hidePlanMode={hidePlanMode} /> diff --git a/apps/web/components/task/chat/chat-input-container.tsx b/apps/web/components/task/chat/chat-input-container.tsx index 18570816b9..0b8465d704 100644 --- a/apps/web/components/task/chat/chat-input-container.tsx +++ b/apps/web/components/task/chat/chat-input-container.tsx @@ -91,6 +91,7 @@ type ChatInputContainerProps = { onImplementPlan?: (fresh: boolean) => void; hideSessionsDropdown?: boolean; minimalToolbar?: boolean; + hideAgentControls?: boolean; /** Hide the plan mode toggle button (for ephemeral/quick chat sessions) */ hidePlanMode?: boolean; }; @@ -304,6 +305,7 @@ function buildEditorAreaProps( onVoiceAutoSend: extras.onVoiceAutoSend, hideSessionsDropdown: p.hideSessionsDropdown, minimalToolbar: p.minimalToolbar, + hideAgentControls: p.hideAgentControls, hidePlanMode: p.hidePlanMode, }; } diff --git a/apps/web/components/task/chat/chat-input-toolbar.tsx b/apps/web/components/task/chat/chat-input-toolbar.tsx index d8cb9ca10e..28337d1073 100644 --- a/apps/web/components/task/chat/chat-input-toolbar.tsx +++ b/apps/web/components/task/chat/chat-input-toolbar.tsx @@ -78,6 +78,8 @@ export type ChatInputToolbarProps = { hideSessionsDropdown?: boolean; /** When true, only render the submit/cancel button — no other controls */ minimalToolbar?: boolean; + /** Hide ACP/session-specific controls while keeping Plan, attachment, and context controls */ + hideAgentControls?: boolean; /** Hide the plan mode toggle button (for ephemeral/quick chat sessions) */ hidePlanMode?: boolean; }; @@ -376,7 +378,9 @@ function buildCollapsibleItems(props: { onEnhancePrompt?: () => void; isEnhancingPrompt: boolean; isUtilityConfigured: boolean; + hideAgentControls?: boolean; }): ToolbarItemConfig[] { + if (props.hideAgentControls) return []; return [ { id: "mcp", @@ -456,6 +460,7 @@ function AttachFilesButton({ onClick }: { onClick: () => void }) { size="sm" className="h-7 gap-1.5 px-2 cursor-pointer hover:bg-muted/40" onClick={onClick} + data-testid="chat-attachments-button" > @@ -513,6 +518,7 @@ const toolbarDefaults = { contextFiles: [] as ContextFile[], isEnhancingPrompt: false, isUtilityConfigured: false, + hideAgentControls: false, hidePlanMode: false, }; @@ -571,6 +577,7 @@ export const ChatInputToolbar = memo(function ChatInputToolbar(rawProps: ChatInp variant="ghost" size="sm" className="h-7 gap-1.5 px-2 cursor-pointer hover:bg-muted/40 relative" + data-testid="chat-context-button" > {props.contextCount > 0 && !isCollapsed && ( diff --git a/apps/web/components/task/chat/use-tiptap-editor.ts b/apps/web/components/task/chat/use-tiptap-editor.ts index 4839401754..9e3436c518 100644 --- a/apps/web/components/task/chat/use-tiptap-editor.ts +++ b/apps/web/components/task/chat/use-tiptap-editor.ts @@ -551,6 +551,7 @@ function useEditorImperativeHandle( const { kind, path, label } = node.attrs; if (kind === "file") mentions.push({ path, name: label, pinned: false }); else if (kind === "prompt") mentions.push({ path, name: label, pinned: false }); + else if (kind === "plan") mentions.push({ path, name: label, pinned: false }); } }); return mentions; diff --git a/apps/web/components/task/new-session-form-actions.test.ts b/apps/web/components/task/new-session-form-actions.test.ts index b4b0831bbb..c0237bea3e 100644 --- a/apps/web/components/task/new-session-form-actions.test.ts +++ b/apps/web/components/task/new-session-form-actions.test.ts @@ -109,8 +109,8 @@ describe("useSessionContextChange", () => { }); it("summarizes sessions into prompt text", async () => { - const promptRef = { current: { value: "" } as unknown as HTMLTextAreaElement }; - const summarize = vi.fn().mockResolvedValue("summary text"); + const promptRef = { current: { value: "stale prompt" } as unknown as HTMLTextAreaElement }; + const summarize = vi.fn().mockResolvedValue({ summary: "summary text" }); const setContextValue = vi.fn(); const setHasPrompt = vi.fn(); const { result } = renderHook(() => @@ -136,7 +136,7 @@ describe("useSessionContextChange", () => { it("handles summarize failures by clearing context and showing toast", async () => { const promptRef = { current: { value: "" } as unknown as HTMLTextAreaElement }; - const summarize = vi.fn().mockResolvedValue(null); + const summarize = vi.fn().mockResolvedValue({ summary: null, error: "connection refused" }); const setContextValue = vi.fn(); const setHasPrompt = vi.fn(); const { result } = renderHook(() => @@ -157,16 +157,16 @@ describe("useSessionContextChange", () => { expect(setContextValue).toHaveBeenCalledWith("blank"); expect(mockToast).toHaveBeenCalledWith({ title: "Summarize failed", - description: - "Could not generate a summary. Check that the summarize utility agent is configured and enabled in settings.", + description: "connection refused", variant: "error", }); - expect(setHasPrompt).not.toHaveBeenCalled(); + expect(promptRef.current.value).toBe(""); + expect(setHasPrompt).toHaveBeenCalledWith(false); }); it("does not toast when summarize succeeds after prompt ref unmounts", async () => { const promptRef = { current: null as HTMLTextAreaElement | null }; - const summarize = vi.fn().mockResolvedValue("summary text"); + const summarize = vi.fn().mockResolvedValue({ summary: "summary text" }); const setContextValue = vi.fn(); const setHasPrompt = vi.fn(); const { result } = renderHook(() => @@ -192,7 +192,7 @@ describe("useSessionContextChange", () => { it("sanitizes unsafe characters from summarize result before setting prompt", async () => { const promptRef = { current: { value: "" } as unknown as HTMLTextAreaElement }; - const summarize = vi.fn().mockResolvedValue("line1\r\n\nline2"); + const summarize = vi.fn().mockResolvedValue({ summary: "line1\r\n\nline2" }); const setContextValue = vi.fn(); const setHasPrompt = vi.fn(); const { result } = renderHook(() => diff --git a/apps/web/components/task/new-session-form-actions.ts b/apps/web/components/task/new-session-form-actions.ts index e86b1ac62b..d4dda96fee 100644 --- a/apps/web/components/task/new-session-form-actions.ts +++ b/apps/web/components/task/new-session-form-actions.ts @@ -4,26 +4,18 @@ import { buildStartRequest } from "@/lib/services/session-launch-helpers"; import { toMessageAttachments } from "@/components/task-create-dialog-helpers"; import type { FileAttachment } from "./chat/file-attachment"; import type { AgentProfileOption } from "@/lib/state/slices"; - -type ToastFn = (opts: { - title: string; - description?: string; - variant?: "error" | "default"; -}) => void; +import type { SummarizeSessionResult } from "@/hooks/use-summarize-session"; +import { applySummarizeSessionResult, type SummaryToastFn } from "./session-context-summary"; type SessionContextChangeOpts = { promptRef: RefObject; initialPrompt: string | null; - summarize: (sessionId: string) => Promise; - toast: ToastFn; + summarize: (sessionId: string) => Promise; + toast: SummaryToastFn; setContextValue: (v: string) => void; setHasPrompt: (v: boolean) => void; }; -function sanitizePromptText(value: string): string { - return value.replace(/\r/g, "").replace(/[<>]/g, " "); -} - export function useSessionContextChange(opts: SessionContextChangeOpts) { const { promptRef, initialPrompt, summarize, toast, setContextValue, setHasPrompt } = opts; return useCallback( @@ -39,18 +31,7 @@ export function useSessionContextChange(opts: SessionContextChangeOpts) { } else if (value.startsWith("summarize:")) { const sessionId = value.slice("summarize:".length); const result = await summarize(sessionId); - if (result === null) { - setContextValue("blank"); - toast({ - title: "Summarize failed", - description: - "Could not generate a summary. Check that the summarize utility agent is configured and enabled in settings.", - variant: "error", - }); - } else if (promptRef.current) { - promptRef.current.value = sanitizePromptText(result); - setHasPrompt(true); - } + applySummarizeSessionResult({ result, promptRef, setContextValue, setHasPrompt, toast }); } }, [initialPrompt, promptRef, summarize, setContextValue, setHasPrompt, toast], @@ -83,7 +64,7 @@ export function useSessionLaunchSubmit({ groupId?: string; attachments: FileAttachment[]; onClose: () => void; - toast: ToastFn; + toast: SummaryToastFn; setActiveSession: (taskId: string, sessionId: string) => void; activateSession: ( sessionId: string, diff --git a/apps/web/components/task/new-subtask-dialog.tsx b/apps/web/components/task/new-subtask-dialog.tsx index 4e82bd8db1..a29c16b2ad 100644 --- a/apps/web/components/task/new-subtask-dialog.tsx +++ b/apps/web/components/task/new-subtask-dialog.tsx @@ -17,7 +17,7 @@ import { import { useSettingsData } from "@/hooks/domains/settings/use-settings-data"; import { useRepositories } from "@/hooks/domains/workspace/use-repositories"; import { useIsUtilityConfigured } from "@/hooks/use-is-utility-configured"; -import { useSummarizeSession } from "@/hooks/use-summarize-session"; +import { useSummarizeSession, type SummarizeSessionResult } from "@/hooks/use-summarize-session"; import { useTaskSessions } from "@/hooks/use-task-sessions"; import { getLocalStorage } from "@/lib/local-storage"; import { STORAGE_KEYS } from "@/lib/settings/constants"; @@ -29,6 +29,7 @@ import { useSubtaskFormState, } from "./new-subtask-form-state"; import { PromptZone, SubtaskFormBody } from "./new-subtask-form-parts"; +import { applySummarizeSessionResult, type SummaryToastFn } from "./session-context-summary"; import { useSubtaskPromptZone, useSubtaskSubmit } from "./use-subtask-submit"; type NewSubtaskDialogProps = { @@ -166,9 +167,10 @@ function useContextChangeHandler(opts: { setHasPrompt: (v: boolean) => void; promptRef: React.RefObject; initialPrompt: string | null; - summarize: (sessionId: string) => Promise; + summarize: (sessionId: string) => Promise; + toast: SummaryToastFn; }) { - const { setContextValue, setHasPrompt, promptRef, initialPrompt, summarize } = opts; + const { setContextValue, setHasPrompt, promptRef, initialPrompt, summarize, toast } = opts; return useCallback( async (value: string) => { if (!value) return; @@ -187,13 +189,10 @@ function useContextChangeHandler(opts: { } if (value.startsWith("summarize:")) { const result = await summarize(value.slice("summarize:".length)); - if (result && promptRef.current) { - promptRef.current.value = result; - setHasPrompt(true); - } + applySummarizeSessionResult({ result, promptRef, setContextValue, setHasPrompt, toast }); } }, - [setContextValue, setHasPrompt, promptRef, initialPrompt, summarize], + [setContextValue, setHasPrompt, promptRef, initialPrompt, summarize, toast], ); } @@ -268,6 +267,7 @@ function NewSubtaskForm({ promptRef: promptZone.promptRef, initialPrompt, summarize, + toast, }); const { handleSubmit } = useSubtaskSubmit({ fs, diff --git a/apps/web/components/task/passthrough-chat-composer.test.ts b/apps/web/components/task/passthrough-chat-composer.test.ts new file mode 100644 index 0000000000..e6ce47d45f --- /dev/null +++ b/apps/web/components/task/passthrough-chat-composer.test.ts @@ -0,0 +1,228 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { ContextFile } from "@/lib/state/context-files-store"; +import type { DiffComment } from "@/lib/diff/types"; +import type { TaskMentionData } from "@/hooks/use-inline-mention"; +import { + buildContextFilesMeta, + buildPassthroughFinalMessage, + buildPassthroughPlanContext, + clearPassthroughComposerContext, + formatPassthroughBaseMessage, +} from "./passthrough-chat-composer"; + +const mockGetTaskPlan = vi.hoisted(() => vi.fn()); + +vi.mock("@/lib/api/domains/plan-api", () => ({ + getTaskPlan: mockGetTaskPlan, +})); + +const SESSION_ID = "session-1"; +const TASK_ID = "task-1"; +const PLAN_CONTEXT_PATH = "plan:context"; + +function file(path: string, name = path): ContextFile { + return { path, name }; +} + +function comment(id = "comment-1"): DiffComment { + return { + id, + source: "diff", + sessionId: SESSION_ID, + filePath: "src/app.ts", + startLine: 10, + endLine: 11, + side: "additions", + codeContent: "const value = 1;", + text: "Please fix this", + status: "pending", + createdAt: "2026-06-17T00:00:00.000Z", + }; +} + +function panelState(overrides: Record = {}) { + return { + resolvedSessionId: SESSION_ID, + taskId: TASK_ID, + contextFiles: [], + prompts: [], + pendingPRFeedback: [], + planComments: [], + planModeEnabled: false, + handleClearPRFeedback: vi.fn(), + clearSessionPlanComments: vi.fn(), + clearEphemeral: vi.fn(), + addContextFile: vi.fn(), + ...overrides, + } as never; +} + +beforeEach(() => { + mockGetTaskPlan.mockReset(); +}); + +describe("passthrough chat composer metadata helpers", () => { + it("filters virtual prompt and plan context files from context_files metadata", () => { + expect( + buildContextFilesMeta([ + file("src/app.ts", "app.ts"), + file("prompt:review", "Review prompt"), + file(PLAN_CONTEXT_PATH, "Plan"), + ]), + ).toEqual([{ path: "src/app.ts", name: "app.ts" }]); + + expect(buildContextFilesMeta([file("prompt:review"), file(PLAN_CONTEXT_PATH)])).toBeUndefined(); + }); + + it("prepends pending review comments when no structured comment payload is supplied", () => { + const result = formatPassthroughBaseMessage("Ship it", undefined, [comment()], panelState()); + + expect(result.commentsToSend).toHaveLength(1); + expect(result.formatted).toContain("### Review Comments"); + expect(result.formatted).toContain("Please fix this"); + expect(result.formatted).toContain("Ship it"); + }); + + it("merges selected context files with inline file, prompt, and task mentions", async () => { + const inlineTask: TaskMentionData = { + taskId: "task-2", + title: "Follow-up task", + workflowId: "workflow-1", + workflowStepId: "step-1", + state: "in_progress", + }; + const result = await buildPassthroughFinalMessage({ + taskId: TASK_ID, + content: "Please check this", + pendingComments: [], + panelState: panelState({ + contextFiles: [file("src/existing.ts", "existing.ts")], + prompts: [{ id: "prompt-1", name: "Review", content: "Look carefully." }], + }), + inlineMentions: [file("src/inline.ts", "inline.ts"), file("prompt:prompt-1", "Review")], + inlineTaskMentions: [inlineTask], + getState: () => + ({ + kanban: { steps: [{ id: "step-1", title: "Review" }] }, + kanbanMulti: { snapshots: {} }, + taskPlans: { byTaskId: {} }, + }) as never, + }); + + expect(result.contextFilesMeta).toEqual([ + { path: "src/existing.ts", name: "existing.ts" }, + { path: "src/inline.ts", name: "inline.ts" }, + ]); + expect(result.content).toContain("CONTEXT FILES"); + expect(result.content).toContain("- src/existing.ts"); + expect(result.content).toContain("- src/inline.ts"); + expect(result.content).toContain("CONTEXT PROMPTS"); + expect(result.content).toContain("Look carefully."); + expect(result.content).toContain("REFERENCED TASKS"); + expect(result.content).toContain("Follow-up task"); + }); +}); + +describe("passthrough chat composer plan context", () => { + it("expands selected plan context and strips the literal @Plan mention", async () => { + const result = await buildPassthroughFinalMessage({ + taskId: TASK_ID, + content: "@Plan", + pendingComments: [], + panelState: panelState(), + inlineMentions: [file(PLAN_CONTEXT_PATH, "Plan")], + getState: () => + ({ + kanban: { steps: [] }, + kanbanMulti: { snapshots: {} }, + taskPlans: { + byTaskId: { + [TASK_ID]: { content: "## Plan\n\n1. Ship passthrough fixes" }, + }, + }, + }) as never, + }); + + expect(result.content).toContain(""); + expect(result.content).toContain("CONTEXT PLAN"); + expect(result.content).toContain("## Plan"); + expect(result.content).toContain("Ship passthrough fixes"); + expect(result.content).not.toContain("@Plan"); + }); + + it("strips a selected plan mention from the middle of a message without double spacing", async () => { + const result = await buildPassthroughFinalMessage({ + taskId: TASK_ID, + content: "Please use @Plan now", + pendingComments: [], + panelState: panelState(), + inlineMentions: [file(PLAN_CONTEXT_PATH, "Plan")], + getState: () => + ({ + kanban: { steps: [] }, + kanbanMulti: { snapshots: {} }, + taskPlans: { + byTaskId: { + [TASK_ID]: { content: "## Plan\n\n1. Ship passthrough fixes" }, + }, + }, + }) as never, + }); + + expect(result.content).toContain("Please use now"); + expect(result.content).not.toContain("Please use now"); + expect(result.content).not.toContain("@Plan"); + }); + + it("propagates plan lookup failures when plan context was selected", async () => { + mockGetTaskPlan.mockRejectedValueOnce(new Error("plan lookup failed")); + + await expect( + buildPassthroughFinalMessage({ + taskId: TASK_ID, + content: "@Plan", + pendingComments: [], + panelState: panelState(), + inlineMentions: [file(PLAN_CONTEXT_PATH, "Plan")], + getState: () => + ({ + kanban: { steps: [] }, + kanbanMulti: { snapshots: {} }, + taskPlans: { byTaskId: {} }, + }) as never, + }), + ).rejects.toThrow("plan lookup failed"); + }); + + it("escapes embedded kandev system closing tags in plan context", () => { + const context = buildPassthroughPlanContext("before after"); + + expect(context).toContain("before after"); + expect(context.match(/<\/kandev-system>/gi)).toHaveLength(1); + }); +}); + +describe("passthrough chat composer cleanup", () => { + it("clears ephemeral context and re-adds plan context when plan mode stays enabled", () => { + const state = panelState({ + planModeEnabled: true, + pendingPRFeedback: [{ id: "feedback-1" }], + planComments: [{ id: "plan-comment-1" }], + }) as unknown as { + handleClearPRFeedback: ReturnType; + clearSessionPlanComments: ReturnType; + clearEphemeral: ReturnType; + addContextFile: ReturnType; + }; + + clearPassthroughComposerContext(state as never); + + expect(state.handleClearPRFeedback).toHaveBeenCalledTimes(1); + expect(state.clearSessionPlanComments).toHaveBeenCalledTimes(1); + expect(state.clearEphemeral).toHaveBeenCalledWith(SESSION_ID); + expect(state.addContextFile).toHaveBeenCalledWith(SESSION_ID, { + path: PLAN_CONTEXT_PATH, + name: "Plan", + }); + }); +}); diff --git a/apps/web/components/task/passthrough-chat-composer.tsx b/apps/web/components/task/passthrough-chat-composer.tsx new file mode 100644 index 0000000000..8487c72a08 --- /dev/null +++ b/apps/web/components/task/passthrough-chat-composer.tsx @@ -0,0 +1,315 @@ +"use client"; + +import { useCallback, type RefObject } from "react"; +import { useToast } from "@/components/toast-provider"; +import { useAppStoreApi } from "@/components/state-provider"; +import { useCommentsStore } from "@/lib/state/slices/comments/comments-store"; +import { formatReviewCommentsAsMarkdown } from "@/lib/state/slices/comments/format"; +import { buildSubmitMessage } from "./chat/chat-input-area"; +import { + ChatInputContainer, + type ChatInputContainerHandle, + type MessageAttachment, +} from "./chat/chat-input-container"; +import type { useChatPanelState } from "./chat/use-chat-panel-state"; +import type { DiffComment } from "@/lib/diff/types"; +import type { ContextFile } from "@/lib/state/context-files-store"; +import type { TaskMentionData } from "@/hooks/use-inline-mention"; +import { buildContextFilesContext, buildTaskMentionsContext } from "@/hooks/use-message-handler"; +import { getWebSocketClient } from "@/lib/ws/connection"; +import { getTaskPlan } from "@/lib/api/domains/plan-api"; +import type { AppState } from "@/lib/state/store"; + +const PLAN_CONTEXT_PATH = "plan:context"; + +export type PassthroughSubmitHandler = ( + content: string, + reviewComments?: DiffComment[], + attachments?: MessageAttachment[], + inlineMentions?: ContextFile[], + inlineTaskMentions?: TaskMentionData[], +) => Promise; + +export function PassthroughComposerPanel({ + refHandle, + onSubmit, + onCancel, + panelState, + taskId, + isMoving, + isSending, +}: { + refHandle: RefObject; + onSubmit: PassthroughSubmitHandler; + onCancel: () => void; + panelState: ReturnType; + taskId: string | null; + isMoving: boolean; + isSending: boolean; +}) { + const hasContextComments = + panelState.planComments.length > 0 || panelState.pendingPRFeedback.length > 0; + return ( +
{ + if (event.key === "Escape") onCancel(); + }} + > + +
+ ); +} + +type PassthroughFinalMessage = { + content: string; + commentsToSend: DiffComment[]; + contextFilesMeta?: Array<{ path: string; name: string }>; +}; + +export function formatPassthroughBaseMessage( + content: string, + reviewComments: DiffComment[] | undefined, + pendingComments: DiffComment[], + panelState: ReturnType, +) { + const commentsToSend = reviewComments ?? pendingComments; + const hasStructuredComments = + !!reviewComments || + panelState.pendingPRFeedback.length > 0 || + panelState.planComments.length > 0; + if (hasStructuredComments) { + return { + formatted: buildSubmitMessage( + content, + commentsToSend.length > 0 ? commentsToSend : undefined, + panelState.pendingPRFeedback, + panelState.planComments, + ), + commentsToSend, + }; + } + if (pendingComments.length > 0) { + return { + formatted: formatReviewCommentsAsMarkdown(pendingComments) + content, + commentsToSend, + }; + } + return { formatted: content, commentsToSend }; +} + +function hasPlanContext(files: ContextFile[]) { + return files.some((file) => file.path === PLAN_CONTEXT_PATH); +} + +function stripSelectedPlanMentions(content: string, files: ContextFile[]) { + if (!hasPlanContext(files)) return content; + return content.replace(/\s*@Plan(?=\s|$)/g, "").trim(); +} + +function sanitizeSystemBlockContent(content: string) { + return content.replace(/<\/kandev-system>/gi, ""); +} + +export function buildPassthroughPlanContext(planContent: string | undefined | null) { + const trimmed = planContent?.trim(); + if (!trimmed) return ""; + return ( + `\n\n\n` + + `CONTEXT PLAN: The user has attached the current task plan as context. ` + + `Use this plan content to understand what they mean by the plan:\n` + + `${sanitizeSystemBlockContent(trimmed)}\n` + + `` + ); +} + +function cachedTaskPlanContent(taskId: string, state: AppState) { + return state.taskPlans.byTaskId[taskId]?.content; +} + +async function loadTaskPlanContent(taskId: string | null, getState: () => AppState) { + if (!taskId) return ""; + const cached = cachedTaskPlanContent(taskId, getState()); + if (cached !== undefined) return cached; + const plan = await getTaskPlan(taskId); + return plan?.content ?? ""; +} + +export async function buildPassthroughFinalMessage({ + taskId, + content, + reviewComments, + pendingComments, + panelState, + inlineMentions, + inlineTaskMentions, + getState, +}: { + taskId: string | null; + content: string; + reviewComments?: DiffComment[]; + pendingComments: DiffComment[]; + panelState: ReturnType; + inlineMentions?: ContextFile[]; + inlineTaskMentions?: TaskMentionData[]; + getState: ReturnType["getState"]; +}): Promise { + const { formatted, commentsToSend } = formatPassthroughBaseMessage( + content, + reviewComments, + pendingComments, + panelState, + ); + const allContextFiles = [...panelState.contextFiles, ...(inlineMentions ?? [])]; + const visibleContent = stripSelectedPlanMentions(formatted, allContextFiles); + const contextFilesContext = buildContextFilesContext(allContextFiles, panelState.prompts); + const planContext = hasPlanContext(allContextFiles) + ? buildPassthroughPlanContext(await loadTaskPlanContent(taskId, getState)) + : ""; + const taskMentionsContext = + inlineTaskMentions && inlineTaskMentions.length > 0 + ? buildTaskMentionsContext(inlineTaskMentions, getState()) + : ""; + return { + content: visibleContent + contextFilesContext + planContext + taskMentionsContext, + commentsToSend, + contextFilesMeta: buildContextFilesMeta(allContextFiles), + }; +} + +export function buildContextFilesMeta(files: ContextFile[]) { + const realContextFiles = files.filter( + (f) => !f.path.startsWith("prompt:") && f.path !== PLAN_CONTEXT_PATH, + ); + if (realContextFiles.length === 0) return undefined; + return realContextFiles.map((f) => ({ path: f.path, name: f.name })); +} + +async function requestPassthroughMessage({ + taskId, + sessionId, + message, + attachments, +}: { + taskId: string; + sessionId: string; + message: PassthroughFinalMessage; + attachments?: MessageAttachment[]; +}) { + const client = getWebSocketClient(); + if (!client) throw new Error("WebSocket client not available"); + const hasAttachments = !!(attachments && attachments.length > 0); + await client.request( + "message.add", + { + task_id: taskId, + session_id: sessionId, + content: message.content, + ...(hasAttachments && { attachments }), + ...(message.contextFilesMeta && { context_files: message.contextFilesMeta }), + }, + hasAttachments ? 30_000 : 10_000, + ); +} + +export function clearPassthroughComposerContext(panelState: ReturnType) { + if (panelState.pendingPRFeedback.length > 0) { + panelState.handleClearPRFeedback(); + } + if (panelState.planComments.length > 0) { + panelState.clearSessionPlanComments(); + } + if (!panelState.resolvedSessionId) return; + panelState.clearEphemeral(panelState.resolvedSessionId); + if (panelState.planModeEnabled) { + panelState.addContextFile(panelState.resolvedSessionId, { + path: "plan:context", + name: "Plan", + }); + } +} + +export function useSendPassthroughMessage({ + taskId, + sessionId, + pendingComments, + panelState, + onSent, +}: { + taskId: string | null; + sessionId: string | null | undefined; + pendingComments: DiffComment[]; + panelState: ReturnType; + onSent: () => void; +}) { + const { toast } = useToast(); + const markCommentsSent = useCommentsStore((s) => s.markCommentsSent); + const storeApi = useAppStoreApi(); + + return useCallback( + async ( + content: string, + reviewComments?: DiffComment[], + attachments?: MessageAttachment[], + inlineMentions?: ContextFile[], + inlineTaskMentions?: TaskMentionData[], + ) => { + if (!taskId || !sessionId) { + toast({ title: "Session not ready", variant: "error" }); + throw new Error("Session not ready"); + } + try { + const message = await buildPassthroughFinalMessage({ + taskId, + content, + reviewComments, + pendingComments, + panelState, + inlineMentions, + inlineTaskMentions, + getState: storeApi.getState, + }); + await requestPassthroughMessage({ taskId, sessionId, message, attachments }); + if (message.commentsToSend.length > 0) { + markCommentsSent(message.commentsToSend.map((c) => c.id)); + } + clearPassthroughComposerContext(panelState); + onSent(); + } catch (error) { + console.error("Failed to send passthrough message:", error); + toast({ title: "Failed to send message", variant: "error" }); + throw error; + } + }, + [taskId, sessionId, toast, pendingComments, panelState, storeApi, markCommentsSent, onSent], + ); +} diff --git a/apps/web/components/task/passthrough-composer.tsx b/apps/web/components/task/passthrough-composer.tsx deleted file mode 100644 index 5819bc8945..0000000000 --- a/apps/web/components/task/passthrough-composer.tsx +++ /dev/null @@ -1,124 +0,0 @@ -"use client"; - -import { - useCallback, - useEffect, - useLayoutEffect, - useRef, - useState, - type KeyboardEvent, -} from "react"; -import { IconSend } from "@tabler/icons-react"; -import { Button } from "@kandev/ui/button"; -import { Textarea } from "@kandev/ui/textarea"; - -// Cap matches the `max-h-32` Tailwind class on the textarea below — 128 px. -const COMPOSER_MAX_HEIGHT_PX = 128; - -/** - * PassthroughComposer is the kandev-controlled compose box rendered alongside - * the PTY in passthrough mode. Submitting forwards the typed text via the - * onSubmit prop (which posts `message.add` over WS); the backend's - * `Executor.Prompt` routes passthrough sessions to PTY stdin so the CLI agent - * actually receives it. Enter submits; Shift+Enter inserts a newline. - */ -export function PassthroughComposer({ - onSubmit, - onCancel, - autoFocus = false, - placeholder = "Message the CLI agent (Enter to send, Shift+Enter for newline)", - header, -}: { - onSubmit: (content: string) => Promise; - onCancel?: () => void; - autoFocus?: boolean; - placeholder?: string; - /** Optional content rendered above the textarea — e.g. a pending-comments preview. */ - header?: React.ReactNode; -}) { - const [value, setValue] = useState(""); - const [isSending, setIsSending] = useState(false); - const textareaRef = useRef(null); - const trimmed = value.trim(); - const canSubmit = trimmed.length > 0 && !isSending; - - // Auto-grow the textarea with content (Shift+Enter newlines, long pastes) - // up to the max-h-32 cap. Done in JS so it works in browsers without - // CSS field-sizing support. - useLayoutEffect(() => { - const el = textareaRef.current; - if (!el) return; - el.style.height = "auto"; - el.style.height = `${Math.min(el.scrollHeight, COMPOSER_MAX_HEIGHT_PX)}px`; - }, [value]); - - useEffect(() => { - if (autoFocus) { - textareaRef.current?.focus(); - } - }, [autoFocus]); - - const submit = useCallback(async () => { - if (!canSubmit) return; - setIsSending(true); - try { - await onSubmit(trimmed); - setValue(""); // only clear on success — preserve text so user can retry on send failure - } catch { - // onSubmit already surfaced the error via toast; the user's typed value - // stays in the textarea intentionally so they can retry without retyping. - } finally { - setIsSending(false); - } - }, [canSubmit, onSubmit, trimmed]); - - const handleKeyDown = useCallback( - (e: KeyboardEvent) => { - if (e.key === "Enter" && !e.shiftKey) { - if (e.nativeEvent.isComposing) return; - e.preventDefault(); - void submit(); - return; - } - if (e.key === "Escape" && onCancel) { - e.preventDefault(); - onCancel(); - } - }, - [submit, onCancel], - ); - - return ( -
- {header} -
-