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:
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"))
|
||||||
|
}
|
||||||
|
}
|
||||||
+70
@@ -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),
|
||||||
),
|
),
|
||||||
|
|||||||
+17
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user