An ask reads at a glance on the phone: a headline, a summary and its points, the rest in a note (issues 383 and 639)
The operator would not read the Telegram ask for ADR 0343: thirty plain lines, every clause on one, no bold, no summary. A settings proposal was the same wall of text, its values masked so the approval was blind. - messenger: an ask's message is the asker's headline and explanation alone; who asked and when, what each answer does, the expiry, where else it is answered and the asker's Details travel as the message's note (asks.Message.Note). Answered, the message is one line, "You chose Approve at 16:06 on Telegram · <headline>". An explanation that does not read at a glance (asks.Glance) is shown and logged. On a kind that proves who answers, an ask's values are shown whole (asks.Ask.Whole), held to the secret rule alone (from the earlier work on issue 383, #178). - telegram: every message in HTML, never reworded: the title bold, a key line monospace, the note apart in a collapsed blockquote; markup escaped. A tap is compared by its words, whitespace aside, and a message shown before is still compared by the digest it was kept under. - desk-channel: the note follows the body. - mesh-knowledge: a record that needs the operator carries a brief (headline, summary, at most three points), the 30th per-record rule; its ask shows the brief and never the clauses. An ask made before is composed again as it was asked (form), so its warrant still holds. - claude-code: an instructions proposal's explanation is one summary line (the proposer's, or the module's), its text in the Details; a question's context is at most 300 characters and its recommendation one point. Older proposals and questions keep their form. The SDK is pinned at mesh-sdk 9588871 (fix/639-an-ask-reads-at-a-glance), which adds Message.Note and asks.Glance.
This commit is contained in:
@@ -130,7 +130,7 @@ func proposalTools(p Paths) []stdio.Tool {
|
||||
text, _ = a["content"].(string)
|
||||
}
|
||||
return Propose(p, ProposeInput{Scope: scopeOf(a), Nodes: nodesOf(a["nodes"]), Name: strArg(a, "name"),
|
||||
Text: text, Remove: boolArg(a, "remove")}, proposing.Store, proposing.Bus, proposing.Now())
|
||||
Text: text, Remove: boolArg(a, "remove"), Summary: strArg(a, "summary")}, proposing.Store, proposing.Bus, proposing.Now())
|
||||
}},
|
||||
{Name: "claude_code_proposals",
|
||||
Description: "The instruction sections proposed for the operator's approval (novox/hq ADR 0274), newest first: what " +
|
||||
@@ -164,14 +164,14 @@ func questionTools(p Paths, notLaunched error) []stdio.Tool {
|
||||
"operator's word to you; it authorises no call that needs the operator's approval.",
|
||||
Input: map[string]any{
|
||||
"headline": str("what is decided, in a few plain words, at most 60 characters, e.g. 'Merge the resolver fix tonight?'"),
|
||||
"context": str("what the operator decides and why it matters, in plain words, at most 1500 bytes"),
|
||||
"context": str("what the operator decides and why it matters, in plain words: two or three short sentences, one line, at most 300 characters"),
|
||||
"options": map[string]any{"type": "array", "description": "two to four answers; exactly one recommended",
|
||||
"items": map[string]any{"type": "object", "properties": map[string]any{
|
||||
"label": str("the button's words, at most 24 characters"),
|
||||
"means": str("what you do when it is chosen, in plain words"),
|
||||
"recommended": map[string]any{"type": "boolean", "description": "true on the one you recommend"},
|
||||
}, "required": []string{"label", "means"}}},
|
||||
"why": str("why the recommended option is the one you recommend"),
|
||||
"why": str("why the recommended option is the one you recommend, at most 120 bytes"),
|
||||
"on_expiry": str("what you do if nobody answers before it expires, e.g. 'I leave the pull request open'"),
|
||||
"by": str("required: your session's name, as who@node or who, e.g. fix-589@g14"),
|
||||
"expires_minutes": map[string]any{"type": "integer",
|
||||
|
||||
@@ -104,6 +104,12 @@ type Proposal struct {
|
||||
Digest string `json:"digest"`
|
||||
From string `json:"from"`
|
||||
At time.Time `json:"at"`
|
||||
// Summary is what the operator's phone shows of it, at a glance, in its proposer's words; empty, the module
|
||||
// says what it does (novox/hq issue 639).
|
||||
Summary string `json:"summary,omitempty"`
|
||||
// Form is how its ask is composed, so every node composes the same one again to hold the warrant to it: 0 for
|
||||
// a proposal made before issue 639 (its text in the explanation), AskGlance for one read at a glance.
|
||||
Form int `json:"form,omitempty"`
|
||||
// Asked is empty once the ask was published, or says why it was not; nothing composes from it.
|
||||
Asked string `json:"asked,omitempty"`
|
||||
}
|
||||
@@ -164,9 +170,109 @@ func (p Proposal) Act(option string) asks.Act {
|
||||
"at": p.At.UTC().Format(time.RFC3339Nano)}
|
||||
}
|
||||
|
||||
// AskGlance is the form of an ask read at a glance (Proposal.Form, Question.Form; novox/hq issue 639).
|
||||
const AskGlance = 1
|
||||
|
||||
// Ask is the ask for a proposal, composed from it alone: every node composes the same one, and holds the
|
||||
// router's warrant to it.
|
||||
// router's warrant to it — in the form it was asked in.
|
||||
func (p Proposal) Ask() asks.Ask {
|
||||
if p.Form == AskGlance {
|
||||
return p.glanceAsk()
|
||||
}
|
||||
return p.listedAsk()
|
||||
}
|
||||
|
||||
// glanceAsk is the ask read at a glance on the phone (novox/hq issue 639): its headline, and as its explanation
|
||||
// the summary of what it does; the text, where it came from, its fingerprint and where to read all of it are its
|
||||
// Details, which the router notes after the answers, collapsed where the channel can.
|
||||
func (p Proposal) glanceAsk() asks.Ask {
|
||||
verb := map[string]string{ActionAdd: "Add", ActionReplace: "Replace", ActionRemove: "Remove"}[p.Action]
|
||||
shown, whole := excerpt(p.Text)
|
||||
part := ""
|
||||
if p.Action != ActionRemove && !whole {
|
||||
part = " Part shown"
|
||||
}
|
||||
headline := fmt.Sprintf("%s Claude instructions '%s'?%s", verb, p.Name, part)
|
||||
if len([]rune(headline)) > asks.HeadlineLength {
|
||||
headline = verb + " Claude instructions?" + part
|
||||
}
|
||||
var d strings.Builder
|
||||
if p.Action == ActionRemove {
|
||||
fmt.Fprintf(&d, "Proposed through the claude-code module on %s at %s UTC. The approved text it removes has the fingerprint %s.",
|
||||
p.From, p.At.UTC().Format("15:04 on 2 Jan"), p.Fingerprint())
|
||||
} else {
|
||||
if whole {
|
||||
d.WriteString("The text:\n" + shown + "\n")
|
||||
} else {
|
||||
d.WriteString("NOT SHOWN IN FULL here. Its first lines:\n" + shown + "\n")
|
||||
}
|
||||
fmt.Fprintf(&d, "Proposed through the claude-code module on %s at %s UTC: %d lines, %d bytes, fingerprint %s.",
|
||||
p.From, p.At.UTC().Format("15:04 on 2 Jan"), p.Lines(), len(p.Text), p.Fingerprint())
|
||||
}
|
||||
fmt.Fprintf(&d, "\nRead all of it at the controller's terminal: mesh-cli ask claude-code claude_code_proposals '{\"id\": \"%s\"}'.", p.ID)
|
||||
does := "the text becomes part of those sessions' instructions"
|
||||
if p.Action == ActionRemove {
|
||||
does = "the instructions are removed from those sessions"
|
||||
}
|
||||
return p.withOptions(asks.Ask{ID: p.ID, Headline: headline, Explanation: p.summary(), Details: d.String()}, does)
|
||||
}
|
||||
|
||||
// summary is what the proposal does, at a glance: its proposer's words, or the module's.
|
||||
func (p Proposal) summary() string {
|
||||
if s := strings.TrimSpace(p.Summary); s != "" {
|
||||
return s
|
||||
}
|
||||
lines := fmt.Sprintf("these %d lines", p.Lines())
|
||||
if p.Lines() == 1 {
|
||||
lines = "this line"
|
||||
}
|
||||
where := strings.ToUpper(p.where()[:1]) + p.where()[1:]
|
||||
switch p.Action {
|
||||
case ActionRemove:
|
||||
return fmt.Sprintf("%s stops reading the instructions '%s' from its next start.", where, p.Name)
|
||||
case ActionReplace:
|
||||
return fmt.Sprintf("%s reads %s in place of the instructions '%s', as the mesh's own word, from its next start until "+
|
||||
"they are removed the same way.", where, lines, p.Name)
|
||||
}
|
||||
return fmt.Sprintf("%s reads %s as the mesh's own word, from its next start until they are removed the same way.", where, lines)
|
||||
}
|
||||
|
||||
// SummaryProblem says why a proposer's summary cannot be shown at a glance, or "".
|
||||
func SummaryProblem(summary string) string {
|
||||
switch s := strings.TrimSpace(summary); {
|
||||
case s == "":
|
||||
return ""
|
||||
case strings.Contains(s, "\n"):
|
||||
return "the summary is more than one line"
|
||||
case len([]rune(s)) > asks.SummaryLength:
|
||||
return fmt.Sprintf("the summary is %d characters, more than the %d a phone shows at a glance", len([]rune(s)), asks.SummaryLength)
|
||||
case !shownLine(s):
|
||||
return "the summary names a path, an address, a host name or a long word, which a phone is not shown"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// withOptions is an ask with what every form of it carries: who answers, its expiry, what it is about, and its two
|
||||
// options, each binding its act.
|
||||
func (p Proposal) withOptions(a asks.Ask, does string) asks.Ask {
|
||||
a.Who, a.Expires, a.OnExpiry = asks.Operator, p.At.Add(ProposalLasts), "the proposal is discarded; nothing changes"
|
||||
a.About = "instructions." + p.Scope + "." + strings.Join(p.Nodes, "+") + "." + p.Name
|
||||
if len(p.Nodes) == 0 {
|
||||
a.About = "instructions." + p.Scope + "." + p.Name
|
||||
}
|
||||
for _, o := range []struct{ id, label, does string }{
|
||||
{OptionApprove, "Approve", does},
|
||||
{OptionDecline, "Decline", "nothing changes; the proposal is discarded"},
|
||||
} {
|
||||
binds, _ := asks.ActDigest(p.Act(o.id))
|
||||
a.Options = append(a.Options, asks.Option{ID: o.id, Label: o.label, Does: o.does, Level: asks.Approve, Binds: binds})
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
// listedAsk is the ask as it was composed before issue 639, the text in its explanation: composed only to hold the
|
||||
// warrant of a proposal made then.
|
||||
func (p Proposal) listedAsk() asks.Ask {
|
||||
verb := map[string]string{ActionAdd: "Add", ActionReplace: "Replace", ActionRemove: "Remove"}[p.Action]
|
||||
shown, whole := excerpt(p.Text)
|
||||
part := ""
|
||||
@@ -617,6 +723,8 @@ type ProposeInput struct {
|
||||
Name string
|
||||
Text string
|
||||
Remove bool
|
||||
// Summary is what the operator's phone shows of it, in one line (novox/hq issue 639); empty, the module says it.
|
||||
Summary string
|
||||
}
|
||||
|
||||
// AtTheTerminalInstead is the other way, said whenever a proposal is refused for want of a channel.
|
||||
@@ -696,7 +804,11 @@ func Propose(p Paths, in ProposeInput, store ProposalStore, bus AskBus, now time
|
||||
return nil, err
|
||||
}
|
||||
active, isActive := approved.Sections[sectionKey(in.Scope, nodes, in.Name)]
|
||||
prop := Proposal{ID: newProposalID(), Scope: in.Scope, Nodes: nodes, Name: in.Name, From: p.Node, At: now.UTC()}
|
||||
if problem := SummaryProblem(in.Summary); problem != "" {
|
||||
return refuse(problem)
|
||||
}
|
||||
prop := Proposal{ID: newProposalID(), Scope: in.Scope, Nodes: nodes, Name: in.Name, From: p.Node, At: now.UTC(),
|
||||
Summary: strings.TrimSpace(in.Summary), Form: AskGlance}
|
||||
switch {
|
||||
case in.Remove:
|
||||
if !isActive {
|
||||
|
||||
@@ -147,14 +147,17 @@ func TestAProposalAsksTheOperatorAtTheLevelApproveAndChangesNothing(t *testing.T
|
||||
if err := a.Check(t0); err != nil {
|
||||
t.Fatalf("the router would refuse the ask: %v", err)
|
||||
}
|
||||
if !strings.HasPrefix(a.Explanation, "Add instructions 'handoff-from-g14' to every Claude session on the mesh?") {
|
||||
t.Errorf("the phone does not say what is asked first: %q", a.Explanation)
|
||||
// At a glance (issue 639): what it does is the explanation, one line; the text is the Details, which the
|
||||
// router notes collapsed after the answers.
|
||||
if a.Explanation != "Every Claude session on the mesh reads these 4 lines as the mesh's own word, from its next start until they are removed the same way." ||
|
||||
asks.Glance(a.Explanation) != nil {
|
||||
t.Errorf("the phone does not say what is asked at a glance: %q", a.Explanation)
|
||||
}
|
||||
if a.Headline != "Add Claude instructions 'handoff-from-g14'?" {
|
||||
t.Errorf("headline: %q", a.Headline)
|
||||
}
|
||||
if !strings.Contains(a.Explanation, "The whole text:\n\n"+strings.TrimRight(handover, "\n")) {
|
||||
t.Errorf("a short text is shown whole: %q", a.Explanation)
|
||||
if !strings.HasPrefix(a.Details, "The text:\n"+strings.TrimRight(handover, "\n")+"\nProposed through the claude-code module on g14 at ") {
|
||||
t.Errorf("a short text is not shown whole: %q", a.Details)
|
||||
}
|
||||
if len(a.Options) != 2 {
|
||||
t.Fatalf("options: %+v", a.Options)
|
||||
@@ -294,7 +297,7 @@ func TestARemovalNeedsTheSameApprovalAndARecordDeletedRemovesNothing(t *testing.
|
||||
t.Fatal(err)
|
||||
}
|
||||
rid := out["proposed"].(string)
|
||||
if !strings.HasPrefix(r.published[rid].Explanation, "Remove the instructions 'handoff-from-g14' from every Claude session on the mesh?") {
|
||||
if r.published[rid].Explanation != "Every Claude session on the mesh stops reading the instructions 'handoff-from-g14' from its next start." {
|
||||
t.Errorf("removal ask: %q", r.published[rid].Explanation)
|
||||
}
|
||||
if !strings.Contains(renderedInstructions(t, p), "### handoff-from-g14") {
|
||||
@@ -326,7 +329,7 @@ func TestANodeScopedSectionReachesOnlyItsNodes(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
id := out["proposed"].(string)
|
||||
if !strings.HasPrefix(r.published[id].Explanation, "Add instructions 'builds' to every Claude session on shanks?") {
|
||||
if !strings.HasPrefix(r.published[id].Explanation, "Every Claude session on shanks reads this line as the mesh's own word") {
|
||||
t.Errorf("%q", r.published[id].Explanation)
|
||||
}
|
||||
r.close(r.chose(id, OptionApprove, t0.Add(time.Hour)))
|
||||
@@ -417,15 +420,16 @@ func TestALongTextIsShownInPartAndReadWholeAtTheTerminal(t *testing.T) {
|
||||
}
|
||||
id := out["proposed"].(string)
|
||||
a := r.published[id]
|
||||
if strings.Contains(a.Explanation, "/etc/claude-code") || !strings.Contains(a.Explanation, "[1 line(s) not shown here") {
|
||||
t.Errorf("a line with a path was shown: %q", a.Explanation)
|
||||
if strings.Contains(a.Details, "/etc/claude-code") || !strings.Contains(a.Details, "[1 line(s) not shown here") ||
|
||||
!strings.HasPrefix(a.Details, "NOT SHOWN IN FULL here.") || !strings.HasSuffix(a.Headline, "Part shown") {
|
||||
t.Errorf("a line with a path was shown, or the cut not said: %q", a.Details)
|
||||
}
|
||||
if !strings.Contains(a.Explanation, "mesh-cli ask claude-code claude_code_proposals") ||
|
||||
!strings.Contains(a.Explanation, "fingerprint there must be "+out["fingerprint"].(string)) {
|
||||
t.Errorf("the phone does not say where to read all of it: %q", a.Explanation)
|
||||
if !strings.Contains(a.Details, "mesh-cli ask claude-code claude_code_proposals") ||
|
||||
!strings.Contains(a.Details, "fingerprint "+out["fingerprint"].(string)) {
|
||||
t.Errorf("the phone does not say where to read all of it: %q", a.Details)
|
||||
}
|
||||
if len(a.Explanation) > 3000 {
|
||||
t.Errorf("the explanation is %d bytes", len(a.Explanation))
|
||||
if len(a.Details) > 3000 || asks.Glance(a.Explanation) != nil {
|
||||
t.Errorf("the details are %d bytes; the explanation %q", len(a.Details), a.Explanation)
|
||||
}
|
||||
list, err := ListProposals(p, store, r, id, t0)
|
||||
if err != nil {
|
||||
@@ -739,3 +743,33 @@ func TestAnAskTakenAfterItsRefusalIsStillAnswered(t *testing.T) {
|
||||
t.Errorf("decided %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A proposer's summary is the explanation, held to a glance; a proposal made before issue 639 is composed as it was
|
||||
// asked, so the warrant for it still holds after the change.
|
||||
func TestAProposalIsAskedAtAGlanceAndAnOlderOneAsItWasAsked(t *testing.T) {
|
||||
p, _ := node(t, "g14")
|
||||
store, r := &memProposals{m: map[string]json.RawMessage{}}, newRouter()
|
||||
out, err := Propose(p, ProposeInput{Scope: ScopeMesh, Name: "handoff-from-g14", Text: handover,
|
||||
Summary: "Every session learns how hand-overs are written, so the next one reads them the same way."}, store, r, t0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
id := out["proposed"].(string)
|
||||
a := r.published[id]
|
||||
if a.Explanation != "Every session learns how hand-overs are written, so the next one reads them the same way." {
|
||||
t.Errorf("the proposer's summary is not the explanation: %q", a.Explanation)
|
||||
}
|
||||
for _, bad := range []string{strings.Repeat("word ", 70), "one\ntwo", "Read it in /etc/claude-code first."} {
|
||||
if _, err := Propose(p, ProposeInput{Scope: ScopeMesh, Name: "other", Text: handover, Summary: bad}, store, r, t0); err == nil ||
|
||||
!strings.Contains(err.Error(), "the summary") {
|
||||
t.Errorf("the summary %q was taken: %v", bad, err)
|
||||
}
|
||||
}
|
||||
prop, _ := readProposal(store, id)
|
||||
older := prop
|
||||
older.Form = 0
|
||||
listed := older.Ask()
|
||||
if !strings.Contains(listed.Explanation, "The whole text:") || listed.Details != "" || listed.Digest() == prop.Ask().Digest() {
|
||||
t.Errorf("a proposal made before is not composed as it was asked: %q", listed.Explanation)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,9 +69,9 @@ const (
|
||||
// The bounds of what a question says.
|
||||
const (
|
||||
FewestQuestionOptions = 2
|
||||
MostContextBytes = 1500
|
||||
MostContextBytes = asks.SummaryLength
|
||||
MostMeansBytes = 200
|
||||
MostWhyBytes = 300
|
||||
MostWhyBytes = 120
|
||||
MostOnExpiryBytes = 200
|
||||
)
|
||||
|
||||
@@ -131,6 +131,8 @@ type Question struct {
|
||||
From string `json:"from"`
|
||||
At time.Time `json:"at"`
|
||||
Expires time.Time `json:"expires"`
|
||||
// Form is how its ask is composed (AskGlance since novox/hq issue 639), so the answer is held to the ask asked.
|
||||
Form int `json:"form,omitempty"`
|
||||
// Asked is empty once the ask was published, or says why it was not; nothing composes from it.
|
||||
Asked string `json:"asked,omitempty"`
|
||||
// Ended is the router's word that the ask ended without a choice, as heard on the decided subject: a refused ask
|
||||
@@ -160,8 +162,41 @@ func (q Question) Act(option string) asks.Act {
|
||||
return act
|
||||
}
|
||||
|
||||
// Ask is the ask for a question, composed from it alone.
|
||||
// Ask is the ask for a question, composed from it alone, in the form it was asked in.
|
||||
func (q Question) Ask() asks.Ask {
|
||||
if q.Form == AskGlance {
|
||||
return q.glanceAsk()
|
||||
}
|
||||
return q.listedAsk()
|
||||
}
|
||||
|
||||
// glanceAsk is the question read at a glance on the phone (novox/hq issue 639): its context, then the recommended
|
||||
// answer and why as its one point; who asked and what the answer does are its Details.
|
||||
func (q Question) glanceAsk() asks.Ask {
|
||||
explanation := strings.TrimSpace(q.Context)
|
||||
if rec, ok := q.recommended(); ok {
|
||||
explanation += fmt.Sprintf("\n• Recommended: %s, because %s.", rec.Label, strings.TrimRight(strings.TrimSpace(q.Why), "."))
|
||||
}
|
||||
details := fmt.Sprintf("Asked by the agent session %s on %s through the claude-code module, at %s UTC. Your answer is told "+
|
||||
"to that session; it performs nothing by itself.", q.By, q.From, q.At.UTC().Format("15:04 on 2 Jan"))
|
||||
return q.withOptions(asks.Ask{ID: q.ID, Headline: q.Headline, Explanation: explanation, Details: details})
|
||||
}
|
||||
|
||||
func (q Question) withOptions(a asks.Ask) asks.Ask {
|
||||
a.Who, a.Expires, a.OnExpiry = asks.Operator, q.Expires, q.OnExpiry
|
||||
for _, o := range q.Options {
|
||||
does := o.Means
|
||||
if o.Recommended {
|
||||
does = "Recommended. " + does
|
||||
}
|
||||
binds, _ := asks.ActDigest(q.Act(o.ID))
|
||||
a.Options = append(a.Options, asks.Option{ID: o.ID, Label: o.Label, Does: does, Level: asks.Approve, Binds: binds})
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
// listedAsk is the ask as it was composed before issue 639: composed only to hold the answer to a question asked then.
|
||||
func (q Question) listedAsk() asks.Ask {
|
||||
var b strings.Builder
|
||||
b.WriteString(strings.TrimSpace(q.Context))
|
||||
if rec, ok := q.recommended(); ok {
|
||||
@@ -199,8 +234,10 @@ func QuestionProblem(q Question) string {
|
||||
switch {
|
||||
case strings.TrimSpace(q.Context) == "":
|
||||
add("it gives no context: say in plain words what the operator decides and why it matters")
|
||||
case len(q.Context) > MostContextBytes:
|
||||
add("its context is %d bytes, more than the %d a phone shows in one glance", len(q.Context), MostContextBytes)
|
||||
case len([]rune(q.Context)) > MostContextBytes:
|
||||
add("its context is %d characters, more than the %d a phone shows in one glance", len([]rune(q.Context)), MostContextBytes)
|
||||
case strings.Contains(strings.TrimSpace(q.Context), "\n"):
|
||||
add("its context is more than one line: a phone shows it at a glance as one short paragraph")
|
||||
}
|
||||
if n := len(q.Options); n < FewestQuestionOptions || n > asks.MostOptions {
|
||||
add("it offers %d options; a question offers %d to %d", n, FewestQuestionOptions, asks.MostOptions)
|
||||
@@ -322,7 +359,7 @@ func AskQuestion(p Paths, in QuestionInput, store QuestionStore, bus AskBus, now
|
||||
}
|
||||
q := Question{ID: newQuestionID(), Headline: strings.TrimSpace(in.Headline), Context: strings.TrimSpace(in.Context),
|
||||
Why: strings.TrimSpace(in.Why), OnExpiry: strings.TrimSpace(in.OnExpiry), By: by, From: p.Node, At: now.UTC(),
|
||||
Expires: now.UTC().Add(life)}
|
||||
Expires: now.UTC().Add(life), Form: AskGlance}
|
||||
for i, o := range in.Options {
|
||||
q.Options = append(q.Options, QuestionOption{ID: fmt.Sprintf("o%d", i+1), Label: strings.TrimSpace(o.Label),
|
||||
Means: strings.TrimSpace(o.Means), Recommended: o.Recommended})
|
||||
|
||||
@@ -68,10 +68,13 @@ func TestAQuestionIsAskedUnderTheModulesNameAtTheLevelApprove(t *testing.T) {
|
||||
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)
|
||||
// At a glance (issue 639): the context and the recommendation are the explanation; who asks is the Details.
|
||||
if !strings.HasSuffix(a.Explanation, "\n• Recommended: Merge now, because it is reviewed and the checks are green.") || asks.Glance(a.Explanation) != nil {
|
||||
t.Errorf("the explanation does not read at a glance:\n%s", a.Explanation)
|
||||
}
|
||||
for _, want := range []string{"the agent session fix-589 on g14", "performs nothing by itself"} {
|
||||
if !strings.Contains(a.Details, want) || strings.Contains(a.Explanation, want) {
|
||||
t.Errorf("the details do not say %q:\n%s", want, a.Details)
|
||||
}
|
||||
}
|
||||
if err := a.Check(t0); err != nil {
|
||||
|
||||
Reference in New Issue
Block a user