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:
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}),
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user