diff options
| -rw-r--r-- | docs/RAW_NARRATIVE.md | 3 | ||||
| -rw-r--r-- | internal/api/server.go | 58 | ||||
| -rw-r--r-- | internal/api/server_test.go | 144 | ||||
| -rw-r--r-- | internal/executor/gemini.go | 40 | ||||
| -rw-r--r-- | internal/storage/db.go | 50 | ||||
| -rw-r--r-- | internal/task/task.go | 1 | ||||
| -rw-r--r-- | web/app.js | 5 | ||||
| -rw-r--r-- | web/test/subtask-placeholder.test.mjs | 24 |
8 files changed, 282 insertions, 43 deletions
diff --git a/docs/RAW_NARRATIVE.md b/docs/RAW_NARRATIVE.md index a9c1492..ee37140 100644 --- a/docs/RAW_NARRATIVE.md +++ b/docs/RAW_NARRATIVE.md @@ -388,3 +388,6 @@ the current state machine including the `BLOCKED→READY` transition for parent | Per-IP rate limiter on elaborate | Done | | Web UI (PWA) | Done | | Push notifications (PWA) | Planned | + +--- 2026-03-16T00:56:20Z --- +Converter sudoku to rust diff --git a/internal/api/server.go b/internal/api/server.go index 59d59eb..65b0181 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -397,14 +397,15 @@ func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { func (s *Server) handleCreateTask(w http.ResponseWriter, r *http.Request) { var input struct { - Name string `json:"name"` - Description string `json:"description"` - Agent task.AgentConfig `json:"agent"` - Claude task.AgentConfig `json:"claude"` // legacy alias - Timeout string `json:"timeout"` - Priority string `json:"priority"` - Tags []string `json:"tags"` - ParentTaskID string `json:"parent_task_id"` + Name string `json:"name"` + Description string `json:"description"` + ElaborationInput string `json:"elaboration_input"` + Agent task.AgentConfig `json:"agent"` + Claude task.AgentConfig `json:"claude"` // legacy alias + Timeout string `json:"timeout"` + Priority string `json:"priority"` + Tags []string `json:"tags"` + ParentTaskID string `json:"parent_task_id"` } if err := json.NewDecoder(r.Body).Decode(&input); err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON: " + err.Error()}) @@ -418,10 +419,11 @@ func (s *Server) handleCreateTask(w http.ResponseWriter, r *http.Request) { now := time.Now().UTC() t := &task.Task{ - ID: uuid.New().String(), - Name: input.Name, - Description: input.Description, - Agent: input.Agent, + ID: uuid.New().String(), + Name: input.Name, + Description: input.Description, + ElaborationInput: input.ElaborationInput, + Agent: input.Agent, Priority: task.Priority(input.Priority), Tags: input.Tags, DependsOn: []string{}, @@ -515,8 +517,16 @@ func (s *Server) handleGetTask(w http.ResponseWriter, r *http.Request) { } func (s *Server) handleRunTask(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") - agent := r.URL.Query().Get("agent") + agentParam := r.URL.Query().Get("agent") // Use a different name to avoid confusion + // 1. Retrieve the original task to preserve agent config if not "auto". + originalTask, err := s.store.GetTask(id) + if err != nil { + writeJSON(w, http.StatusNotFound, map[string]string{"error": "task not found"}) + return + } + + // 2. Reset the task for retry, which clears the agent config. t, err := s.store.ResetTaskForRetry(id) if err != nil { if strings.Contains(err.Error(), "not found") { @@ -531,9 +541,27 @@ func (s *Server) handleRunTask(w http.ResponseWriter, r *http.Request) { return } - if agent != "" && agent != "auto" { - t.Agent.Type = agent + // 3. Restore original agent type and model if not explicitly overridden by query parameter. + // Only restore if original task had a specific agent type set and query parameter is not overriding it. + if originalTask.Agent.Type != "" && agentParam == "" { + t.Agent.Type = originalTask.Agent.Type + t.Agent.Model = originalTask.Agent.Model + } + + // 4. Handle agent query parameter override. + if agentParam != "" && agentParam != "auto" { + t.Agent.Type = agentParam + } + + // 5. Update task agent in DB if it has changed from the reset (only if originalTask.Agent.Type was explicitly set, or agentParam was set). + if originalTask.Agent.Type != t.Agent.Type || originalTask.Agent.Model != t.Agent.Model { + if err := s.store.UpdateTaskAgent(t.ID, t.Agent); err != nil { + s.logger.Error("failed to update task agent config", "error", err, "taskID", t.ID) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } } + // The task `t` now has the correct agent configuration. if err := s.pool.Submit(context.Background(), t); err != nil { writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": fmt.Sprintf("executor pool: %v", err)}) 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) diff --git a/internal/executor/gemini.go b/internal/executor/gemini.go index 67ea7dd..bf284c6 100644 --- a/internal/executor/gemini.go +++ b/internal/executor/gemini.go @@ -3,6 +3,7 @@ package executor import ( "context" "fmt" + "io" "log/slog" "os" "os/exec" @@ -53,6 +54,7 @@ func (r *GeminiRunner) Run(ctx context.Context, t *task.Task, e *storage.Executi if err := os.MkdirAll(logDir, 0700); err != nil { return fmt.Errorf("creating log dir: %w", err) } + if e.StdoutPath == "" { e.StdoutPath = filepath.Join(logDir, "stdout.log") e.StderrPath = filepath.Join(logDir, "stderr.log") @@ -137,7 +139,7 @@ func (r *GeminiRunner) execOnce(ctx context.Context, args []string, workingDir, go func() { defer wg.Done() // Reusing parseStream as the JSONL format should be compatible - costUSD, streamErr = parseStream(stdoutR, stdoutFile, r.Logger) + costUSD, streamErr = parseGeminiStream(stdoutR, stdoutFile, r.Logger) stdoutR.Close() }() @@ -164,6 +166,42 @@ func (r *GeminiRunner) execOnce(ctx context.Context, args []string, workingDir, return nil } +// parseGeminiStream reads streaming JSON from the gemini CLI, unwraps markdown +// code blocks, writes the inner JSON to w, and returns (costUSD, error). +// For now, it focuses on unwrapping and writing, not detailed parsing of cost/errors. +func parseGeminiStream(r io.Reader, w io.Writer, logger *slog.Logger) (float64, error) { + fullOutput, err := io.ReadAll(r) + if err != nil { + return 0, fmt.Errorf("reading full gemini output: %w", err) + } + + outputStr := strings.TrimSpace(string(fullOutput)) // Trim leading/trailing whitespace/newlines from the whole output + + jsonContent := outputStr // Default to raw output if no markdown block is found or malformed + jsonStartIdx := strings.Index(outputStr, "```json") + if jsonStartIdx != -1 { + // Found "```json", now look for the closing "```" + jsonEndIdx := strings.LastIndex(outputStr, "```") + if jsonEndIdx != -1 && jsonEndIdx > jsonStartIdx { + // Extract content between the markdown fences. + jsonContent = outputStr[jsonStartIdx+len("```json"):jsonEndIdx] + jsonContent = strings.TrimSpace(jsonContent) // Trim again after extraction, to remove potential inner newlines + } else { + logger.Warn("Malformed markdown JSON block from Gemini (missing closing ``` or invalid structure), falling back to raw output.", "outputLength", len(outputStr)) + } + } else { + logger.Warn("No markdown JSON block found from Gemini, falling back to raw output.", "outputLength", len(outputStr)) + } + + // Write the (possibly extracted and trimmed) JSON content to the writer. + _, writeErr := w.Write([]byte(jsonContent)) + if writeErr != nil { + return 0, fmt.Errorf("writing extracted gemini json: %w", writeErr) + } + + return 0, nil // For now, no cost/error parsing for Gemini stream +} + func (r *GeminiRunner) buildArgs(t *task.Task, e *storage.Execution, questionFile string) []string { // Gemini CLI uses a different command structure: gemini "instructions" [flags] diff --git a/internal/storage/db.go b/internal/storage/db.go index a77b1b1..038480b 100644 --- a/internal/storage/db.go +++ b/internal/storage/db.go @@ -85,6 +85,7 @@ func (s *DB) migrate() error { `ALTER TABLE tasks ADD COLUMN interactions_json TEXT NOT NULL DEFAULT '[]'`, `ALTER TABLE executions ADD COLUMN changestats_json TEXT`, `ALTER TABLE executions ADD COLUMN commits_json TEXT NOT NULL DEFAULT '[]'`, + `ALTER TABLE tasks ADD COLUMN elaboration_input TEXT`, } for _, m := range migrations { if _, err := s.db.Exec(m); err != nil { @@ -122,9 +123,9 @@ func (s *DB) CreateTask(t *task.Task) error { } _, err = s.db.Exec(` - INSERT INTO tasks (id, name, description, config_json, priority, timeout_ns, retry_json, tags_json, depends_on_json, parent_task_id, state, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - t.ID, t.Name, t.Description, string(configJSON), string(t.Priority), + INSERT INTO tasks (id, name, description, elaboration_input, config_json, priority, timeout_ns, retry_json, tags_json, depends_on_json, parent_task_id, state, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + t.ID, t.Name, t.Description, t.ElaborationInput, string(configJSON), string(t.Priority), t.Timeout.Duration.Nanoseconds(), string(retryJSON), string(tagsJSON), string(depsJSON), t.ParentTaskID, string(t.State), t.CreatedAt.UTC(), t.UpdatedAt.UTC(), ) @@ -133,13 +134,13 @@ func (s *DB) CreateTask(t *task.Task) error { // GetTask retrieves a task by ID. func (s *DB) GetTask(id string) (*task.Task, error) { - row := s.db.QueryRow(`SELECT id, name, description, config_json, priority, timeout_ns, retry_json, tags_json, depends_on_json, parent_task_id, state, created_at, updated_at, rejection_comment, question_json, summary, interactions_json FROM tasks WHERE id = ?`, id) + row := s.db.QueryRow(`SELECT id, name, description, elaboration_input, config_json, priority, timeout_ns, retry_json, tags_json, depends_on_json, parent_task_id, state, created_at, updated_at, rejection_comment, question_json, summary, interactions_json FROM tasks WHERE id = ?`, id) return scanTask(row) } // ListTasks returns tasks matching the given filter. func (s *DB) ListTasks(filter TaskFilter) ([]*task.Task, error) { - query := `SELECT id, name, description, config_json, priority, timeout_ns, retry_json, tags_json, depends_on_json, parent_task_id, state, created_at, updated_at, rejection_comment, question_json, summary, interactions_json FROM tasks WHERE 1=1` + query := `SELECT id, name, description, elaboration_input, config_json, priority, timeout_ns, retry_json, tags_json, depends_on_json, parent_task_id, state, created_at, updated_at, rejection_comment, question_json, summary, interactions_json FROM tasks WHERE 1=1` var args []interface{} if filter.State != "" { @@ -175,7 +176,7 @@ func (s *DB) ListTasks(filter TaskFilter) ([]*task.Task, error) { // ListSubtasks returns all tasks whose parent_task_id matches the given ID. func (s *DB) ListSubtasks(parentID string) ([]*task.Task, error) { - rows, err := s.db.Query(`SELECT id, name, description, config_json, priority, timeout_ns, retry_json, tags_json, depends_on_json, parent_task_id, state, created_at, updated_at, rejection_comment, question_json, summary, interactions_json FROM tasks WHERE parent_task_id = ? ORDER BY created_at ASC`, parentID) + rows, err := s.db.Query(`SELECT id, name, description, elaboration_input, config_json, priority, timeout_ns, retry_json, tags_json, depends_on_json, parent_task_id, state, created_at, updated_at, rejection_comment, question_json, summary, interactions_json FROM tasks WHERE parent_task_id = ? ORDER BY created_at ASC`, parentID) if err != nil { return nil, err } @@ -228,7 +229,7 @@ func (s *DB) ResetTaskForRetry(id string) (*task.Task, error) { } defer tx.Rollback() //nolint:errcheck - t, err := scanTask(tx.QueryRow(`SELECT id, name, description, config_json, priority, timeout_ns, retry_json, tags_json, depends_on_json, parent_task_id, state, created_at, updated_at, rejection_comment, question_json, summary, interactions_json FROM tasks WHERE id = ?`, id)) + t, err := scanTask(tx.QueryRow(`SELECT id, name, description, elaboration_input, config_json, priority, timeout_ns, retry_json, tags_json, depends_on_json, parent_task_id, state, created_at, updated_at, rejection_comment, question_json, summary, interactions_json FROM tasks WHERE id = ?`, id)) if err != nil { if err == sql.ErrNoRows { return nil, fmt.Errorf("task %q not found", id) @@ -664,22 +665,24 @@ type scanner interface { func scanTask(row scanner) (*task.Task, error) { var ( - t task.Task - configJSON string - retryJSON string - tagsJSON string - depsJSON string - state string - priority string - timeoutNS int64 - parentTaskID sql.NullString - rejectionComment sql.NullString - questionJSON sql.NullString - summary sql.NullString - interactionsJSON sql.NullString + t task.Task + configJSON string + retryJSON string + tagsJSON string + depsJSON string + state string + priority string + timeoutNS int64 + parentTaskID sql.NullString + elaborationInput sql.NullString + rejectionComment sql.NullString + questionJSON sql.NullString + summary sql.NullString + interactionsJSON sql.NullString ) - err := row.Scan(&t.ID, &t.Name, &t.Description, &configJSON, &priority, &timeoutNS, &retryJSON, &tagsJSON, &depsJSON, &parentTaskID, &state, &t.CreatedAt, &t.UpdatedAt, &rejectionComment, &questionJSON, &summary, &interactionsJSON) + err := row.Scan(&t.ID, &t.Name, &t.Description, &elaborationInput, &configJSON, &priority, &timeoutNS, &retryJSON, &tagsJSON, &depsJSON, &parentTaskID, &state, &t.CreatedAt, &t.UpdatedAt, &rejectionComment, &questionJSON, &summary, &interactionsJSON) t.ParentTaskID = parentTaskID.String + t.ElaborationInput = elaborationInput.String t.RejectionComment = rejectionComment.String t.QuestionJSON = questionJSON.String t.Summary = summary.String @@ -689,6 +692,11 @@ func scanTask(row scanner) (*task.Task, error) { t.State = task.State(state) t.Priority = task.Priority(priority) t.Timeout.Duration = time.Duration(timeoutNS) + // Add debug log for configJSON + // The logger is not available directly in db.go, so I'll use fmt.Printf for now. + // For production code, a logger should be injected. + // fmt.Printf("DEBUG: configJSON from DB: %s\n", configJSON) + // TODO: Replace with proper logger when available. if err := json.Unmarshal([]byte(configJSON), &t.Agent); err != nil { return nil, fmt.Errorf("unmarshaling agent config: %w", err) } diff --git a/internal/task/task.go b/internal/task/task.go index 6a9d1db..b3660d3 100644 --- a/internal/task/task.go +++ b/internal/task/task.go @@ -83,6 +83,7 @@ type Task struct { State State `yaml:"-" json:"state"` RejectionComment string `yaml:"-" json:"rejection_comment,omitempty"` QuestionJSON string `yaml:"-" json:"question,omitempty"` + ElaborationInput string `yaml:"-" json:"elaboration_input,omitempty"` Summary string `yaml:"-" json:"summary,omitempty"` Interactions []Interaction `yaml:"-" json:"interactions,omitempty"` CreatedAt time.Time `yaml:"-" json:"created_at"` @@ -883,7 +883,7 @@ async function renderSubtaskRollup(task, footer) { const res = await fetch(`${API_BASE}/api/tasks/${task.id}/subtasks`); const subtasks = await res.json(); if (!subtasks || subtasks.length === 0) { - const blurb = task.description || task.name; + const blurb = task.elaboration_input || task.description || task.name; container.textContent = blurb ? truncateToWordBoundary(blurb) : 'Waiting for subtasks…'; return; } @@ -1555,9 +1555,12 @@ async function createTask(formData) { const workingDir = selectVal === '__new__' ? document.getElementById('new-project-input').value.trim() : selectVal; + const elaboratePromptEl = document.getElementById('elaborate-prompt'); + const elaborationInput = elaboratePromptEl ? elaboratePromptEl.value.trim() : ''; const body = { name: formData.get('name'), description: '', + elaboration_input: elaborationInput || undefined, agent: { instructions: formData.get('instructions'), project_dir: workingDir, diff --git a/web/test/subtask-placeholder.test.mjs b/web/test/subtask-placeholder.test.mjs index 2449faa..b279804 100644 --- a/web/test/subtask-placeholder.test.mjs +++ b/web/test/subtask-placeholder.test.mjs @@ -9,9 +9,10 @@ import assert from 'node:assert/strict'; // // When a task is BLOCKED/READY and the subtask list is empty, the rollup shows // meaningful content instead of a generic placeholder: -// 1. task.description truncated to ~120 chars (word boundary) -// 2. fallback to task.name if no description -// 3. fallback to 'Waiting for subtasks…' if neither +// 1. task.elaboration_input (raw user prompt) if present +// 2. task.description truncated to ~120 chars (word boundary) +// 3. fallback to task.name if no description +// 4. fallback to 'Waiting for subtasks…' if neither function truncateToWordBoundary(text, maxLen = 120) { if (!text || text.length <= maxLen) return text; @@ -20,7 +21,7 @@ function truncateToWordBoundary(text, maxLen = 120) { } function getSubtaskPlaceholder(task) { - const blurb = task.description || task.name; + const blurb = task.elaboration_input || task.description || task.name; return blurb ? truncateToWordBoundary(blurb) : 'Waiting for subtasks…'; } @@ -60,7 +61,20 @@ describe('truncateToWordBoundary', () => { }); describe('getSubtaskPlaceholder', () => { - it('uses task.description when available', () => { + it('prefers task.elaboration_input over description and name', () => { + const task = { elaboration_input: 'fix the login bug', description: 'Fix authentication issue', name: 'auth-fix' }; + assert.equal(getSubtaskPlaceholder(task), 'fix the login bug'); + }); + + it('truncates task.elaboration_input at 120 chars', () => { + const longInput = 'Please fix the login bug that causes users to be logged out unexpectedly when they navigate between pages in the application user interface'; + const task = { elaboration_input: longInput, name: 'auth-fix' }; + const result = getSubtaskPlaceholder(task); + assert.ok(result.endsWith('…'), 'should end with ellipsis'); + assert.ok(result.length <= 122, `result too long: ${result.length}`); + }); + + it('uses task.description when elaboration_input is absent', () => { const task = { description: 'Fix auth bug', name: 'auth-fix' }; assert.equal(getSubtaskPlaceholder(task), 'Fix auth bug'); }); |
