feat(tui-go): approval ergonomics — queue + tier-gated confirm (E §3)
- pending approvals are a navigable queue (PendingQueue/PendingIdx; ↑↓/jk, count
shown), no modal stacking; resolve removes the specific gate by request id
- y/n/e keys (approve/reject/steer); T0–T2 act on one key, T3+ requires arm+confirm
("press y again"), any other key disarms; reject/steer/auto never gated
- snapshot restore now rehydrates the full pending list, not just the first
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,257 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/correx/tui-go/internal/protocol"
|
||||
"github.com/correx/tui-go/internal/ws"
|
||||
)
|
||||
|
||||
// approval_test.go covers the approval-ergonomics gaps (BACKLOG §E §3): single-key
|
||||
// approve/reject/steer for low tiers, an arm→confirm gate for T3+, and a navigable
|
||||
// pending-approval queue (never modal-stacked). Deterministic: an unconnected ws
|
||||
// client buffers Sent frames, Drain reads them back.
|
||||
|
||||
// approvalModel builds an entered session and queues each given approval through the
|
||||
// production applyServer path, so the queue/Pending state matches real event flow.
|
||||
func approvalModel(approvals ...protocol.ServerMessage) (Model, *ws.Client) {
|
||||
client := ws.New("", 0) // unconnected; Send buffers, Drain reads it back
|
||||
m := NewModel(client)
|
||||
m.width, m.height = 120, 40
|
||||
m.theme = NewTheme(SoftBlue)
|
||||
m.selectedID = "s1"
|
||||
m.sessionEntered = true
|
||||
m.sessions = []Session{{ID: "s1", Status: "ACTIVE", Name: "healthcheck", LastEventAt: fixedNow}}
|
||||
for _, a := range approvals {
|
||||
m.applyServer(a)
|
||||
}
|
||||
return m, client
|
||||
}
|
||||
|
||||
// apprReq builds an approval.required frame for the given request id and tier.
|
||||
func apprReq(reqID, tier string) protocol.ServerMessage {
|
||||
return msg(protocol.TypeApprovalRequired, withTool("file_write"), func(m *protocol.ServerMessage) {
|
||||
m.RequestID, m.Tier = reqID, tier
|
||||
m.RiskSummary = &protocol.RiskSummaryDto{Level: "LOW"}
|
||||
})
|
||||
}
|
||||
|
||||
// applyKey drives a key through the production handleKey entry and returns the model.
|
||||
func applyKey(m Model, k tea.KeyMsg) Model {
|
||||
updated, _ := m.handleKey(k)
|
||||
return updated.(Model)
|
||||
}
|
||||
|
||||
func runeKey(r rune) tea.KeyMsg { return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}} }
|
||||
|
||||
// decodeApproval pulls the single ApprovalResponse frame off the client and returns
|
||||
// its request id + decision. Fails if there isn't exactly one decision frame.
|
||||
func decodeApproval(t *testing.T, client *ws.Client) (requestID, decision string) {
|
||||
t.Helper()
|
||||
var resp map[string]any
|
||||
n := 0
|
||||
for _, f := range client.Drain() {
|
||||
var raw map[string]any
|
||||
if err := json.Unmarshal(f, &raw); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if raw["type"] == "com.correx.apps.server.protocol.ClientMessage.ApprovalResponse" {
|
||||
resp = raw
|
||||
n++
|
||||
}
|
||||
}
|
||||
if n != 1 {
|
||||
t.Fatalf("expected exactly one ApprovalResponse frame, got %d", n)
|
||||
}
|
||||
rid, _ := resp["requestId"].(string)
|
||||
dec, _ := resp["decision"].(string)
|
||||
return rid, dec
|
||||
}
|
||||
|
||||
// noApprovalSent asserts the client buffered no ApprovalResponse frame.
|
||||
func noApprovalSent(t *testing.T, client *ws.Client) {
|
||||
t.Helper()
|
||||
for _, f := range client.Drain() {
|
||||
var raw map[string]any
|
||||
_ = json.Unmarshal(f, &raw)
|
||||
if raw["type"] == "com.correx.apps.server.protocol.ClientMessage.ApprovalResponse" {
|
||||
t.Fatalf("did not expect an ApprovalResponse frame yet")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestApprovalLowTierApprovesImmediately(t *testing.T) {
|
||||
m, client := approvalModel(apprReq("req-1", "T1"))
|
||||
if m.displayState() != StateApproval {
|
||||
t.Fatalf("want StateApproval, got %v", m.displayState())
|
||||
}
|
||||
m = applyKey(m, runeKey('y'))
|
||||
rid, dec := decodeApproval(t, client)
|
||||
if rid != "req-1" || dec != "APPROVE" {
|
||||
t.Fatalf("want approve req-1, got %s/%s", rid, dec)
|
||||
}
|
||||
if s := m.session("s1"); s == nil || s.Pending != nil {
|
||||
t.Fatalf("pending gate should be cleared after a low-tier approve")
|
||||
}
|
||||
if m.approvalArmed {
|
||||
t.Fatalf("low-tier approve must not leave an armed confirm")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApprovalHighTierRequiresSecondConfirm(t *testing.T) {
|
||||
m, client := approvalModel(apprReq("req-1", "T3"))
|
||||
// First `y` arms; nothing is sent yet.
|
||||
m = applyKey(m, runeKey('y'))
|
||||
if !m.approvalArmed {
|
||||
t.Fatalf("T3 approve should arm a confirm, not send")
|
||||
}
|
||||
noApprovalSent(t, client)
|
||||
if s := m.session("s1"); s == nil || s.Pending == nil {
|
||||
t.Fatalf("gate must remain pending while armed")
|
||||
}
|
||||
// Second `y` confirms and sends.
|
||||
m = applyKey(m, runeKey('y'))
|
||||
rid, dec := decodeApproval(t, client)
|
||||
if rid != "req-1" || dec != "APPROVE" {
|
||||
t.Fatalf("want approve req-1 after confirm, got %s/%s", rid, dec)
|
||||
}
|
||||
if m.approvalArmed {
|
||||
t.Fatalf("arm should clear after the confirming keypress")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApprovalHighTierArmCancelledByOtherKey(t *testing.T) {
|
||||
m, client := approvalModel(apprReq("req-1", "T3"))
|
||||
m = applyKey(m, runeKey('y')) // arm
|
||||
if !m.approvalArmed {
|
||||
t.Fatalf("expected armed after first y")
|
||||
}
|
||||
// A non-confirm key (here `n` reject) cancels the arm. The arm itself must be
|
||||
// gone, and no spurious APPROVE may slip through.
|
||||
m = applyKey(m, runeKey('n'))
|
||||
if m.approvalArmed {
|
||||
t.Fatalf("arm should be cancelled by a non-confirm key")
|
||||
}
|
||||
rid, dec := decodeApproval(t, client)
|
||||
if dec != "REJECT" || rid != "req-1" {
|
||||
t.Fatalf("non-confirm key should route to its own action (reject), got %s/%s", rid, dec)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApprovalHighTierArmCancelledByNavKey(t *testing.T) {
|
||||
// Two T3 gates: arming then pressing ↓ must disarm and only move the cursor —
|
||||
// no decision may be emitted.
|
||||
m, client := approvalModel(apprReq("req-1", "T3"), apprReq("req-2", "T3"))
|
||||
m = applyKey(m, runeKey('y')) // arm req-1
|
||||
m = applyKey(m, tea.KeyMsg{Type: tea.KeyDown})
|
||||
if m.approvalArmed {
|
||||
t.Fatalf("nav key should disarm")
|
||||
}
|
||||
noApprovalSent(t, client)
|
||||
if s := m.session("s1"); s == nil || s.PendingIdx != 1 {
|
||||
t.Fatalf("expected cursor advanced to index 1, got %d", s.PendingIdx)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApprovalQueueNavigatesAndCounts(t *testing.T) {
|
||||
m, _ := approvalModel(apprReq("req-1", "T1"), apprReq("req-2", "T1"), apprReq("req-3", "T1"))
|
||||
s := m.session("s1")
|
||||
if s == nil || len(s.PendingQueue) != 3 {
|
||||
t.Fatalf("expected 3 queued approvals, got %d", len(s.PendingQueue))
|
||||
}
|
||||
// The band shows a "1/3" count and the current request, never stacked modals.
|
||||
out := stripANSI(m.renderApprovalBand(m.approvalBandHeight()))
|
||||
if !strings.Contains(out, "1/3") {
|
||||
t.Fatalf("band should show queue count 1/3:\n%s", out)
|
||||
}
|
||||
// ↓ advances the selection; Pending follows the cursor.
|
||||
m = applyKey(m, tea.KeyMsg{Type: tea.KeyDown})
|
||||
s = m.session("s1")
|
||||
if s.PendingIdx != 1 || s.Pending.RequestID != "req-2" {
|
||||
t.Fatalf("↓ should select req-2, got idx=%d id=%s", s.PendingIdx, s.Pending.RequestID)
|
||||
}
|
||||
out = stripANSI(m.renderApprovalBand(m.approvalBandHeight()))
|
||||
if !strings.Contains(out, "2/3") {
|
||||
t.Fatalf("band should show 2/3 after ↓:\n%s", out)
|
||||
}
|
||||
// ↑ wraps back.
|
||||
m = applyKey(m, tea.KeyMsg{Type: tea.KeyUp})
|
||||
if m.session("s1").PendingIdx != 0 {
|
||||
t.Fatalf("↑ should return to index 0")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApprovalResolveFrontAdvancesQueue(t *testing.T) {
|
||||
m, client := approvalModel(apprReq("req-1", "T1"), apprReq("req-2", "T1"))
|
||||
// Approve the front gate (low tier → immediate).
|
||||
m = applyKey(m, runeKey('y'))
|
||||
if rid, dec := decodeApproval(t, client); rid != "req-1" || dec != "APPROVE" {
|
||||
t.Fatalf("want approve req-1, got %s/%s", rid, dec)
|
||||
}
|
||||
s := m.session("s1")
|
||||
if s == nil || len(s.PendingQueue) != 1 || s.Pending.RequestID != "req-2" {
|
||||
t.Fatalf("resolving the front gate should advance to req-2, got %+v", s.PendingQueue)
|
||||
}
|
||||
// A server-side resolve of the remaining gate empties the queue and exits approval.
|
||||
m.applyServer(msg(protocol.TypeApprovalResolved, func(mm *protocol.ServerMessage) {
|
||||
mm.RequestID, mm.Outcome = "req-2", "APPROVED"
|
||||
}))
|
||||
if s := m.session("s1"); s == nil || s.Pending != nil || len(s.PendingQueue) != 0 {
|
||||
t.Fatalf("server resolve should clear the last gate")
|
||||
}
|
||||
if m.displayState() != StateInSession {
|
||||
t.Fatalf("empty queue should leave approval state, got %v", m.displayState())
|
||||
}
|
||||
}
|
||||
|
||||
func TestApprovalServerResolveRemovesSpecificGate(t *testing.T) {
|
||||
// A resolve for the *back* gate must drop only that one and keep the front shown.
|
||||
m, _ := approvalModel(apprReq("req-1", "T1"), apprReq("req-2", "T1"))
|
||||
m.applyServer(msg(protocol.TypeApprovalResolved, func(mm *protocol.ServerMessage) {
|
||||
mm.RequestID, mm.Outcome = "req-2", "APPROVED"
|
||||
}))
|
||||
s := m.session("s1")
|
||||
if s == nil || len(s.PendingQueue) != 1 || s.Pending.RequestID != "req-1" {
|
||||
t.Fatalf("resolving req-2 should leave req-1 shown, got %+v", s.PendingQueue)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApprovalRejectAndSteerRoute(t *testing.T) {
|
||||
// Reject is single-key for a low tier.
|
||||
m, client := approvalModel(apprReq("req-1", "T1"))
|
||||
m = applyKey(m, runeKey('n'))
|
||||
if rid, dec := decodeApproval(t, client); rid != "req-1" || dec != "REJECT" {
|
||||
t.Fatalf("want reject req-1, got %s/%s", rid, dec)
|
||||
}
|
||||
|
||||
// Steer enters the existing note-input path (no decision sent yet).
|
||||
m2, client2 := approvalModel(apprReq("req-2", "T1"))
|
||||
m2 = applyKey(m2, runeKey('e'))
|
||||
if !m2.steering || m2.editMode != ModeInsert {
|
||||
t.Fatalf("steer key should enter the steering-note input path")
|
||||
}
|
||||
noApprovalSent(t, client2)
|
||||
// Typing a note then Enter approves with the note riding along.
|
||||
for _, r := range "use sudo" {
|
||||
m2 = applyKey(m2, runeKey(r))
|
||||
}
|
||||
m2 = applyKey(m2, tea.KeyMsg{Type: tea.KeyEnter})
|
||||
frames := client2.Drain()
|
||||
var note string
|
||||
for _, f := range frames {
|
||||
var raw map[string]any
|
||||
_ = json.Unmarshal(f, &raw)
|
||||
if raw["type"] == "com.correx.apps.server.protocol.ClientMessage.ApprovalResponse" {
|
||||
note, _ = raw["steeringNote"].(string)
|
||||
if raw["decision"] != "APPROVE" {
|
||||
t.Fatalf("steer+enter should approve, got %v", raw["decision"])
|
||||
}
|
||||
}
|
||||
}
|
||||
if note != "use sudo" {
|
||||
t.Fatalf("steering note should ride along with the decision, got %q", note)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user