diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/routes/TaskRoutes.kt b/apps/server/src/main/kotlin/com/correx/apps/server/routes/TaskRoutes.kt index 80084996..88e7a656 100644 --- a/apps/server/src/main/kotlin/com/correx/apps/server/routes/TaskRoutes.kt +++ b/apps/server/src/main/kotlin/com/correx/apps/server/routes/TaskRoutes.kt @@ -8,6 +8,7 @@ import com.correx.core.events.types.TaskNoteAuthor import com.correx.core.events.types.TaskTargetKind import com.correx.core.tasks.Task import com.correx.core.tasks.TaskContextAssembler +import com.correx.core.tasks.TaskSearch import com.correx.core.tasks.TaskService import com.correx.core.tasks.TaskStatus import com.correx.core.tasks.TaskTargetKinds @@ -83,8 +84,8 @@ data class NoteRequest(val body: String, val author: String? = null) * Full REST surface for tasks. The write path mirrors the [TaskService] / `task_*` tools; both * append to the event log, which stays the single source of truth (nothing is stored separately). * A task's project is encoded in its id, so item routes need only the `{id}`. The list route takes - * an optional `project` (cross-project board without it); create requires `project` in the body. - * [GET /tasks/{id}/context] serves the agent bundle. + * optional `project` (cross-project board without it), `status`, and `q` (ranked text search); + * create requires `project` in the body. [GET /tasks/{id}/context] serves the agent bundle. */ fun Route.taskRoutes(module: ServerModule) { val service = TaskService(module.eventStore) @@ -113,11 +114,16 @@ private fun Route.taskCollectionRoutes(service: TaskService) { } // project is optional: scoped to one project when given, else the whole cross-project board. val project = call.request.queryParameters["project"] - val tasks = (if (project != null) service.list(ProjectId(project)) else service.listAll()) + val base = (if (project != null) service.list(ProjectId(project)) else service.listAll()) .filter { statusFilter == null || it.state.status == statusFilter } - .sortedByDescending { it.state.updatedAt } // most recently touched first - .map { it.toResponse() } - call.respond(tasks) + // q ranks by relevance; without it the board is most-recently-touched first. + val q = call.request.queryParameters["q"] + val ordered = if (q.isNullOrBlank()) { + base.sortedByDescending { it.state.updatedAt } + } else { + TaskSearch.search(base, q) + } + call.respond(ordered.map { it.toResponse() }) } post { val body = call.receive() diff --git a/apps/server/src/test/kotlin/com/correx/apps/server/routes/TaskRoutesTest.kt b/apps/server/src/test/kotlin/com/correx/apps/server/routes/TaskRoutesTest.kt index a598b99c..8a8823ae 100644 --- a/apps/server/src/test/kotlin/com/correx/apps/server/routes/TaskRoutesTest.kt +++ b/apps/server/src/test/kotlin/com/correx/apps/server/routes/TaskRoutesTest.kt @@ -130,8 +130,10 @@ class TaskRoutesTest { assertEquals("IN_PROGRESS", claimed.json()["status"]!!.jsonPrimitive.content) assertEquals("claude-opus", claimed.json()["claimant"]!!.jsonPrimitive.content) - assertEquals("IN_REVIEW", client.post("/tasks/demo-1/submit-for-review").json()["status"]!!.jsonPrimitive.content) - assertEquals("DONE", client.post("/tasks/demo-1/complete").json()["status"]!!.jsonPrimitive.content) + val reviewed = client.post("/tasks/demo-1/submit-for-review").json() + assertEquals("IN_REVIEW", reviewed["status"]!!.jsonPrimitive.content) + val completed = client.post("/tasks/demo-1/complete").json() + assertEquals("DONE", completed["status"]!!.jsonPrimitive.content) } } @@ -185,6 +187,25 @@ class TaskRoutesTest { } } + @Test + fun `GET tasks with q returns only ranked search hits`(@TempDir tempDir: Path) { + testApplication { + application { configureServer(buildModule(tempDir)) } + val client = createClient {} + client.createTask() // demo-1: "JWT refresh" / "users stay authed" + client.post("/tasks") { + contentType(ContentType.Application.Json) + setBody("""{"project":"demo","title":"Rotate keys","goal":"rotate signing keys"}""") + } + + val res = client.get("/tasks?q=jwt") + assertEquals(HttpStatusCode.OK, res.status) + val rows = testJson.parseToJsonElement(res.bodyAsText()) as JsonArray + assertEquals(1, rows.size) + assertEquals("demo-1", (rows[0] as JsonObject)["id"]!!.jsonPrimitive.content) + } + } + @Test fun `context bundle is served for a known task`(@TempDir tempDir: Path) { testApplication { diff --git a/apps/tui-go/internal/app/model.go b/apps/tui-go/internal/app/model.go index ad3b42cc..17dc1aa6 100644 --- a/apps/tui-go/internal/app/model.go +++ b/apps/tui-go/internal/app/model.go @@ -326,6 +326,8 @@ type Model struct { taskListErr string taskDetail bool taskDetailScroll int + taskFilter string // `/` substring narrow over id/title/goal + taskFilterTyping bool // command palette paletteFilter string diff --git a/apps/tui-go/internal/app/tasks_overlay.go b/apps/tui-go/internal/app/tasks_overlay.go index b80d111d..9c15952e 100644 --- a/apps/tui-go/internal/app/tasks_overlay.go +++ b/apps/tui-go/internal/app/tasks_overlay.go @@ -85,10 +85,28 @@ func (m *Model) openTasks() tea.Cmd { m.taskListErr = "" m.taskDetail = false m.taskDetailScroll = 0 + m.taskFilter = "" + m.taskFilterTyping = false m.taskListLoading = true return fetchTasks(m.client.HTTPBase()) } +// filteredTasks narrows the board by the `/` query — a case-insensitive substring over +// id/title/goal. An empty query returns the whole list. +func (m Model) filteredTasks() []TaskSummary { + if strings.TrimSpace(m.taskFilter) == "" { + return m.taskList + } + q := strings.ToLower(m.taskFilter) + var out []TaskSummary + for _, tk := range m.taskList { + if strings.Contains(strings.ToLower(tk.ID+" "+tk.Title+" "+tk.Goal), q) { + out = append(out, tk) + } + } + return out +} + // applyTasksLoaded folds a GET /tasks result into the board, clamping the cursor and // surfacing any fetch error in place of a crash. func (m *Model) applyTasksLoaded(msg tasksLoadedMsg) { @@ -107,10 +125,11 @@ func (m *Model) applyTasksLoaded(msg tasksLoadedMsg) { // selectedTask returns the highlighted task, or false when the list is empty / the // cursor is out of range. func (m Model) selectedTask() (TaskSummary, bool) { - if m.taskListIndex < 0 || m.taskListIndex >= len(m.taskList) { + f := m.filteredTasks() + if m.taskListIndex < 0 || m.taskListIndex >= len(f) { return TaskSummary{}, false } - return m.taskList[m.taskListIndex], true + return f[m.taskListIndex], true } // handleTasksKey owns every key while the board is open. In list mode: navigate, r @@ -136,15 +155,36 @@ func (m Model) handleTasksKey(k keyMsg) (tea.Model, tea.Cmd) { } return m, nil } + // While editing the `/` filter, keys feed the query (esc/enter stop editing, keeping it). + if m.taskFilterTyping { + switch { + case k.Type == keyEnter || k.Type == keyEsc: + m.taskFilterTyping = false + case k.Type == keyBackspace: + if n := len(m.taskFilter); n > 0 { + m.taskFilter = m.taskFilter[:n-1] + m.taskListIndex = 0 + } + case k.Type == keyRunes || k.Type == keySpace: + m.taskFilter += string(k.Runes) + m.taskListIndex = 0 + } + return m, nil + } switch { case k.Type == keyEsc || runeIs(k, "T"): m.overlay = OverlayNone + case runeIs(k, "/"): + m.taskFilterTyping = true + case runeIs(k, "c"): + m.taskFilter = "" + m.taskListIndex = 0 case k.Type == keyUp || runeIs(k, "k"): if m.taskListIndex > 0 { m.taskListIndex-- } case k.Type == keyDown || runeIs(k, "j"): - if m.taskListIndex < len(m.taskList)-1 { + if m.taskListIndex < len(m.filteredTasks())-1 { m.taskListIndex++ } case runeIs(k, "r"): @@ -195,11 +235,12 @@ func (m Model) tasksModal() string { } t := m.theme w := m.modalWidth() + tasks := m.filteredTasks() var b strings.Builder b.WriteString(m.titleLine("tasks")) - if len(m.taskList) > 0 { - b.WriteString(mbg(t, " ("+itoa(m.taskListIndex+1)+"/"+itoa(len(m.taskList))+")", t.P.Faint)) + if len(tasks) > 0 { + b.WriteString(mbg(t, " ("+itoa(m.taskListIndex+1)+"/"+itoa(len(tasks))+")", t.P.Faint)) } b.WriteString("\n\n") @@ -213,9 +254,19 @@ func (m Model) tasksModal() string { b.WriteString("\n" + modalHints(t, [][2]string{{"T/esc", "close"}})) return t.Overlay.Width(w).Render(b.String()) } - if len(m.taskList) == 0 { - b.WriteString(mbg(t, " no tasks", t.P.Faint) + "\n") - b.WriteString("\n" + modalHints(t, [][2]string{{"r", "refresh"}, {"T/esc", "close"}})) + + // Filter prompt: shown while editing the `/` query or whenever one is set. + if m.taskFilterTyping || m.taskFilter != "" { + b.WriteString(m.taskFilterLine() + "\n\n") + } + + if len(tasks) == 0 { + msg := " no tasks" + if m.taskFilter != "" { + msg = " no tasks match \"" + m.taskFilter + "\"" + } + b.WriteString(mbg(t, msg, t.P.Faint) + "\n") + b.WriteString("\n" + modalHints(t, m.taskListHints())) return t.Overlay.Width(w).Render(b.String()) } @@ -225,33 +276,50 @@ func (m Model) tasksModal() string { bodyH = 4 } off := 0 - if len(m.taskList) > bodyH { + if len(tasks) > bodyH { off = m.taskListIndex - bodyH/2 if off < 0 { off = 0 } - if off > len(m.taskList)-bodyH { - off = len(m.taskList) - bodyH + if off > len(tasks)-bodyH { + off = len(tasks) - bodyH } } end := off + bodyH - if end > len(m.taskList) { - end = len(m.taskList) + if end > len(tasks) { + end = len(tasks) } for i := off; i < end; i++ { - b.WriteString(m.taskListRow(i) + "\n") + b.WriteString(m.taskListRow(tasks, i) + "\n") } - b.WriteString("\n" + modalHints(t, [][2]string{ - {"↑↓", "select"}, {"enter", "detail"}, {"r", "refresh"}, {"T/esc", "close"}, - })) + b.WriteString("\n" + modalHints(t, m.taskListHints())) return t.Overlay.Width(w).Render(b.String()) } -// taskListRow renders one board line: marker, id, status, title, and claimant. -func (m Model) taskListRow(i int) string { +// taskListHints is the list-mode footer (shared by the populated and empty-filter views). +func (m Model) taskListHints() [][2]string { + return [][2]string{{"↑↓", "select"}, {"enter", "detail"}, {"/", "filter"}, {"r", "refresh"}, {"T/esc", "close"}} +} + +// taskFilterLine renders the `/` filter prompt over the current query, with a caret while editing. +func (m Model) taskFilterLine() string { t := m.theme - tk := m.taskList[i] + prompt := lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.BgPanel).Render("/ ") + caret := mbg(t, "", t.P.BgPanel) + if m.taskFilterTyping && m.caretVisible() { + caret = lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.BgPanel).Render("▏") + } + if m.taskFilter == "" { + return prompt + caret + mbg(t, "type to filter tasks…", t.P.Faint) + } + return prompt + mbg(t, m.taskFilter, t.P.FgStrong) + caret +} + +// taskListRow renders one board line: marker, id, status, title, and claimant. +func (m Model) taskListRow(tasks []TaskSummary, i int) string { + t := m.theme + tk := tasks[i] marker := mbg(t, " ", t.P.BgPanel) idFg := t.P.Fg diff --git a/apps/tui-go/internal/app/tasks_overlay_test.go b/apps/tui-go/internal/app/tasks_overlay_test.go index b656ec27..1fb93554 100644 --- a/apps/tui-go/internal/app/tasks_overlay_test.go +++ b/apps/tui-go/internal/app/tasks_overlay_test.go @@ -113,6 +113,41 @@ func TestTasksNavigationBoundsChecked(t *testing.T) { } } +func TestTasksFilterNarrowsList(t *testing.T) { + m := tasksModel() + m.overlay = OverlayTasks + m.taskList = sampleTasks() + + // `/` enters filter typing; a query narrows the board to matching rows. + updated, _ := m.handleTasksKey(keyMsg{Type: keyRunes, Runes: []rune("/")}) + m = updated.(Model) + if !m.taskFilterTyping { + t.Fatalf("expected filter typing after /") + } + for _, r := range "invoice" { + updated, _ = m.handleTasksKey(keyMsg{Type: keyRunes, Runes: []rune{r}}) + m = updated.(Model) + } + if got := m.filteredTasks(); len(got) != 1 || got[0].ID != "billing-3" { + t.Fatalf("expected only billing-3 to match 'invoice', got %+v", got) + } + // enter stops editing but keeps the query; the selected task is the filtered one. + updated, _ = m.handleTasksKey(keyMsg{Type: keyEnter}) + m = updated.(Model) + if m.taskFilterTyping { + t.Fatalf("expected typing to stop on enter") + } + if sel, ok := m.selectedTask(); !ok || sel.ID != "billing-3" { + t.Fatalf("expected selected task billing-3 under filter, got %+v ok=%v", sel, ok) + } + // `c` clears the filter back to the full board. + updated, _ = m.handleTasksKey(keyMsg{Type: keyRunes, Runes: []rune("c")}) + m = updated.(Model) + if len(m.filteredTasks()) != 2 { + t.Fatalf("expected full board after clear, got %d", len(m.filteredTasks())) + } +} + func TestTasksFetchErrorShownNoPanic(t *testing.T) { m := tasksModel() m.overlay = OverlayTasks diff --git a/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskSearch.kt b/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskSearch.kt new file mode 100644 index 00000000..73ceb47c --- /dev/null +++ b/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskSearch.kt @@ -0,0 +1,46 @@ +package com.correx.core.tasks + +/** + * Exact/substring task search. A task matches when every whitespace-separated term in the query + * appears (case-insensitive) in some searchable field; results are ranked by the strongest field a + * term hits (title > key > goal > acceptance criteria > notes), ties broken by id for stability. + * Pure and store-free so it can be reused over [TaskService.list]/[TaskService.listAll] and unit + * tested directly. + */ +object TaskSearch { + + fun search(tasks: List, query: String): List { + val terms = query.trim().lowercase().split(WHITESPACE).filter { it.isNotBlank() } + if (terms.isEmpty()) return tasks + return tasks + .mapNotNull { task -> score(task, terms)?.let { task to it } } + .sortedWith(compareByDescending> { it.second }.thenBy { it.first.taskId.value }) + .map { it.first } + } + + /** Sum of the best per-term field weight, or null when any term matches no field. */ + private fun score(task: Task, terms: List): Int? { + val s = task.state + val fields = listOf( + (s.title ?: "") to TITLE_WEIGHT, + (s.key ?: "") to KEY_WEIGHT, + (s.goal ?: "") to GOAL_WEIGHT, + s.acceptanceCriteria.joinToString(" ") to CRITERIA_WEIGHT, + s.notes.joinToString(" ") { it.body } to NOTES_WEIGHT, + ).map { (text, weight) -> text.lowercase() to weight } + + var total = 0 + for (term in terms) { + val best = fields.filter { it.first.contains(term) }.maxOfOrNull { it.second } ?: return null + total += best + } + return total + } + + private val WHITESPACE = Regex("\\s+") + private const val TITLE_WEIGHT = 5 + private const val KEY_WEIGHT = 4 + private const val GOAL_WEIGHT = 3 + private const val CRITERIA_WEIGHT = 2 + private const val NOTES_WEIGHT = 1 +} diff --git a/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskService.kt b/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskService.kt index 7083c07a..4aee0d68 100644 --- a/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskService.kt +++ b/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskService.kt @@ -70,6 +70,10 @@ class TaskService( return if (state.deleted) null else Task(taskId, state) } + /** Ranked text search over one project (when given) or the whole cross-project board. */ + fun search(query: String, projectId: ProjectId? = null): List = + TaskSearch.search(projectId?.let { list(it) } ?: listAll(), query) + suspend fun createTask( projectId: ProjectId, title: String, diff --git a/core/tasks/src/test/kotlin/com/correx/core/tasks/TaskSearchTest.kt b/core/tasks/src/test/kotlin/com/correx/core/tasks/TaskSearchTest.kt new file mode 100644 index 00000000..e149092a --- /dev/null +++ b/core/tasks/src/test/kotlin/com/correx/core/tasks/TaskSearchTest.kt @@ -0,0 +1,59 @@ +package com.correx.core.tasks + +import com.correx.core.events.types.TaskId +import com.correx.core.events.types.TaskNoteAuthor +import kotlinx.datetime.Instant +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class TaskSearchTest { + + private fun task( + id: String, + title: String?, + goal: String? = null, + criteria: List = emptyList(), + notes: List = emptyList(), + ) = Task( + TaskId(id), + TaskState( + key = id, + title = title, + goal = goal, + acceptanceCriteria = criteria, + notes = notes.map { TaskNote(TaskNoteAuthor.AGENT, it, Instant.parse("2026-01-01T00:00:00Z")) }, + ), + ) + + @Test + fun `requires all terms and ranks title hits above body hits`() { + val titleHit = task("auth-1", title = "JWT refresh flow", goal = "stay authed") + val goalHit = task("auth-2", title = "Token rotation", goal = "rotate the jwt refresh token") + val noMatch = task("auth-3", title = "Logging", goal = "structured logs") + + val results = TaskSearch.search(listOf(goalHit, noMatch, titleHit), "jwt refresh") + + assertEquals(listOf("auth-1", "auth-2"), results.map { it.taskId.value }) + } + + @Test + fun `matches across acceptance criteria and notes`() { + val viaNotes = task("auth-1", title = "x", notes = listOf("the migration failed on rollback")) + val viaCriteria = task("auth-2", title = "y", criteria = listOf("rollback is idempotent")) + + val results = TaskSearch.search(listOf(viaNotes, viaCriteria), "rollback") + + assertEquals(setOf("auth-1", "auth-2"), results.map { it.taskId.value }.toSet()) + } + + @Test + fun `blank query returns the input unchanged`() { + val tasks = listOf(task("auth-1", "a"), task("auth-2", "b")) + assertEquals(tasks, TaskSearch.search(tasks, " ")) + } + + @Test + fun `no match yields empty`() { + assertEquals(emptyList(), TaskSearch.search(listOf(task("auth-1", "nothing here")), "zebra")) + } +} diff --git a/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskSearchTool.kt b/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskSearchTool.kt new file mode 100644 index 00000000..a4b80c39 --- /dev/null +++ b/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskSearchTool.kt @@ -0,0 +1,70 @@ +package com.correx.infrastructure.tools.task + +import com.correx.core.approvals.Tier +import com.correx.core.events.events.ToolRequest +import com.correx.core.events.types.ProjectId +import com.correx.core.tasks.TaskService +import com.correx.core.tools.contract.Tool +import com.correx.core.tools.contract.ToolCapability +import com.correx.core.tools.contract.ToolExecutor +import com.correx.core.tools.contract.ToolResult +import com.correx.core.tools.contract.ValidationResult +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.add +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import kotlinx.serialization.json.putJsonObject + +private const val DEFAULT_LIMIT = 20 + +/** + * Agent-facing tool: find tasks by text across projects (matches id/title/goal/criteria/notes), + * ranked by relevance. Read-only, so it sits at the lowest tier (like [TaskContextTool]) — agents + * use it to discover task ids before fetching a context bundle. + */ +class TaskSearchTool(private val service: TaskService) : Tool, ToolExecutor { + + override val name: String = "task_search" + override val description: String = + "Search tasks by text (matches id/title/goal/acceptance criteria/notes), ranked by " + + "relevance. Returns 'id [STATUS] title' lines. Use to find a task id, then task_context." + override val parametersSchema: JsonObject = buildJsonObject { + put("type", "object") + putJsonObject("properties") { + putJsonObject("query") { put("type", "string"); put("description", "Free-text query; all terms must match.") } + putJsonObject("project") { + put("type", "string") + put("description", "Limit to a project (e.g. 'auth'). Omit to search every project.") + } + putJsonObject("limit") { put("type", "integer"); put("description", "Max results (default $DEFAULT_LIMIT).") } + } + put("required", buildJsonArray { add(JsonPrimitive("query")) }) + } + override val tier: Tier = Tier.T1 + override val requiredCapabilities: Set = emptySet() + + override fun validateRequest(request: ToolRequest): ValidationResult { + request.stringParam("query") ?: return ValidationResult.Invalid("Missing 'query' (string).") + return ValidationResult.Valid + } + + override suspend fun execute(request: ToolRequest): ToolResult { + val query = request.stringParam("query") + ?: return ToolResult.Failure(request.invocationId, "Missing 'query' (string).", recoverable = false) + val project = request.stringParam("project")?.let { ProjectId(it) } + val limit = (request.parameters["limit"] as? Number)?.toInt()?.takeIf { it > 0 } ?: DEFAULT_LIMIT + val hits = service.search(query, project).take(limit) + val output = if (hits.isEmpty()) { + "no tasks match \"$query\"" + } else { + hits.joinToString("\n") { "${it.taskId.value} [${it.state.status.name}] ${it.state.title.orEmpty()}".trimEnd() } + } + return ToolResult.Success( + invocationId = request.invocationId, + output = output, + metadata = mapOf("query" to query, "count" to hits.size.toString()), + ) + } +} diff --git a/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskTools.kt b/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskTools.kt index 869f0ac6..3c5e61c6 100644 --- a/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskTools.kt +++ b/infrastructure/tools/src/main/kotlin/com/correx/infrastructure/tools/task/TaskTools.kt @@ -26,6 +26,7 @@ object TaskTools { TaskCreateTool(service), TaskUpdateTool(service), TaskDeleteTool(service), + TaskSearchTool(service), TaskContextTool( TaskContextAssembler(service, retriever, documentResolver, artifactResolver, sessionResolver), ), diff --git a/infrastructure/tools/src/test/kotlin/com/correx/infrastructure/tools/task/TaskToolsTest.kt b/infrastructure/tools/src/test/kotlin/com/correx/infrastructure/tools/task/TaskToolsTest.kt index 2bd5b10f..3c9707d3 100644 --- a/infrastructure/tools/src/test/kotlin/com/correx/infrastructure/tools/task/TaskToolsTest.kt +++ b/infrastructure/tools/src/test/kotlin/com/correx/infrastructure/tools/task/TaskToolsTest.kt @@ -18,6 +18,7 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.runBlocking import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse import org.junit.jupiter.api.Assertions.assertNull import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test @@ -28,6 +29,7 @@ class TaskToolsTest { private val create = TaskCreateTool(service) private val update = TaskUpdateTool(service) private val delete = TaskDeleteTool(service) + private val search = TaskSearchTool(service) private val context = TaskContextTool(TaskContextAssembler(service)) private var counter = 0 @@ -138,6 +140,21 @@ class TaskToolsTest { assertTrue(context.execute(request("task_context", mapOf("id" to "auth-999"))) is ToolResult.Failure) } + @Test + fun `task_search ranks matches across projects and reports misses`() = runBlocking { + create.execute(request("task_create", mapOf("project" to "auth", "title" to "JWT refresh", "goal" to "stay authed"))) + create.execute(request("task_create", mapOf("project" to "auth", "title" to "Rotate keys", "goal" to "rotate signing keys"))) + + val hit = search.execute(request("task_search", mapOf("query" to "jwt"))) + assertTrue(hit is ToolResult.Success) + val out = (hit as ToolResult.Success).output + assertTrue(out.contains("auth-1")) + assertFalse(out.contains("auth-2")) + + val miss = search.execute(request("task_search", mapOf("query" to "zebra"))) + assertTrue((miss as ToolResult.Success).output.contains("no tasks match")) + } + @Test fun `task_delete tombstones the task`() = runBlocking { val id = createTask()