Root cause of "chat gave me no further feedback — had to go to Ops": two compounding bugs, found by reading the actual production session transcript. 1. InlineApproval.svelte — all of last session's live-status/self-heal work — was never imported or rendered anywhere. Chat.svelte had its own separate, much dumber approval bar (no status tracking, no destructive handling, just silently disappears after clicking) that WAS the one users actually saw. Deleted the dead bar and its state; InlineApproval now renders per-message. 2. chat.ts's extractApprovals hardcoded `tool.name === 'request_execution'`, so any approval raised by the newer `run` tool was invisible — no card, no feedback, nothing to self-heal, forcing the operator to the Ops page with zero acknowledgement in the conversation. This was the actual proximate cause of last night's destroy-135 session. Fixed to match on response shape, not tool name, so it doesn't silently break again for the next new gated tool. 3. Nomos was telling operators "type something like 'I confirm destroy 135'" for destructive actions (SOUL.md) but no backend path ever consumed that phrase — chat-assent explicitly (and correctly) excludes destructive from loose assent, but I never built the alternative. Added isTypedConfirmation() (cmd/nomos/assent.go): stricter than loose assent, requires an explicit "confirm" statement, only applies to destructive- flagged pending approvals. 4. InlineApproval's completed-state hardcoded "Provisioned successfully" — wrong/confusing for a destroy or arbitrary `run` command. Now says "Completed on <target>" and shows the actual command output, verified live against the real destroy-135 execution. Verified live in a real browser against the production API/DB (dev server proxying to :8090): the historical stuck session now retroactively renders both executions as resolved with correct wording and real output. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
100 lines
3.0 KiB
Go
100 lines
3.0 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"testing"
|
|
)
|
|
|
|
func TestIsAssent_Positive(t *testing.T) {
|
|
cases := []string{
|
|
"go ahead", "Go ahead.", "yes", "Yes!", "yeah", "yep", "do it",
|
|
"proceed", "approve", "ship it", "sounds good", "lgtm", "please do",
|
|
"ok go ahead and run it",
|
|
}
|
|
for _, c := range cases {
|
|
if !isAssent(c) {
|
|
t.Errorf("isAssent(%q) = false, want true", c)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestIsAssent_Negative(t *testing.T) {
|
|
cases := []string{
|
|
"no", "no, don't", "wait", "hold on", "not yet", "cancel that",
|
|
"nevermind", "what's the plan for tomorrow?", "how many CPUs does strong have?",
|
|
"maybe later", "",
|
|
}
|
|
for _, c := range cases {
|
|
if isAssent(c) {
|
|
t.Errorf("isAssent(%q) = true, want false", c)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestIsAssent_NegationBeatsAssentWord(t *testing.T) {
|
|
// Contains "yes" as a substring pattern risk word but is clearly not
|
|
// assent — negation must win.
|
|
cases := []string{
|
|
"no, don't do it yet",
|
|
"wait, not yet please",
|
|
}
|
|
for _, c := range cases {
|
|
if isAssent(c) {
|
|
t.Errorf("isAssent(%q) = true, want false (negation should block)", c)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestIsTypedConfirmation(t *testing.T) {
|
|
positive := []string{
|
|
"I confirm destroy 135 in strong",
|
|
"confirm",
|
|
"Confirmed.",
|
|
"yes I confirm",
|
|
}
|
|
for _, c := range positive {
|
|
if !isTypedConfirmation(c) {
|
|
t.Errorf("isTypedConfirmation(%q) = false, want true", c)
|
|
}
|
|
}
|
|
negative := []string{
|
|
"yes", "go ahead", "do it", "proceed", "lgtm", // loose assent must NOT satisfy this
|
|
"no, don't confirm yet", "wait", "",
|
|
}
|
|
for _, c := range negative {
|
|
if isTypedConfirmation(c) {
|
|
t.Errorf("isTypedConfirmation(%q) = true, want false (only explicit confirm should pass)", c)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestExtractPendingApprovals(t *testing.T) {
|
|
mkCall := func(text string) persistedCall {
|
|
b, _ := json.Marshal(text)
|
|
return persistedCall{id: "x", name: "run", result: json.RawMessage(b)}
|
|
}
|
|
calls := []persistedCall{
|
|
mkCall("run on host:strong requires approval (risk: config_mutation) — execution 019f4930-e22b-7c47-8c6e-715dcd59df19 queued. Present the command..."),
|
|
mkCall("some unrelated read-only result, no approval here"),
|
|
mkCall("run on lxc:caddy requires approval (risk: destructive) — execution 019f4931-aaaa-7c47-8c6e-715dcd59df20 queued. This is classified DESTRUCTIVE — flag that clearly."),
|
|
}
|
|
got := extractPendingApprovals(calls)
|
|
if len(got) != 2 {
|
|
t.Fatalf("expected 2 pending approvals, got %d: %+v", len(got), got)
|
|
}
|
|
if got[0].execID != "019f4930-e22b-7c47-8c6e-715dcd59df19" || got[0].destructive {
|
|
t.Errorf("first approval wrong: %+v", got[0])
|
|
}
|
|
if got[1].execID != "019f4931-aaaa-7c47-8c6e-715dcd59df20" || !got[1].destructive {
|
|
t.Errorf("second approval should be flagged destructive: %+v", got[1])
|
|
}
|
|
}
|
|
|
|
func TestExtractPendingApprovals_NoneWhenNoneQueued(t *testing.T) {
|
|
b, _ := json.Marshal("fleet is healthy, nothing to report")
|
|
calls := []persistedCall{{id: "x", result: json.RawMessage(b)}}
|
|
if got := extractPendingApprovals(calls); len(got) != 0 {
|
|
t.Errorf("expected no pending approvals, got %+v", got)
|
|
}
|
|
}
|