cd6549fa51
The other half of the stage-0 rule. actionAct intercepts task_status ahead of both ecosystem clients, because the board is Maven's own store and reaching a capability registry would answer a question about his task list with a gap. Three answers besides the move, and none of them guesses. No match says so. More than one match asks which, since closing the wrong task marks work he never finished as done. No task named asks which too, because the router claims the turn without the referent and the list lives here. Matching is normalised containment either direction, over the same store.NormalizeTaskText key capture dedupes on — he shortens what he said as often as he pads it. Deliberately not fuzzy: a ranked best guess always returns exactly one answer, and the one thing this has to be able to say is that it is not sure. A candidate he says is done takes both legal store moves. The store refuses candidate → done, and saying it out loud IS the confirmation the candidate was waiting for. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SoL7EBdYC5Mhz3DJd49GJy
343 lines
12 KiB
Go
343 lines
12 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/kami/maven/internal/ipc"
|
|
"github.com/kami/maven/internal/phraser"
|
|
"github.com/kami/maven/internal/router"
|
|
)
|
|
|
|
// taskAPI answers only the three task methods; every other call is
|
|
// unimplemented, which is the assertion that capture needs nothing else — in
|
|
// particular no embedder, so a filed task costs no model call.
|
|
type taskAPI struct {
|
|
ipc.UnimplementedCoreAPI
|
|
|
|
captured []ipc.CaptureTaskReq
|
|
created bool
|
|
promoted bool
|
|
capErr error
|
|
|
|
tasks []ipc.Task
|
|
listArg string
|
|
listErr error
|
|
|
|
moved []setStatusCall
|
|
moveErr error
|
|
}
|
|
|
|
func (a *taskAPI) CaptureTask(_ context.Context, req ipc.CaptureTaskReq) (ipc.CaptureTaskResp, error) {
|
|
a.captured = append(a.captured, req)
|
|
if a.capErr != nil {
|
|
return ipc.CaptureTaskResp{}, a.capErr
|
|
}
|
|
return ipc.CaptureTaskResp{ID: 1, Created: a.created, Promoted: a.promoted}, nil
|
|
}
|
|
|
|
func (a *taskAPI) ListTasks(_ context.Context, status string) ([]ipc.Task, error) {
|
|
a.listArg = status
|
|
return a.tasks, a.listErr
|
|
}
|
|
|
|
func taskNow() time.Time { return time.Date(2026, 8, 1, 9, 0, 0, 0, time.UTC) }
|
|
|
|
func taskHandler(api ipc.CoreAPI) *reactiveHandler {
|
|
return &reactiveHandler{api: api, now: taskNow}
|
|
}
|
|
|
|
func TestCaptureTaskFromNoteFilesTheTask(t *testing.T) {
|
|
api := &taskAPI{created: true}
|
|
h := taskHandler(api)
|
|
reply, ok := h.captureTaskFromNote(context.Background(), router.Decision{
|
|
Intent: router.IntentNote, Utterance: "добавь в задачи купить молоко",
|
|
})
|
|
if !ok {
|
|
t.Fatal("an explicit capture must claim the turn")
|
|
}
|
|
if len(api.captured) != 1 {
|
|
t.Fatalf("captured %d, want 1", len(api.captured))
|
|
}
|
|
got := api.captured[0]
|
|
if got.Text != "купить молоко" {
|
|
t.Errorf("text = %q, want the marker stripped", got.Text)
|
|
}
|
|
if got.Source != "tap:voice" {
|
|
t.Errorf("source = %q, want tap:voice", got.Source)
|
|
}
|
|
if got.Status != "open" {
|
|
t.Errorf("status = %q — work he stated is open, never a candidate", got.Status)
|
|
}
|
|
if !got.Ts.Equal(taskNow()) {
|
|
t.Errorf("ts = %v, want the handler clock", got.Ts)
|
|
}
|
|
if !strings.Contains(reply, "купить молоко") {
|
|
t.Errorf("reply = %q, want it to read the task back", reply)
|
|
}
|
|
}
|
|
|
|
// A note is still a note: capture only fires on an explicit marker, so
|
|
// ordinary recall is untouched.
|
|
func TestCaptureTaskFromNotePassesOrdinaryNotes(t *testing.T) {
|
|
api := &taskAPI{}
|
|
h := taskHandler(api)
|
|
for _, u := range []string{"надо бы поспать", "мне понравился этот фильм", "запиши что я пил воду"} {
|
|
if _, ok := h.captureTaskFromNote(context.Background(), router.Decision{Utterance: u}); ok {
|
|
t.Errorf("%q was captured as a task", u)
|
|
}
|
|
}
|
|
if len(api.captured) != 0 {
|
|
t.Errorf("captured %d requests, want none", len(api.captured))
|
|
}
|
|
}
|
|
|
|
func TestCaptureTaskFromNoteSaysAlreadyOnTheList(t *testing.T) {
|
|
h := taskHandler(&taskAPI{created: false})
|
|
reply, ok := h.captureTaskFromNote(context.Background(), router.Decision{Utterance: "добавь в задачи купить молоко"})
|
|
if !ok {
|
|
t.Fatal("expected the capture path to claim it")
|
|
}
|
|
if !strings.Contains(reply, "уже") {
|
|
t.Errorf("reply = %q — a deduped capture must not claim it saved something new", reply)
|
|
}
|
|
}
|
|
|
|
func TestCaptureTaskFromNoteReportsStoreFailure(t *testing.T) {
|
|
h := taskHandler(&taskAPI{capErr: errors.New("db is on fire")})
|
|
reply, ok := h.captureTaskFromNote(context.Background(), router.Decision{Utterance: "добавь задачу починить кран"})
|
|
if !ok {
|
|
t.Fatal("a failed capture still claims the turn — the note path must not double-write")
|
|
}
|
|
if !phraser.IsAck(phraser.FailTask, nil, reply) {
|
|
t.Errorf("reply = %q, want an honest failure", reply)
|
|
}
|
|
}
|
|
|
|
func TestQueryTasksRecitesTheLiveList(t *testing.T) {
|
|
api := &taskAPI{tasks: []ipc.Task{
|
|
{ID: 1, Text: "купить молоко", Status: "open"},
|
|
{ID: 2, Text: "продлить страховку", Status: "candidate"},
|
|
}}
|
|
h := taskHandler(api)
|
|
reply, ok := h.queryTasks(context.Background(), &queryTurn{
|
|
dec: router.Decision{Intent: router.IntentQuery, Utterance: "какие у меня задачи?"},
|
|
})
|
|
if !ok {
|
|
t.Fatal("the task source must claim a task-list question")
|
|
}
|
|
if api.listArg != "live" {
|
|
t.Errorf("ListTasks(%q), want \"live\" — a resolved task is not outstanding work", api.listArg)
|
|
}
|
|
if !strings.Contains(reply, "купить молоко") || !strings.Contains(reply, "продлить страховку") {
|
|
t.Errorf("reply = %q, want both tasks", reply)
|
|
}
|
|
// The candidate must be named as unconfirmed, not recited as his work.
|
|
openIdx := strings.Index(reply, "купить молоко")
|
|
candIdx := strings.Index(reply, "продлить страховку")
|
|
if !(openIdx < candIdx) {
|
|
t.Errorf("reply = %q, want confirmed work before candidates", reply)
|
|
}
|
|
if !strings.Contains(reply, "не подтверждал") {
|
|
t.Errorf("reply = %q, want the candidate flagged as unconfirmed", reply)
|
|
}
|
|
}
|
|
|
|
// The stated urgency rides through capture as a weight, so the ranker can use
|
|
// it later (Vikunja #129). "срочно" is not part of the task text.
|
|
func TestCaptureTaskCarriesStatedUrgency(t *testing.T) {
|
|
api := &taskAPI{created: true}
|
|
h := taskHandler(api)
|
|
if _, ok := h.captureTaskFromNote(context.Background(), router.Decision{
|
|
Utterance: "добавь в задачи срочно оплатить интернет",
|
|
}); !ok {
|
|
t.Fatal("expected a capture")
|
|
}
|
|
got := api.captured[0]
|
|
if got.Text != "оплатить интернет" {
|
|
t.Errorf("text = %q, want the urgency word out of the task", got.Text)
|
|
}
|
|
if got.Weight == 0 {
|
|
t.Error("weight = 0 — he said срочно and it was dropped")
|
|
}
|
|
}
|
|
|
|
// The recital is ordered by the ranker, not by insertion: a deadline he named
|
|
// comes before undated work.
|
|
func TestQueryTasksRecitesInPriorityOrder(t *testing.T) {
|
|
due := taskNow()
|
|
api := &taskAPI{tasks: []ipc.Task{
|
|
{ID: 1, Text: "купить молоко", Status: "open", CreatedTs: taskNow()},
|
|
{ID: 2, Text: "оплатить интернет", Status: "open", CreatedTs: taskNow(), Due: &due},
|
|
}}
|
|
h := taskHandler(api)
|
|
reply, _ := h.queryTasks(context.Background(), &queryTurn{
|
|
dec: router.Decision{Utterance: "какие у меня задачи?"},
|
|
})
|
|
if strings.Index(reply, "оплатить интернет") > strings.Index(reply, "купить молоко") {
|
|
t.Errorf("reply = %q, want the dated task first", reply)
|
|
}
|
|
if !strings.Contains(reply, "сегодня") {
|
|
t.Errorf("reply = %q, want the reason named", reply)
|
|
}
|
|
}
|
|
|
|
func TestQueryTasksEmptyList(t *testing.T) {
|
|
h := taskHandler(&taskAPI{})
|
|
reply, ok := h.queryTasks(context.Background(), &queryTurn{
|
|
dec: router.Decision{Utterance: "что мне нужно сделать?"},
|
|
})
|
|
if !ok {
|
|
t.Fatal("expected the task source to claim it")
|
|
}
|
|
if reply != "задач нет." {
|
|
t.Errorf("reply = %q", reply)
|
|
}
|
|
}
|
|
|
|
func TestQueryTasksPassesOtherQuestions(t *testing.T) {
|
|
api := &taskAPI{}
|
|
h := taskHandler(api)
|
|
for _, u := range []string{"как дела?", "какая погода в москве?", "что у меня сегодня?"} {
|
|
if _, ok := h.queryTasks(context.Background(), &queryTurn{dec: router.Decision{Utterance: u}}); ok {
|
|
t.Errorf("the task source claimed %q", u)
|
|
}
|
|
}
|
|
if api.listArg != "" {
|
|
t.Error("a non-task question must not read the task list")
|
|
}
|
|
}
|
|
|
|
// The chain must reach the task source before the recall sources, or "что мне
|
|
// нужно сделать?" gets answered by whatever note is nearest.
|
|
func TestQuerySourcesOrderTasksBeforeRecall(t *testing.T) {
|
|
var tasksAt, notesAt = -1, -1
|
|
for i, src := range querySources {
|
|
switch src.name {
|
|
case "tasks":
|
|
tasksAt = i
|
|
case "notes":
|
|
notesAt = i
|
|
}
|
|
}
|
|
if tasksAt < 0 || notesAt < 0 {
|
|
t.Fatalf("sources missing: tasks=%d notes=%d", tasksAt, notesAt)
|
|
}
|
|
if tasksAt > notesAt {
|
|
t.Errorf("tasks source at %d, after notes at %d", tasksAt, notesAt)
|
|
}
|
|
}
|
|
|
|
// Saying a task out loud that Maven had only proposed is a confirmation. She
|
|
// used to answer "это уже в списке" and then read it back, in the same
|
|
// conversation, as something he had not confirmed.
|
|
func TestCaptureTaskFromNoteAcknowledgesAPromotion(t *testing.T) {
|
|
api := &taskAPI{promoted: true}
|
|
h := taskHandler(api)
|
|
reply, ok := h.captureTaskFromNote(context.Background(), router.Decision{
|
|
Intent: router.IntentNote, Utterance: "добавь в задачи продлить страховку",
|
|
})
|
|
if !ok {
|
|
t.Fatal("an explicit capture must claim the turn")
|
|
}
|
|
if strings.Contains(reply, "уже в списке") {
|
|
t.Errorf("reply = %q — he just confirmed it, that is not a duplicate", reply)
|
|
}
|
|
if !strings.Contains(reply, "продлить страховку") {
|
|
t.Errorf("reply = %q, want the task named back", reply)
|
|
}
|
|
// Persona: feminine, informal.
|
|
for _, bad := range []string{"рад ", "вы ", "ваш"} {
|
|
if strings.Contains(reply, bad) {
|
|
t.Errorf("reply %q contains %q", reply, bad)
|
|
}
|
|
}
|
|
}
|
|
|
|
// setStatusCall — one SetTaskStatus the arm made, in order, so a candidate he
|
|
// says is done can be shown to take both legal moves.
|
|
type setStatusCall struct {
|
|
id int64
|
|
status string
|
|
by string
|
|
}
|
|
|
|
func (a *taskAPI) SetTaskStatus(_ context.Context, id int64, status string, _ time.Time, by string) error {
|
|
a.moved = append(a.moved, setStatusCall{id: id, status: status, by: by})
|
|
return a.moveErr
|
|
}
|
|
|
|
func TestResolveTaskStatusMovesTheNamedTask(t *testing.T) {
|
|
api := &taskAPI{tasks: []ipc.Task{
|
|
{ID: 7, Text: "купить молоко", Status: "open"},
|
|
{ID: 8, Text: "оплатить интернет", Status: "open"},
|
|
}}
|
|
h := taskHandler(api)
|
|
reply := h.resolveTaskStatus(context.Background(), router.Decision{
|
|
Intent: router.IntentAct,
|
|
Slots: router.Slots{Fn: router.TaskStatusFn, HasFn: true, Value: "done", Text: "молоко"},
|
|
})
|
|
if api.listArg != "live" {
|
|
t.Errorf("listed %q, want live — a resolved task cannot be resolved again", api.listArg)
|
|
}
|
|
if len(api.moved) != 1 {
|
|
t.Fatalf("moved %d tasks, want 1: %+v", len(api.moved), api.moved)
|
|
}
|
|
if api.moved[0].id != 7 || api.moved[0].status != "done" {
|
|
t.Errorf("moved %+v, want id 7 → done", api.moved[0])
|
|
}
|
|
if !strings.Contains(reply, "купить молоко") {
|
|
t.Errorf("reply = %q, want the task named back", reply)
|
|
}
|
|
}
|
|
|
|
func TestResolveTaskStatusRefusesToGuess(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
tasks []ipc.Task
|
|
named string
|
|
want string
|
|
}{
|
|
{"no match", []ipc.Task{{ID: 7, Text: "купить молоко", Status: "open"}}, "позвонить маме", "не нашла"},
|
|
{"two matches", []ipc.Task{
|
|
{ID: 7, Text: "купить молоко", Status: "open"},
|
|
{ID: 8, Text: "купить молоко и хлеб", Status: "open"},
|
|
}, "купить молоко", "несколько"},
|
|
{"none named", []ipc.Task{{ID: 7, Text: "купить молоко", Status: "open"}}, "", "какую"},
|
|
}
|
|
for _, c := range cases {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
api := &taskAPI{tasks: c.tasks}
|
|
h := taskHandler(api)
|
|
reply := h.resolveTaskStatus(context.Background(), router.Decision{
|
|
Slots: router.Slots{Fn: router.TaskStatusFn, HasFn: true, Value: "done", Text: c.named},
|
|
})
|
|
if len(api.moved) != 0 {
|
|
t.Errorf("moved %+v — closing the wrong task is the failure this arm exists to avoid", api.moved)
|
|
}
|
|
if !strings.Contains(reply, c.want) {
|
|
t.Errorf("reply = %q, want it to contain %q", reply, c.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestResolveTaskStatusOpensACandidateFirst(t *testing.T) {
|
|
// The store refuses candidate → done. Saying it is done is the confirmation
|
|
// the candidate was waiting for, so the arm makes both legal moves.
|
|
api := &taskAPI{tasks: []ipc.Task{{ID: 9, Text: "продлить домен", Status: "candidate"}}}
|
|
h := taskHandler(api)
|
|
h.resolveTaskStatus(context.Background(), router.Decision{
|
|
Slots: router.Slots{Fn: router.TaskStatusFn, HasFn: true, Value: "done", Text: "продлить домен"},
|
|
})
|
|
if len(api.moved) != 2 {
|
|
t.Fatalf("moved %+v, want open then done", api.moved)
|
|
}
|
|
if api.moved[0].status != "open" || api.moved[1].status != "done" {
|
|
t.Errorf("moved %+v, want open then done", api.moved)
|
|
}
|
|
}
|