fix: render passthrough terminal in quick chat (#744)

* fix(backend): start agent immediately for passthrough quick-chat sessions

Passthrough profiles need a running PTY for the terminal to attach. When
launchPrepare is called with a passthrough profile, upgrade the request to
launchStart so the PTY exists by the time the frontend renders the terminal.

* fix(web): render passthrough terminal in quick chat for passthrough profiles

Quick chat used MessageList + ChatInputArea unconditionally, so passthrough
profiles showed an empty chat with no agent output (PTY never attached).
Render PassthroughTerminal when the active session is passthrough, mirroring
the task-page branch. Passthrough is detected via session.is_passthrough or,
before WS hydrates, via the agent profile's cli_passthrough flag — looked up
through a new agentProfileId stored on quickChat session entries (set by the
picker on selection and seeded by SSR for restored sessions).

* style(web): apply prettier formatting

* fix(web): address review feedback on quick-chat passthrough rendering

- Narrow `metadata.agent_profile_id` to string at runtime via a
  `readAgentProfileId` helper instead of a blind type cast.
- Collapse `useIsQuickChatPassthrough` into a single `useShallow`
  selector to avoid three separate store subscriptions.

* fix(backend): prevent launchPrepare ↔ launchStart recursion for passthrough

When passthrough quick-chat sessions hit a workflow step that blocks
auto-start, launchStart's downgrade to launchPrepare would have re-upgraded
back into launchStart and looped forever. Skip the passthrough upgrade when
AutoStart=true, since that flag uniquely identifies the downgrade path.

Adds tests for `isPassthroughProfile` and a regression test that runs the
launchStart→launchPrepare path under a deadline so future recursions surface.

* test(web): cover existing agentProfileId preservation on reopen

* style(web): apply prettier formatting

* test(backend): seed task with blocked workflow step in recursion regression

Without persisted task and step, shouldBlockAutoStart short-circuited to
false, the test bypassed the launchStart→launchPrepare downgrade entirely,
and removing the recursion guard would still pass. Seed the task with a
WorkflowStepID mapping to a step lacking auto_start_agent so the guard is
the only thing preventing the bounce. Verified by temporarily removing the
guard — the test now times out as expected.

* test(backend): cover ResolveAgentProfile error path in isPassthroughProfile
This commit is contained in:
José Almeida
2026-04-30 18:36:26 +01:00
committed by GitHub
parent 3d2a04e776
commit 89332e1cbb
11 changed files with 272 additions and 11 deletions
@@ -117,6 +117,7 @@ func (m *mockTaskRepo) UpdateTaskState(_ context.Context, taskID string, state v
type mockAgentManager struct {
isPassthrough bool
isAgentRunning bool
resolveProfileErr error
restartProcessCalls []string // tracks execution IDs passed to RestartAgentProcess
restartProcessErr error
promptErr error
@@ -208,8 +209,12 @@ func (m *mockAgentManager) IsAgentRunningForSession(_ context.Context, _ string)
return m.isAgentRunning
}
func (m *mockAgentManager) ResolveAgentProfile(_ context.Context, _ string) (*executor.AgentProfileInfo, error) {
if m.resolveProfileErr != nil {
return nil, m.resolveProfileErr
}
return &executor.AgentProfileInfo{
SupportsMCP: true,
SupportsMCP: true,
CLIPassthrough: m.isPassthrough,
}, nil
}
func (m *mockAgentManager) RestartAgentProcess(_ context.Context, agentExecutionID string) error {
@@ -103,7 +103,17 @@ func (s *Service) LaunchSession(ctx context.Context, req *LaunchSessionRequest)
}
// launchPrepare creates a session entry without launching the agent.
// Passthrough profiles can't be "prepared" without a running PTY — the terminal
// has nothing to attach to until the agent process exists. Upgrade those calls
// to a full start so the PTY is ready by the time the user sees the terminal.
//
// AutoStart=true means we arrived here from launchStart's blocked-auto-start
// downgrade path; skipping the upgrade in that case avoids a launchStart ↔
// launchPrepare bounce.
func (s *Service) launchPrepare(ctx context.Context, req *LaunchSessionRequest) (*LaunchSessionResponse, error) {
if !req.AutoStart && s.isPassthroughProfile(ctx, req.AgentProfileID) {
return s.launchStart(ctx, req)
}
sessionID, err := s.PrepareTaskSession(
ctx, req.TaskID, req.AgentProfileID, req.ExecutorID,
req.ExecutorProfileID, req.WorkflowStepID, req.LaunchWorkspace,
@@ -119,6 +129,17 @@ func (s *Service) launchPrepare(ctx context.Context, req *LaunchSessionRequest)
}, nil
}
func (s *Service) isPassthroughProfile(ctx context.Context, profileID string) bool {
if profileID == "" || s.agentManager == nil {
return false
}
info, err := s.agentManager.ResolveAgentProfile(ctx, profileID)
if err != nil || info == nil {
return false
}
return info.CLIPassthrough
}
// launchStart creates a new session and launches the agent.
// If the request is an auto-start and the task's current workflow step does not
// have auto_start_agent, the request is downgraded to a prepare (workspace-only,
@@ -2,9 +2,14 @@ package orchestrator
import (
"context"
"errors"
"testing"
"time"
"github.com/kandev/kandev/internal/task/models"
sqliterepo "github.com/kandev/kandev/internal/task/repository/sqlite"
wfmodels "github.com/kandev/kandev/internal/workflow/models"
v1 "github.com/kandev/kandev/pkg/api/v1"
)
func TestResolveIntent(t *testing.T) {
@@ -186,6 +191,142 @@ func TestLaunchRestoreWorkspace_Success(t *testing.T) {
}
}
// --- launchPrepare passthrough upgrade ---
func TestIsPassthroughProfile(t *testing.T) {
repo := setupTestRepo(t)
passthroughMgr := &mockAgentManager{isPassthrough: true}
regularMgr := &mockAgentManager{isPassthrough: false}
t.Run("passthrough profile detected", func(t *testing.T) {
svc := createTestServiceWithAgent(repo, newMockStepGetter(), newMockTaskRepo(), passthroughMgr)
if !svc.isPassthroughProfile(context.Background(), "profile1") {
t.Error("expected isPassthroughProfile=true for passthrough profile")
}
})
t.Run("non-passthrough profile not detected", func(t *testing.T) {
svc := createTestServiceWithAgent(repo, newMockStepGetter(), newMockTaskRepo(), regularMgr)
if svc.isPassthroughProfile(context.Background(), "profile1") {
t.Error("expected isPassthroughProfile=false for non-passthrough profile")
}
})
t.Run("empty profile id returns false", func(t *testing.T) {
svc := createTestServiceWithAgent(repo, newMockStepGetter(), newMockTaskRepo(), passthroughMgr)
if svc.isPassthroughProfile(context.Background(), "") {
t.Error("expected isPassthroughProfile=false for empty profile id")
}
})
t.Run("nil agent manager returns false", func(t *testing.T) {
svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo())
svc.agentManager = nil
if svc.isPassthroughProfile(context.Background(), "profile1") {
t.Error("expected isPassthroughProfile=false when agent manager is nil")
}
})
t.Run("resolver error returns false", func(t *testing.T) {
errorMgr := &mockAgentManager{resolveProfileErr: errors.New("lookup failed")}
svc := createTestServiceWithAgent(repo, newMockStepGetter(), newMockTaskRepo(), errorMgr)
if svc.isPassthroughProfile(context.Background(), "profile1") {
t.Error("expected isPassthroughProfile=false when resolver errors")
}
})
}
// TestLaunchPrepare_PassthroughDoesNotRecurse guards against an infinite
// launchStart ↔ launchPrepare bounce when a passthrough profile is combined
// with AutoStart=true and a step that blocks auto-start.
//
// To actually exercise the guard, the task must be persisted with a
// WorkflowStepID that maps to a step lacking `auto_start_agent` — otherwise
// `shouldBlockAutoStart` short-circuits to false and the test bypasses the
// downgrade path entirely. Wiring the full scheduler+executor stack is too
// heavy, so we run in a goroutine with a panic recover: stack-overflow
// recursion would never return, while the legitimate downstream nil-deref
// panic from the stub scheduler still completes within the deadline.
func TestLaunchPrepare_PassthroughDoesNotRecurse(t *testing.T) {
repo := setupTestRepo(t)
mgr := &mockAgentManager{isPassthrough: true}
stepGetter := newMockStepGetter()
stepGetter.steps["step-blocked"] = &wfmodels.WorkflowStep{
ID: "step-blocked",
Name: "blocked",
// no on_enter actions => auto_start_agent missing => blocked
}
taskRepo := newMockTaskRepo()
svc := createTestServiceWithAgent(repo, stepGetter, taskRepo, mgr)
// Persist a task with the blocking step so shouldBlockAutoStart returns
// true, forcing launchStart to downgrade into launchPrepare. Without the
// `!req.AutoStart` guard, that re-enters launchStart and recurses.
seedTaskAndSessionWithStep(t, repo, "task1", "sess-pass", "step-blocked")
done := make(chan struct{})
go func() {
defer func() {
// Stub scheduler nil-deref is expected. Recursion would never reach
// this point.
_ = recover()
close(done)
}()
_, _ = svc.LaunchSession(context.Background(), &LaunchSessionRequest{
TaskID: "task1",
Intent: IntentStart,
AgentProfileID: "profile-pass",
AutoStart: true,
WorkflowStepID: "step-blocked",
})
}()
select {
case <-done:
// returned without recursing
case <-time.After(2 * time.Second):
t.Fatal("LaunchSession recursed indefinitely (timed out)")
}
}
// seedTaskAndSessionWithStep is a variant of seedTaskAndSession that wires
// the task to a workflow step, required by shouldBlockAutoStart.
func seedTaskAndSessionWithStep(t *testing.T, repo *sqliterepo.Repository, taskID, sessionID, stepID string) {
t.Helper()
ctx := context.Background()
now := time.Now().UTC()
ws := &models.Workspace{ID: "ws1", Name: "Test", CreatedAt: now, UpdatedAt: now}
_ = repo.CreateWorkspace(ctx, ws)
wf := &models.Workflow{ID: "wf1", WorkspaceID: "ws1", Name: "Test Workflow", CreatedAt: now, UpdatedAt: now}
_ = repo.CreateWorkflow(ctx, wf)
task := &models.Task{
ID: taskID,
WorkflowID: "wf1",
WorkflowStepID: stepID,
Title: "Test Task",
State: v1.TaskStateInProgress,
CreatedAt: now,
UpdatedAt: now,
}
if err := repo.CreateTask(ctx, task); err != nil {
t.Fatalf("failed to create task: %v", err)
}
session := &models.TaskSession{
ID: sessionID,
TaskID: taskID,
State: models.TaskSessionStateCreated,
StartedAt: now,
UpdatedAt: now,
}
if err := repo.CreateTaskSession(ctx, session); err != nil {
t.Fatalf("failed to create session: %v", err)
}
}
func TestLaunchRestoreWorkspace_IncludesWorktreeInfo(t *testing.T) {
ctx := context.Background()
repo := setupTestRepo(t)
+9
View File
@@ -47,6 +47,14 @@ function buildUserSettingsState(
return { ...mapUserSettingsResponse(resp), workspaceId };
}
function readAgentProfileId(
metadata: Record<string, unknown> | null | undefined,
): string | undefined {
if (!metadata || typeof metadata !== "object") return undefined;
const value = metadata.agent_profile_id;
return typeof value === "string" ? value : undefined;
}
function resolveActiveId<T extends { id: string }>(
items: T[],
preferredId?: string,
@@ -159,6 +167,7 @@ export default async function Page({ searchParams }: PageProps) {
sessionId: t.primary_session_id!,
workspaceId: t.workspace_id,
name: t.title !== "Quick Chat" ? t.title : undefined,
agentProfileId: readAgentProfileId(t.metadata),
}));
initialState = {
@@ -0,0 +1,50 @@
import { describe, it, expect } from "vitest";
import { create } from "zustand";
import { immer } from "zustand/middleware/immer";
import { createUISlice } from "@/lib/state/slices/ui/ui-slice";
import type { UISlice } from "@/lib/state/slices/ui/types";
const SESSION_ID = "sess-1";
const WORKSPACE_ID = "ws-1";
const PROFILE_ID = "profile-pass";
function makeStore() {
return create<UISlice>()(immer(createUISlice));
}
function findSession(store: ReturnType<typeof makeStore>) {
return store.getState().quickChat.sessions.find((s) => s.sessionId === SESSION_ID);
}
describe("openQuickChat agentProfileId persistence", () => {
it("stores agentProfileId on a new session entry", () => {
const store = makeStore();
store.getState().openQuickChat(SESSION_ID, WORKSPACE_ID, PROFILE_ID);
expect(findSession(store)?.agentProfileId).toBe(PROFILE_ID);
expect(store.getState().quickChat.activeSessionId).toBe(SESSION_ID);
});
it("backfills agentProfileId on an existing session entry without one", () => {
const store = makeStore();
store.getState().openQuickChat(SESSION_ID, WORKSPACE_ID);
expect(findSession(store)?.agentProfileId).toBeUndefined();
store.getState().openQuickChat(SESSION_ID, WORKSPACE_ID, PROFILE_ID);
expect(findSession(store)?.agentProfileId).toBe(PROFILE_ID);
});
it("does not duplicate sessions when reopening with the same id", () => {
const store = makeStore();
store.getState().openQuickChat(SESSION_ID, WORKSPACE_ID, "profile-a");
store.getState().openQuickChat(SESSION_ID, WORKSPACE_ID, "profile-a");
expect(
store.getState().quickChat.sessions.filter((s) => s.sessionId === SESSION_ID),
).toHaveLength(1);
});
it("keeps existing agentProfileId when reopened without one", () => {
const store = makeStore();
store.getState().openQuickChat(SESSION_ID, WORKSPACE_ID, PROFILE_ID);
store.getState().openQuickChat(SESSION_ID, WORKSPACE_ID); // no profile
expect(findSession(store)?.agentProfileId).toBe(PROFILE_ID);
});
});
@@ -1,10 +1,12 @@
"use client";
import { memo } from "react";
import { useShallow } from "zustand/react/shallow";
import { Dialog, DialogContent, DialogTitle } from "@kandev/ui/dialog";
import { Button } from "@kandev/ui/button";
import { IconMessageCircle, IconPlus, IconX } from "@tabler/icons-react";
import { useAppStore } from "@/components/state-provider";
import { PassthroughTerminal } from "@/components/task/passthrough-terminal";
import { QuickChatContent } from "./quick-chat-content";
import { QuickChatDeleteDialog } from "./quick-chat-delete-dialog";
import { useQuickChatModal } from "./use-quick-chat-modal";
@@ -76,6 +78,32 @@ function QuickChatTabs({
);
}
function useIsQuickChatPassthrough(sessionId: string) {
return useAppStore(
useShallow((s) => {
const session = s.taskSessions.items[sessionId];
if (typeof session?.is_passthrough === "boolean") return session.is_passthrough;
const profileId =
session?.agent_profile_id ??
s.quickChat.sessions.find((qs) => qs.sessionId === sessionId)?.agentProfileId;
if (!profileId) return false;
return s.agentProfiles.items.find((p) => p.id === profileId)?.cli_passthrough === true;
}),
);
}
function QuickChatSessionView({ sessionId }: { sessionId: string }) {
const isPassthrough = useIsQuickChatPassthrough(sessionId);
if (isPassthrough) {
return (
<div className="flex-1 min-h-0 overflow-hidden">
<PassthroughTerminal key={sessionId} sessionId={sessionId} mode="agent" />
</div>
);
}
return <QuickChatContent sessionId={sessionId} />;
}
function AgentPickerView({ onSelectAgent }: { onSelectAgent: (agentId: string) => void }) {
const agentProfiles = useAppStore((s) => s.agentProfiles.items) ?? [];
@@ -145,7 +173,7 @@ export const QuickChatModal = memo(function QuickChatModal({ workspaceId }: Quic
onNewChat={handleNewChat}
/>
{activeSessionId && !activeSessionNeedsAgent && (
<QuickChatContent sessionId={activeSessionId} />
<QuickChatSessionView sessionId={activeSessionId} />
)}
{activeSessionNeedsAgent && <AgentPickerView onSelectAgent={handleSelectAgent} />}
</DialogContent>
@@ -62,7 +62,7 @@ export function useQuickChatModal(workspaceId: string) {
title: initialName,
});
if (store.activeSessionId === "") store.closeQuickChatSession("");
store.openQuickChat(response.session_id, workspaceId);
store.openQuickChat(response.session_id, workspaceId, agentId);
store.renameQuickChatSession(response.session_id, initialName);
setShowAgentPicker(false);
} catch (error) {
@@ -87,6 +87,7 @@ export async function listQuickChatSessions(workspaceId: string, options?: ApiRe
title: string;
workspace_id: string;
primary_session_id?: string | null;
metadata?: Record<string, unknown> | null;
}>;
}>(`/api/v1/workspaces/${workspaceId}/tasks?only_ephemeral=true&exclude_config=true`, options);
}
+7 -2
View File
@@ -67,7 +67,12 @@ export type SystemHealthState = {
export type QuickChatState = {
isOpen: boolean;
sessions: Array<{ sessionId: string; workspaceId: string; name?: string }>;
sessions: Array<{
sessionId: string;
workspaceId: string;
name?: string;
agentProfileId?: string;
}>;
activeSessionId: string | null;
};
@@ -133,7 +138,7 @@ export type UISliceActions = {
setSystemHealth: (response: SystemHealthResponse) => void;
setSystemHealthLoading: (loading: boolean) => void;
invalidateSystemHealth: () => void;
openQuickChat: (sessionId: string, workspaceId: string) => void;
openQuickChat: (sessionId: string, workspaceId: string, agentProfileId?: string) => void;
closeQuickChat: () => void;
closeQuickChatSession: (sessionId: string) => void;
setActiveQuickChatSession: (sessionId: string) => void;
+6 -5
View File
@@ -514,7 +514,7 @@ export const createUISlice: StateCreator<UISlice, [["zustand/immer", never]], []
draft.documentPanel.activeDocumentBySessionId[sessionId] = doc;
setLocalStorage(`active-document-${sessionId}`, doc as ActiveDocument | null);
}),
openQuickChat: (sessionId, workspaceId) =>
openQuickChat: (sessionId, workspaceId, agentProfileId) =>
set((draft) => {
draft.quickChat.isOpen = true;
// If sessionId is empty, create a placeholder tab for agent selection
@@ -527,10 +527,11 @@ export const createUISlice: StateCreator<UISlice, [["zustand/immer", never]], []
draft.quickChat.activeSessionId = "";
return;
}
// Add session if not already in list
const exists = draft.quickChat.sessions.some((s) => s.sessionId === sessionId);
if (!exists) {
draft.quickChat.sessions.push({ sessionId, workspaceId });
const existing = draft.quickChat.sessions.find((s) => s.sessionId === sessionId);
if (existing) {
if (agentProfileId) existing.agentProfileId = agentProfileId;
} else {
draft.quickChat.sessions.push({ sessionId, workspaceId, agentProfileId });
}
draft.quickChat.activeSessionId = sessionId;
}),
+1 -1
View File
@@ -331,7 +331,7 @@ export type AppState = {
setSystemHealth: (response: SystemHealthResponse) => void;
setSystemHealthLoading: (loading: boolean) => void;
invalidateSystemHealth: () => void;
openQuickChat: (sessionId: string, workspaceId: string) => void;
openQuickChat: (sessionId: string, workspaceId: string, agentProfileId?: string) => void;
closeQuickChat: () => void;
closeQuickChatSession: (sessionId: string) => void;
setActiveQuickChatSession: (sessionId: string) => void;