v0.4.7 2ff82fa

confirm_test.go RawHistoryFolder

1package core
2
3import (
4	"encoding/json"
5	"strings"
6	"sync"
7	"testing"
8)
9
10type recordingConfirmer struct {
11	mu      sync.Mutex
12	calls   []string
13	replies []ConfirmDecision
14	idx     int
15}
16
17func (r *recordingConfirmer) Confirm(toolName, preview string) ConfirmDecision {
18	r.mu.Lock()
19	defer r.mu.Unlock()
20	r.calls = append(r.calls, toolName+"/"+preview)
21	if r.idx >= len(r.replies) {
22		return ConfirmDecision{Allow: false, Reason: "no reply queued"}
23	}
24	d := r.replies[r.idx]
25	r.idx++
26	return d
27}
28
29type detailedConfirmer struct {
30	recordingConfirmer
31	call ToolCallConfirmation
32}
33
34func (d *detailedConfirmer) ConfirmToolCall(call ToolCallConfirmation) ConfirmDecision {
35	d.call = call
36	return ConfirmDecision{Allow: true}
37}
38
39func TestConfirmGatePassesRichToolCallPreview(t *testing.T) {
40	confirmer := &detailedConfirmer{}
41	gate := NewConfirmGate(confirmer)
42	call := ToolCallConfirmation{
43		ID:      "call-1",
44		Name:    "edit",
45		Summary: "sample.go",
46		Content: "-old\n+new\n",
47	}
48	if allow, _, _ := gate.CheckToolCall(call); !allow {
49		t.Fatal("rich confirmation should allow")
50	}
51	if confirmer.call != call {
52		t.Fatalf("confirmation = %+v, want %+v", confirmer.call, call)
53	}
54}
55
56func TestConfirmGateNilAllowsEverything(t *testing.T) {
57	var g *ConfirmGate
58	allow, reason, args := g.Check("bash", "rm -rf /")
59	if !allow || reason != "" || args != nil {
60		t.Fatalf("nil gate should allow, got allow=%v reason=%q args=%s", allow, reason, args)
61	}
62}
63
64func TestConfirmGateNilInnerRefuses(t *testing.T) {
65	g := NewConfirmGate(nil)
66	allow, reason, _ := g.Check("bash", "ls")
67	if allow {
68		t.Fatal("gate with nil inner should refuse")
69	}
70	if reason == "" {
71		t.Fatal("refusal must carry a reason for the model to learn from")
72	}
73}
74
75func TestConfirmGateAllowOnce(t *testing.T) {
76	rc := &recordingConfirmer{replies: []ConfirmDecision{
77		{Allow: true},
78		{Allow: true},
79	}}
80	g := NewConfirmGate(rc)
81	if allow, _, _ := g.Check("bash", "ls"); !allow {
82		t.Fatal("call 1 should allow")
83	}
84	if allow, _, _ := g.Check("bash", "ls"); !allow {
85		t.Fatal("call 2 should allow")
86	}
87	// Two calls, two confirmer invocations (no remember).
88	if len(rc.calls) != 2 {
89		t.Errorf("want 2 confirmer calls, got %d", len(rc.calls))
90	}
91}
92
93func TestConfirmGateRememberTool(t *testing.T) {
94	rc := &recordingConfirmer{replies: []ConfirmDecision{
95		{Allow: true, RememberTool: true},
96	}}
97	g := NewConfirmGate(rc)
98
99	// First call prompts and remembers.
100	if allow, _, _ := g.Check("bash", "ls"); !allow {
101		t.Fatal("call 1 should allow")
102	}
103	// Second call short-circuits; confirmer must NOT be invoked.
104	if allow, _, _ := g.Check("bash", "pwd"); !allow {
105		t.Fatal("call 2 should allow from memory")
106	}
107	// Different tool still prompts.
108	rc.replies = append(rc.replies, ConfirmDecision{Allow: false, Reason: "no"})
109	if allow, reason, _ := g.Check("read", "foo.txt"); allow || reason != "no" {
110		t.Errorf("different tool should re-prompt; got allow=%v reason=%q", allow, reason)
111	}
112	if len(rc.calls) != 2 {
113		t.Errorf("want 2 confirmer calls (bash+read), got %d: %v", len(rc.calls), rc.calls)
114	}
115}
116
117func TestConfirmGateRememberAll(t *testing.T) {
118	rc := &recordingConfirmer{replies: []ConfirmDecision{
119		{Allow: true, RememberAll: true},
120	}}
121	g := NewConfirmGate(rc)
122	if allow, _, _ := g.Check("bash", "ls"); !allow {
123		t.Fatal("call 1 should allow")
124	}
125	// From now on everything short-circuits.
126	if allow, _, _ := g.Check("read", "foo.txt"); !allow {
127		t.Fatal("call 2 should allow")
128	}
129	if allow, _, _ := g.Check("write", "bar.txt"); !allow {
130		t.Fatal("call 3 should allow")
131	}
132	if len(rc.calls) != 1 {
133		t.Errorf("want 1 confirmer call (remember-all short-circuits the rest), got %d", len(rc.calls))
134	}
135}
136
137func TestConfirmGateRefuseSurfacesReason(t *testing.T) {
138	rc := &recordingConfirmer{replies: []ConfirmDecision{
139		{Allow: false, Reason: "do not run sudo"},
140	}}
141	g := NewConfirmGate(rc)
142	allow, reason, _ := g.Check("bash", "sudo rm -rf /")
143	if allow || reason != "do not run sudo" {
144		t.Errorf("want block + reason, got allow=%v reason=%q", allow, reason)
145	}
146}
147
148func TestConfirmGateRefuseEmptyReasonGetsDefault(t *testing.T) {
149	rc := &recordingConfirmer{replies: []ConfirmDecision{
150		{Allow: false},
151	}}
152	g := NewConfirmGate(rc)
153	_, reason, _ := g.Check("bash", "x")
154	if reason == "" {
155		t.Fatal("empty reason must be replaced with a sensible default")
156	}
157}
158
159func TestConfirmGateAllowAllRuntime(t *testing.T) {
160	rc := &recordingConfirmer{replies: []ConfirmDecision{
161		{Allow: false, Reason: "no"},
162	}}
163	g := NewConfirmGate(rc)
164	// Refuse first call
165	if allow, _, _ := g.Check("bash", "ls"); allow {
166		t.Fatal("call 1 should refuse")
167	}
168	// User types /yolo
169	g.AllowAll()
170	// All subsequent calls allowed without prompting.
171	if allow, _, _ := g.Check("bash", "rm -rf tmp"); !allow {
172		t.Fatal("after AllowAll, should allow")
173	}
174	if allow, _, _ := g.Check("read", "x"); !allow {
175		t.Fatal("after AllowAll, should allow any tool")
176	}
177	if len(rc.calls) != 1 {
178		t.Errorf("want 1 confirmer call (before AllowAll), got %d", len(rc.calls))
179	}
180}
181
182func TestBuildPreview(t *testing.T) {
183	cases := []struct {
184		name string
185		args string
186		want string
187	}{
188		{"bash command", `{"command":"ls -la"}`, "ls -la"},
189		{"path", `{"path":"/tmp/x.txt"}`, "/tmp/x.txt"},
190		{"file_path", `{"file_path":"a.go"}`, "a.go"},
191		{"url", `{"url":"https://example.com"}`, "https://example.com"},
192		{"truncation", `{"command":"` + string(make([]byte, 200)) + `"}`, ""},
193		{"unparseable", `{not json`, `{not json`},
194		{"empty", ``, ``},
195	}
196	for _, c := range cases {
197		t.Run(c.name, func(t *testing.T) {
198			got := BuildPreview(json.RawMessage(c.args), 50)
199			if c.name == "truncation" && !hasEllipsis(got) {
200				t.Errorf("%s: expected ellipsis truncation, got %q", c.name, got)
201				return
202			}
203			if c.want != "" && got != c.want {
204				t.Errorf("%s: want %q, got %q", c.name, c.want, got)
205			}
206		})
207	}
208}
209
210func hasEllipsis(s string) bool {
211	return strings.HasSuffix(s, "...")
212}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close