feat(cli-mode): forward Preview chat input to PTY in passthrough sessions (#1042)

* feat(cli-mode): route Preview chat input through PTY stdin in passthrough sessions

Branch Executor.Prompt on lifecycle.Manager.IsPassthroughSession so that
follow-up prompts from any UI surface (the kanban Preview screen, mobile
session view, etc.) reach the running CLI agent via PTY stdin instead of
silently falling through to the ACP path. WritePassthroughStdin failures
propagate so Service.handlePromptError reverts session state and surfaces
the error; MarkPassthroughRunning failures are non-fatal because the data
is already in the PTY.

Closes the backend half of #989; the Preview-side UI compose box ships
in a follow-up commit.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(cli-mode): add Preview-screen compose box for passthrough sessions (#989)

In the kanban Preview tab, render a small kandev-controlled composer below
the PassthroughTerminal when the session is is_passthrough. Submitting (Enter
or click) posts message.add over WS — the backend's Executor.Prompt routes
passthrough sessions to PTY stdin so the CLI agent actually receives it.
Shift+Enter inserts a newline; empty/whitespace input is disabled.

This closes #989 together with the previous backend commit.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(cli-mode): warn when dropping attachments on passthrough prompts

Code review found that a passthrough prompt carrying non-empty text and
attachments silently dropped the attachments — surprising for any UI
surface (e.g. the full-page TaskChatPanel) that lets users attach images
to a passthrough task. The plan flagged this exact "silent drop" risk.
Pass attachments into promptPassthrough and log a warning with the
count when any are dropped; empty-text-with-attachments still errors
out as before. Adds a regression-guard test.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* test(cli-mode): cover passthrough composer routing end-to-end

QA pass added integration-level tests at the Service.PromptTask layer
that exercise the full wiring (real Service + real Executor, with a
mocked agent manager) for the three issue-#989 acceptance paths:
prompt-routes-to-PTY, empty-prompt-surfaces-error, and write-failure-
reverts-session. Also adds a unicode/multiline preservation guard at
the Executor.Prompt unit layer. Lives in a new file to avoid pushing
task_operations_test.go over the file-length-limit threshold.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(cli-mode): preserve composer text on send failure + clarify dispatchOnly

Addresses two pieces of PR #1042 review feedback.

1. PassthroughComposer used to clear the textarea unconditionally after
   onSubmit resolved, but the WS handler swallowed transport errors and
   resolved normally — so a network blip silently wiped the user's typed
   message with no way to retry. Add a passthrough-specific send handler
   that rethrows after toasting, and gate the setValue("") in the composer
   on success. Preserves the ACP handler's swallow-and-resolve contract
   to avoid leaking unhandled rejections into the TaskChatPanel chain.
   New regression test: "preserves the typed text when onSubmit rejects".

2. Add a one-line comment at the passthrough branch in Executor.Prompt
   explaining why dispatchOnly is not forwarded — PTY writes are inherently
   fire-and-forget, so the flag has no analogue.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(cli-mode): toast disconnect + auto-grow passthrough composer textarea

Two follow-ups from PR #1042 review on commit e403ca8e.

1. handleSendPassthroughMessage threw when getWebSocketClient() returned
   null without surfacing a toast first — Composer.submit's catch then
   preserved the typed text but the user had no idea why the send didn't
   land (greptile P1, cubic P2). Toast a "Not connected" error before
   re-throwing so the failure is visible.

2. PassthroughComposer textarea stayed 36px tall on Shift+Enter multiline
   input because rows={1} + resize-none + only a CSS-based field-sizing
   hint meant non-Chromium browsers never grew the box (Claude UX
   suggestion). Add a useLayoutEffect that resets and re-measures
   scrollHeight on every value change, capped at max-h-32 (128 px) with
   overflow-y-auto for content that exceeds the cap.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
João Salavisa
2026-05-23 17:06:30 +01:00
committed by GitHub
parent a6ab8deff6
commit f3daeaf416
6 changed files with 665 additions and 7 deletions
@@ -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.
@@ -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
}
@@ -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)
}
}
@@ -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)
}
}
@@ -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(<PassthroughComposer onSubmit={onSubmit} />);
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(<PassthroughComposer onSubmit={onSubmit} />);
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(<PassthroughComposer onSubmit={onSubmit} />);
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(<PassthroughComposer onSubmit={onSubmit} />);
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(<PassthroughComposer onSubmit={onSubmit} />);
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<void>((resolve) => {
resolveSubmit = resolve;
});
const onSubmit = vi.fn().mockReturnValue(submitPromise);
render(<PassthroughComposer onSubmit={onSubmit} />);
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("");
});
});
@@ -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 (
<div className="h-full bg-card">
<PassthroughTerminal sessionId={session.id} mode="agent" />
<div className="flex h-full flex-col bg-card">
<div className="flex-1 min-h-0">
<PassthroughTerminal sessionId={session.id} mode="agent" />
</div>
<PassthroughComposer onSubmit={handleSendPassthroughMessage} />
</div>
);
}
@@ -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<void>;
}) {
const [value, setValue] = useState("");
const [isSending, setIsSending] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement | null>(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<HTMLTextAreaElement>) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
void submit();
}
},
[submit],
);
return (
<div
className="flex flex-shrink-0 items-end gap-2 border-t bg-card px-2 py-2"
data-testid="passthrough-composer"
>
<Textarea
ref={textareaRef}
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Message the CLI agent (Enter to send, Shift+Enter for newline)"
rows={1}
disabled={isSending}
className="min-h-9 max-h-32 flex-1 resize-none overflow-y-auto"
data-testid="passthrough-composer-textarea"
/>
<Button
type="button"
size="sm"
variant="default"
onClick={() => void submit()}
disabled={!canSubmit}
className="cursor-pointer h-9 shrink-0"
data-testid="passthrough-composer-submit"
aria-label="Send message to CLI agent"
>
<IconSend className="h-4 w-4" />
</Button>
</div>
);
}
function RunningSpinner() {
return <GridSpinner className="text-muted-foreground shrink-0 text-[12px]" />;
}