summaryrefslogtreecommitdiff
path: root/internal/api/server_test.go
diff options
context:
space:
mode:
authorPeter Stone <thepeterstone@gmail.com>2026-03-06 00:07:18 +0000
committerPeter Stone <thepeterstone@gmail.com>2026-03-06 00:07:18 +0000
commit7466b1751c4126735769a3304e1db80dab166a9e (patch)
treec5d0fe9d1018e62e3857480d471a0f6f8ebee104 /internal/api/server_test.go
parenta33211d0ad07f5aaf2d8bb51ba18e6790a153bb4 (diff)
feat: blocked task state for agent questions via session resume
When an agent needs user input it writes a question to $CLAUDOMATOR_QUESTION_FILE and exits. The runner detects the file and returns BlockedError; the pool transitions the task to BLOCKED and stores the question JSON on the task record. The user answers via POST /api/tasks/{id}/answer. The server looks up the claude session_id from the most recent execution and submits a resume execution (claude --resume <session-id> "<answer>"), freeing the executor slot entirely while waiting. Changes: - task: add StateBlocked, transitions RUNNING→BLOCKED, BLOCKED→QUEUED - storage: add session_id to executions, question_json to tasks; add GetLatestExecution and UpdateTaskQuestion methods - executor: BlockedError type; ClaudeRunner pre-assigns --session-id, sets CLAUDOMATOR_QUESTION_FILE env var, detects question file on exit; buildArgs handles --resume mode; Pool.SubmitResume for resume path - api: handleAnswerQuestion rewritten to create resume execution - preamble: add question protocol instructions for agents - web: BLOCKED state badge (indigo), question text + option buttons or free-text input with Submit on the task card footer Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat (limited to 'internal/api/server_test.go')
-rw-r--r--internal/api/server_test.go70
1 files changed, 35 insertions, 35 deletions
diff --git a/internal/api/server_test.go b/internal/api/server_test.go
index af93a77..2325b0b 100644
--- a/internal/api/server_test.go
+++ b/internal/api/server_test.go
@@ -339,8 +339,7 @@ func TestCORS_Headers(t *testing.T) {
func TestAnswerQuestion_NoTask_Returns404(t *testing.T) {
srv, _ := testServer(t)
- payload := `{"question_id": "toolu_abc", "answer": "blue"}`
- req := httptest.NewRequest("POST", "/api/tasks/nonexistent/answer", bytes.NewBufferString(payload))
+ req := httptest.NewRequest("POST", "/api/tasks/nonexistent/answer", bytes.NewBufferString(`{"answer":"blue"}`))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
@@ -351,64 +350,65 @@ func TestAnswerQuestion_NoTask_Returns404(t *testing.T) {
}
}
-func TestAnswerQuestion_NoPendingQuestion_Returns404(t *testing.T) {
+func TestAnswerQuestion_TaskNotBlocked_Returns409(t *testing.T) {
srv, store := testServer(t)
createTaskWithState(t, store, "answer-task-1", task.StatePending)
- payload := `{"question_id": "toolu_nonexistent", "answer": "blue"}`
- req := httptest.NewRequest("POST", "/api/tasks/answer-task-1/answer", bytes.NewBufferString(payload))
+ req := httptest.NewRequest("POST", "/api/tasks/answer-task-1/answer", bytes.NewBufferString(`{"answer":"blue"}`))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
srv.Handler().ServeHTTP(w, req)
- if w.Code != http.StatusNotFound {
- t.Errorf("status: want 404, got %d; body: %s", w.Code, w.Body.String())
- }
- var body map[string]string
- json.NewDecoder(w.Body).Decode(&body)
- if body["error"] != "no pending question with that ID" {
- t.Errorf("error: want 'no pending question with that ID', got %q", body["error"])
+ if w.Code != http.StatusConflict {
+ t.Errorf("status: want 409, got %d; body: %s", w.Code, w.Body.String())
}
}
-func TestAnswerQuestion_WithPendingQuestion_Returns200(t *testing.T) {
+func TestAnswerQuestion_MissingAnswer_Returns400(t *testing.T) {
srv, store := testServer(t)
- createTaskWithState(t, store, "answer-task-2", task.StateRunning)
-
- ch := srv.pool.Questions.Register("answer-task-2", "toolu_Q1", []byte(`{}`))
+ createTaskWithState(t, store, "answer-task-2", task.StateBlocked)
- go func() {
- payload := `{"question_id": "toolu_Q1", "answer": "red"}`
- req := httptest.NewRequest("POST", "/api/tasks/answer-task-2/answer", bytes.NewBufferString(payload))
- req.Header.Set("Content-Type", "application/json")
- w := httptest.NewRecorder()
- srv.Handler().ServeHTTP(w, req)
+ req := httptest.NewRequest("POST", "/api/tasks/answer-task-2/answer", bytes.NewBufferString(`{}`))
+ req.Header.Set("Content-Type", "application/json")
+ w := httptest.NewRecorder()
- if w.Code != http.StatusOK {
- t.Errorf("status: want 200, got %d; body: %s", w.Code, w.Body.String())
- }
- }()
+ srv.Handler().ServeHTTP(w, req)
- answer := <-ch
- if answer != "red" {
- t.Errorf("answer: want 'red', got %q", answer)
+ if w.Code != http.StatusBadRequest {
+ t.Errorf("status: want 400, got %d; body: %s", w.Code, w.Body.String())
}
}
-func TestAnswerQuestion_MissingQuestionID_Returns400(t *testing.T) {
+func TestAnswerQuestion_BlockedTask_QueuesResume(t *testing.T) {
srv, store := testServer(t)
- createTaskWithState(t, store, "answer-task-3", task.StateRunning)
+ createTaskWithState(t, store, "answer-task-3", task.StateBlocked)
+
+ // Create an execution with a session ID, as the runner would have.
+ exec := &storage.Execution{
+ ID: "exec-blocked-1",
+ TaskID: "answer-task-3",
+ SessionID: "550e8400-e29b-41d4-a716-446655440001",
+ Status: "BLOCKED",
+ }
+ if err := store.CreateExecution(exec); err != nil {
+ t.Fatalf("create execution: %v", err)
+ }
- payload := `{"answer": "blue"}`
- req := httptest.NewRequest("POST", "/api/tasks/answer-task-3/answer", bytes.NewBufferString(payload))
+ req := httptest.NewRequest("POST", "/api/tasks/answer-task-3/answer", bytes.NewBufferString(`{"answer":"main"}`))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
srv.Handler().ServeHTTP(w, req)
- if w.Code != http.StatusBadRequest {
- t.Errorf("status: want 400, got %d", w.Code)
+ if w.Code != http.StatusOK {
+ t.Errorf("status: want 200, got %d; body: %s", w.Code, w.Body.String())
+ }
+
+ // Task should now be QUEUED (or RUNNING since the mock runner is instant).
+ got, _ := store.GetTask("answer-task-3")
+ if got.State != task.StateQueued && got.State != task.StateRunning && got.State != task.StateReady {
+ t.Errorf("task state: want QUEUED/RUNNING/READY after answer, got %v", got.State)
}
}