diff options
Diffstat (limited to 'internal/api')
| -rw-r--r-- | internal/api/server.go | 36 | ||||
| -rw-r--r-- | internal/api/server_test.go | 123 |
2 files changed, 152 insertions, 7 deletions
diff --git a/internal/api/server.go b/internal/api/server.go index c545253..df35536 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -24,6 +24,7 @@ type questionStore interface { GetLatestExecution(taskID string) (*storage.Execution, error) UpdateTaskQuestion(taskID, questionJSON string) error UpdateTaskState(id string, newState task.State) error + AppendTaskInteraction(taskID string, interaction task.Interaction) error } // Server provides the REST API and WebSocket endpoint for Claudomator. @@ -250,6 +251,25 @@ func (s *Server) handleAnswerQuestion(w http.ResponseWriter, r *http.Request) { return } + // Record the Q&A interaction before clearing the question. + if tk.QuestionJSON != "" { + var qData struct { + Text string `json:"text"` + Options []string `json:"options"` + } + if jsonErr := json.Unmarshal([]byte(tk.QuestionJSON), &qData); jsonErr == nil { + interaction := task.Interaction{ + QuestionText: qData.Text, + Options: qData.Options, + Answer: input.Answer, + AskedAt: tk.UpdatedAt, + } + if appendErr := s.questionStore.AppendTaskInteraction(taskID, interaction); appendErr != nil { + s.logger.Error("failed to append interaction", "taskID", taskID, "error", appendErr) + } + } + } + // Clear the question and transition to QUEUED. if err := s.questionStore.UpdateTaskQuestion(taskID, ""); err != nil { writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to clear question"}) @@ -277,6 +297,14 @@ func (s *Server) handleAnswerQuestion(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]string{"message": "task queued for resume", "task_id": taskID}) } +// resumableStates are the task states from which a session-based resume is valid. +var resumableStates = map[task.State]string{ + task.StateTimedOut: "Your previous execution timed out. Please continue where you left off and complete the task.", + task.StateCancelled: "Your previous execution was cancelled. Please continue where you left off and complete the task.", + task.StateFailed: "Your previous execution failed. Please review what happened and continue from where you left off.", + task.StateBudgetExceeded: "Your previous execution exceeded its budget. Please continue where you left off and complete the task.", +} + func (s *Server) handleResumeTimedOutTask(w http.ResponseWriter, r *http.Request) { taskID := r.PathValue("id") @@ -285,8 +313,10 @@ func (s *Server) handleResumeTimedOutTask(w http.ResponseWriter, r *http.Request writeJSON(w, http.StatusNotFound, map[string]string{"error": "task not found"}) return } - if tk.State != task.StateTimedOut { - writeJSON(w, http.StatusConflict, map[string]string{"error": "task is not timed out"}) + + resumeMsg, resumable := resumableStates[tk.State] + if !resumable { + writeJSON(w, http.StatusConflict, map[string]string{"error": "task is not in a resumable state"}) return } @@ -302,7 +332,7 @@ func (s *Server) handleResumeTimedOutTask(w http.ResponseWriter, r *http.Request ID: uuid.New().String(), TaskID: taskID, ResumeSessionID: latest.SessionID, - ResumeAnswer: "Your previous execution timed out. Please continue where you left off and complete the task.", + ResumeAnswer: resumeMsg, } if err := s.pool.SubmitResume(context.Background(), tk, resumeExec); err != nil { writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": err.Error()}) diff --git a/internal/api/server_test.go b/internal/api/server_test.go index afdc9d2..c90e3b3 100644 --- a/internal/api/server_test.go +++ b/internal/api/server_test.go @@ -894,10 +894,11 @@ func TestServer_CancelTask_Completed_Returns409(t *testing.T) { // mockQuestionStore implements questionStore for testing handleAnswerQuestion. type mockQuestionStore struct { - getTaskFn func(id string) (*task.Task, error) - getLatestExecutionFn func(taskID string) (*storage.Execution, error) - updateTaskQuestionFn func(taskID, questionJSON string) error - updateTaskStateFn func(id string, newState task.State) error + getTaskFn func(id string) (*task.Task, error) + getLatestExecutionFn func(taskID string) (*storage.Execution, error) + updateTaskQuestionFn func(taskID, questionJSON string) error + updateTaskStateFn func(id string, newState task.State) error + appendInteractionFn func(taskID string, interaction task.Interaction) error } func (m *mockQuestionStore) GetTask(id string) (*task.Task, error) { @@ -912,6 +913,12 @@ func (m *mockQuestionStore) UpdateTaskQuestion(taskID, questionJSON string) erro func (m *mockQuestionStore) UpdateTaskState(id string, newState task.State) error { return m.updateTaskStateFn(id, newState) } +func (m *mockQuestionStore) AppendTaskInteraction(taskID string, interaction task.Interaction) error { + if m.appendInteractionFn != nil { + return m.appendInteractionFn(taskID, interaction) + } + return nil +} func TestServer_AnswerQuestion_UpdateQuestionFails_Returns500(t *testing.T) { srv, _ := testServer(t) @@ -1178,6 +1185,114 @@ func TestResumeTimedOut_ResponseShape(t *testing.T) { } } +func TestResumeInterrupted_Cancelled_Success_Returns202(t *testing.T) { + srv, store := testServer(t) + createTaskWithState(t, store, "resume-cancelled-1", task.StateCancelled) + + exec := &storage.Execution{ + ID: "exec-cancelled-1", + TaskID: "resume-cancelled-1", + SessionID: "550e8400-e29b-41d4-a716-446655440030", + Status: "CANCELLED", + } + if err := store.CreateExecution(exec); err != nil { + t.Fatalf("create execution: %v", err) + } + + req := httptest.NewRequest("POST", "/api/tasks/resume-cancelled-1/resume", nil) + w := httptest.NewRecorder() + srv.Handler().ServeHTTP(w, req) + + if w.Code != http.StatusAccepted { + t.Errorf("status: want 202, got %d; body: %s", w.Code, w.Body.String()) + } + got, _ := store.GetTask("resume-cancelled-1") + if got.State != task.StateQueued && got.State != task.StateRunning && got.State != task.StateReady { + t.Errorf("task state: want QUEUED/RUNNING/READY after resume, got %v", got.State) + } +} + +func TestResumeInterrupted_Failed_Success_Returns202(t *testing.T) { + srv, store := testServer(t) + createTaskWithState(t, store, "resume-failed-1", task.StateFailed) + + exec := &storage.Execution{ + ID: "exec-failed-1", + TaskID: "resume-failed-1", + SessionID: "550e8400-e29b-41d4-a716-446655440031", + Status: "FAILED", + } + if err := store.CreateExecution(exec); err != nil { + t.Fatalf("create execution: %v", err) + } + + req := httptest.NewRequest("POST", "/api/tasks/resume-failed-1/resume", nil) + w := httptest.NewRecorder() + srv.Handler().ServeHTTP(w, req) + + if w.Code != http.StatusAccepted { + t.Errorf("status: want 202, got %d; body: %s", w.Code, w.Body.String()) + } + got, _ := store.GetTask("resume-failed-1") + if got.State != task.StateQueued && got.State != task.StateRunning && got.State != task.StateReady { + t.Errorf("task state: want QUEUED/RUNNING/READY after resume, got %v", got.State) + } +} + +func TestResumeInterrupted_BudgetExceeded_Success_Returns202(t *testing.T) { + srv, store := testServer(t) + createTaskWithState(t, store, "resume-budget-1", task.StateBudgetExceeded) + + exec := &storage.Execution{ + ID: "exec-budget-1", + TaskID: "resume-budget-1", + SessionID: "550e8400-e29b-41d4-a716-446655440032", + Status: "BUDGET_EXCEEDED", + } + if err := store.CreateExecution(exec); err != nil { + t.Fatalf("create execution: %v", err) + } + + req := httptest.NewRequest("POST", "/api/tasks/resume-budget-1/resume", nil) + w := httptest.NewRecorder() + srv.Handler().ServeHTTP(w, req) + + if w.Code != http.StatusAccepted { + t.Errorf("status: want 202, got %d; body: %s", w.Code, w.Body.String()) + } + got, _ := store.GetTask("resume-budget-1") + if got.State != task.StateQueued && got.State != task.StateRunning && got.State != task.StateReady { + t.Errorf("task state: want QUEUED/RUNNING/READY after resume, got %v", got.State) + } +} + +func TestResumeInterrupted_NoSession_Returns500(t *testing.T) { + srv, store := testServer(t) + createTaskWithState(t, store, "resume-nosess-1", task.StateCancelled) + + // No execution — no session ID available. + req := httptest.NewRequest("POST", "/api/tasks/resume-nosess-1/resume", nil) + w := httptest.NewRecorder() + srv.Handler().ServeHTTP(w, req) + + if w.Code != http.StatusInternalServerError { + t.Errorf("status: want 500, got %d; body: %s", w.Code, w.Body.String()) + } +} + +func TestResumeInterrupted_WrongState_Returns409(t *testing.T) { + srv, store := testServer(t) + createTaskWithState(t, store, "resume-wrong-1", task.StatePending) + + req := httptest.NewRequest("POST", "/api/tasks/resume-wrong-1/resume", nil) + w := httptest.NewRecorder() + srv.Handler().ServeHTTP(w, req) + + if w.Code != http.StatusConflict { + t.Errorf("status: want 409, got %d; body: %s", w.Code, w.Body.String()) + } +} + func TestRateLimit_ValidateRejectsExcess(t *testing.T) { srv, _ := testServer(t) srv.elaborateLimiter = newIPRateLimiter(0, 1) |
