A single Go binary that grok dials out to over a WebSocket, and a HeroUI web UI for driving the session it is attached to. The roles are inverted relative to the terminal: over the /rc link grok is the ACP Agent and glance is the Client. That makes glance a stock ACP client and the web Stop button a real session/cancel rather than a bespoke control message. Both notification rails are mirrored. The stable session/update rail carries correctness; x.ai/session_notification is presentation only and degrades rather than erroring, because its ~60 variants are grok internal and drift with every upstream sync. _meta is forwarded byte for byte so viewers can dedup and order. Permissions race: the terminal and any browser may answer, first responder wins, and the loser's UI retracts by itself. All three interaction methods go through that path, not just permissions. Auth is TOTP only, with no accounts to have. A bootstrap token printed at first start gates /setup, which is a 404 without it; state lives in one 0600 JSON file and history in an in-memory ring, so there is no database and no recovery story beyond deleting the file. ARCHITECTURE.md covers the topology and the limits of that auth model; CLAUDE.md covers building, the fakeagent loop, and the end-to-end checklist that unit tests cannot replace. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
145 lines
4.7 KiB
Go
145 lines
4.7 KiB
Go
package acp
|
|
|
|
import (
|
|
"encoding/json"
|
|
"testing"
|
|
)
|
|
|
|
func decode(t *testing.T, raw string) Frame {
|
|
t.Helper()
|
|
var f Frame
|
|
if err := json.Unmarshal([]byte(raw), &f); err != nil {
|
|
t.Fatalf("decode %s: %v", raw, err)
|
|
}
|
|
return f
|
|
}
|
|
|
|
func TestKindClassifiesEveryShape(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
raw string
|
|
want Kind
|
|
}{
|
|
{"request", `{"jsonrpc":"2.0","id":7,"method":"session/request_permission","params":{}}`, KindRequest},
|
|
{"notification", `{"jsonrpc":"2.0","method":"session/update","params":{}}`, KindNotification},
|
|
{"response", `{"jsonrpc":"2.0","id":7,"result":{"ok":true}}`, KindResponse},
|
|
{"error response", `{"jsonrpc":"2.0","id":7,"error":{"code":-32601,"message":"nope"}}`, KindResponse},
|
|
{"string id request", `{"jsonrpc":"2.0","id":"abc","method":"x.ai/ask_user_question"}`, KindRequest},
|
|
// A null id is JSON-RPC's "no id", not id zero: treating it as a request
|
|
// would have glance reply to something nothing is listening for.
|
|
{"null id", `{"jsonrpc":"2.0","id":null,"method":"session/update"}`, KindNotification},
|
|
{"empty", `{"jsonrpc":"2.0"}`, KindInvalid},
|
|
{"id only", `{"jsonrpc":"2.0","id":7}`, KindInvalid},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
frame := decode(t, tc.raw)
|
|
if got := frame.Kind(); got != tc.want {
|
|
t.Fatalf("Kind() = %v, want %v", got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestInteractionAndTranscriptMethods(t *testing.T) {
|
|
// These three are the set that races between the terminal and the browser.
|
|
// Dropping one would strand a remote user whenever the agent used it.
|
|
for _, m := range []string{MethodRequestPermission, MethodAskUserQuestion, MethodExitPlanMode} {
|
|
if !IsInteraction(m) {
|
|
t.Fatalf("IsInteraction(%q) = false", m)
|
|
}
|
|
}
|
|
for _, m := range []string{MethodSessionUpdate, "session/list", "fs/read_text_file", ""} {
|
|
if IsInteraction(m) {
|
|
t.Fatalf("IsInteraction(%q) = true", m)
|
|
}
|
|
}
|
|
|
|
// Both rails must reach the browser: the stable one carries correctness, the
|
|
// xAI one carries the streaming deltas that make a transcript readable.
|
|
for _, m := range []string{MethodSessionUpdate, MethodXAINotification, "x.ai/session/update"} {
|
|
if !IsTranscript(m) {
|
|
t.Fatalf("IsTranscript(%q) = false", m)
|
|
}
|
|
}
|
|
for _, m := range []string{MethodRCStatus, MethodRCInteractionCancelled, ""} {
|
|
if IsTranscript(m) {
|
|
t.Fatalf("IsTranscript(%q) = true", m)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestFrameBuilders(t *testing.T) {
|
|
req, err := NewRequest(42, MethodSessionPrompt, PromptParams{SessionID: "s1", Text: "hi"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if req.JSONRPC != "2.0" || string(req.ID) != "42" || req.Kind() != KindRequest {
|
|
t.Fatalf("bad request frame: %+v", req)
|
|
}
|
|
|
|
// Absent beats null: some JSON-RPC peers distinguish the two.
|
|
note, err := NewNotification(MethodRCViewers, nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if note.Params != nil {
|
|
t.Fatalf("nil params encoded as %s, want absent", note.Params)
|
|
}
|
|
if note.Kind() != KindNotification {
|
|
t.Fatalf("notification classified as %v", note.Kind())
|
|
}
|
|
|
|
resp, err := NewResponse(json.RawMessage(`7`), map[string]string{"outcome": "allow"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if resp.Kind() != KindResponse || string(resp.ID) != "7" {
|
|
t.Fatalf("bad response frame: %+v", resp)
|
|
}
|
|
|
|
fail := NewErrorResponse(json.RawMessage(`7`), CodeMethodNotFound, "Method not found")
|
|
if fail.Error == nil || fail.Error.Code != CodeMethodNotFound {
|
|
t.Fatalf("bad error frame: %+v", fail)
|
|
}
|
|
if fail.Kind() != KindResponse {
|
|
t.Fatalf("error response classified as %v", fail.Kind())
|
|
}
|
|
}
|
|
|
|
func TestToolCallIDProbesBothShapes(t *testing.T) {
|
|
cases := map[string]string{
|
|
`{"toolCallId":"tc_1"}`: "tc_1",
|
|
`{"toolCall":{"toolCallId":"tc_2"}}`: "tc_2",
|
|
`{"toolCallId":"","toolCall":{}}`: "",
|
|
`{"sessionId":"s1"}`: "",
|
|
`not json`: "",
|
|
``: "",
|
|
`{"toolCallId":"tc_3","toolCall":{"toolCallId":"tc_other"}}`: "tc_3",
|
|
}
|
|
for params, want := range cases {
|
|
if got := ToolCallID(json.RawMessage(params)); got != want {
|
|
t.Fatalf("ToolCallID(%s) = %q, want %q", params, got, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSessionMetaLabelDegradesGracefully(t *testing.T) {
|
|
cases := []struct {
|
|
meta SessionMeta
|
|
want string
|
|
}{
|
|
{SessionMeta{Title: "fix the parser", CWD: "/src", SessionID: "s1"}, "fix the parser"},
|
|
{SessionMeta{CWD: "/src", SessionID: "s1"}, "/src"},
|
|
{SessionMeta{SessionID: "s1"}, "s1"},
|
|
// A session that connected but has not yet reported anything still needs
|
|
// a row in the UI rather than a blank one.
|
|
{SessionMeta{}, "session"},
|
|
}
|
|
for _, tc := range cases {
|
|
if got := tc.meta.Label(); got != tc.want {
|
|
t.Fatalf("Label(%+v) = %q, want %q", tc.meta, got, tc.want)
|
|
}
|
|
}
|
|
}
|