diff --git a/apps/backend/internal/orchestrator/executor/executor_interaction.go b/apps/backend/internal/orchestrator/executor/executor_interaction.go index 3f2e85dd01..20eff1166d 100644 --- a/apps/backend/internal/orchestrator/executor/executor_interaction.go +++ b/apps/backend/internal/orchestrator/executor/executor_interaction.go @@ -117,6 +117,13 @@ func (e *Executor) StopByTaskID(ctx context.Context, taskID string, reason strin return nil } +// passthroughSubmitSequence is appended to a passthrough prompt before writing +// to PTY stdin. "\r" submits the current line in every TUI agent CLI currently +// supported in passthrough mode (Claude, Codex, OpenCode, Cursor, Auggie, …). +// If a future agent needs a different sequence, lift this into a per-agent +// PassthroughConfig field — see the plan for issue #989. +const passthroughSubmitSequence = "\r" + // Prompt sends a follow-up prompt to a running agent for a task // Returns PromptResult indicating if the agent needs input // Attachments (images) are passed to the agent if provided @@ -147,6 +154,16 @@ func (e *Executor) Prompt(ctx context.Context, taskID, sessionID string, prompt zap.Int("attachments_count", len(attachments)), zap.Bool("dispatch_only", dispatchOnly)) + // Passthrough sessions don't speak ACP — route the prompt through PTY stdin + // so the CLI agent actually receives it. The Preview-screen chat input (and + // any other surface that calls message.add against a passthrough session) + // reaches this branch via Service.PromptTask → Executor.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) + } + result, err := e.agentManager.PromptAgent(ctx, executionID, prompt, attachments, dispatchOnly) if err != nil { if errors.Is(err, lifecycle.ErrExecutionNotFound) { @@ -157,6 +174,37 @@ func (e *Executor) Prompt(ctx context.Context, taskID, sessionID string, prompt return result, nil } +// 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). +// +// A WritePassthroughStdin failure (no live PTY, runner unavailable) is returned +// 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)") + } + 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 err := e.agentManager.WritePassthroughStdin(ctx, sessionID, prompt+passthroughSubmitSequence); err != nil { + return nil, fmt.Errorf("failed to write to passthrough stdin: %w", err) + } + if err := e.agentManager.MarkPassthroughRunning(sessionID); err != nil { + e.logger.Warn("failed to mark passthrough as running after prompt; data already in PTY", + zap.String("task_id", taskID), + zap.String("session_id", sessionID), + zap.Error(err)) + } + return &PromptResult{StopReason: "passthrough_dispatched"}, nil +} + // SwitchModel switches the model for a running session. It first attempts an in-place switch // via ACP session/set_model (instant, no process restart). If the agent doesn't support // in-place switching, it falls back to stopping and restarting the agent with the new model. diff --git a/apps/backend/internal/orchestrator/executor/executor_mocks_test.go b/apps/backend/internal/orchestrator/executor/executor_mocks_test.go index 124561cff1..eb19984780 100644 --- a/apps/backend/internal/orchestrator/executor/executor_mocks_test.go +++ b/apps/backend/internal/orchestrator/executor/executor_mocks_test.go @@ -24,9 +24,22 @@ type mockAgentManager struct { getExecutionIDForSessionFunc func(ctx context.Context, sessionID string) (string, error) isAgentRunningForSessionFunc func(ctx context.Context, sessionID string) bool cleanupStaleExecutionFunc func(ctx context.Context, sessionID string) error + promptAgentFunc func(ctx context.Context, agentExecutionID, prompt string, attachments []v1.MessageAttachment, dispatchOnly bool) (*PromptResult, error) + isPassthroughSessionFunc func(ctx context.Context, sessionID string) bool + writePassthroughStdinFunc func(ctx context.Context, sessionID, data string) error + markPassthroughRunningFunc func(sessionID string) error launchAgentCallCount int cleanupStaleExecutionCallCount int isAgentRunningForSessionCallArgs []string + promptAgentCallCount int + writePassthroughStdinCalls []passthroughStdinCall + markPassthroughRunningCalls []string +} + +// passthroughStdinCall captures one invocation of WritePassthroughStdin for assertions. +type passthroughStdinCall struct { + SessionID string + Data string } func (m *mockAgentManager) LaunchAgent(ctx context.Context, req *LaunchAgentRequest) (*LaunchAgentResponse, error) { @@ -74,7 +87,11 @@ func (m *mockAgentManager) StopAgentWithReason(ctx context.Context, agentExecuti return m.StopAgent(ctx, agentExecutionID, force) } -func (m *mockAgentManager) PromptAgent(ctx context.Context, agentExecutionID string, prompt string, _ []v1.MessageAttachment, _ bool) (*PromptResult, error) { +func (m *mockAgentManager) PromptAgent(ctx context.Context, agentExecutionID string, prompt string, attachments []v1.MessageAttachment, dispatchOnly bool) (*PromptResult, error) { + m.promptAgentCallCount++ + if m.promptAgentFunc != nil { + return m.promptAgentFunc(ctx, agentExecutionID, prompt, attachments, dispatchOnly) + } return nil, nil } @@ -110,12 +127,26 @@ func (m *mockAgentManager) GetSessionAuthMethods(_ string) []streams.AuthMethodI return nil } func (m *mockAgentManager) IsPassthroughSession(ctx context.Context, sessionID string) bool { + if m.isPassthroughSessionFunc != nil { + return m.isPassthroughSessionFunc(ctx, sessionID) + } return false } -func (m *mockAgentManager) WritePassthroughStdin(_ context.Context, _ string, _ string) error { +func (m *mockAgentManager) WritePassthroughStdin(ctx context.Context, sessionID string, data string) error { + m.writePassthroughStdinCalls = append(m.writePassthroughStdinCalls, passthroughStdinCall{ + SessionID: sessionID, + Data: data, + }) + if m.writePassthroughStdinFunc != nil { + return m.writePassthroughStdinFunc(ctx, sessionID, data) + } return nil } -func (m *mockAgentManager) MarkPassthroughRunning(_ string) error { +func (m *mockAgentManager) MarkPassthroughRunning(sessionID string) error { + m.markPassthroughRunningCalls = append(m.markPassthroughRunningCalls, sessionID) + if m.markPassthroughRunningFunc != nil { + return m.markPassthroughRunningFunc(sessionID) + } return nil } diff --git a/apps/backend/internal/orchestrator/executor/executor_passthrough_prompt_test.go b/apps/backend/internal/orchestrator/executor/executor_passthrough_prompt_test.go new file mode 100644 index 0000000000..5ae33988c1 --- /dev/null +++ b/apps/backend/internal/orchestrator/executor/executor_passthrough_prompt_test.go @@ -0,0 +1,213 @@ +package executor + +import ( + "context" + "errors" + "strings" + "testing" + + "github.com/kandev/kandev/internal/task/models" + v1 "github.com/kandev/kandev/pkg/api/v1" +) + +// seedPassthroughSession installs a task + session into the mock repo and wires +// the agent manager to claim a valid execution ID, so Executor.Prompt reaches +// the IsPassthroughSession branch instead of bailing on lookup errors. +func seedPassthroughSession(t *testing.T, repo *mockRepository, agentManager *mockAgentManager, taskID, sessionID, execID string) { + t.Helper() + repo.sessions[sessionID] = &models.TaskSession{ + ID: sessionID, + TaskID: taskID, + State: models.TaskSessionStateWaitingForInput, + } + agentManager.getExecutionIDForSessionFunc = func(_ context.Context, sid string) (string, error) { + if sid != sessionID { + t.Fatalf("unexpected session lookup: got %q want %q", sid, sessionID) + } + return execID, nil + } +} + +func TestExecutor_Prompt_PassthroughWritesStdin(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) + + result, err := exec.Prompt(context.Background(), "task-1", "sess-1", "hello agent", nil, false) + if err != nil { + t.Fatalf("Prompt returned error: %v", err) + } + if result == nil || result.StopReason != "passthrough_dispatched" { + t.Fatalf("expected passthrough_dispatched result, got %+v", result) + } + + if got := len(agentManager.writePassthroughStdinCalls); got != 1 { + t.Fatalf("expected 1 WritePassthroughStdin call, got %d", got) + } + call := agentManager.writePassthroughStdinCalls[0] + if call.SessionID != "sess-1" { + t.Errorf("WritePassthroughStdin session = %q, want sess-1", call.SessionID) + } + wantData := "hello agent" + passthroughSubmitSequence + if call.Data != wantData { + t.Errorf("WritePassthroughStdin data = %q, want %q", call.Data, wantData) + } + if !strings.HasSuffix(call.Data, "\r") { + t.Errorf("expected submit sequence suffix \\r, got %q", call.Data) + } + + if got := len(agentManager.markPassthroughRunningCalls); got != 1 { + t.Fatalf("expected 1 MarkPassthroughRunning call, got %d", got) + } + if agentManager.markPassthroughRunningCalls[0] != "sess-1" { + t.Errorf("MarkPassthroughRunning session = %q, want sess-1", agentManager.markPassthroughRunningCalls[0]) + } + + if agentManager.promptAgentCallCount != 0 { + t.Errorf("PromptAgent must not be called for passthrough sessions; call count = %d", agentManager.promptAgentCallCount) + } +} + +func TestExecutor_Prompt_PassthroughWriteErrorReturnsError(t *testing.T) { + repo := newMockRepository() + writeErr := errors.New("session sess-1 is not in passthrough mode") + agentManager := &mockAgentManager{ + isPassthroughSessionFunc: func(_ context.Context, _ string) bool { return true }, + writePassthroughStdinFunc: func(_ context.Context, _ string, _ string) error { return writeErr }, + } + seedPassthroughSession(t, repo, agentManager, "task-1", "sess-1", "exec-1") + exec := newTestExecutor(t, agentManager, repo) + + result, err := exec.Prompt(context.Background(), "task-1", "sess-1", "hi", nil, false) + if err == nil { + t.Fatalf("expected error from Prompt, got nil (result=%+v)", result) + } + if !errors.Is(err, writeErr) { + t.Errorf("expected wrapped writeErr in chain, got %v", err) + } + if result != nil { + t.Errorf("expected nil result on error, got %+v", result) + } + // MarkPassthroughRunning must not be called when the write fails — otherwise + // the UI would flash "running" for a prompt the agent never received. + if got := len(agentManager.markPassthroughRunningCalls); got != 0 { + t.Errorf("MarkPassthroughRunning must not be called when stdin write fails; got %d call(s)", got) + } +} + +func TestExecutor_Prompt_PassthroughMarkRunningErrorIsNonFatal(t *testing.T) { + repo := newMockRepository() + agentManager := &mockAgentManager{ + isPassthroughSessionFunc: func(_ context.Context, _ string) bool { return true }, + markPassthroughRunningFunc: func(_ string) error { return errors.New("status flip failed") }, + } + seedPassthroughSession(t, repo, agentManager, "task-1", "sess-1", "exec-1") + exec := newTestExecutor(t, agentManager, repo) + + result, err := exec.Prompt(context.Background(), "task-1", "sess-1", "hi", nil, false) + if err != nil { + t.Fatalf("expected nil error when MarkPassthroughRunning fails (data is already in PTY), got %v", err) + } + if result == nil || result.StopReason != "passthrough_dispatched" { + t.Fatalf("expected passthrough_dispatched result, got %+v", result) + } + if got := len(agentManager.writePassthroughStdinCalls); got != 1 { + t.Errorf("expected the stdin write to have happened exactly once, got %d", got) + } +} + +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. + 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) + + atts := []v1.MessageAttachment{{Type: "image", MimeType: "image/png", Name: "screenshot.png"}} + 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) + } + if result == nil || result.StopReason != "passthrough_dispatched" { + t.Fatalf("expected passthrough_dispatched result, got %+v", result) + } + 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) + } +} + +func TestExecutor_Prompt_PassthroughEmptyPromptReturnsError(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", "", nil, false) + if err == nil { + t.Fatal("expected error for empty passthrough prompt, got nil") + } + if got := len(agentManager.writePassthroughStdinCalls); got != 0 { + t.Errorf("WritePassthroughStdin must not be called for empty 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" +// at the right moment, even when the prompt itself contains LFs (Shift+Enter +// inserts those in the composer). +func TestExecutor_Prompt_PassthroughPreservesUnicodeAndMultiline(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) + + prompt := "café ☕\nline2\n日本語" + if _, err := exec.Prompt(context.Background(), "task-1", "sess-1", prompt, nil, false); err != nil { + t.Fatalf("Prompt returned error: %v", err) + } + if got := len(agentManager.writePassthroughStdinCalls); got != 1 { + t.Fatalf("expected 1 WritePassthroughStdin call, got %d", got) + } + want := prompt + "\r" + if agentManager.writePassthroughStdinCalls[0].Data != want { + t.Errorf("PTY payload = %q, want %q", agentManager.writePassthroughStdinCalls[0].Data, want) + } +} + +func TestExecutor_Prompt_ACPPathUnchanged(t *testing.T) { + repo := newMockRepository() + agentManager := &mockAgentManager{ + // IsPassthroughSession defaults to false; explicit false here for clarity. + isPassthroughSessionFunc: func(_ context.Context, _ string) bool { return false }, + } + seedPassthroughSession(t, repo, agentManager, "task-1", "sess-1", "exec-1") + exec := newTestExecutor(t, agentManager, repo) + + if _, err := exec.Prompt(context.Background(), "task-1", "sess-1", "hello", nil, false); err != nil { + t.Fatalf("Prompt returned error: %v", err) + } + if agentManager.promptAgentCallCount != 1 { + t.Errorf("expected PromptAgent to be called once, got %d", agentManager.promptAgentCallCount) + } + if got := len(agentManager.writePassthroughStdinCalls); got != 0 { + t.Errorf("WritePassthroughStdin must not be called in ACP mode; got %d", got) + } + if got := len(agentManager.markPassthroughRunningCalls); got != 0 { + t.Errorf("MarkPassthroughRunning must not be called in ACP mode; got %d", got) + } +} diff --git a/apps/backend/internal/orchestrator/task_operations_passthrough_test.go b/apps/backend/internal/orchestrator/task_operations_passthrough_test.go new file mode 100644 index 0000000000..c7036281a5 --- /dev/null +++ b/apps/backend/internal/orchestrator/task_operations_passthrough_test.go @@ -0,0 +1,139 @@ +package orchestrator + +import ( + "context" + "errors" + "strings" + "testing" + + "github.com/kandev/kandev/internal/orchestrator/executor" + "github.com/kandev/kandev/internal/task/models" + v1 "github.com/kandev/kandev/pkg/api/v1" +) + +// TestPromptTask_PassthroughRoutesToPTYStdin walks the full Service.PromptTask +// → Executor.Prompt → promptPassthrough path for a passthrough session and +// asserts the prompt reaches PTY stdin (not the ACP PromptAgent) with the +// submit-key suffix appended. This is the closest-to-prod regression guard for +// issue #989: it exercises session-state housekeeping, prompt routing, and the +// passthrough primitives in one go via the real orchestrator service + real +// executor. +func TestPromptTask_PassthroughRoutesToPTYStdin(t *testing.T) { + repo := setupTestRepo(t) + agentMgr := &mockAgentManager{ + isPassthrough: true, + isAgentRunning: true, + } + svc := createTestServiceWithAgent(repo, newMockStepGetter(), newMockTaskRepo(), agentMgr) + svc.executor = executor.NewExecutor(agentMgr, repo, testLogger(), executor.ExecutorConfig{}) + + seedTaskAndSession(t, repo, "task1", "session1", models.TaskSessionStateWaitingForInput) + seedExecutorRunning(t, repo, "session1", "task1", "exec-1") + + result, err := svc.PromptTask(context.Background(), "task1", "session1", "deploy please", "", false, nil, false) + if err != nil { + t.Fatalf("PromptTask returned error: %v", err) + } + if result == nil || result.StopReason != "passthrough_dispatched" { + t.Fatalf("expected passthrough_dispatched result, got %+v", result) + } + + agentMgr.mu.Lock() + stdinCalls := append([]passthroughStdinCall(nil), agentMgr.passthroughStdinCalls...) + markCalls := append([]string(nil), agentMgr.markPassthroughCalls...) + prompts := append([]string(nil), agentMgr.capturedPrompts...) + agentMgr.mu.Unlock() + + if len(stdinCalls) != 1 { + t.Fatalf("expected 1 WritePassthroughStdin call, got %d", len(stdinCalls)) + } + if stdinCalls[0].SessionID != "session1" { + t.Errorf("WritePassthroughStdin session = %q, want session1", stdinCalls[0].SessionID) + } + if stdinCalls[0].Data != "deploy please\r" { + t.Errorf("WritePassthroughStdin data = %q, want %q", stdinCalls[0].Data, "deploy please\r") + } + if len(markCalls) != 1 || markCalls[0] != "session1" { + t.Errorf("expected MarkPassthroughRunning([session1]), got %v", markCalls) + } + if len(prompts) != 0 { + t.Errorf("PromptAgent (ACP) must not be called for passthrough sessions, got prompts %v", prompts) + } +} + +// TestPromptTask_PassthroughEmptyPromptSurfacesError ensures the no-text + +// attachment-only edge case (which wsAddMessage permits) returns a clear error +// to the caller rather than silently dropping. Service.handlePromptError will +// surface this to the user via createPromptErrorMessage. +func TestPromptTask_PassthroughEmptyPromptSurfacesError(t *testing.T) { + repo := setupTestRepo(t) + agentMgr := &mockAgentManager{ + isPassthrough: true, + isAgentRunning: true, + } + svc := createTestServiceWithAgent(repo, newMockStepGetter(), newMockTaskRepo(), agentMgr) + svc.executor = executor.NewExecutor(agentMgr, repo, testLogger(), executor.ExecutorConfig{}) + + seedTaskAndSession(t, repo, "task1", "session1", models.TaskSessionStateWaitingForInput) + seedExecutorRunning(t, repo, "session1", "task1", "exec-1") + + attachments := []v1.MessageAttachment{{Type: "image", MimeType: "image/png", Name: "x.png"}} + _, err := svc.PromptTask(context.Background(), "task1", "session1", "", "", false, attachments, false) + if err == nil { + t.Fatal("expected error for empty prompt + attachments in passthrough mode, got nil") + } + if !strings.Contains(err.Error(), "passthrough") { + t.Errorf("expected error to mention passthrough, got %v", err) + } + + // Session state must be reverted to WAITING_FOR_INPUT by handlePromptError — + // no stuck "Agent is running" composer. + updated, gerr := repo.GetTaskSession(context.Background(), "session1") + if gerr != nil { + t.Fatalf("failed to reload session: %v", gerr) + } + if updated.State != models.TaskSessionStateWaitingForInput { + t.Errorf("expected session reverted to WAITING_FOR_INPUT, got %q", updated.State) + } +} + +// TestPromptTask_PassthroughWriteFailureRevertsSession ensures that when the +// PTY is gone (e.g., agent process exited but execution record lingers) the +// write failure surfaces as an error AND the session is reverted out of +// RUNNING — satisfying the issue's "clear error instead of silently dropping +// the comment" requirement. +func TestPromptTask_PassthroughWriteFailureRevertsSession(t *testing.T) { + repo := setupTestRepo(t) + agentMgr := &mockAgentManager{ + isPassthrough: true, + isAgentRunning: true, + passthroughStdinErr: errors.New("interactive runner not available"), + } + svc := createTestServiceWithAgent(repo, newMockStepGetter(), newMockTaskRepo(), agentMgr) + svc.executor = executor.NewExecutor(agentMgr, repo, testLogger(), executor.ExecutorConfig{}) + + seedTaskAndSession(t, repo, "task1", "session1", models.TaskSessionStateWaitingForInput) + seedExecutorRunning(t, repo, "session1", "task1", "exec-1") + + _, err := svc.PromptTask(context.Background(), "task1", "session1", "anything", "", false, nil, false) + if err == nil { + t.Fatal("expected error from PromptTask when PTY write fails, got nil") + } + + updated, gerr := repo.GetTaskSession(context.Background(), "session1") + if gerr != nil { + t.Fatalf("failed to reload session: %v", gerr) + } + if updated.State != models.TaskSessionStateWaitingForInput { + t.Errorf("expected session reverted to WAITING_FOR_INPUT, got %q", updated.State) + } + + // MarkPassthroughRunning must NOT be called when the write failed — otherwise + // the UI would flash "running" for a prompt the agent never received. + agentMgr.mu.Lock() + markCount := len(agentMgr.markPassthroughCalls) + agentMgr.mu.Unlock() + if markCount != 0 { + t.Errorf("MarkPassthroughRunning must not fire on PTY write failure; got %d call(s)", markCount) + } +} diff --git a/apps/web/components/task/preview-session-tabs.test.tsx b/apps/web/components/task/preview-session-tabs.test.tsx new file mode 100644 index 0000000000..f361e96ab6 --- /dev/null +++ b/apps/web/components/task/preview-session-tabs.test.tsx @@ -0,0 +1,101 @@ +import { describe, it, expect, vi, afterEach } from "vitest"; +import { render, fireEvent, screen, cleanup, waitFor } from "@testing-library/react"; +import { PassthroughComposer } from "./preview-session-tabs"; + +const TEXTAREA_TID = "passthrough-composer-textarea"; +const SUBMIT_TID = "passthrough-composer-submit"; + +describe("PassthroughComposer", () => { + afterEach(() => { + cleanup(); + }); + + it("submits trimmed text on Enter and clears the textarea", async () => { + const onSubmit = vi.fn().mockResolvedValue(undefined); + render(); + + const textarea = screen.getByTestId(TEXTAREA_TID) as HTMLTextAreaElement; + fireEvent.change(textarea, { target: { value: " hello agent " } }); + fireEvent.keyDown(textarea, { key: "Enter" }); + + await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1)); + expect(onSubmit).toHaveBeenCalledWith("hello agent"); + await waitFor(() => expect(textarea.value).toBe("")); + }); + + it("submits when the send button is clicked", async () => { + const onSubmit = vi.fn().mockResolvedValue(undefined); + render(); + + fireEvent.change(screen.getByTestId(TEXTAREA_TID), { + target: { value: "ping" }, + }); + fireEvent.click(screen.getByTestId(SUBMIT_TID)); + + await waitFor(() => expect(onSubmit).toHaveBeenCalledWith("ping")); + }); + + it("Shift+Enter inserts a newline and does not submit", () => { + const onSubmit = vi.fn().mockResolvedValue(undefined); + render(); + + const textarea = screen.getByTestId(TEXTAREA_TID); + fireEvent.change(textarea, { target: { value: "line1" } }); + fireEvent.keyDown(textarea, { key: "Enter", shiftKey: true }); + + expect(onSubmit).not.toHaveBeenCalled(); + }); + + it("does not submit empty or whitespace-only input", () => { + const onSubmit = vi.fn().mockResolvedValue(undefined); + render(); + + const textarea = screen.getByTestId(TEXTAREA_TID); + fireEvent.keyDown(textarea, { key: "Enter" }); + expect(onSubmit).not.toHaveBeenCalled(); + + fireEvent.change(textarea, { target: { value: " \n " } }); + fireEvent.keyDown(textarea, { key: "Enter" }); + expect(onSubmit).not.toHaveBeenCalled(); + + const submitBtn = screen.getByTestId(SUBMIT_TID) as HTMLButtonElement; + expect(submitBtn.disabled).toBe(true); + }); + + it("preserves the typed text when onSubmit rejects so the user can retry", async () => { + const onSubmit = vi.fn().mockRejectedValue(new Error("network down")); + render(); + + const textarea = screen.getByTestId(TEXTAREA_TID) as HTMLTextAreaElement; + fireEvent.change(textarea, { target: { value: "important request" } }); + fireEvent.keyDown(textarea, { key: "Enter" }); + + await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1)); + // Composer must NOT clear on failure — losing typed text on a flaky WS + // disconnect was a real UX regression (issue #989 review feedback). + expect(textarea.value).toBe("important request"); + // Composer must re-enable so the user can resubmit. + await waitFor(() => expect(textarea.disabled).toBe(false)); + }); + + it("disables the composer while a submission is in flight", async () => { + let resolveSubmit!: () => void; + const submitPromise = new Promise((resolve) => { + resolveSubmit = resolve; + }); + const onSubmit = vi.fn().mockReturnValue(submitPromise); + + render(); + const textarea = screen.getByTestId(TEXTAREA_TID) as HTMLTextAreaElement; + const submitBtn = screen.getByTestId(SUBMIT_TID) as HTMLButtonElement; + fireEvent.change(textarea, { target: { value: "hi" } }); + fireEvent.keyDown(textarea, { key: "Enter" }); + + await waitFor(() => expect(textarea.disabled).toBe(true)); + expect(submitBtn.disabled).toBe(true); + + resolveSubmit(); + await waitFor(() => expect(textarea.disabled).toBe(false)); + expect(textarea.value).toBe(""); + }); +}); diff --git a/apps/web/components/task/preview-session-tabs.tsx b/apps/web/components/task/preview-session-tabs.tsx index 25845d19a7..4ace3ef338 100644 --- a/apps/web/components/task/preview-session-tabs.tsx +++ b/apps/web/components/task/preview-session-tabs.tsx @@ -1,8 +1,9 @@ "use client"; -import { useCallback, useMemo } from "react"; -import { IconLoader2 } from "@tabler/icons-react"; +import { useCallback, useLayoutEffect, useMemo, useRef, useState, type KeyboardEvent } from "react"; +import { IconLoader2, IconSend } from "@tabler/icons-react"; import { Button } from "@kandev/ui/button"; +import { Textarea } from "@kandev/ui/textarea"; import { AgentLogo } from "@/components/agent-logo"; import { GridSpinner } from "@/components/grid-spinner"; import { SessionTabs, type SessionTab } from "@/components/session-tabs"; @@ -143,6 +144,10 @@ function SessionAgentLogo({ profile }: { profile: AgentProfileOption | null | un function PreviewSessionBody({ session, taskId }: { session: TaskSession; taskId: string }) { const { toast } = useToast(); + // Used by the non-passthrough TaskChatPanel branch. Swallows errors after + // surfacing a toast because the existing chat-input-state.handleSubmit + // already optimistically clears the input before this resolves; rethrowing + // here would surface as an unhandled rejection further up the chain. const handleSendMessage = useCallback( async (content: string) => { const client = getWebSocketClient(); @@ -161,10 +166,43 @@ function PreviewSessionBody({ session, taskId }: { session: TaskSession; taskId: [taskId, session.id, toast], ); + // Used by PassthroughComposer. Rethrows after toasting so the composer can + // keep the user's typed text intact on failure (Composer.submit's catch + // skips the setValue("") clear when onSubmit rejects). Separate from the + // ACP handler above to avoid leaking unhandled rejections into the chat- + // input-state chain that TaskChatPanel uses. + const handleSendPassthroughMessage = useCallback( + async (content: string) => { + const client = getWebSocketClient(); + if (!client) { + // Surface the disconnect to the user before re-throwing so the + // composer's catch (which preserves the typed text) doesn't swallow + // the failure silently. + toast({ title: "Not connected — please reload to retry", variant: "error" }); + throw new Error("WebSocket client not available"); + } + try { + await client.request( + "message.add", + { task_id: taskId, session_id: session.id, content }, + 10000, + ); + } catch (error) { + console.error("Failed to send passthrough message:", error); + toast({ title: "Failed to send message", variant: "error" }); + throw error; + } + }, + [taskId, session.id, toast], + ); + if (session.is_passthrough) { return ( -
- +
+
+ +
+
); } @@ -176,6 +214,94 @@ function PreviewSessionBody({ session, taskId }: { session: TaskSession; taskId: ); } +/** + * 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. + */ +// Cap matches the `max-h-32` Tailwind class on the textarea below — 128 px. +const COMPOSER_MAX_HEIGHT_PX = 128; + +export function PassthroughComposer({ + onSubmit, +}: { + onSubmit: (content: string) => Promise; +}) { + 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]); + + 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 (PreviewSessionBody.handleSendPassthroughMessage) 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) { + e.preventDefault(); + void submit(); + } + }, + [submit], + ); + + return ( +
+