diff --git a/apps/backend/internal/orchestrator/event_handlers_test.go b/apps/backend/internal/orchestrator/event_handlers_test.go index 62c2c5c39d..42af6868f5 100644 --- a/apps/backend/internal/orchestrator/event_handlers_test.go +++ b/apps/backend/internal/orchestrator/event_handlers_test.go @@ -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 { diff --git a/apps/backend/internal/orchestrator/session_launch.go b/apps/backend/internal/orchestrator/session_launch.go index 6b785f3008..fd3543d0d0 100644 --- a/apps/backend/internal/orchestrator/session_launch.go +++ b/apps/backend/internal/orchestrator/session_launch.go @@ -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, diff --git a/apps/backend/internal/orchestrator/session_launch_test.go b/apps/backend/internal/orchestrator/session_launch_test.go index 00cf5290f8..8fd6e12902 100644 --- a/apps/backend/internal/orchestrator/session_launch_test.go +++ b/apps/backend/internal/orchestrator/session_launch_test.go @@ -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) diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx index 1e8678d2cc..5ad96c5fe1 100644 --- a/apps/web/app/page.tsx +++ b/apps/web/app/page.tsx @@ -47,6 +47,14 @@ function buildUserSettingsState( return { ...mapUserSettingsResponse(resp), workspaceId }; } +function readAgentProfileId( + metadata: Record | 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( 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 = { diff --git a/apps/web/components/quick-chat/quick-chat-modal.test.ts b/apps/web/components/quick-chat/quick-chat-modal.test.ts new file mode 100644 index 0000000000..770880d1fe --- /dev/null +++ b/apps/web/components/quick-chat/quick-chat-modal.test.ts @@ -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()(immer(createUISlice)); +} + +function findSession(store: ReturnType) { + 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); + }); +}); diff --git a/apps/web/components/quick-chat/quick-chat-modal.tsx b/apps/web/components/quick-chat/quick-chat-modal.tsx index f0073a1b4d..2034190530 100644 --- a/apps/web/components/quick-chat/quick-chat-modal.tsx +++ b/apps/web/components/quick-chat/quick-chat-modal.tsx @@ -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 ( +
+ +
+ ); + } + return ; +} + 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 && ( - + )} {activeSessionNeedsAgent && } diff --git a/apps/web/components/quick-chat/use-quick-chat-modal.ts b/apps/web/components/quick-chat/use-quick-chat-modal.ts index a2088a4ddd..bf91eab647 100644 --- a/apps/web/components/quick-chat/use-quick-chat-modal.ts +++ b/apps/web/components/quick-chat/use-quick-chat-modal.ts @@ -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) { diff --git a/apps/web/lib/api/domains/workspace-api.ts b/apps/web/lib/api/domains/workspace-api.ts index caa9c51a6c..f95558307f 100644 --- a/apps/web/lib/api/domains/workspace-api.ts +++ b/apps/web/lib/api/domains/workspace-api.ts @@ -87,6 +87,7 @@ export async function listQuickChatSessions(workspaceId: string, options?: ApiRe title: string; workspace_id: string; primary_session_id?: string | null; + metadata?: Record | null; }>; }>(`/api/v1/workspaces/${workspaceId}/tasks?only_ephemeral=true&exclude_config=true`, options); } diff --git a/apps/web/lib/state/slices/ui/types.ts b/apps/web/lib/state/slices/ui/types.ts index a803c8283a..865cc98079 100644 --- a/apps/web/lib/state/slices/ui/types.ts +++ b/apps/web/lib/state/slices/ui/types.ts @@ -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; diff --git a/apps/web/lib/state/slices/ui/ui-slice.ts b/apps/web/lib/state/slices/ui/ui-slice.ts index 5e20f5e935..4ec14b96ce 100644 --- a/apps/web/lib/state/slices/ui/ui-slice.ts +++ b/apps/web/lib/state/slices/ui/ui-slice.ts @@ -514,7 +514,7 @@ export const createUISlice: StateCreator + 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 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; }), diff --git a/apps/web/lib/state/store.ts b/apps/web/lib/state/store.ts index 52c229ec06..91768e16f9 100644 --- a/apps/web/lib/state/store.ts +++ b/apps/web/lib/state/store.ts @@ -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;