Let an agent session ask the operator on Telegram and read the answer (issue 589, hq ADR 0332)
mesh/merge-gate head 9446bec2f969 merged into trunk 9c9a76d220f8 — pass: builds claude-code → ace, g14, novox, shanks; no bus step; every machine composes …
mesh/repo-check head 9446bec2f969 merged into trunk 9c9a76d220f8 — fail: its merge-check.sh failed: --- FAIL: TestTheRoutersRefusalIsSaidAndDiscards (0.08s)
mesh/delivery superseded: a newer head of the same pull request
mesh/merge-gate head 9446bec2f969 merged into trunk 9c9a76d220f8 — pass: builds claude-code → ace, g14, novox, shanks; no bus step; every machine composes …
mesh/repo-check head 9446bec2f969 merged into trunk 9c9a76d220f8 — fail: its merge-check.sh failed: --- FAIL: TestTheRoutersRefusalIsSaidAndDiscards (0.08s)
mesh/delivery superseded: a newer head of the same pull request
A session that needs a decision could only ask in a terminal the operator may not watch. claude_code_ask raises the question under this module's name at the level approve, so only a channel that proves who answered carries it; claude_code_answer reads the chosen option back by its key from the router's own record, held to the question kept in the new questions state.
This commit is contained in:
@@ -0,0 +1,287 @@
|
||||
package main
|
||||
|
||||
// A session's question to the operator (novox/hq ADR 0332, issue 589): asked under this module's name at the level
|
||||
// approve, refused outside its bounds, at most two waiting, and its answer read back by its key only when the
|
||||
// router's record holds to the question asked.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
)
|
||||
|
||||
func mergeTonight() QuestionInput {
|
||||
return QuestionInput{
|
||||
Headline: "Merge the resolver fix tonight?",
|
||||
Context: "The fix for the resolver on the laptop is reviewed and both checks are green. Merging now rolls it to every node within the hour.",
|
||||
Options: []QuestionOption{
|
||||
{Label: "Merge now", Means: "I merge it and follow the rollout", Recommended: true},
|
||||
{Label: "Wait", Means: "I leave the pull request open until you say"},
|
||||
},
|
||||
Why: "it is reviewed and the checks are green",
|
||||
OnExpiry: "I leave the pull request open",
|
||||
By: "fix-589@g14",
|
||||
}
|
||||
}
|
||||
|
||||
func askMergeTonight(t *testing.T, p Paths, store *memProposals, r *router, at time.Time) string {
|
||||
t.Helper()
|
||||
out, err := AskQuestion(p, mergeTonight(), store, r, at)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return out["key"].(string)
|
||||
}
|
||||
|
||||
func TestAQuestionIsAskedUnderTheModulesNameAtTheLevelApprove(t *testing.T) {
|
||||
p, _ := node(t, "g14")
|
||||
store, r := &memProposals{m: map[string]json.RawMessage{}}, newRouter()
|
||||
out, err := AskQuestion(p, mergeTonight(), store, r, t0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
key := out["key"].(string)
|
||||
if !strings.HasPrefix(key, QuestionPrefix) || !strings.Contains(out["router"].(string), "took the question") {
|
||||
t.Fatalf("answered %v", out)
|
||||
}
|
||||
if len(r.subjects) != 1 || r.subjects[0] != asks.AskSubject(Module) {
|
||||
t.Fatalf("published on %v", r.subjects)
|
||||
}
|
||||
a := r.published[key]
|
||||
if a.Who != asks.Operator || a.OnExpiry != "I leave the pull request open" || !a.Expires.Equal(t0.Add(QuestionDefault)) {
|
||||
t.Errorf("ask %+v", a)
|
||||
}
|
||||
if len(a.Options) != 2 {
|
||||
t.Fatalf("options %+v", a.Options)
|
||||
}
|
||||
for _, o := range a.Options {
|
||||
if o.Level != asks.Approve || o.Binds == "" {
|
||||
t.Errorf("option %s is %s binding %q: every option is an approval bound to the question", o.ID, o.Level, o.Binds)
|
||||
}
|
||||
}
|
||||
if !strings.HasPrefix(a.Options[0].Does, "Recommended. ") || strings.HasPrefix(a.Options[1].Does, "Recommended") {
|
||||
t.Errorf("the recommendation is not on the recommended option alone: %+v", a.Options)
|
||||
}
|
||||
for _, want := range []string{"Recommended: Merge now, because it is reviewed and the checks are green.",
|
||||
"the agent session fix-589 on g14", "performs nothing by itself"} {
|
||||
if !strings.Contains(a.Explanation, want) {
|
||||
t.Errorf("the explanation does not say %q:\n%s", want, a.Explanation)
|
||||
}
|
||||
}
|
||||
if err := a.Check(t0); err != nil {
|
||||
t.Errorf("the ask does not pass the asks contract: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheChosenAnswerIsReadBackByItsKey(t *testing.T) {
|
||||
p, _ := node(t, "g14")
|
||||
store, r := &memProposals{m: map[string]json.RawMessage{}}, newRouter()
|
||||
key := askMergeTonight(t, p, store, r, t0)
|
||||
out, err := ReadAnswer(store, r, key, t0.Add(time.Minute))
|
||||
if err != nil || out["state"] != AnswerWaiting {
|
||||
t.Fatalf("before an answer: %v %v", out, err)
|
||||
}
|
||||
r.close(r.chose(key, "o2", t0.Add(10*time.Minute)))
|
||||
out, err = ReadAnswer(store, r, key, t0.Add(11*time.Minute))
|
||||
if err != nil || out["state"] != AnswerChosen {
|
||||
t.Fatalf("after the choice: %v %v", out, err)
|
||||
}
|
||||
chosen := out["chosen"].(map[string]any)
|
||||
if chosen["id"] != "o2" || chosen["label"] != "Wait" || chosen["means"] != "I leave the pull request open until you say" {
|
||||
t.Errorf("chosen %v", chosen)
|
||||
}
|
||||
if !strings.Contains(out["by"].(string), "the operator, via telegram") || out["channel"] != "telegram" {
|
||||
t.Errorf("by %v on %v", out["by"], out["channel"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnAnswerThatDoesNotHoldToTheQuestionIsNotProven(t *testing.T) {
|
||||
p, _ := node(t, "g14")
|
||||
store, r := &memProposals{m: map[string]json.RawMessage{}}, newRouter()
|
||||
key := askMergeTonight(t, p, store, r, t0)
|
||||
w := r.chose(key, "o1", t0.Add(time.Minute))
|
||||
|
||||
// The question rewritten on the bus after it was asked: the operator was shown other words.
|
||||
var q Question
|
||||
_ = json.Unmarshal(store.m[key], &q)
|
||||
q.Context = "Merging now deletes nothing and rolls it to every node."
|
||||
_ = store.Put(key, q)
|
||||
r.close(w)
|
||||
if out, _ := ReadAnswer(store, r, key, t0.Add(2*time.Minute)); out["state"] != AnswerNotProven {
|
||||
t.Errorf("a question changed after it was asked read as %v", out)
|
||||
}
|
||||
|
||||
// A warrant for another ask's digest.
|
||||
store2, r2 := &memProposals{m: map[string]json.RawMessage{}}, newRouter()
|
||||
key2 := askMergeTonight(t, p, store2, r2, t0)
|
||||
w2 := r2.chose(key2, "o1", t0.Add(time.Minute))
|
||||
w2.AskDigest = "sha256:0000"
|
||||
r2.close(w2)
|
||||
if out, _ := ReadAnswer(store2, r2, key2, t0.Add(2*time.Minute)); out["state"] != AnswerNotProven {
|
||||
t.Errorf("a warrant for another ask read as %v", out)
|
||||
}
|
||||
|
||||
// A choice made after the question expired.
|
||||
store3, r3 := &memProposals{m: map[string]json.RawMessage{}}, newRouter()
|
||||
key3 := askMergeTonight(t, p, store3, r3, t0)
|
||||
r3.close(r3.chose(key3, "o1", t0.Add(QuestionDefault+time.Minute)))
|
||||
if out, _ := ReadAnswer(store3, r3, key3, t0.Add(2*QuestionDefault)); out["state"] != AnswerNotProven {
|
||||
t.Errorf("a choice after expiry read as %v", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAQuestionIsRefusedOutsideItsBounds(t *testing.T) {
|
||||
p, _ := node(t, "g14")
|
||||
for name, change := range map[string]func(*QuestionInput){
|
||||
"one option": func(in *QuestionInput) { in.Options = in.Options[:1] },
|
||||
"five options": func(in *QuestionInput) { in.Options = append(in.Options, in.Options[1], in.Options[1], in.Options[1]) },
|
||||
"none recommended": func(in *QuestionInput) { in.Options[0].Recommended = false },
|
||||
"two recommended": func(in *QuestionInput) { in.Options[1].Recommended = true },
|
||||
"no why": func(in *QuestionInput) { in.Why = "" },
|
||||
"no context": func(in *QuestionInput) { in.Context = "" },
|
||||
"no on-expiry": func(in *QuestionInput) { in.OnExpiry = "" },
|
||||
"a long headline": func(in *QuestionInput) { in.Headline = strings.Repeat("Merge ", 11) },
|
||||
"a long label": func(in *QuestionInput) { in.Options[0].Label = "Merge it now and follow the rollout" },
|
||||
"an option unsaid": func(in *QuestionInput) { in.Options[1].Means = "" },
|
||||
"too short": func(in *QuestionInput) { in.Expires = time.Minute },
|
||||
"too long": func(in *QuestionInput) { in.Expires = 2 * 24 * time.Hour },
|
||||
"another node": func(in *QuestionInput) { in.By = "fix-589@shanks" },
|
||||
"an invisible char": func(in *QuestionInput) { in.Context += "\u200b" },
|
||||
} {
|
||||
store, r := &memProposals{m: map[string]json.RawMessage{}}, newRouter()
|
||||
in := mergeTonight()
|
||||
in.Options = append([]QuestionOption(nil), in.Options...)
|
||||
change(&in)
|
||||
if _, err := AskQuestion(p, in, store, r, t0); err == nil || !strings.Contains(err.Error(), "Nothing was asked") {
|
||||
t.Errorf("%s: %v", name, err)
|
||||
}
|
||||
if len(r.subjects) != 0 || len(store.m) != 0 {
|
||||
t.Errorf("%s: asked or kept anyway", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAtMostTwoQuestionsWaitSoAProposalFindsRoom(t *testing.T) {
|
||||
p, _ := node(t, "g14")
|
||||
store, r := &memProposals{m: map[string]json.RawMessage{}}, newRouter()
|
||||
first := askMergeTonight(t, p, store, r, t0)
|
||||
askMergeTonight(t, p, store, r, t0)
|
||||
if _, err := AskQuestion(p, mergeTonight(), store, r, t0); err == nil || !strings.Contains(err.Error(), "2 questions already wait") {
|
||||
t.Fatalf("a third question: %v", err)
|
||||
}
|
||||
r.close(r.chose(first, "o1", t0.Add(time.Minute)))
|
||||
if _, err := AskQuestion(p, mergeTonight(), store, r, t0.Add(2*time.Minute)); err != nil {
|
||||
t.Fatalf("a question once one was answered: %v", err)
|
||||
}
|
||||
// A proposal is still asked beside two waiting questions.
|
||||
pstore := &memProposals{m: map[string]json.RawMessage{}}
|
||||
if _, err := Propose(p, ProposeInput{Scope: ScopeMesh, Name: "x", Text: "Text.\n"}, pstore, r, t0); err != nil {
|
||||
t.Errorf("a proposal beside two questions: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// questionRefusingRouter refuses every ask as the router does for words that may not leave the mesh: on the decided
|
||||
// subject, with no record.
|
||||
type questionRefusingRouter struct {
|
||||
*router
|
||||
store ProposalStore
|
||||
id string
|
||||
}
|
||||
|
||||
func (r *questionRefusingRouter) Publish(subject string, body any, id string) error {
|
||||
r.id = id
|
||||
go func() {
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
HeardQuestionEnd(r.store, asks.Warrant{Ask: id, Asker: Module, Outcome: asks.OutcomeRefused,
|
||||
Words: "its words carry path (a file path), which may not leave the mesh", At: t0})
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestTheRoutersRefusalIsSaidAndKept(t *testing.T) {
|
||||
p, _ := node(t, "g14")
|
||||
store := &memProposals{m: map[string]json.RawMessage{}}
|
||||
refusing := &questionRefusingRouter{router: newRouter(), store: store}
|
||||
within, every := RouterAnswersWithin, RouterAnswersEvery
|
||||
RouterAnswersWithin, RouterAnswersEvery = time.Second, 10*time.Millisecond
|
||||
t.Cleanup(func() { RouterAnswersWithin, RouterAnswersEvery = within, every })
|
||||
_, err := AskQuestion(p, mergeTonight(), store, refusing, t0)
|
||||
if err == nil || !strings.Contains(err.Error(), "may not leave the mesh") {
|
||||
t.Fatalf("refusal: %v", err)
|
||||
}
|
||||
out, err := ReadAnswer(store, refusing, refusing.id, t0.Add(time.Minute))
|
||||
if err != nil || out["state"] != AnswerEnded || !strings.Contains(out["words"].(string), "refused") {
|
||||
t.Errorf("read after the refusal: %v %v", out, err)
|
||||
}
|
||||
if open := openQuestions(store, refusing, t0.Add(time.Minute)); len(open) != 0 {
|
||||
t.Errorf("a refused question still counts as waiting: %v", open)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnExpiredQuestionSaysWhatTheSessionDoes(t *testing.T) {
|
||||
p, _ := node(t, "g14")
|
||||
store, r := &memProposals{m: map[string]json.RawMessage{}}, newRouter()
|
||||
key := askMergeTonight(t, p, store, r, t0)
|
||||
r.close(asks.Warrant{Ask: key, Outcome: asks.OutcomeExpired, At: t0.Add(QuestionDefault)})
|
||||
out, err := ReadAnswer(store, r, key, t0.Add(QuestionDefault+time.Minute))
|
||||
if err != nil || out["state"] != AnswerEnded || out["now"] != "do what you said: I leave the pull request open" {
|
||||
t.Errorf("expired: %v %v", out, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheQuestionToolsAreServed(t *testing.T) {
|
||||
p, _ := node(t, "g14")
|
||||
r := newRouter()
|
||||
questions := &memProposals{m: map[string]json.RawMessage{}}
|
||||
proposing = &Proposing{Store: &memProposals{m: map[string]json.RawMessage{}}, Bus: r, Now: func() time.Time { return t0 },
|
||||
Write: writer(map[string]string{}), Questions: questions}
|
||||
t.Cleanup(func() { proposing = nil })
|
||||
s := serve(t, p, &bus{kept: map[string]map[string]any{}}, memConfig{})
|
||||
text, isErr := s.call(t, "claude_code_ask", map[string]any{
|
||||
"headline": "Merge the resolver fix tonight?", "context": "Reviewed and green.",
|
||||
"options": []any{
|
||||
map[string]any{"label": "Merge now", "means": "I merge it", "recommended": true},
|
||||
map[string]any{"label": "Wait", "means": "I leave it open"},
|
||||
},
|
||||
"why": "it is reviewed", "on_expiry": "I leave it open", "by": "fix-589@g14", "expires_minutes": 30,
|
||||
})
|
||||
if isErr || !strings.Contains(text, `"key"`) {
|
||||
t.Fatalf("%v %s", isErr, text)
|
||||
}
|
||||
var asked map[string]any
|
||||
_ = json.Unmarshal([]byte(text), &asked)
|
||||
key, _ := asked["key"].(string)
|
||||
if a := r.published[key]; !a.Expires.Equal(t0.Add(30 * time.Minute)) {
|
||||
t.Errorf("expires %v", a.Expires)
|
||||
}
|
||||
r.close(r.chose(key, "o1", t0.Add(time.Minute)))
|
||||
text, isErr = s.call(t, "claude_code_answer", map[string]any{"key": key})
|
||||
if isErr || !strings.Contains(text, `"chosen"`) || !strings.Contains(text, "Merge now") {
|
||||
t.Fatalf("%v %s", isErr, text)
|
||||
}
|
||||
}
|
||||
|
||||
// One read waits at most AnswerWaitsAtMost (60 s), whatever the session asks for, and says waiting when nobody
|
||||
// answered meanwhile.
|
||||
func TestAReadWaitsNoLongerThanItsBound(t *testing.T) {
|
||||
if AnswerWaitsAtMost != 60*time.Second {
|
||||
t.Fatalf("a read may wait %s; ADR 0332 says 60 s", AnswerWaitsAtMost)
|
||||
}
|
||||
p, _ := node(t, "g14")
|
||||
store, r := &memProposals{m: map[string]json.RawMessage{}}, newRouter()
|
||||
key := askMergeTonight(t, p, store, r, t0)
|
||||
most, every := AnswerWaitsAtMost, AnswerLooksEvery
|
||||
AnswerWaitsAtMost, AnswerLooksEvery = 100*time.Millisecond, 10*time.Millisecond
|
||||
t.Cleanup(func() { AnswerWaitsAtMost, AnswerLooksEvery = most, every })
|
||||
start := time.Now()
|
||||
out, err := WaitForAnswer(store, r, key, time.Hour, func() time.Time { return t0.Add(time.Minute) })
|
||||
if err != nil || out["state"] != AnswerWaiting {
|
||||
t.Fatalf("%v %v", out, err)
|
||||
}
|
||||
if took := time.Since(start); took > 2*time.Second {
|
||||
t.Errorf("a read asked to wait an hour took %s", took)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user