feat(tasks): task search across REST, tool, and TUI

Ranked exact/substring search: TaskSearch (all terms AND, ranked title >
key > goal > criteria > notes) over one project or the whole board via
TaskService.search. Surfaced as GET /tasks?q=, a read-only task_search tool
for agents, and a `/` filter in the TUI board.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-23 22:08:11 +00:00
parent 1d7fab4ee4
commit 99f781687f
11 changed files with 357 additions and 28 deletions
@@ -8,6 +8,7 @@ import com.correx.core.events.types.TaskNoteAuthor
import com.correx.core.events.types.TaskTargetKind import com.correx.core.events.types.TaskTargetKind
import com.correx.core.tasks.Task import com.correx.core.tasks.Task
import com.correx.core.tasks.TaskContextAssembler import com.correx.core.tasks.TaskContextAssembler
import com.correx.core.tasks.TaskSearch
import com.correx.core.tasks.TaskService import com.correx.core.tasks.TaskService
import com.correx.core.tasks.TaskStatus import com.correx.core.tasks.TaskStatus
import com.correx.core.tasks.TaskTargetKinds 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 * 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). * 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 * 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. * optional `project` (cross-project board without it), `status`, and `q` (ranked text search);
* [GET /tasks/{id}/context] serves the agent bundle. * create requires `project` in the body. [GET /tasks/{id}/context] serves the agent bundle.
*/ */
fun Route.taskRoutes(module: ServerModule) { fun Route.taskRoutes(module: ServerModule) {
val service = TaskService(module.eventStore) 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. // project is optional: scoped to one project when given, else the whole cross-project board.
val project = call.request.queryParameters["project"] 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 } .filter { statusFilter == null || it.state.status == statusFilter }
.sortedByDescending { it.state.updatedAt } // most recently touched first // q ranks by relevance; without it the board is most-recently-touched first.
.map { it.toResponse() } val q = call.request.queryParameters["q"]
call.respond(tasks) val ordered = if (q.isNullOrBlank()) {
base.sortedByDescending { it.state.updatedAt }
} else {
TaskSearch.search(base, q)
}
call.respond(ordered.map { it.toResponse() })
} }
post { post {
val body = call.receive<CreateTaskRequest>() val body = call.receive<CreateTaskRequest>()
@@ -130,8 +130,10 @@ class TaskRoutesTest {
assertEquals("IN_PROGRESS", claimed.json()["status"]!!.jsonPrimitive.content) assertEquals("IN_PROGRESS", claimed.json()["status"]!!.jsonPrimitive.content)
assertEquals("claude-opus", claimed.json()["claimant"]!!.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) val reviewed = client.post("/tasks/demo-1/submit-for-review").json()
assertEquals("DONE", client.post("/tasks/demo-1/complete").json()["status"]!!.jsonPrimitive.content) 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 @Test
fun `context bundle is served for a known task`(@TempDir tempDir: Path) { fun `context bundle is served for a known task`(@TempDir tempDir: Path) {
testApplication { testApplication {
+2
View File
@@ -326,6 +326,8 @@ type Model struct {
taskListErr string taskListErr string
taskDetail bool taskDetail bool
taskDetailScroll int taskDetailScroll int
taskFilter string // `/` substring narrow over id/title/goal
taskFilterTyping bool
// command palette // command palette
paletteFilter string paletteFilter string
+88 -20
View File
@@ -85,10 +85,28 @@ func (m *Model) openTasks() tea.Cmd {
m.taskListErr = "" m.taskListErr = ""
m.taskDetail = false m.taskDetail = false
m.taskDetailScroll = 0 m.taskDetailScroll = 0
m.taskFilter = ""
m.taskFilterTyping = false
m.taskListLoading = true m.taskListLoading = true
return fetchTasks(m.client.HTTPBase()) 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 // applyTasksLoaded folds a GET /tasks result into the board, clamping the cursor and
// surfacing any fetch error in place of a crash. // surfacing any fetch error in place of a crash.
func (m *Model) applyTasksLoaded(msg tasksLoadedMsg) { 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 // selectedTask returns the highlighted task, or false when the list is empty / the
// cursor is out of range. // cursor is out of range.
func (m Model) selectedTask() (TaskSummary, bool) { 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 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 // 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 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 { switch {
case k.Type == keyEsc || runeIs(k, "T"): case k.Type == keyEsc || runeIs(k, "T"):
m.overlay = OverlayNone 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"): case k.Type == keyUp || runeIs(k, "k"):
if m.taskListIndex > 0 { if m.taskListIndex > 0 {
m.taskListIndex-- m.taskListIndex--
} }
case k.Type == keyDown || runeIs(k, "j"): case k.Type == keyDown || runeIs(k, "j"):
if m.taskListIndex < len(m.taskList)-1 { if m.taskListIndex < len(m.filteredTasks())-1 {
m.taskListIndex++ m.taskListIndex++
} }
case runeIs(k, "r"): case runeIs(k, "r"):
@@ -195,11 +235,12 @@ func (m Model) tasksModal() string {
} }
t := m.theme t := m.theme
w := m.modalWidth() w := m.modalWidth()
tasks := m.filteredTasks()
var b strings.Builder var b strings.Builder
b.WriteString(m.titleLine("tasks")) b.WriteString(m.titleLine("tasks"))
if len(m.taskList) > 0 { if len(tasks) > 0 {
b.WriteString(mbg(t, " ("+itoa(m.taskListIndex+1)+"/"+itoa(len(m.taskList))+")", t.P.Faint)) b.WriteString(mbg(t, " ("+itoa(m.taskListIndex+1)+"/"+itoa(len(tasks))+")", t.P.Faint))
} }
b.WriteString("\n\n") b.WriteString("\n\n")
@@ -213,9 +254,19 @@ func (m Model) tasksModal() string {
b.WriteString("\n" + modalHints(t, [][2]string{{"T/esc", "close"}})) b.WriteString("\n" + modalHints(t, [][2]string{{"T/esc", "close"}}))
return t.Overlay.Width(w).Render(b.String()) return t.Overlay.Width(w).Render(b.String())
} }
if len(m.taskList) == 0 {
b.WriteString(mbg(t, " no tasks", t.P.Faint) + "\n") // Filter prompt: shown while editing the `/` query or whenever one is set.
b.WriteString("\n" + modalHints(t, [][2]string{{"r", "refresh"}, {"T/esc", "close"}})) 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()) return t.Overlay.Width(w).Render(b.String())
} }
@@ -225,33 +276,50 @@ func (m Model) tasksModal() string {
bodyH = 4 bodyH = 4
} }
off := 0 off := 0
if len(m.taskList) > bodyH { if len(tasks) > bodyH {
off = m.taskListIndex - bodyH/2 off = m.taskListIndex - bodyH/2
if off < 0 { if off < 0 {
off = 0 off = 0
} }
if off > len(m.taskList)-bodyH { if off > len(tasks)-bodyH {
off = len(m.taskList) - bodyH off = len(tasks) - bodyH
} }
} }
end := off + bodyH end := off + bodyH
if end > len(m.taskList) { if end > len(tasks) {
end = len(m.taskList) end = len(tasks)
} }
for i := off; i < end; i++ { 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{ b.WriteString("\n" + modalHints(t, m.taskListHints()))
{"↑↓", "select"}, {"enter", "detail"}, {"r", "refresh"}, {"T/esc", "close"},
}))
return t.Overlay.Width(w).Render(b.String()) return t.Overlay.Width(w).Render(b.String())
} }
// taskListRow renders one board line: marker, id, status, title, and claimant. // taskListHints is the list-mode footer (shared by the populated and empty-filter views).
func (m Model) taskListRow(i int) string { 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 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) marker := mbg(t, " ", t.P.BgPanel)
idFg := t.P.Fg idFg := t.P.Fg
@@ -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) { func TestTasksFetchErrorShownNoPanic(t *testing.T) {
m := tasksModel() m := tasksModel()
m.overlay = OverlayTasks m.overlay = OverlayTasks
@@ -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<Task>, query: String): List<Task> {
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<Pair<Task, Int>> { 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<String>): 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
}
@@ -70,6 +70,10 @@ class TaskService(
return if (state.deleted) null else Task(taskId, state) 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<Task> =
TaskSearch.search(projectId?.let { list(it) } ?: listAll(), query)
suspend fun createTask( suspend fun createTask(
projectId: ProjectId, projectId: ProjectId,
title: String, title: String,
@@ -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<String> = emptyList(),
notes: List<String> = 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<Task>(), TaskSearch.search(listOf(task("auth-1", "nothing here")), "zebra"))
}
}
@@ -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<ToolCapability> = 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()),
)
}
}
@@ -26,6 +26,7 @@ object TaskTools {
TaskCreateTool(service), TaskCreateTool(service),
TaskUpdateTool(service), TaskUpdateTool(service),
TaskDeleteTool(service), TaskDeleteTool(service),
TaskSearchTool(service),
TaskContextTool( TaskContextTool(
TaskContextAssembler(service, retriever, documentResolver, artifactResolver, sessionResolver), TaskContextAssembler(service, retriever, documentResolver, artifactResolver, sessionResolver),
), ),
@@ -18,6 +18,7 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import org.junit.jupiter.api.Assertions.assertEquals 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.assertNull
import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
@@ -28,6 +29,7 @@ class TaskToolsTest {
private val create = TaskCreateTool(service) private val create = TaskCreateTool(service)
private val update = TaskUpdateTool(service) private val update = TaskUpdateTool(service)
private val delete = TaskDeleteTool(service) private val delete = TaskDeleteTool(service)
private val search = TaskSearchTool(service)
private val context = TaskContextTool(TaskContextAssembler(service)) private val context = TaskContextTool(TaskContextAssembler(service))
private var counter = 0 private var counter = 0
@@ -138,6 +140,21 @@ class TaskToolsTest {
assertTrue(context.execute(request("task_context", mapOf("id" to "auth-999"))) is ToolResult.Failure) 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 @Test
fun `task_delete tombstones the task`() = runBlocking { fun `task_delete tombstones the task`() = runBlocking {
val id = createTask() val id = createTask()