From d911021b7e4a0c9f77ca9996b0ebdabb03c56696 Mon Sep 17 00:00:00 2001 From: Claudomator Agent Date: Mon, 16 Mar 2026 01:10:00 +0000 Subject: feat: add elaboration_input field to tasks for richer subtask placeholder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add ElaborationInput field to Task struct (task.go) - Add DB migration and update CREATE/SELECT/scan in storage/db.go - Update handleCreateTask to accept elaboration_input from API - Update renderSubtaskRollup in app.js to prefer elaboration_input over description - Capture elaborate prompt in createTask() form submission - Update subtask-placeholder tests to cover elaboration_input priority - Fix missing io import in gemini.go When a task card is waiting for subtasks, it now shows: 1. The raw user prompt from elaboration (if stored) 2. The task description truncated at word boundary (~120 chars) 3. The task name as fallback 4. 'Waiting for subtasks…' only when all fields are empty Co-Authored-By: Claude Sonnet 4.6 --- internal/api/server_test.go | 144 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 144 insertions(+) (limited to 'internal/api/server_test.go') diff --git a/internal/api/server_test.go b/internal/api/server_test.go index d090313..a670f33 100644 --- a/internal/api/server_test.go +++ b/internal/api/server_test.go @@ -132,6 +132,150 @@ func pollState(t *testing.T, store *storage.DB, taskID string, wantState task.St return "" } +func testServerWithGeminiMockRunner(t *testing.T) (*Server, *storage.DB) { + t.Helper() + dbPath := filepath.Join(t.TempDir(), "test.db") + store, err := storage.Open(dbPath) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { store.Close() }) + + logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelDebug})) + + // Create the mock gemini binary script. + mockBinDir := t.TempDir() + mockGeminiPath := filepath.Join(mockBinDir, "mock-gemini-binary.sh") + mockScriptContent := `#!/bin/bash +# Mock gemini binary that outputs stream-json wrapped in markdown to stdout. +echo "```json" +echo "{\"type\":\"content_block_start\",\"content_block\":{\"text\":\"Hello, Gemini!\",\"type\":\"text\"}}" +echo "{\"type\":\"content_block_delta\",\"content_block\":{\"text\":\" How are you?\"}}" +echo "{\"type\":\"content_block_end\"}" +echo "{\"type\":\"message_delta\",\"message\":{\"role\":\"model\"}}" +echo "{\"type\":\"message_end\"}" +echo "```" +exit 0 +` + if err := os.WriteFile(mockGeminiPath, []byte(mockScriptContent), 0755); err != nil { + t.Fatalf("writing mock gemini script: %v", err) + } + + // Configure GeminiRunner to use the mock script. + geminiRunner := &executor.GeminiRunner{ + BinaryPath: mockGeminiPath, + Logger: logger, + LogDir: t.TempDir(), // Ensure log directory is temporary for test + APIURL: "http://localhost:8080", // Placeholder, not used by this mock + } + + runners := map[string]executor.Runner{ + "claude": &mockRunner{}, // Keep mock for claude to not interfere + "gemini": geminiRunner, + } + pool := executor.NewPool(2, runners, store, logger) + srv := NewServer(store, pool, logger, "claude", "gemini") // Pass original binary paths + return srv, store +} + +// TestGeminiLogs_ParsedCorrectly verifies that Gemini's markdown-wrapped stream-json +// output is correctly unwrapped and parsed before being written to stdout.log +// and exposed via the /api/tasks/{id}/executions/{exec-id}/log endpoint. +func TestGeminiLogs_ParsedCorrectly(t *testing.T) { + srv, store := testServerWithGeminiMockRunner(t) + + // Expected parsed JSON lines. + expectedParsedLogs := []string{ + `{"type":"content_block_start","content_block":{"text":"Hello, Gemini!","type":"text"}}`, + `{"type":"content_block_delta","content_block":{"text":" How are you?"}}`, + `{"type":"content_block_end"}`, + `{"type":"message_delta","message":{"role":"model"}}`, + `{"type":"message_end"}`, + } + + // 1. Create a task with Gemini agent. + tk := createTestTask(t, srv, `{ + "name": "Gemini Log Test Task", + "description": "Test Gemini log parsing", + "agent": { + "type": "gemini", + "instructions": "generate some output", + "model": "gemini-2.5-flash-lite" + } + }`) + + // 2. Run the task. + req := httptest.NewRequest("POST", "/api/tasks/"+tk.ID+"/run", nil) + w := httptest.NewRecorder() + srv.Handler().ServeHTTP(w, req) + + if w.Code != http.StatusAccepted { + t.Fatalf("run task status: want 202, got %d; body: %s", w.Code, w.Body.String()) + } + + // 3. Wait for the task to complete. + pollState(t, store, tk.ID, task.StateCompleted, 2*time.Second) + + // Re-fetch the task to ensure we have the updated execution details. + updatedTask, err := store.GetTask(tk.ID) + if err != nil { + t.Fatalf("re-fetching task: %v", err) + } + + // 4. Get the execution details to find the log path. + executions, err := store.ListExecutions(updatedTask.ID) + if err != nil { + t.Fatalf("listing executions: %v", err) + } + if len(executions) != 1 { + t.Fatalf("want 1 execution, got %d", len(executions)) + } + exec := executions[0] + t.Logf("Re-fetched execution: %+v", exec) // Log the entire execution struct + + // 5. Verify the content of stdout.log directly. + t.Logf("Attempting to read stdout.log from: %q", exec.StdoutPath) + stdoutContent, err := os.ReadFile(exec.StdoutPath) + if err != nil { + t.Fatalf("reading stdout.log: %v", err) + } + stdoutLines := strings.Split(strings.TrimSpace(string(stdoutContent)), "\n") + if len(stdoutLines) != len(expectedParsedLogs) { + t.Errorf("stdout.log line count: want %d, got %d\nContent:\n%s", len(expectedParsedLogs), len(stdoutLines), stdoutContent) + } + for i, line := range stdoutLines { + if i >= len(expectedParsedLogs) { + break + } + if line != expectedParsedLogs[i] { + t.Errorf("stdout.log line %d: want %q, got %q", i, expectedParsedLogs[i], line) + } + } + + // 6. Verify the content retrieved via the API endpoint. + req = httptest.NewRequest("GET", "/api/tasks/"+tk.ID+"/executions/"+exec.ID+"/log", nil) + w = httptest.NewRecorder() + srv.Handler().ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("GET /log status: want 200, got %d; body: %s", w.Code, w.Body.String()) + } + + apiLogContent := strings.TrimSpace(w.Body.String()) + apiLogLines := strings.Split(apiLogContent, "\n") + if len(apiLogLines) != len(expectedParsedLogs) { + t.Errorf("API log line count: want %d, got %d\nContent:\n%s", len(expectedParsedLogs), len(apiLogLines), apiLogContent) + } + for i, line := range apiLogLines { + if i >= len(expectedParsedLogs) { + break + } + if line != expectedParsedLogs[i] { + t.Errorf("API log line %d: want %q, got %q", i, expectedParsedLogs[i], line) + } + } +} + func TestListWorkspaces_UsesConfiguredRoot(t *testing.T) { srv, _ := testServer(t) -- cgit v1.2.3