diff --git a/PERF.md b/PERF.md index b045a43d7a..95b496a350 100644 --- a/PERF.md +++ b/PERF.md @@ -1570,6 +1570,14 @@ measures **52,713** bytes and the lean **44,935**, and both waivers fall to sit exactly on the measurement: the full cap is **52,713** (4,713 over its target) and the lean cap **44,935** (13,435 over). +**`automation` learns `change`, and pays for it (2026-10-14).** The op, the +sentence in the tool's description that says when to use it and the page's +"one that exists is CHANGED, never proposed again" were taken out of what the +description already said twice — a watch's look and condition, which the schema +says beside their own fields — and out of the `id` field's list of the ops that +take one. Both arms come out **22** bytes lighter: the full cap is **52,691** +(4,691 over) and the lean cap **44,913** (13,413 over). + ## Following through on a completion claim A turn may decline handoff once per request when its own continuation says no diff --git a/docs/changes/unreleased/1829-automation-change-op.md b/docs/changes/unreleased/1829-automation-change-op.md new file mode 100644 index 0000000000..4d8da5a185 --- /dev/null +++ b/docs/changes/unreleased/1829-automation-change-op.md @@ -0,0 +1,11 @@ +--- +kind: added +title: the conversation can change an automation in place, on a save this change? card +pr: 1829 +surface: [chat, engine, docs] +invalidates: + - "The `automation` tool had no way to change an existing automation, so \"move my reminder to 5pm\" proposed a second one beside the first, often a finished one. It has a `change` op: an id and only what changes, saved through `Store.Update`, which wakes a finished one-time reminder given a new moment." + - "The manual said the chat had no verb that changes an automation in place, and that changing one was `e` or a new proposal that replaces it. Asking in the conversation changes it in place, and `e` on `/automations` still does." + - "`save this change?` was a card only a typed `/automations edit` raised. The model's `change` raises the same card; any card whose automation already has an id is a change, with only `1 Save` and `0 Don't save`." + - "The prefix waivers were 4,713 bytes on the fixed arm and 13,435 on the lean. They are 4,691 and 13,413: `change` was paid for out of the tool description's repeat of the watch fields." +--- diff --git a/docs/design/automations/DESIGN.md b/docs/design/automations/DESIGN.md index 10709f22d0..11e8e70a80 100644 --- a/docs/design/automations/DESIGN.md +++ b/docs/design/automations/DESIGN.md @@ -40,11 +40,15 @@ codeaf is open`), because the model judges every look; with no known price for the judging model it shows nothing. **Three ways in, one card.** Said in a conversation (the model calls the -`automation` tool); typed with exact syntax (`/automations add …`, no model in -between); or edited on the list (`e` puts the automation's own -`/automations edit …` line in the box). All three end on a card, and nothing is -saved before the person says yes on it. A typed line's card is the surface's -own (`save this automation?`, `save this change?`), because no model asked. +`automation` tool — `propose` for a new one, `change` with its id for one that +exists); typed with exact syntax (`/automations add …`, no model in between); or +edited on the list (`e` puts the automation's own `/automations edit …` line in +the box). All three end on a card, and nothing is saved before the person says +yes on it. A typed add's card is the surface's own (`save this automation?`), +because no model asked. A change's card is `save this change?` from either door — +the model's `change` or a typed `edit` — with only `1 Save` and `0 Don't save`: +an automation with an id on a card is a change to it, and session's +`AutomationHead` and `AutomationOptions` decide that once for both. **The typed grammar** is a word and one value per clause, a value with a space in it quoted (`internal/automation`'s `command.go`): diff --git a/internal/automation/store.go b/internal/automation/store.go index 5b2b61c2bc..ceedc9ca7e 100644 --- a/internal/automation/store.go +++ b/internal/automation/store.go @@ -338,7 +338,7 @@ func (s *Store) Update(a Automation) (Automation, error) { if lookChanged(current.Look, a.Look) { a.Seen, a.Memo = "", "" } - if scheduleChanged(current.Schedule, a.Schedule) { + if ScheduleChanged(current.Schedule, a.Schedule) { if a.Schedule.Repeats() && a.Schedule.Interval() > 0 { a.Schedule.Anchor = now } @@ -997,7 +997,12 @@ func lookChanged(a, b *Look) bool { return a.Command != b.Command || a.Files != b.Files || a.Tool != b.Tool || string(a.Args) != string(b.Args) || a.Condition != b.Condition } -func scheduleChanged(a, b Schedule) bool { +// ScheduleChanged reports whether b is a different schedule from a: another +// moment, another rhythm or another zone. It is the ONE reading of that, which +// [Store.Update] reschedules on and a change's card works its next run out by +// (internal/session's automation_tool.go), so the card cannot promise a next run +// the save does not give. +func ScheduleChanged(a, b Schedule) bool { return !a.At.Equal(b.At) || strings.TrimSpace(a.Every) != strings.TrimSpace(b.Every) || a.Zone != b.Zone } diff --git a/internal/manual/chat/automations.md b/internal/manual/chat/automations.md index 02dedb052f..a7e1d55ac9 100644 --- a/internal/manual/chat/automations.md +++ b/internal/manual/chat/automations.md @@ -252,28 +252,37 @@ catches up for the paused time; a one-time reminder resumed after its moment run late. A finished one offers no pause. The foot names the row's verbs: `enter its history · → verbs: run now, pause, edit, delete, open where it was asked · tab next place · esc`. -## Change an automation — edit its time, what it does or where it runs - -Press `→` then `e` on its row in `/automations`, and the automation's own line lands in the +## Change an automation — move my reminder to 5pm, reschedule it, edit its time, what it does or where it runs + +**Ask me.** "Move my water reminder to 5pm", "make the weekly update Tuesdays", "have the +CI watch look every 30 minutes": I find it with the `automation` tool's `list` and change +it in place with its `change` op, sending only what changes. Its card is headed +`save this change?` and shows the automation as it would be, with `next run` where a new +one says `first run`: `1 Save` says +`The automation runs this way from its next time.`, `0 Don't save` says `Nothing is saved.`, +and a correction typed under it goes back to me. Saved, it leaves one dim line — +`drink water · changed` — and it keeps its id, its history, where it runs and the +conversation its news arrives in. No second automation is left beside the old one. + +**Or press `→` then `e`** on its row in `/automations`, and its own line lands in the message box, exactly as it would have to be typed: ``` /automations edit 3f2a9c1e5b7d4a60 title "weekly update" zone America/Toronto every "0 9 * * 1" do "draft the weekly update from the git log" folder /home/you/code/app ``` -Change what you want and press enter. Nothing is saved yet: a card headed -`save this change?` shows the automation as it would be, and `1 Save` saves it — -`saved weekly update · next Mon 12 Oct at 09:00`. Any clause you leave out stays as it -was. A new schedule is worked out again from now, and a finished one-time automation given -a new moment wakes again. A watch whose look or condition changed forgets what it had -seen, so its next yes speaks. +Change what you want and press enter: the same `save this change?` card, and `1 Save` says +`saved weekly update · next Mon 12 Oct at 09:00`. Any clause you leave out stays as it was. -A watch that looks through a tool is written without its look — a tool's arguments are not -something anybody types on one line — and the edit leaves that look as it was. +Either way, a new schedule is worked out again from now, and **a finished one-time reminder +given a new moment wakes again**. A new rhythm keeps the zone the old one was read in. A +watch whose look or condition changed forgets what it had seen, so its next yes speaks. A +paused automation stays paused until it is resumed. A change cannot turn a watch back into +a plain reminder: delete it and ask for the reminder. -**I have no verb that changes an existing automation in place.** In a conversation I can -pause, resume, run, delete, list it and read its history; changing one is `e`, or a new -proposal that replaces it. +A watch that looks through a tool is written without its look in the typed line — a +tool's arguments are not something anybody types on one line — and the edit leaves that +look as it was. ## History — every run, read a run's whole result, why did I get this reminder, and open where it was asked @@ -598,11 +607,13 @@ along, and switching one off. You never name a tool. When what you say is a reminder, scheduled work or a watch, I call the **`automation`** tool to propose it, and its card is what you see. The same tool +**changes** one that exists — "move my reminder to 5pm" — on a `save this change?` card, **lists** the automations with their ids, **pauses**, **resumes**, **deletes** and **runs** one now, and reads one's **history** — so "pause the CI watch", "delete the weekly update" -and "what did the nightly bump do on Tuesday" work in any conversation. A change made that -way needs no card and leaves one dim line in the conversation — `ci on main · paused`, -`weekly update · deleted` — while making a new one always goes through its card. +and "what did the nightly bump do on Tuesday" work in any conversation. A pause, resume, +delete or run made that way needs no card and leaves one dim line in the conversation — +`ci on main · paused`, `weekly update · deleted` — while making a new one or changing one +always goes through its card. A run of scheduled work ends by calling **`automation_report`** with `done` or `incomplete` and a short summary: that summary is the line you read, and a run that never calls it is diff --git a/internal/manual/chat/keys.md b/internal/manual/chat/keys.md index 949658f78e..c4db413e9a 100644 --- a/internal/manual/chat/keys.md +++ b/internal/manual/chat/keys.md @@ -2621,17 +2621,16 @@ verbs, and their letters work only while that strip is drawn: A verb that cannot work on the row is not drawn: `s` only while a run is in hand and `r` only while none is, no `pause` on a finished one, no `o` with no conversation behind it. A click on a row opens it; the wheel walks. Home's `automations` panel has none of these -keys — its rows open this place, and `ctrl+e`, `ctrl+x` and `alt+e` do nothing to an -automation. +keys — its rows open this place — and `ctrl+e`, `ctrl+x` and `alt+e` do nothing to one. **On an automation's card** — `wants to remind you`, `wants to schedule work`, `wants to watch for something` — `1` Save · `2` Save and run it now (`2` Save and check it now on a watch; no `2` on a reminder or on work kept in a separate worktree) · `0` Don't save · `o` Change…, which asks for your correction in words · `esc` later, which puts the card off until `alt+y` raises it. A card raised by a typed `/automations add` or `edit` — -`save this automation?`, `save this change?` — has only `1` and `0`. In home's `ask here` -pane the same keys answer it while the pane has the keyboard, and `esc` there hands the -keyboard back to the list instead. +`save this automation?`, `save this change?` — has only `1` and `0`; a change I raise +has `1`, `0` and `o`. In home's `ask here` pane the same keys answer it while the pane has +the keyboard, and `esc` there hands it back to the list. *Automations* says what each verb and each answer does. diff --git a/internal/manual/chat/questions.md b/internal/manual/chat/questions.md index 04842c6bb1..c7b5f724cd 100644 --- a/internal/manual/chat/questions.md +++ b/internal/manual/chat/questions.md @@ -1307,7 +1307,8 @@ card — the title, when it first runs, what it says or does, where work runs an for anything but a reminder, what one run may take and spend (*Automations*, *The card*) — and its question goes on the block above your box. The first line says the kind: `wants to remind you`, `wants to schedule work` or -`wants to watch for something`. The line under it is +`wants to watch for something` — or `save this change?` when I change one that exists, +with only `1 Save` and `0 Don't save`. The line under it is `nothing is saved until you say so`. The answers are `1 Save`; `2 Save and run it now`, or `2 Save and check it now` on @@ -1439,8 +1440,8 @@ It depends on what was proposed, and the card says which beside `1 Save`: (on a watch, `Saves it, then takes one look now, here, while you watch.`), and `0 Don't save` says `Nothing is saved and nothing runs.` On the card a typed `/automations add` raises, `1 Save` says -`It runs on this schedule while codeaf is open.`; on an edit's, -`The automation runs this way from its next time.` +`It runs on this schedule while codeaf is open.`; on a change's — typed with `edit`, or +asked of me in a conversation — `The automation runs this way from its next time.` Saving is not forever: pausing or deleting one later is `/automations`, or asking me (*Automations*). A rule such as "always use tabs" is not an automation — it has no diff --git a/internal/manual/chat_test.go b/internal/manual/chat_test.go index eac69377ef..d6911e1de3 100644 --- a/internal/manual/chat_test.go +++ b/internal/manual/chat_test.go @@ -2585,6 +2585,12 @@ func TestTheChatManualAnswersTheQuestionsPeopleAsk(t *testing.T) { {"I pressed esc on a reminder card and it did not say no", "automations"}, {"where did 2 change when or where go on the standing card", "automations"}, {"how do I change the time on a standing card", "automations"}, + // The wave that gave `automation` its `change` op. Before it the chat + // could only propose a second automation beside the first, and the page + // said so; these are the sentences a person says to the one they have. + {"move my reminder to 5pm", "automations"}, + {"can you reschedule my water reminder", "automations"}, + {"why is my old reminder still there after I asked you to change it", "automations"}, // The wave that gave the engine on the far machine its own ambient // side. This is what a person asks before they rely on it — and what an // attached window keeps running there now is that machine's automations. diff --git a/internal/session/automation_change_test.go b/internal/session/automation_change_test.go new file mode 100644 index 0000000000..ef8a976a6c --- /dev/null +++ b/internal/session/automation_change_test.go @@ -0,0 +1,347 @@ +package session + +import ( + "context" + "encoding/json" + "strings" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/automation" +) + +// finishedReminder is a one-time reminder that has already gone off, which is +// what "move my reminder to 5pm" usually finds: the store's clock is put three +// hours back to make it and take it, then given back to the real one. +func finishedReminder(t *testing.T, store *automation.Store) automation.Automation { + t.Helper() + past := time.Now().Add(-3 * time.Hour).Truncate(time.Second) + store.SetClock(func() time.Time { return past }) + made, err := store.Create(automation.Automation{ + Title: "drink water", Words: "remind me in an hour to drink water", Workspace: t.TempDir(), + Schedule: automation.Schedule{At: past.Add(time.Hour)}, Action: automation.Action{Say: "drink water"}, + }) + if err != nil { + t.Fatal(err) + } + due, err := store.Due(past.Add(2 * time.Hour)) + if err != nil || len(due) != 1 { + t.Fatalf("due = %v, %v", due, err) + } + if _, err := store.Take(due[0], past.Add(2*time.Hour)); err != nil { + t.Fatal(err) + } + store.SetClock(time.Now) + finished, err := store.Get(made.ID) + if err != nil || finished.Status != automation.StatusFinished { + t.Fatalf("the reminder did not finish: %+v, %v", finished, err) + } + return finished +} + +// nextFivePM is the "5pm" a person means: today's, or tomorrow's once today's +// has gone. +func nextFivePM(now time.Time) time.Time { + at := time.Date(now.Year(), now.Month(), now.Day(), 17, 0, 0, 0, now.Location()) + if !at.After(now.Add(time.Minute)) { + at = at.AddDate(0, 0, 1) + } + return at +} + +// "MOVE MY REMINDER TO 5PM" CHANGES THE ONE THERE IS. The card is the change +// card, a yes goes through [automation.Store.Update], and the finished reminder +// wakes at its new moment — rather than a second reminder being proposed beside +// a finished one, which was the only door before `change`. +func TestChangingAFinishedReminderWakesItAtItsNewTime(t *testing.T) { + store := automationStoreFor(t) + reminder := finishedReminder(t, store) + fivePM := nextFivePM(time.Now()) + completer := &scriptedCompleter{steps: []step{ + automationCall("a1", map[string]any{ + "op": "change", "id": reminder.ID, "when": map[string]any{"at": fivePM.Format(time.RFC3339)}, + }), + finalText("moved it to 5pm"), + }} + agent := automationAgent(t, completer, store, nil) + events, err := agent.Submit(context.Background(), "move my water reminder to 5pm") + if err != nil { + t.Fatal(err) + } + collected := drainAnsweringAutomation(t, events, func(event Event) { + if err := agent.ResolveQuestion(Answer{Kind: QuestionAutomation, ID: event.Automation.ID, Key: AutomationSaveKey}); err != nil { + t.Errorf("resolve: %v", err) + } + }) + + card, found := firstOfKind(collected, EventAutomationProposal) + if !found { + t.Fatalf("no card was raised: %v", kinds(collected)) + } + notice := *card.Automation + if notice.Automation.ID != reminder.ID || notice.Automation.Action.Say != "drink water" { + t.Fatalf("the card is not the reminder that exists: %+v", notice.Automation) + } + if head := AutomationQuestion(notice).Head; head != AutomationChangeHead { + t.Fatalf("the card is headed %q, want %q", head, AutomationChangeHead) + } + if len(notice.Options) != 2 || notice.Options[0].Key != AutomationSaveKey || notice.Options[1].Key != AutomationNoKey || + notice.Options[0].Consequence != "The automation runs this way from its next time." { + t.Fatalf("the change card's answers are %+v", notice.Options) + } + if !notice.Next.Equal(fivePM) { + t.Fatalf("the card says it next runs %v, want %v", notice.Next, fivePM) + } + + all, _ := store.List() + if len(all) != 1 { + t.Fatalf("a change left %d automations, want the one it changed", len(all)) + } + got := all[0] + if got.ID != reminder.ID || got.Status != automation.StatusActive { + t.Fatalf("the reminder is %s %s, want %s active", got.ID, got.Status, reminder.ID) + } + if !got.Next.Equal(fivePM) || !got.Schedule.At.Equal(fivePM) { + t.Fatalf("the reminder next runs %v (at %v), want %v", got.Next, got.Schedule.At, fivePM) + } + if got.Title != "drink water" || got.Action.Say != "drink water" || got.Words != reminder.Words || got.Workspace != reminder.Workspace { + t.Fatalf("a change of time changed more than the time: %+v", got) + } + if update, found := firstOfKind(collected, EventAutomationUpdate); !found || update.Automation.Update != "changed" { + t.Fatalf("nothing reported it changed: %v", kinds(collected)) + } + if output := toolOutput(t, collected, "automation"); !strings.Contains(output, "changed "+reminder.ID) || !strings.Contains(output, "next run: ") { + t.Fatalf("tool result = %q", output) + } +} + +// A CHANGE THE PERSON DOES NOT SAVE CHANGES NOTHING, and a correction is sent +// back in their words for the model to change it again with. +func TestAnUnsavedChangeLeavesTheAutomationAsItWas(t *testing.T) { + for _, c := range []struct { + name string + answer Answer + says string + }{ + {"don't save", Answer{Key: AutomationNoKey}, "nothing was changed: the person said no."}, + {"a correction", Answer{Change: "make it 6 instead"}, "the person changed it: make it 6 instead"}, + } { + t.Run(c.name, func(t *testing.T) { + store := automationStoreFor(t) + made, err := store.Create(automation.Automation{Title: "weekly update", Workspace: t.TempDir(), + Schedule: automation.Schedule{Every: "0 9 * * 1", Zone: "UTC"}, Action: automation.Action{Do: "draft the weekly update"}}) + if err != nil { + t.Fatal(err) + } + completer := &scriptedCompleter{steps: []step{ + automationCall("a1", map[string]any{"op": "change", "id": made.ID, "when": map[string]any{"every": "0 9 * * 2"}}), + finalText("ok"), + }} + agent := automationAgent(t, completer, store, nil) + events, _ := agent.Submit(context.Background(), "make the weekly update Tuesdays") + collected := drainAnsweringAutomation(t, events, func(event Event) { + answer := c.answer + answer.Kind, answer.ID = QuestionAutomation, event.Automation.ID + if err := agent.ResolveQuestion(answer); err != nil { + t.Errorf("resolve: %v", err) + } + }) + if output := toolOutput(t, collected, "automation"); !strings.Contains(output, c.says) { + t.Fatalf("tool result = %q", output) + } + got, _ := store.Get(made.ID) + if got.Revision != made.Revision || got.Schedule.Every != "0 9 * * 1" { + t.Fatalf("an unsaved change was saved: %+v", got) + } + }) + } +} + +// WHAT IS NOT SENT STAYS, AND WHAT IS SENT REPLACES ONLY ITSELF. +func TestAChangeLaysOnlyWhatWasSentOverWhatIsSaved(t *testing.T) { + store := automationStoreFor(t) + agent := automationAgent(t, &scriptedCompleter{}, store, nil) + now := time.Now() + change := func(stored automation.Automation, body map[string]any) (automation.Automation, bool, string) { + t.Helper() + raw, _ := json.Marshal(body) + var parsed automationArguments + if err := json.Unmarshal(raw, &parsed); err != nil { + t.Fatal(err) + } + return agent.automationChanged(stored, parsed, now) + } + + watch := automation.Automation{ID: "w", Title: "ci", Workspace: t.TempDir(), + Schedule: automation.Schedule{Every: "0 * * * *", Zone: "America/Toronto"}, + Look: &automation.Look{Command: "gh run list -L1", Condition: "it failed", Once: true}, + Action: automation.Action{Say: "CI is red"}, Limits: automation.Limits{Time: time.Minute, USD: 1}} + got, rescheduled, problem := change(watch, map[string]any{"look": map[string]any{"condition": "the latest run on main failed"}}) + if problem != "" || rescheduled || got.Look.Command != "gh run list -L1" || !got.Look.Once || got.Look.Condition != "the latest run on main failed" { + t.Fatalf("a new condition = %+v (rescheduled %v, %q)", got.Look, rescheduled, problem) + } + if watch.Look.Condition != "it failed" { + t.Fatal("a change wrote through to the automation it was laid over") + } + got, rescheduled, problem = change(watch, map[string]any{"when": map[string]any{"every": "*/30 * * * *"}, "limits": map[string]any{"usd": 2}}) + if problem != "" || !rescheduled || got.Schedule.Zone != "America/Toronto" || got.Limits.USD != 2 || got.Limits.Time != time.Minute { + t.Fatalf("a new rhythm = %+v %+v (rescheduled %v, %q)", got.Schedule, got.Limits, rescheduled, problem) + } + + // THE SAME `when` RE-SENT IS NO NEW SCHEDULE, even beside a field that does + // change: a finished watch told to stop telling once stays finished, because + // the save will not reschedule it either. + finished := watch + finished.Status = automation.StatusFinished + got, rescheduled, problem = change(finished, map[string]any{"when": map[string]any{"every": "0 * * * *"}, "look": map[string]any{"once": false}}) + if problem != "" || rescheduled || got.Look.Once || automation.ScheduleChanged(finished.Schedule, got.Schedule) { + t.Fatalf("the same rhythm re-sent = %+v (rescheduled %v, %q)", got.Schedule, rescheduled, problem) + } + + work := automation.Automation{ID: "k", Title: "tidy", Workspace: t.TempDir(), Worktree: true, + Schedule: automation.Schedule{Every: "1d"}, Action: automation.Action{Do: "tidy branches"}} + got, _, problem = change(work, map[string]any{"say": "time to tidy your branches"}) + if problem != "" || got.Action.Do != "" || got.Action.Say != "time to tidy your branches" || got.Worktree { + t.Fatalf("work turned into a line = %+v (%q)", got, problem) + } + if err := got.Validate(); err != nil { + t.Fatalf("work turned into a line does not validate: %v", err) + } + + if _, _, problem := change(work, map[string]any{"when": map[string]any{"words": "every day"}}); !strings.Contains(problem, "send only what changes") { + t.Fatalf("a change with nothing in it = %q", problem) + } + if _, _, problem := change(work, map[string]any{"when": map[string]any{"at": now.Add(-time.Hour).Format(time.RFC3339)}}); !strings.Contains(problem, "has already passed") { + t.Fatalf("a moment that has passed = %q", problem) + } +} + +// A CHANGE NEEDS THE ID OF SOMETHING THAT EXISTS, and nobody watching is told +// in the change's own words. +func TestAChangeNeedsAnAutomationAndSomebodyToSayYes(t *testing.T) { + store := automationStoreFor(t) + agent := automationAgent(t, &scriptedCompleter{}, store, nil) + call := func(body map[string]any) string { + t.Helper() + raw, _ := json.Marshal(body) + out, _, err := agent.automationTool(context.Background(), raw) + if err != nil { + t.Fatal(err) + } + return out + } + if out := call(map[string]any{"op": "change", "say": "hi"}); !strings.Contains(out, "id is required") { + t.Fatalf("no id = %q", out) + } + if out := call(map[string]any{"op": "change", "id": "nope", "say": "hi"}); !strings.Contains(out, "no automation with that id") { + t.Fatalf("an unknown id = %q", out) + } + if out := call(map[string]any{"op": "rename"}); !strings.Contains(out, "propose, change, list") { + t.Fatalf("an unknown op = %q", out) + } + + reminder := finishedReminder(t, store) + completer := &scriptedCompleter{steps: []step{ + automationCall("a1", map[string]any{"op": "change", "id": reminder.ID, "when": map[string]any{"in": "2h"}}), + finalText("ok"), + }} + unwatched := automationAgent(t, completer, store, func(c *Config) { c.AskConsent = false }) + events, _ := unwatched.Submit(context.Background(), "move my reminder to later") + if output := toolOutput(t, drainAnsweringAutomation(t, events, nil), "automation"); !strings.Contains(output, "can only be changed in a conversation somebody is in") { + t.Fatalf("tool result = %q", output) + } + if got, _ := store.Get(reminder.ID); got.Status != automation.StatusFinished { + t.Fatalf("a change nobody saw was saved: %+v", got) + } +} + +// changeTurn is one turn in which the model sends one `change`, its card +// answered by answer. +func changeTurn(t *testing.T, store *automation.Store, body map[string]any, answer func(*Agent, Event)) []Event { + t.Helper() + body["op"] = "change" + completer := &scriptedCompleter{steps: []step{automationCall("a1", body), finalText("ok")}} + agent := automationAgent(t, completer, store, nil) + events, err := agent.Submit(context.Background(), "change it") + if err != nil { + t.Fatal(err) + } + return drainAnsweringAutomation(t, events, func(event Event) { answer(agent, event) }) +} + +func saveTheCard(agent *Agent, event Event) { + agent.ResolveAutomation(event.Automation.ID, AutomationAnswer{Save: true}) +} + +// A `when` RE-SENT AS IT WAS IS NOT A NEW SCHEDULE. Models send unchanged +// fields beside the one that changed; the card has to promise the next run the +// save keeps, and the grid an interval is anchored to must not move. A rhythm +// saved with no zone — the machine's — is not given one by the re-send either. +func TestReSendingTheSameWhenKeepsTheNextRun(t *testing.T) { + store := automationStoreFor(t) + made, err := store.Create(automation.Automation{Title: "stretch", Workspace: t.TempDir(), + Schedule: automation.Schedule{Every: "1h"}, Action: automation.Action{Say: "stand up"}}) + if err != nil { + t.Fatal(err) + } + stored, _ := store.Get(made.ID) + collected := changeTurn(t, store, map[string]any{"id": made.ID, "when": map[string]any{"every": "1h"}, "say": "stand up and stretch"}, saveTheCard) + if card, found := firstOfKind(collected, EventAutomationProposal); !found || !card.Automation.Next.Equal(stored.Next) { + t.Fatalf("the card promised %v, the automation next runs %v", card.Automation, stored.Next) + } + got, _ := store.Get(made.ID) + if got.Action.Say != "stand up and stretch" { + t.Fatalf("the change was not saved: %+v", got) + } + if !got.Next.Equal(stored.Next) || !got.Schedule.Anchor.Equal(stored.Schedule.Anchor) || got.Schedule.Zone != "" { + t.Fatalf("re-sending the same rhythm moved it: next %v anchor %v zone %q, was %v %v %q", + got.Next, got.Schedule.Anchor, got.Schedule.Zone, stored.Next, stored.Schedule.Anchor, stored.Schedule.Zone) + } +} + +// AN AUTOMATION DELETED WHILE ITS CHANGE CARD IS UP IS NOT BROUGHT BACK. +func TestAChangeToOneDeletedWhileItsCardIsUpSavesNothing(t *testing.T) { + store := automationStoreFor(t) + made, err := store.Create(automation.Automation{Title: "leave", Workspace: t.TempDir(), + Schedule: automation.Schedule{At: time.Now().Add(time.Hour)}, Action: automation.Action{Say: "time to leave"}}) + if err != nil { + t.Fatal(err) + } + collected := changeTurn(t, store, map[string]any{"id": made.ID, "say": "leave now"}, func(agent *Agent, event Event) { + if err := store.Delete(made.ID); err != nil { + t.Errorf("delete: %v", err) + } + saveTheCard(agent, event) + }) + if output := toolOutput(t, collected, "automation"); !strings.Contains(output, "nothing was changed: it was deleted while the card was up") { + t.Fatalf("tool result = %q", output) + } + if all, _ := store.List(); len(all) != 0 { + t.Fatalf("a change brought back a deleted automation: %+v", all) + } +} + +// A PAUSED AUTOMATION STAYS PAUSED THROUGH A CHANGE, and its card promises no +// run, because a paused one does not run whatever its schedule says. +func TestAPausedAutomationStaysPausedThroughAChange(t *testing.T) { + store := automationStoreFor(t) + made, err := store.Create(automation.Automation{Title: "weekly update", Workspace: t.TempDir(), + Schedule: automation.Schedule{Every: "0 9 * * 1", Zone: "UTC"}, Action: automation.Action{Do: "draft the weekly update"}}) + if err != nil { + t.Fatal(err) + } + if _, err := store.SetStatus(made.ID, automation.StatusPaused); err != nil { + t.Fatal(err) + } + collected := changeTurn(t, store, map[string]any{"id": made.ID, "when": map[string]any{"every": "0 9 * * 2"}}, saveTheCard) + if card, found := firstOfKind(collected, EventAutomationProposal); !found || !card.Automation.Next.IsZero() { + t.Fatalf("a paused automation's change card promised a run: %+v", card.Automation) + } + got, _ := store.Get(made.ID) + if got.Status != automation.StatusPaused || got.Schedule.Every != "0 9 * * 2" { + t.Fatalf("after the change it is %s on %q", got.Status, got.Schedule.Every) + } + if output := toolOutput(t, collected, "automation"); !strings.Contains(output, "it is still paused") { + t.Fatalf("tool result = %q", output) + } +} diff --git a/internal/session/automation_contract.go b/internal/session/automation_contract.go index 42bc6a63d6..d74cd3d5d8 100644 --- a/internal/session/automation_contract.go +++ b/internal/session/automation_contract.go @@ -44,8 +44,8 @@ type AutomationNotice struct { CostWords string `json:"costWords,omitempty"` // Options are the card's answers, from [AutomationOptions]. Options []AnswerOption `json:"options,omitempty"` - // Update is set on a report: "saved", "paused", "resumed", "deleted", - // "running". Text is its one line. + // Update is set on a report: "saved", "changed", "paused", "resumed", + // "deleted", "running". Text is its one line. Update string `json:"update,omitempty"` Text string `json:"text,omitempty"` } @@ -86,8 +86,21 @@ func (a *Agent) ResolveAutomation(id uint64, answer AutomationAnswer) { } } +// AutomationChangeHead is the first line of a card that changes an automation +// already saved, whichever door raised it: the model's `change`, or a typed +// `/automations edit` (internal/tui3's automationscmd.go). +const AutomationChangeHead = "save this change?" + // AutomationHead is the card's first line: what KIND of thing is being asked. +// +// A CARD ABOUT AN AUTOMATION WITH AN ID IS A CHANGE TO IT. A proposal's +// automation is given its id only when it is saved, so the id is the one fact +// that tells the two cards apart, on the engine's side of the wire and on every +// surface's, without a field of its own to keep in step. func AutomationHead(item automation.Automation) string { + if strings.TrimSpace(item.ID) != "" { + return AutomationChangeHead + } switch item.Kind() { case automation.KindReminder: return "wants to remind you" @@ -113,7 +126,18 @@ func AutomationRunsNow(item automation.Automation) bool { // AutomationOptions are one card's answers. The keys do not move: 1 saves, 2 // saves and runs it now, 0 saves nothing. The decline is last and safe. +// +// A CHANGE HAS NO "RUN IT NOW" ([AutomationHead] says which card is a change). +// What it settles is how the automation runs from here on, and running one now +// is `run`, a verb of its own; so both doors that change one offer exactly +// these two answers. func AutomationOptions(item automation.Automation) []AnswerOption { + if strings.TrimSpace(item.ID) != "" { + return []AnswerOption{ + {Key: AutomationSaveKey, Label: "Save", Consequence: "The automation runs this way from its next time."}, + {Key: AutomationNoKey, Label: "Don't save", Consequence: "Nothing is saved.", Safe: true}, + } + } options := []AnswerOption{{Key: AutomationSaveKey, Label: "Save", Consequence: automationSaveConsequence(item)}} if AutomationRunsNow(item) { label, consequence := "Save and run it now", "Saves it, then does it once now, here, while you watch." diff --git a/internal/session/automation_tool.go b/internal/session/automation_tool.go index 470287cb0a..c838e05091 100644 --- a/internal/session/automation_tool.go +++ b/internal/session/automation_tool.go @@ -59,14 +59,14 @@ func (a *Agent) automationTools() []bare.Tool { } var automationDescription = "Set up work codeaf does on a clock — a reminder, scheduled work, or a watch — and manage the ones that exist. THE PERSON NEVER NAMES THIS TOOL; you recognise it from what their sentence IS.\n\n" + - "KINDS. A REMINDER says a fixed line at a time (`say`): \"remind me at 6 to leave\". SCHEDULED WORK runs a brief at a time or on a rhythm (`do`): \"every Monday at 9, draft the weekly update from the git log\". A WATCH looks at something on a rhythm and, when its condition turns true, says a line or runs work (`look` + `say` or `do`): \"tell me when CI on main goes red\". Automations run ONLY while codeaf is open; anything that fell due while it was closed runs once when it opens, marked late.\n\n" + + "KINDS. A REMINDER says a fixed line at a time (`say`): \"remind me at 6 to leave\". SCHEDULED WORK runs a brief at a time or on a rhythm (`do`): \"every Monday at 9, draft the weekly update\". A WATCH looks at something on a rhythm and, when its condition turns true, says a line or runs work (`look` + `say` or `do`): \"tell me when CI on main goes red\". Automations run ONLY while codeaf is open; anything that fell due while it was closed runs once when it opens, marked late.\n\n" + "NOT A RULE. \"Always use tabs here\", \"never touch the public API\", a convention or preference for future work is a memory marked always — use `remember` with always, not this tool. Something the person wants done ONCE, now, is just done now.\n\n" + - "PROPOSE puts a card in front of the person; nothing is saved until they say yes, and the card shows exactly what you send. Keep their sentence verbatim in `words`; give a short `title`. WHEN: `when.at` is one moment (RFC 3339 with offset, worked out from the `Now` line); `when.in` is a distance from now (\"20m\", \"2h\", \"1d\"); `when.every` is a rhythm — a five-field cron line read in the person's zone (\"0 9 * * 1\" is Mondays 09:00, \"30 8 * * 1-5\" weekdays 08:30) or an interval (\"15m\", \"2h\", \"1d\"). A moment that has already passed is refused. A WATCH needs `when.every` (how often it looks), a `look` (exactly one of a shell `command` run in the project, a `files` glob, or a `tool` with `args` — with `service` when the tool belongs to a connected account) and a `condition`: the plain sentence the model checks every look against (\"the latest run on main failed\"). Prefer a look whose output carries the thing itself (a status field, a file's contents), not a bare status code. `look.once` makes it tell once and stop. A watch speaks when its condition CHANGES to true, and is quiet while it stays true. Send `worktree` only for work that edits code and should be kept on a branch for review. Send `limits` only when the person named a time or a price (defaults: 30m and $5 a run).\n\n" + - "MANAGE: `list` shows them with their ids; `pause`, `resume`, `delete`, `run` (now, outside its schedule) and `history` take an `id`." + "PROPOSE puts a card in front of the person; nothing is saved until they say yes, and the card shows exactly what you send. Keep their sentence verbatim in `words`; give a short `title`. WHEN: `when.at` is one moment (RFC 3339 with offset, worked out from the `Now` line); `when.in` is a distance from now (\"20m\", \"2h\", \"1d\"); `when.every` is a rhythm — a five-field cron line read in the person's zone (\"0 9 * * 1\" is Mondays 09:00, \"30 8 * * 1-5\" weekdays 08:30) or an interval (\"15m\", \"2h\", \"1d\"). A moment that has already passed is refused. A WATCH needs `when.every` (how often it looks), a `look` (exactly one of `command`, `files`, or `tool` with `args`) and a `condition` (\"the latest run on main failed\"). Prefer a look whose output carries the thing itself (a status field, a file's contents), not a bare status code. `look.once` makes it tell once and stop. A watch speaks when its condition CHANGES to true, and is quiet while it stays true. Send `worktree` only for work that edits code and should be kept on a branch for review. Send `limits` only when the person named a time or a price (defaults: 30m and $5 a run).\n\n" + + "CHANGE: \"move my reminder to 5pm\" is `change` with its `id` and only what changes — never a second proposal; it raises the same card, and a new `when` wakes a finished one. MANAGE: `list` shows ids; `pause`, `resume`, `delete`, `run` (now) and `history` take an `id`." var automationSchemaJSON = `{"type":"object","properties":{` + - `"op":{"type":"string","enum":["propose","list","pause","resume","delete","run","history"]},` + - `"id":{"type":"string","description":"Which automation, for pause, resume, delete, run and history — from list."},` + + `"op":{"type":"string","enum":["propose","change","list","pause","resume","delete","run","history"]},` + + `"id":{"type":"string","description":"Which automation, from list."},` + `"title":{"type":"string","description":"A short name, a few words."},` + `"words":{"type":"string","description":"The person's own sentence, verbatim."},` + `"when":{"type":"object","properties":{` + @@ -91,34 +91,45 @@ var automationSchemaJSON = `{"type":"object","properties":{` + `},"required":["op"]}` type automationArguments struct { - Op string `json:"op"` - ID string `json:"id"` - Title string `json:"title"` + Op string `json:"op"` + ID string `json:"id"` + Title string `json:"title"` + Words string `json:"words"` + When automationWhenArguments `json:"when"` + Look *automationLookArguments `json:"look"` + Say string `json:"say"` + Do string `json:"do"` + // Worktree is a pointer because a change has to tell "keep it where it + // runs" (absent) from "run it in the checkout" (false). + Worktree *bool `json:"worktree"` + Limits automationLimitsArguments `json:"limits"` +} + +type automationWhenArguments struct { + At string `json:"at"` + In string `json:"in"` + Every string `json:"every"` Words string `json:"words"` - When struct { - At string `json:"at"` - In string `json:"in"` - Every string `json:"every"` - Words string `json:"words"` - } `json:"when"` - Look *struct { - Command string `json:"command"` - Files string `json:"files"` - Tool string `json:"tool"` - Service string `json:"service"` - Args json.RawMessage `json:"args"` - Condition string `json:"condition"` - Once bool `json:"once"` - } `json:"look"` - Say string `json:"say"` - Do string `json:"do"` - Worktree bool `json:"worktree"` - Limits struct { - Time string `json:"time"` - USD *float64 `json:"usd"` - } `json:"limits"` } +type automationLookArguments struct { + Command string `json:"command"` + Files string `json:"files"` + Tool string `json:"tool"` + Service string `json:"service"` + Args json.RawMessage `json:"args"` + Condition string `json:"condition"` + Once *bool `json:"once"` +} + +type automationLimitsArguments struct { + Time string `json:"time"` + USD *float64 `json:"usd"` +} + +// automationOps is every op, in the order a refusal names them. +const automationOps = "propose, change, list, pause, resume, delete, run or history" + // automationTool is the belt's entry point, with the clock stamped on every // answer: this is the tool whose subject is WHEN, and the model needs the real // time at exactly the moment it is told a moment has passed. @@ -144,6 +155,8 @@ func (a *Agent) automationDispatch(ctx context.Context, args json.RawMessage) (s switch op := strings.ToLower(strings.TrimSpace(parsed.Op)); op { case "propose": return a.automationPropose(ctx, store, parsed) + case "change": + return a.automationChange(ctx, store, parsed) case "list": return automationList(store, time.Now()) case "pause", "resume": @@ -159,9 +172,9 @@ func (a *Agent) automationDispatch(ctx context.Context, args json.RawMessage) (s case "history": return automationHistory(store, parsed.ID, time.Now()) case "": - return "Invalid arguments: op is required — propose, list, pause, resume, delete, run or history", true, nil + return "Invalid arguments: op is required — " + automationOps, true, nil default: - return "Invalid arguments: no op called " + strconv.Quote(parsed.Op) + " — propose, list, pause, resume, delete, run or history", true, nil + return "Invalid arguments: no op called " + strconv.Quote(parsed.Op) + " — " + automationOps, true, nil } } @@ -191,13 +204,9 @@ func (a *Agent) automationPropose(ctx context.Context, store *automation.Store, Options: AutomationOptions(item), } answer, err := a.askAutomation(ctx, ¬ice) - switch { - case errors.Is(err, errAutomationUnwatched): - return "nobody is here to say yes — an automation can only be set up in a conversation somebody is in", true, nil - case errors.Is(err, errAutomationUnanswered): - return "the card was left unanswered — nothing was saved", false, nil - case err != nil: - return "the card was never answered: " + err.Error(), true, nil + if err != nil { + out, failed := automationNoAnswer(err, "set up", "saved") + return out, failed, nil } if !answer.Save { if change := strings.TrimSpace(answer.Change); change != "" { @@ -252,71 +261,276 @@ func (a *Agent) automationFrom(parsed automationArguments, now time.Time) (autom Title: strings.TrimSpace(parsed.Title), Words: strings.TrimSpace(parsed.Words), Workspace: a.automationWorkspace(), - Worktree: parsed.Worktree, + Worktree: parsed.Worktree != nil && *parsed.Worktree, Action: automation.Action{Say: strings.TrimSpace(parsed.Say), Do: strings.TrimSpace(parsed.Do)}, Origin: a.automationOrigin(), } if item.Title == "" { item.Title = clip(firstNonEmptyString(item.Words, item.Action.Say, item.Action.Do), 60) } - at, in, every := strings.TrimSpace(parsed.When.At), strings.TrimSpace(parsed.When.In), strings.TrimSpace(parsed.When.Every) + schedule, sent, problem := automationSchedule(parsed.When, a.automationZone(), now) + switch { + case problem != "": + return item, problem + case !sent: + return item, automationOneWhen + } + item.Schedule = schedule + item.Look = automationLook(parsed.Look, nil) + limits, problem := automationLimits(parsed.Limits, automation.Limits{}) + if problem != "" { + return item, problem + } + item.Limits = limits + return item, "" +} + +const automationOneWhen = "Invalid arguments: send exactly one of when.at, when.in or when.every" + +// automationSchedule reads `when` into a schedule, and says whether one was +// sent at all: a proposal refuses a `when` with nothing in it, and a change +// reads it as "keep the schedule it has". A new rhythm is read in zone. +func automationSchedule(when automationWhenArguments, zone string, now time.Time) (automation.Schedule, bool, string) { + at, in, every := strings.TrimSpace(when.At), strings.TrimSpace(when.In), strings.TrimSpace(when.Every) set := 0 for _, field := range []string{at, in, every} { if field != "" { set++ } } - if set != 1 { - return item, "Invalid arguments: send exactly one of when.at, when.in or when.every" - } switch { + case set == 0: + return automation.Schedule{}, false, "" + case set > 1: + return automation.Schedule{}, false, automationOneWhen case every != "": - item.Schedule = automation.Schedule{Every: every, Zone: a.automationZone()} + return automation.Schedule{Every: every, Zone: zone}, true, "" case in != "": span, err := automation.ParseInterval(in) if err != nil { - return item, "Invalid arguments: when.in is a distance like \"20m\", \"2h\" or \"1d\"" + return automation.Schedule{}, false, "Invalid arguments: when.in is a distance like \"20m\", \"2h\" or \"1d\"" } - item.Schedule = automation.Schedule{At: now.Add(span)} - default: - moment, err := automationMoment(at) + return automation.Schedule{At: now.Add(span)}, true, "" + } + moment, err := automationMoment(at) + if err != nil { + return automation.Schedule{}, false, "Invalid arguments: when.at " + err.Error() + } + if moment.Before(now.Add(-automationPastGrace)) { + return automation.Schedule{}, false, fmt.Sprintf("when.at %s has already passed — it is %s now. For a distance from now send when.in; for a clock time, compute it from now.", + moment.Format("2006-01-02 15:04 -07:00"), now.Format("2006-01-02 15:04 -07:00")) + } + return automation.Schedule{At: moment}, true, "" +} + +// automationLook lays the look that was sent over the one there is, which for +// a proposal is none. A new target — a command, files or a tool — replaces the +// old target whole; the condition and `once` stay unless they are sent, as the +// typed `look` and `files` clauses keep them (internal/automation's Apply). +func automationLook(sent *automationLookArguments, base *automation.Look) *automation.Look { + if sent == nil { + return base + } + var look automation.Look + if base != nil { + look = *base + } + command, files, tool := strings.TrimSpace(sent.Command), strings.TrimSpace(sent.Files), strings.TrimSpace(sent.Tool) + if command != "" || files != "" || tool != "" { + look.Command, look.Files, look.Tool, look.Service, look.Args = command, files, tool, "", nil + } + if service := strings.TrimSpace(sent.Service); service != "" { + look.Service = service + } + if len(sent.Args) > 0 && string(sent.Args) != "null" { + look.Args = sent.Args + } + if condition := strings.TrimSpace(sent.Condition); condition != "" { + look.Condition = condition + } + if sent.Once != nil { + look.Once = *sent.Once + } + return &look +} + +// automationLimits lays the limits that were sent over the ones there are. +func automationLimits(sent automationLimitsArguments, base automation.Limits) (automation.Limits, string) { + limits := base + if raw := strings.TrimSpace(sent.Time); raw != "" { + limit, err := automation.ParseInterval(raw) if err != nil { - return item, "Invalid arguments: when.at " + err.Error() - } - if moment.Before(now.Add(-automationPastGrace)) { - return item, fmt.Sprintf("when.at %s has already passed — it is %s now. For a distance from now send when.in; for a clock time, compute it from now.", - moment.Format("2006-01-02 15:04 -07:00"), now.Format("2006-01-02 15:04 -07:00")) + return limits, "Invalid arguments: limits.time is a duration like \"30m\" or \"2h\"" } - item.Schedule = automation.Schedule{At: moment} - } - if look := parsed.Look; look != nil { - item.Look = &automation.Look{ - Command: strings.TrimSpace(look.Command), - Files: strings.TrimSpace(look.Files), - Tool: strings.TrimSpace(look.Tool), - Service: strings.TrimSpace(look.Service), - Args: look.Args, - Condition: strings.TrimSpace(look.Condition), - Once: look.Once, + limits.Time = limit + } + if sent.USD != nil { + if *sent.USD <= 0 || math.IsNaN(*sent.USD) { + return limits, "Invalid arguments: limits.usd is a price above zero" } - if string(item.Look.Args) == "null" { - item.Look.Args = nil + limits.USD = *sent.USD + } + return limits, "" +} + +// automationNoAnswer is what a card that got no answer comes to, in the words +// of what the call was for: a proposal is "set up" and "saved", a change is +// "changed" and "changed". +func automationNoAnswer(err error, can, nothing string) (string, bool) { + switch { + case errors.Is(err, errAutomationUnwatched): + return "nobody is here to say yes — an automation can only be " + can + " in a conversation somebody is in", true + case errors.Is(err, errAutomationUnanswered): + return "the card was left unanswered — nothing was " + nothing, false + } + return "the card was never answered: " + err.Error(), true +} + +// ── changing ──────────────────────────────────────────────────────────────── + +// automationChange changes an automation that exists, on the same card a +// proposal raises, headed `save this change?` ([AutomationHead]). +// +// IT IS WHAT "MOVE MY REMINDER TO 5PM" IS. Before it, the model's only door +// was a new proposal, which left the old automation — often a finished one — +// standing beside the new one in `/automations`. A change keeps the id, the +// place it runs, the conversation it reports to and its history, and +// [automation.Store.Update] works a changed schedule out again from now, which +// is what wakes a finished one-time reminder that is given a new moment. +func (a *Agent) automationChange(ctx context.Context, store *automation.Store, parsed automationArguments) (string, bool, error) { + id := strings.TrimSpace(parsed.ID) + if id == "" { + return "Invalid arguments: id is required — list shows them", true, nil + } + stored, err := store.Get(id) + if err != nil { + return automationNotFound(err), true, nil + } + now := time.Now() + item, rescheduled, problem := a.automationChanged(stored, parsed, now) + if problem != "" { + return problem, true, nil + } + if err := item.Validate(); err != nil { + return "Invalid arguments: " + err.Error(), true, nil + } + // THE CARD'S NEXT RUN IS THE ONE THE SAVE WILL GIVE IT: worked out again from + // now for a new schedule, the one it already has otherwise — none for a + // finished one — and none while it is paused, because a paused automation + // does not run whatever its schedule says. + var next time.Time + switch { + case item.Status == automation.StatusPaused: + case rescheduled: + if next, err = item.Schedule.First(now); err != nil { + return "Invalid arguments: " + err.Error(), true, nil } + default: + next = stored.Next } - if raw := strings.TrimSpace(parsed.Limits.Time); raw != "" { - limit, err := automation.ParseInterval(raw) - if err != nil { - return item, "Invalid arguments: limits.time is a duration like \"30m\" or \"2h\"" + whenWords := automation.Describe(item.Schedule, now) + if rescheduled { + whenWords = firstNonEmptyString(strings.TrimSpace(parsed.When.Words), whenWords) + } + notice := AutomationNotice{ + Automation: item, + WhenWords: whenWords, + Exact: automation.Exact(item.Schedule), + Next: next, + CostWords: a.automationCostWords(item, now), + Options: AutomationOptions(item), + } + answer, err := a.askAutomation(ctx, ¬ice) + if err != nil { + out, failed := automationNoAnswer(err, "changed", "changed") + return out, failed, nil + } + if !answer.Save { + if change := strings.TrimSpace(answer.Change); change != "" { + return "the person changed it: " + change + "\nNothing is changed yet. Send the change again with that.", false, nil } - item.Limits.Time = limit + return "nothing was changed: the person said no.", false, nil } - if parsed.Limits.USD != nil { - if *parsed.Limits.USD <= 0 || math.IsNaN(*parsed.Limits.USD) { - return item, "Invalid arguments: limits.usd is a price above zero" + saved, err := store.Update(item) + switch { + case errors.Is(err, automation.ErrNotFound): + return "nothing was changed: it was deleted while the card was up", true, nil + case err != nil: + return "nothing was changed: " + err.Error(), true, nil + } + a.emitAutomationUpdate("changed", saved, "") + line := fmt.Sprintf("changed %s: %s\nit runs: %s", saved.ID, saved.Title, whenWords) + switch { + case saved.Status == automation.StatusPaused: + line += "\nit is still paused — resume starts it again" + case saved.Status == automation.StatusFinished: + line += "\nit has already run and runs again only if when changes" + case !saved.Next.IsZero(): + line += "\nnext run: " + automation.Moment(saved.Next, now) + } + return line + "\nThey answered the card. Your WHOLE reply is one short line saying what changed and when it next runs — no preamble, and do not ask again.", false, nil +} + +// automationChanged lays a change over the automation as it is saved: what was +// sent replaces what was there, and everything else stays. It says whether the +// schedule was replaced, and refuses a change that sends nothing to change. +func (a *Agent) automationChanged(stored automation.Automation, parsed automationArguments, now time.Time) (automation.Automation, bool, string) { + item := stored + sent := false + if title := strings.TrimSpace(parsed.Title); title != "" { + item.Title, sent = title, true + } + if words := strings.TrimSpace(parsed.Words); words != "" { + item.Words, sent = words, true + } + // A NEW RHYTHM KEEPS THE ZONE THE OLD ONE WAS READ IN, as the typed `every` + // clause does — an empty one too, which is the machine's zone and was chosen + // as such. Only a one-time automation, which has no rhythm to have read, is + // given this conversation's. + zone := stored.Schedule.Zone + if !stored.Schedule.Repeats() { + zone = firstNonEmptyString(zone, a.automationZone()) + } + schedule, whenSent, problem := automationSchedule(parsed.When, zone, now) + if problem != "" { + return item, false, problem + } + // A `when` RE-SENT AS IT WAS IS NOT A NEW SCHEDULE. Models send unchanged + // fields beside the one that changed, and [automation.Store.Update] keeps + // the next run and the status unless [automation.ScheduleChanged] says + // otherwise — so the card has to ask the same question, or it promises a + // next run the save never gives. + rescheduled := whenSent && automation.ScheduleChanged(stored.Schedule, schedule) + if rescheduled { + item.Schedule = schedule + } + sent = sent || whenSent + // A line and work are one slot: sending either replaces what it says or + // does, and [automation.Automation.Validate] refuses both at once. + if say, do := strings.TrimSpace(parsed.Say), strings.TrimSpace(parsed.Do); say != "" || do != "" { + item.Action, sent = automation.Action{Say: say, Do: do}, true + } + switch { + case parsed.Worktree != nil: + item.Worktree, sent = *parsed.Worktree, true + case item.Action.Do == "": + // Only work runs in a worktree, so work turned into a line leaves it. + item.Worktree = false + } + if parsed.Look != nil { + item.Look, sent = automationLook(parsed.Look, stored.Look), true + } + if strings.TrimSpace(parsed.Limits.Time) != "" || parsed.Limits.USD != nil { + limits, problem := automationLimits(parsed.Limits, stored.Limits) + if problem != "" { + return item, false, problem } - item.Limits.USD = *parsed.Limits.USD + item.Limits, sent = limits, true } - return item, "" + if !sent { + return item, false, "Invalid arguments: send only what changes — title, words, when, say, do, look, worktree or limits — beside the id" + } + return item, rescheduled, "" } // automationOrigin is the conversation an automation is made in: where its diff --git a/internal/session/beltfacts.go b/internal/session/beltfacts.go index dfc266bb53..7cc3a3e41d 100644 --- a/internal/session/beltfacts.go +++ b/internal/session/beltfacts.go @@ -676,8 +676,9 @@ var automationFacts = []beltFact{{ present: "SOMETHING TO LEAVE BEHIND — a reminder, work on a rhythm, a watch on the\n" + "world — is `automation`'s: \"remind me at 6\", \"every Monday draft the update\",\n" + "\"tell me when CI goes red\". PROPOSE IT, and never do it instead of proposing\n" + - "it, which answers a request they did not make. `automation`'s own description\n" + - "says how to tell one from the work in front of you and how to say when.", + "it, which answers a request they did not make; one that exists is CHANGED,\n" + + "never proposed again. `automation`'s own description says how to tell one\n" + + "from the work in front of you and how to say when.", // AND THE ABSENT CASE NAMES NO VERB: a sentence naming a tool this belt does // not carry is the lie the whole file exists to prevent (prompt_belt_test.go // asks it of every shape). diff --git a/internal/session/prefixbudget_test.go b/internal/session/prefixbudget_test.go index 857d94b225..e705a3c348 100644 --- a/internal/session/prefixbudget_test.go +++ b/internal/session/prefixbudget_test.go @@ -577,9 +577,16 @@ const fixedPrefixTarget = 48_000 // shorter. The fixed prefix measures 52,713 bytes and the lean 44,935, and both // waivers FALL to sit exactly on the measurement: the bill this file has // carried for `stand` since it was first weighed is mostly paid. +// +// 2026-10-14, `automation` learns `change`. The op, its sentence in the tool's +// description and the page's "one that exists is CHANGED, never proposed again" +// were paid for out of what the description said twice — a watch's look and +// condition, already said beside their own fields in the schema — and the `id` +// field's list of the ops that take one. Both arms come out 22 bytes lighter, +// at 52,691 and 44,913, and both waivers fall by exactly that. const ( - fixedPrefixWaiver = 4_713 - leanPrefixWaiver = 13_435 + fixedPrefixWaiver = 4_691 + leanPrefixWaiver = 13_413 ) // THE LEAN PROFILE GETS A BUDGET OF ITS OWN (2026-09-10, the prompt diet's lane diff --git a/internal/tui3/automation.go b/internal/tui3/automation.go index 27abc19bf7..368807cbb2 100644 --- a/internal/tui3/automation.go +++ b/internal/tui3/automation.go @@ -276,7 +276,13 @@ func automationBands(notice session.AutomationNotice) []string { if when != "" { when += " · " } - when += "first run " + notice.Next.Local().Format("Mon 2 Jan 15:04") + // A CHANGE'S RUN IS ITS NEXT, NOT ITS FIRST: the automation already + // exists, and an id on the card is what says so ([session.AutomationHead]). + word := "first run " + if strings.TrimSpace(item.ID) != "" { + word = "next run " + } + when += word + notice.Next.Local().Format("Mon 2 Jan 15:04") } if when != "" { bands = append(bands, when) diff --git a/internal/tui3/automationchange_test.go b/internal/tui3/automationchange_test.go new file mode 100644 index 0000000000..1ab945ae45 --- /dev/null +++ b/internal/tui3/automationchange_test.go @@ -0,0 +1,47 @@ +package tui3 + +import ( + "strings" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/session" +) + +// A CHANGE THE CONVERSATION ASKS FOR IS DRAWN AS THE TYPED EDIT'S CARD: the +// same `save this change?` head and the same two answers, whichever door raised +// it, because an automation with an id on a card is a change to it +// ([session.AutomationHead]). +func TestAChangeTheModelAsksForDrawsTheEditsCard(t *testing.T) { + a, _ := automationLab(t, labWork("weekly update", "0 9 * * 1")) + item := a.watch.list[0] + item.Schedule.Every = "0 9 * * 2" + proposal := automationProposalOf(7, item, "every Tuesday at 09:00") + proposal.Automation.Next = time.Now().Add(time.Hour) + a.proposeAutomation(proposal) + if a.auto == nil || a.auto.settled() { + t.Fatalf("the change raised no card: %+v", a.auto) + } + rows := AutomationCardRows(a, a.auto, 80, false) + if len(rows) == 0 || !strings.Contains(plain(rows[0]), typedEditHead) { + t.Fatalf("the change card's head is %q, want %q", plain(rows[0]), typedEditHead) + } + // AND ITS RUN IS ITS NEXT: the automation exists, so nothing about it is first. + if drawn := plain(strings.Join(rows, "\n")); !strings.Contains(drawn, "next run ") || strings.Contains(drawn, "first run ") { + t.Fatalf("the change card does not say its next run:\n%s", drawn) + } + question := a.automationShown(a.auto).question + if question.Head != typedEditHead { + t.Fatalf("the change's question is headed %q", question.Head) + } + var labels []string + for _, option := range question.Options { + labels = append(labels, option.Label) + } + if strings.Join(labels, "|") != typedSaveLabel+"|"+typedNoLabel { + t.Fatalf("a change offers %v, want only %s and %s", labels, typedSaveLabel, typedNoLabel) + } + if question.Options[1].Key != session.AutomationNoKey || question.Options[1].Consequence != typedNoSays { + t.Fatalf("the change's no is %+v", question.Options[1]) + } +} diff --git a/internal/tui3/automationscmd.go b/internal/tui3/automationscmd.go index 75c09249b2..d73ad56595 100644 --- a/internal/tui3/automationscmd.go +++ b/internal/tui3/automationscmd.go @@ -34,15 +34,17 @@ const typedAutomationKind session.QuestionKind = "surface-automation" // of question with its own card by id alone (question.go). func typedAutomationID(seq uint64) uint64 { return 1<<40 + seq } -// The words a typed card says. +// The words a typed card says. An edit's head and answers are the engine's +// own ([session.AutomationChangeHead], [session.AutomationOptions]), because a +// change asked for in the conversation raises the same card and the two must +// not drift apart. const ( typedAddHead = "save this automation?" - typedEditHead = "save this change?" + typedEditHead = session.AutomationChangeHead typedSaveLabel = "Save" typedNoLabel = "Don't save" typedNoSays = "Nothing is saved." typedSaveSays = "It runs on this schedule while codeaf is open." - typedEditSays = "The automation runs this way from its next time." typedReason = "you typed it, so nothing is saved until you say so" typedNoAutoWord = "no automation has the id " typedSavedWord = "saved " @@ -168,7 +170,7 @@ func (a *app) confirmTypedAutomation(item automation.Automation, edit bool) { head := typedAddHead if edit { head = typedEditHead - notice.Options[0].Consequence = typedEditSays + notice.Options = session.AutomationOptions(item) } if a.auto != nil && !a.auto.settled() { a.auto.verdict = autoEndedWord