package handlers import ( "context" "encoding/json" "fmt" "net/http" "net/http/httptest" "strings" "sync" "testing" "time" "github.com/go-chi/chi/v5" "github.com/orchard9/rdev/internal/domain" "github.com/orchard9/rdev/internal/port" "github.com/orchard9/rdev/internal/service" ) // mockConversationRepository implements port.ConversationRepository for testing. type mockConversationRepository struct { conversations map[string]*domain.Conversation messages map[string][]*domain.Message nextConvID int nextMsgID int } func newMockConversationRepository() *mockConversationRepository { return &mockConversationRepository{ conversations: make(map[string]*domain.Conversation), messages: make(map[string][]*domain.Message), } } func (m *mockConversationRepository) CreateConversation(_ context.Context, projectID, title string) (*domain.Conversation, error) { m.nextConvID++ conv := &domain.Conversation{ ID: domain.ConversationID(fmt.Sprintf("conv-%d", m.nextConvID)), ProjectID: projectID, Title: title, CreatedAt: time.Now(), UpdatedAt: time.Now(), } m.conversations[string(conv.ID)] = conv return conv, nil } func (m *mockConversationRepository) GetConversation(_ context.Context, id domain.ConversationID) (*domain.Conversation, error) { c, ok := m.conversations[string(id)] if !ok { return nil, fmt.Errorf("conversation not found: %s", id) } return c, nil } func (m *mockConversationRepository) ListConversations(_ context.Context, projectID string) ([]*domain.Conversation, error) { var result []*domain.Conversation for _, c := range m.conversations { if c.ProjectID == projectID { result = append(result, c) } } return result, nil } func (m *mockConversationRepository) UpdateConversationTitle(_ context.Context, id domain.ConversationID, title string) error { if c, ok := m.conversations[string(id)]; ok { c.Title = title } return nil } func (m *mockConversationRepository) DeleteConversation(_ context.Context, id domain.ConversationID) error { delete(m.conversations, string(id)) delete(m.messages, string(id)) return nil } func (m *mockConversationRepository) AddMessage(_ context.Context, conversationID domain.ConversationID, role domain.MessageRole, content string) (*domain.Message, error) { m.nextMsgID++ msg := &domain.Message{ ID: domain.MessageID(fmt.Sprintf("msg-%d", m.nextMsgID)), ConversationID: conversationID, Role: role, Content: content, CreatedAt: time.Now(), } m.messages[string(conversationID)] = append(m.messages[string(conversationID)], msg) return msg, nil } func (m *mockConversationRepository) GetMessages(_ context.Context, conversationID domain.ConversationID) ([]*domain.Message, error) { return m.messages[string(conversationID)], nil } func (m *mockConversationRepository) GetConversationWithMessages(_ context.Context, id domain.ConversationID) (*domain.ConversationWithMessages, error) { c, ok := m.conversations[string(id)] if !ok { return nil, fmt.Errorf("conversation not found: %s", id) } return &domain.ConversationWithMessages{ Conversation: *c, Messages: m.messages[string(id)], }, nil } // Compile-time check. var _ port.ConversationRepository = (*mockConversationRepository)(nil) // mockSessionRepository implements port.SessionRepository for testing. type mockSessionRepository struct { sessions map[string]*domain.Session err error claudeSessionIDPersisted chan struct{} persistedOnce sync.Once } func newMockSessionRepository() *mockSessionRepository { return &mockSessionRepository{ sessions: make(map[string]*domain.Session), claudeSessionIDPersisted: make(chan struct{}), } } func (m *mockSessionRepository) Create(_ context.Context, session *domain.Session) error { if m.err != nil { return m.err } // Check unique constraint: only one active per project. for _, s := range m.sessions { if s.ProjectID == session.ProjectID && s.Status == domain.SessionStatusActive { return domain.ErrSessionExists } } session.ID = domain.SessionID("test-session-id") m.sessions[string(session.ID)] = session return nil } func (m *mockSessionRepository) Get(_ context.Context, id domain.SessionID) (*domain.Session, error) { if m.err != nil { return nil, m.err } s, ok := m.sessions[string(id)] if !ok { return nil, domain.ErrSessionNotFound } return s, nil } func (m *mockSessionRepository) GetActiveByProject(_ context.Context, projectID domain.ProjectID) (*domain.Session, error) { if m.err != nil { return nil, m.err } for _, s := range m.sessions { if s.ProjectID == projectID && s.Status == domain.SessionStatusActive { return s, nil } } return nil, domain.ErrSessionNotFound } func (m *mockSessionRepository) ListByProject(_ context.Context, projectID domain.ProjectID) ([]*domain.Session, error) { if m.err != nil { return nil, m.err } var result []*domain.Session for _, s := range m.sessions { if s.ProjectID == projectID { result = append(result, s) } } return result, nil } func (m *mockSessionRepository) TouchActivity(_ context.Context, id domain.SessionID) error { if m.err != nil { return m.err } s, ok := m.sessions[string(id)] if !ok || s.Status != domain.SessionStatusActive { return domain.ErrSessionNotActive } s.LastActivityAt = time.Now() return nil } func (m *mockSessionRepository) SetEnded(_ context.Context, id domain.SessionID) error { if m.err != nil { return m.err } s, ok := m.sessions[string(id)] if !ok || s.Status != domain.SessionStatusActive { return domain.ErrSessionNotActive } s.Status = domain.SessionStatusEnded now := time.Now() s.EndedAt = &now return nil } func (m *mockSessionRepository) CleanupExpired(_ context.Context) ([]*domain.Session, error) { if m.err != nil { return nil, m.err } var expired []*domain.Session for _, s := range m.sessions { if s.Status == domain.SessionStatusActive && time.Now().After(s.ExpiresAt) { s.Status = domain.SessionStatusExpired now := time.Now() s.EndedAt = &now expired = append(expired, s) } } return expired, nil } func (m *mockSessionRepository) SetClaudeSessionID(_ context.Context, id domain.SessionID, claudeSessionID, conversationRecordID string) error { if m.err != nil { return m.err } s, ok := m.sessions[string(id)] if !ok { return domain.ErrSessionNotFound } s.ClaudeSessionID = claudeSessionID s.ConversationRecordID = conversationRecordID m.persistedOnce.Do(func() { close(m.claudeSessionIDPersisted) }) return nil } // mockCheckoutRepository implements port.CheckoutRepository for testing sessions. type mockCheckoutRepository struct { checkouts map[string]*domain.Checkout err error } func newMockCheckoutRepository() *mockCheckoutRepository { return &mockCheckoutRepository{checkouts: make(map[string]*domain.Checkout)} } func (m *mockCheckoutRepository) Create(_ context.Context, c *domain.Checkout) error { if m.err != nil { return m.err } c.ID = domain.CheckoutID("test-checkout-id") m.checkouts[string(c.ID)] = c return nil } func (m *mockCheckoutRepository) Get(_ context.Context, id domain.CheckoutID) (*domain.Checkout, error) { if m.err != nil { return nil, m.err } c, ok := m.checkouts[string(id)] if !ok { return nil, domain.ErrCheckoutNotFound } return c, nil } func (m *mockCheckoutRepository) GetByProjectBranch(_ context.Context, _ domain.ProjectID, _ string) (*domain.Checkout, error) { return nil, domain.ErrCheckoutNotFound } func (m *mockCheckoutRepository) List(_ context.Context, _ domain.CheckoutListOptions) ([]*domain.Checkout, error) { return nil, nil } func (m *mockCheckoutRepository) ListByProject(_ context.Context, projectID domain.ProjectID) ([]*domain.Checkout, error) { if m.err != nil { return nil, m.err } var result []*domain.Checkout for _, c := range m.checkouts { if c.ProjectID == projectID { result = append(result, c) } } return result, nil } func (m *mockCheckoutRepository) UpdateStatus(_ context.Context, id domain.CheckoutID, status domain.CheckoutStatus) error { if c, ok := m.checkouts[string(id)]; ok { c.Status = status } return nil } func (m *mockCheckoutRepository) SetCheckedIn(_ context.Context, id domain.CheckoutID, _ string) error { if c, ok := m.checkouts[string(id)]; ok { c.Status = domain.CheckoutStatusCheckedIn now := time.Now() c.CheckedInAt = &now } return nil } func (m *mockCheckoutRepository) SetReviewTask(_ context.Context, _ domain.CheckoutID, _ string) error { return nil } func (m *mockCheckoutRepository) CleanupExpired(_ context.Context) ([]int64, error) { return nil, nil } // setupSessionTest creates a sessions handler with mock dependencies. // It reuses mockProjectRepo from queue_test.go. func setupSessionTest() (*SessionsHandler, *mockSessionRepository, *mockProjectRepo) { handler, sessionRepo, projectRepo, _, _ := setupSessionTestFull() return handler, sessionRepo, projectRepo } // setupSessionTestFull creates a sessions handler with all mock dependencies exposed. func setupSessionTestFull() (*SessionsHandler, *mockSessionRepository, *mockProjectRepo, *mockConversationRepository, *mockExecutor) { sessionRepo := newMockSessionRepository() checkoutRepo := newMockCheckoutRepository() projectRepo := newMockProjectRepo() gitRepo := newMockGitRepository() previewMgr := newMockPreviewManager() convRepo := newMockConversationRepository() // Add a test project. projectRepo.projects["test-project"] = &domain.Project{ ID: "test-project", Name: "test-project", PodName: "test-project-0", Status: domain.ProjectStatusRunning, } checkoutService := service.NewCheckoutService( checkoutRepo, gitRepo, projectRepo, service.CheckoutServiceConfig{ GiteaURL: "https://git.threesix.ai", DefaultOwner: "threesix", DefaultExpiry: 24 * time.Hour, }, ) sessionService := service.NewSessionService( sessionRepo, checkoutService, projectRepo, previewMgr, service.SessionServiceConfig{ PreviewDomain: "preview.threesix.ai", DefaultExpiry: 24 * time.Hour, }, ) conversationService := service.NewConversationService(convRepo) executor := newMockExecutor() streams := newMockStreamPublisher() handler := NewSessionsHandler(sessionService, executor, streams). WithConversationService(conversationService) return handler, sessionRepo, projectRepo, convRepo, executor } func TestSessionsHandler_Create(t *testing.T) { handler, _, _ := setupSessionTest() router := chi.NewRouter() router.Use(testAdminAuth) handler.Mount(router) t.Run("create_session", func(t *testing.T) { body := `{"branch": "develop"}` req := httptest.NewRequest(http.MethodPost, "/projects/test-project/sessions", strings.NewReader(body)) req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() router.ServeHTTP(rec, req) if rec.Code != http.StatusCreated { t.Fatalf("got status %d, want %d; body: %s", rec.Code, http.StatusCreated, rec.Body.String()) } var resp map[string]any if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { t.Fatalf("unmarshal: %v", err) } data, ok := resp["data"].(map[string]any) if !ok { t.Fatalf("expected data map, got %T", resp["data"]) } if data["id"] == "" { t.Error("expected non-empty session id") } if data["preview_url"] == "" { t.Error("expected non-empty preview_url") } if data["auth_clone_url"] == "" { t.Error("expected auth_clone_url on creation") } if data["instructions"] == "" { t.Error("expected instructions on creation") } if data["pod_name"] != "test-project-0" { t.Errorf("expected pod_name=test-project-0, got %v", data["pod_name"]) } }) t.Run("create_session_no_branch", func(t *testing.T) { body := `{}` req := httptest.NewRequest(http.MethodPost, "/projects/test-project/sessions", strings.NewReader(body)) req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() router.ServeHTTP(rec, req) if rec.Code != http.StatusBadRequest { t.Errorf("got status %d, want %d", rec.Code, http.StatusBadRequest) } }) t.Run("create_session_project_not_found", func(t *testing.T) { body := `{"branch": "develop"}` req := httptest.NewRequest(http.MethodPost, "/projects/nonexistent/sessions", strings.NewReader(body)) req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() router.ServeHTTP(rec, req) if rec.Code != http.StatusNotFound { t.Errorf("got status %d, want %d; body: %s", rec.Code, http.StatusNotFound, rec.Body.String()) } }) } func TestSessionsHandler_Get(t *testing.T) { handler, sessionRepo, _ := setupSessionTest() // Seed a session. session := &domain.Session{ ID: "session-abc", ProjectID: "test-project", CheckoutID: "checkout-abc", PodName: "test-project-0", PreviewURL: "https://abc.preview.threesix.ai", PreviewHost: "abc.preview.threesix.ai", CreatedBy: "test", CreatedAt: time.Now(), ExpiresAt: time.Now().Add(24 * time.Hour), Status: domain.SessionStatusActive, } sessionRepo.sessions["session-abc"] = session router := chi.NewRouter() router.Use(testAdminAuth) handler.Mount(router) t.Run("get_existing", func(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "/projects/test-project/sessions/session-abc", nil) rec := httptest.NewRecorder() router.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Errorf("got status %d, want %d; body: %s", rec.Code, http.StatusOK, rec.Body.String()) } }) t.Run("get_wrong_project", func(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "/projects/other-project/sessions/session-abc", nil) rec := httptest.NewRecorder() router.ServeHTTP(rec, req) if rec.Code != http.StatusNotFound { t.Errorf("got status %d, want %d", rec.Code, http.StatusNotFound) } }) t.Run("get_not_found", func(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "/projects/test-project/sessions/nonexistent", nil) rec := httptest.NewRecorder() router.ServeHTTP(rec, req) if rec.Code != http.StatusNotFound { t.Errorf("got status %d, want %d", rec.Code, http.StatusNotFound) } }) } func TestSessionsHandler_List(t *testing.T) { handler, sessionRepo, _ := setupSessionTest() // Seed sessions. sessionRepo.sessions["session-1"] = &domain.Session{ ID: "session-1", ProjectID: "test-project", CheckoutID: "checkout-1", PodName: "test-project-0", PreviewURL: "https://s1.preview.threesix.ai", PreviewHost: "s1.preview.threesix.ai", CreatedBy: "test", CreatedAt: time.Now(), ExpiresAt: time.Now().Add(24 * time.Hour), Status: domain.SessionStatusActive, } sessionRepo.sessions["session-2"] = &domain.Session{ ID: "session-2", ProjectID: "test-project", CheckoutID: "checkout-2", PodName: "test-project-0", PreviewURL: "https://s2.preview.threesix.ai", PreviewHost: "s2.preview.threesix.ai", CreatedBy: "test", CreatedAt: time.Now().Add(-1 * time.Hour), ExpiresAt: time.Now().Add(23 * time.Hour), Status: domain.SessionStatusEnded, } router := chi.NewRouter() router.Use(testAdminAuth) handler.Mount(router) req := httptest.NewRequest(http.MethodGet, "/projects/test-project/sessions", nil) rec := httptest.NewRecorder() router.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("got status %d, want %d; body: %s", rec.Code, http.StatusOK, rec.Body.String()) } var resp map[string]any if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { t.Fatalf("unmarshal: %v", err) } data, ok := resp["data"].(map[string]any) if !ok { t.Fatalf("expected data map, got %T", resp["data"]) } sessions, ok := data["sessions"].([]any) if !ok { t.Fatalf("expected sessions array, got %T", data["sessions"]) } if len(sessions) != 2 { t.Errorf("got %d sessions, want 2", len(sessions)) } } func TestSessionsHandler_Checkin(t *testing.T) { handler, sessionRepo, _ := setupSessionTest() // Seed an active session with a matching checkout. sessionRepo.sessions["session-end"] = &domain.Session{ ID: "session-end", ProjectID: "test-project", CheckoutID: "checkout-end", PodName: "test-project-0", PreviewURL: "https://end.preview.threesix.ai", PreviewHost: "end.preview.threesix.ai", CreatedBy: "test", CreatedAt: time.Now(), ExpiresAt: time.Now().Add(24 * time.Hour), Status: domain.SessionStatusActive, } router := chi.NewRouter() router.Use(testAdminAuth) handler.Mount(router) t.Run("checkin_session", func(t *testing.T) { // Checkout doesn't exist in the mock, but session service continues on checkout errors. body := `{"skip_review": true}` req := httptest.NewRequest(http.MethodPost, "/projects/test-project/sessions/session-end/checkin", strings.NewReader(body)) req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() router.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Errorf("got status %d, want %d; body: %s", rec.Code, http.StatusOK, rec.Body.String()) } }) t.Run("checkin_already_ended", func(t *testing.T) { body := `{}` req := httptest.NewRequest(http.MethodPost, "/projects/test-project/sessions/session-end/checkin", strings.NewReader(body)) req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() router.ServeHTTP(rec, req) if rec.Code != http.StatusBadRequest { t.Errorf("got status %d, want %d; body: %s", rec.Code, http.StatusBadRequest, rec.Body.String()) } }) } func TestWorkersHandler_PoolStatus(t *testing.T) { registry := newMockWorkerRegistry() queue := newMockWorkQueue() workerService := service.NewWorkerService(registry, queue) handler := NewWorkersHandler(workerService).WithWorkQueue(queue) registry.workers["w1"] = &domain.Worker{ ID: "w1", Hostname: "h1", Status: domain.WorkerStatusIdle, RegisteredAt: time.Now(), LastHeartbeat: time.Now(), } registry.workers["w2"] = &domain.Worker{ ID: "w2", Hostname: "h2", Status: domain.WorkerStatusBusy, RegisteredAt: time.Now(), LastHeartbeat: time.Now(), } // Add a pending task to the queue. queue.tasks["t1"] = &domain.WorkTask{ID: "t1", Status: domain.WorkTaskStatusPending} router := chi.NewRouter() router.Use(testAdminAuth) handler.Mount(router) req := httptest.NewRequest(http.MethodGet, "/workers/pool", nil) rec := httptest.NewRecorder() router.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("got status %d, want %d; body: %s", rec.Code, http.StatusOK, rec.Body.String()) } var resp map[string]any if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { t.Fatalf("unmarshal: %v", err) } data, ok := resp["data"].(map[string]any) if !ok { t.Fatalf("expected data map, got %T", resp["data"]) } if int(data["total"].(float64)) != 2 { t.Errorf("total: got %v, want 2", data["total"]) } if int(data["idle"].(float64)) != 1 { t.Errorf("idle: got %v, want 1", data["idle"]) } if int(data["busy"].(float64)) != 1 { t.Errorf("busy: got %v, want 1", data["busy"]) } if int(data["available"].(float64)) != 1 { t.Errorf("available: got %v, want 1", data["available"]) } if int(data["queue_depth"].(float64)) != 1 { t.Errorf("queue_depth: got %v, want 1", data["queue_depth"]) } } // Verify port.PreviewManager is implemented by the mock. var _ port.PreviewManager = (*mockPreviewManager)(nil) func TestSessionsHandler_Exec_ContinueConversation(t *testing.T) { handler, sessionRepo, _, convRepo, _ := setupSessionTestFull() // Configure executor to emit JSONL output with a session_id. jsonlOutput := `{"type":"assistant","session_id":"new-claude-sess-id","message":{"role":"assistant","content":[{"type":"text","text":"Done"}]}}` jsonlExec := &jsonlMockExecutor{ result: &domain.CommandResult{ExitCode: 0, DurationMs: 100}, lines: []domain.OutputLine{ {Stream: "stdout", Line: jsonlOutput, Timestamp: time.Now()}, }, } handler.executor = jsonlExec // Seed an active session with a stored claude_session_id. sessionRepo.sessions["session-conv"] = &domain.Session{ ID: "session-conv", ProjectID: "test-project", CheckoutID: "checkout-conv", PodName: "test-project-0", PreviewURL: "https://conv.preview.threesix.ai", PreviewHost: "conv.preview.threesix.ai", CreatedBy: "test", CreatedAt: time.Now(), ExpiresAt: time.Now().Add(24 * time.Hour), Status: domain.SessionStatusActive, ClaudeSessionID: "prior-claude-sess-id", } router := chi.NewRouter() router.Use(testAdminAuth) handler.Mount(router) t.Run("exec_returns_201_with_stream_url", func(t *testing.T) { body := `{"type": "claude", "prompt": "add a test", "continue_conversation": true}` req := httptest.NewRequest(http.MethodPost, "/projects/test-project/sessions/session-conv/exec", strings.NewReader(body)) req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() router.ServeHTTP(rec, req) if rec.Code != http.StatusCreated { t.Fatalf("got status %d, want %d; body: %s", rec.Code, http.StatusCreated, rec.Body.String()) } var resp map[string]any if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { t.Fatalf("unmarshal: %v", err) } data, _ := resp["data"].(map[string]any) if data["stream_url"] == "" { t.Error("expected non-empty stream_url") } if data["status"] != "running" { t.Errorf("expected status=running, got %v", data["status"]) } }) // Wait for the background goroutine to persist the claude session ID. // SetClaudeSessionID is called after Execute and conversation writes, so // waiting for it guarantees all prior side-effects are visible. waitForPersist := func(t *testing.T) { t.Helper() select { case <-sessionRepo.claudeSessionIDPersisted: case <-time.After(2 * time.Second): t.Fatal("timed out waiting for claude session ID to be persisted") } } // Verify the executor received the prior session ID as resume. t.Run("resume_session_id_forwarded", func(t *testing.T) { waitForPersist(t) if jsonlExec.lastResumeSessionID != "prior-claude-sess-id" { t.Errorf("expected lastResumeSessionID=prior-claude-sess-id, got %q", jsonlExec.lastResumeSessionID) } }) // Verify the conversation record was created and session updated. t.Run("conversation_record_created", func(t *testing.T) { waitForPersist(t) if len(convRepo.conversations) == 0 { t.Error("expected a conversation record to be created") } s := sessionRepo.sessions["session-conv"] if s.ClaudeSessionID != "new-claude-sess-id" { t.Errorf("expected ClaudeSessionID=new-claude-sess-id, got %q", s.ClaudeSessionID) } }) } func TestSessionsHandler_Exec_ConversationIDOverride(t *testing.T) { handler, sessionRepo, _, _, _ := setupSessionTestFull() jsonlOutput := `{"type":"result","session_id":"fresh-sess","result":"The fix is done."}` jsonlExec := &jsonlMockExecutor{ result: &domain.CommandResult{ExitCode: 0, DurationMs: 100}, lines: []domain.OutputLine{ {Stream: "stdout", Line: jsonlOutput, Timestamp: time.Now()}, }, } handler.executor = jsonlExec sessionRepo.sessions["session-override"] = &domain.Session{ ID: "session-override", ProjectID: "test-project", CheckoutID: "checkout-override", PodName: "test-project-0", PreviewURL: "https://override.preview.threesix.ai", PreviewHost: "override.preview.threesix.ai", CreatedBy: "test", CreatedAt: time.Now(), ExpiresAt: time.Now().Add(24 * time.Hour), Status: domain.SessionStatusActive, } router := chi.NewRouter() router.Use(testAdminAuth) handler.Mount(router) body := `{"type": "claude", "prompt": "fix auth", "conversation_id": "explicit-claude-sess-id"}` req := httptest.NewRequest(http.MethodPost, "/projects/test-project/sessions/session-override/exec", strings.NewReader(body)) req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() router.ServeHTTP(rec, req) if rec.Code != http.StatusCreated { t.Fatalf("got status %d, want %d; body: %s", rec.Code, http.StatusCreated, rec.Body.String()) } // Wait for the background goroutine to persist the session ID (deterministic sync). select { case <-sessionRepo.claudeSessionIDPersisted: case <-time.After(2 * time.Second): t.Fatal("timed out waiting for claude session ID to be persisted") } // Verify the executor received the explicit conversation ID as resume. if jsonlExec.lastResumeSessionID != "explicit-claude-sess-id" { t.Errorf("expected lastResumeSessionID=explicit-claude-sess-id, got %q", jsonlExec.lastResumeSessionID) } } // jsonlMockExecutor implements port.CommandExecutor and emits pre-configured lines. type jsonlMockExecutor struct { result *domain.CommandResult lines []domain.OutputLine lastResumeSessionID string lastCmd *domain.Command } func (m *jsonlMockExecutor) Execute(_ context.Context, cmd *domain.Command, _ string, handler domain.OutputHandler) (*domain.CommandResult, error) { m.lastCmd = cmd m.lastResumeSessionID = cmd.ResumeSessionID if handler != nil { for _, line := range m.lines { handler(line) } } if m.result == nil { return &domain.CommandResult{ExitCode: 0, DurationMs: 50}, nil } return m.result, nil } func (m *jsonlMockExecutor) Cancel(_ context.Context, _ domain.CommandID) error { return nil } func (m *jsonlMockExecutor) PodExists(_ context.Context, _ string) (bool, error) { return true, nil } func (m *jsonlMockExecutor) CheckConnection(_ context.Context) error { return nil } var _ port.CommandExecutor = (*jsonlMockExecutor)(nil)