grok-glance: web control plane for grok's /rc remote control

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>
This commit is contained in:
iceBear67
2026-08-15 10:07:33 +00:00
co-authored by Claude Opus 5
commit 051efe8fec
45 changed files with 11006 additions and 0 deletions
+585
View File
@@ -0,0 +1,585 @@
package hub
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"sync"
"sync/atomic"
"time"
"github.com/coder/websocket"
"github.com/user/grok-glance/internal/acp"
)
const (
// One turn of a busy session produces thousands of streaming deltas. This
// holds a few minutes of that -- enough that a browser opened mid-turn sees
// the turn, not so much that an idle server holds megabytes per agent.
defaultRingCapacity = 4096
// Depth of the per-agent outbound queue. Large enough to absorb a burst of
// browser input; a full queue means the socket is wedged, and dropping the
// connection is better than blocking a request handler on it.
agentSendQueue = 64
// How long a call to grok waits before giving up. `session/prompt` is the
// exception: it blocks for the whole turn and gets no deadline of its own.
callTimeout = 30 * time.Second
)
// ErrAgentGone means the grok instance disconnected before answering.
var ErrAgentGone = errors.New("agent disconnected")
// Interaction is a reverse-request from grok that a human must answer:
// a tool permission, a question, or a plan approval.
//
// Both the terminal and any browser can answer. Whoever answers first wins and
// the other side's dialog is retracted -- from glance's perspective that means
// either a browser answers (and glance replies to grok), or grok sends
// `x.ai/rc/interaction_cancelled` because the terminal got there first.
type Interaction struct {
// ID is grok's JSON-RPC id, echoed back in the response.
ID json.RawMessage `json:"id"`
// Method is the ACP method, so the UI knows which dialog to render.
Method string `json:"method"`
// Params is passed through untouched: the browser renders from it, and
// glance has no reason to understand its every field.
Params json.RawMessage `json:"params"`
// ToolCallID keys the retraction on both sides.
ToolCallID string `json:"toolCallId,omitempty"`
OpenedAt time.Time `json:"openedAt"`
}
// Agent is one connected grok instance and the single session it mirrors.
type Agent struct {
ID string
KeyName string
hub *Hub
conn *websocket.Conn
log *slog.Logger
// outbound is drained by the writer goroutine. coder/websocket permits one
// concurrent writer, and prompts, replies and heartbeats all originate on
// different goroutines.
outbound chan acp.Frame
// done closes when the connection is torn down, unblocking everything
// waiting on this agent.
done chan struct{}
closeOne sync.Once
nextID atomic.Uint64
mu sync.RWMutex
meta acp.SessionMeta
connectedAt time.Time
lastActivity time.Time
turnActive bool
ring *ring
interactions map[string]*Interaction
// calls correlates responses to requests glance sent. Each channel is
// buffered so a reply never blocks the reader goroutine, even if the caller
// timed out and stopped listening.
calls map[uint64]chan acp.Frame
}
// AgentSummary is the JSON view of an agent for the session list.
type AgentSummary struct {
ID string `json:"id"`
KeyName string `json:"keyName"`
Session acp.SessionMeta `json:"session"`
Label string `json:"label"`
ConnectedAt time.Time `json:"connectedAt"`
LastActivity time.Time `json:"lastActivity"`
TurnActive bool `json:"turnActive"`
Pending int `json:"pending"`
Frames int `json:"frames"`
Dropped int `json:"dropped"`
}
// Summary snapshots the agent for the UI.
func (a *Agent) Summary() AgentSummary {
a.mu.RLock()
defer a.mu.RUnlock()
return AgentSummary{
ID: a.ID,
KeyName: a.KeyName,
Session: a.meta,
Label: a.meta.Label(),
ConnectedAt: a.connectedAt,
LastActivity: a.lastActivity,
TurnActive: a.turnActive,
Pending: len(a.interactions),
Frames: a.ring.size,
Dropped: a.ring.dropped,
}
}
// Transcript returns the buffered frames, oldest first, plus how many were
// dropped off the front.
func (a *Agent) Transcript() ([]json.RawMessage, int) {
a.mu.RLock()
defer a.mu.RUnlock()
return a.ring.snapshot(), a.ring.dropped
}
// OpenInteractions lists what is currently waiting on a human.
func (a *Agent) OpenInteractions() []*Interaction {
a.mu.RLock()
defer a.mu.RUnlock()
out := make([]*Interaction, 0, len(a.interactions))
for _, in := range a.interactions {
out = append(out, in)
}
return out
}
// Prompt runs a turn. It returns when the turn ends, which can be minutes --
// callers that only want the turn *started* should not wait on it.
func (a *Agent) Prompt(ctx context.Context, text string) (json.RawMessage, error) {
a.mu.RLock()
sessionID := a.meta.SessionID
a.mu.RUnlock()
a.markTurn(true)
frame, err := a.call(ctx, acp.MethodSessionPrompt, acp.PromptParams{
SessionID: sessionID,
Text: text,
})
if err != nil {
return nil, err
}
if frame.Error != nil {
return nil, frame.Error
}
return frame.Result, nil
}
// Cancel interrupts the running turn.
//
// grok classifies this the same way it classifies Esc, but attributes it to
// glance rather than to the terminal, so the session log records who stopped it.
func (a *Agent) Cancel(ctx context.Context) error {
a.mu.RLock()
sessionID := a.meta.SessionID
a.mu.RUnlock()
ctx, cancel := context.WithTimeout(ctx, callTimeout)
defer cancel()
frame, err := a.call(ctx, acp.MethodSessionCancel, acp.CancelParams{SessionID: sessionID})
if err != nil {
return err
}
if frame.Error != nil {
return frame.Error
}
return nil
}
// Answer resolves an open interaction with a result from the browser.
//
// It reports whether the interaction was still open: a false return is the
// normal outcome of losing the race to the terminal, not an error, and the UI
// shows "already handled elsewhere" rather than a failure.
func (a *Agent) Answer(id string, result json.RawMessage) bool {
a.mu.Lock()
interaction, ok := a.interactions[id]
if ok {
delete(a.interactions, id)
}
a.mu.Unlock()
if !ok {
return false
}
frame, err := acp.NewResponse(interaction.ID, json.RawMessage(result))
if err != nil {
a.log.Warn("could not encode interaction answer", "err", err)
return false
}
a.send(frame)
a.hub.broadcast(interactionResolvedEvent(a.ID, interaction, "browser"))
return true
}
// Decline hands an interaction back to the terminal without answering it.
//
// grok treats a JSON-RPC error on an interaction as "glance is not answering
// this", leaves the terminal's dialog up, and the turn proceeds normally once
// the user answers there.
func (a *Agent) Decline(id, reason string) bool {
a.mu.Lock()
interaction, ok := a.interactions[id]
if ok {
delete(a.interactions, id)
}
a.mu.Unlock()
if !ok {
return false
}
a.send(acp.NewErrorResponse(interaction.ID, acp.CodeInternal, reason))
a.hub.broadcast(interactionResolvedEvent(a.ID, interaction, "declined"))
return true
}
// NotifyViewers tells the bridge how many browsers are watching, so the
// terminal's `/rc status` can say so. Cosmetic and best-effort.
func (a *Agent) NotifyViewers(n int) {
frame, err := acp.NewNotification(acp.MethodRCViewers, acp.ViewersParams{Count: n})
if err != nil {
return
}
a.send(frame)
}
// call sends a request and waits for its response.
func (a *Agent) call(ctx context.Context, method string, params any) (acp.Frame, error) {
id := a.nextID.Add(1)
frame, err := acp.NewRequest(id, method, params)
if err != nil {
return acp.Frame{}, err
}
reply := make(chan acp.Frame, 1)
a.mu.Lock()
a.calls[id] = reply
a.mu.Unlock()
defer func() {
a.mu.Lock()
delete(a.calls, id)
a.mu.Unlock()
}()
if !a.trySend(frame) {
return acp.Frame{}, ErrAgentGone
}
select {
case f := <-reply:
return f, nil
case <-a.done:
return acp.Frame{}, ErrAgentGone
case <-ctx.Done():
return acp.Frame{}, ctx.Err()
}
}
func (a *Agent) send(frame acp.Frame) {
a.trySend(frame)
}
// trySend queues a frame, reporting whether it was accepted. A full queue means
// the socket is not draining; killing the connection turns a silent stall into a
// reconnect, which the bridge handles by design.
func (a *Agent) trySend(frame acp.Frame) bool {
select {
case a.outbound <- frame:
return true
case <-a.done:
return false
default:
a.log.Warn("agent send queue full; dropping connection", "agent", a.ID)
a.close()
return false
}
}
func (a *Agent) close() {
a.closeOne.Do(func() {
close(a.done)
a.conn.CloseNow()
})
}
func (a *Agent) markTurn(active bool) {
a.mu.Lock()
a.turnActive = active
a.lastActivity = time.Now()
a.mu.Unlock()
}
// serve runs the agent's read and write loops until the socket dies.
func (a *Agent) serve(ctx context.Context) {
ctx, cancel := context.WithCancel(ctx)
defer cancel()
go a.writeLoop(ctx)
for {
typ, data, err := a.conn.Read(ctx)
if err != nil {
a.log.Info("agent disconnected", "agent", a.ID, "err", err)
return
}
if typ != websocket.MessageText {
continue
}
a.handleFrame(data)
}
}
func (a *Agent) writeLoop(ctx context.Context) {
// A periodic ping is what turns a silently dead TCP connection (laptop
// asleep, NAT entry evicted) into a normal disconnect the bridge reconnects
// from, instead of an agent that shows as connected forever.
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-a.done:
return
case <-ticker.C:
pingCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
err := a.conn.Ping(pingCtx)
cancel()
if err != nil {
a.close()
return
}
case frame := <-a.outbound:
raw, err := json.Marshal(frame)
if err != nil {
a.log.Warn("could not encode frame for agent", "err", err)
continue
}
writeCtx, cancel := context.WithTimeout(ctx, 15*time.Second)
err = a.conn.Write(writeCtx, websocket.MessageText, raw)
cancel()
if err != nil {
a.log.Info("agent write failed", "agent", a.ID, "err", err)
a.close()
return
}
}
}
}
// handleFrame dispatches one inbound frame from grok.
func (a *Agent) handleFrame(raw []byte) {
var frame acp.Frame
if err := json.Unmarshal(raw, &frame); err != nil {
a.log.Warn("unparseable frame from agent", "agent", a.ID, "err", err)
return
}
switch frame.Kind() {
case acp.KindResponse:
a.resolveCall(frame)
case acp.KindRequest:
if acp.IsInteraction(frame.Method) {
a.openInteraction(frame)
return
}
// grok drives nothing else on this link; saying so beats a fabricated
// success that would leave it waiting for behaviour glance does not have.
a.send(acp.NewErrorResponse(frame.ID, acp.CodeMethodNotFound,
fmt.Sprintf("Method not found: %s", frame.Method)))
case acp.KindNotification:
a.handleNotification(frame, raw)
default:
a.log.Warn("frame from agent is neither request, response nor notification", "agent", a.ID)
}
}
func (a *Agent) resolveCall(frame acp.Frame) {
var id uint64
if err := json.Unmarshal(frame.ID, &id); err != nil {
a.log.Warn("response from agent with unusable id", "agent", a.ID)
return
}
a.mu.Lock()
reply, ok := a.calls[id]
delete(a.calls, id)
a.mu.Unlock()
if !ok {
// The caller timed out, or this is a duplicate. Neither is worth more
// than a debug line.
a.log.Debug("response for an unknown call", "agent", a.ID, "id", id)
return
}
reply <- frame
}
func (a *Agent) openInteraction(frame acp.Frame) {
interaction := &Interaction{
ID: frame.ID,
Method: frame.Method,
Params: frame.Params,
ToolCallID: acp.ToolCallID(frame.Params),
OpenedAt: time.Now(),
}
a.mu.Lock()
a.interactions[string(frame.ID)] = interaction
a.lastActivity = interaction.OpenedAt
a.mu.Unlock()
a.hub.broadcast(Event{
Type: EventInteraction,
Agent: a.ID,
Interaction: interaction,
})
}
func (a *Agent) handleNotification(frame acp.Frame, raw []byte) {
switch {
case frame.Method == acp.MethodRCStatus:
var params acp.StatusParams
if err := json.Unmarshal(frame.Params, &params); err == nil {
a.mu.Lock()
a.meta = params.Session
a.mu.Unlock()
a.hub.broadcast(Event{Type: EventAgents, Agents: a.hub.Summaries()})
}
case frame.Method == acp.MethodRCInteractionCancelled:
// The terminal answered first. Close the browser's dialog with the same
// wording it would get if another browser had answered.
var params acp.InteractionCancelledParams
if err := json.Unmarshal(frame.Params, &params); err != nil {
return
}
a.retract(fmt.Sprintf("%d", params.ID), params.ToolCallID, "terminal")
case acp.IsTranscript(frame.Method):
a.recordTranscript(frame, raw)
default:
a.log.Debug("unhandled notification from agent", "agent", a.ID, "method", frame.Method)
}
}
// recordTranscript rings the frame and fans it out.
//
// The frame is stored and forwarded exactly as it arrived: `_meta` carries
// `eventId`, `promptId`, `chunkId` and `isReplay`, which is what lets a viewer
// dedup and order the stream the same way the terminal does. Rewriting it here
// would quietly break that.
func (a *Agent) recordTranscript(frame acp.Frame, raw []byte) {
kind, toolCallID := classifyUpdate(frame.Params)
a.mu.Lock()
a.ring.push(json.RawMessage(raw))
a.lastActivity = time.Now()
switch kind {
case updateTurnEnd:
a.turnActive = false
case updateInTurn:
a.turnActive = true
}
a.mu.Unlock()
// `interaction_resolved` is grok's own signal that a reverse-request was
// answered somewhere. It reaches glance for interactions the bridge never
// raced, so it is handled alongside `x.ai/rc/interaction_cancelled` rather
// than instead of it.
if kind == updateInteractionResolved && toolCallID != "" {
a.retractByToolCall(toolCallID, "terminal")
}
a.hub.broadcast(Event{Type: EventFrame, Agent: a.ID, Frame: json.RawMessage(raw)})
}
// retract closes an interaction that was answered elsewhere.
func (a *Agent) retract(id, toolCallID, by string) {
a.mu.Lock()
interaction, ok := a.interactions[id]
if ok {
delete(a.interactions, id)
}
a.mu.Unlock()
if !ok {
if toolCallID != "" {
a.retractByToolCall(toolCallID, by)
}
return
}
a.hub.broadcast(interactionResolvedEvent(a.ID, interaction, by))
}
func (a *Agent) retractByToolCall(toolCallID, by string) {
a.mu.Lock()
var found *Interaction
for key, in := range a.interactions {
if in.ToolCallID == toolCallID {
found = in
delete(a.interactions, key)
break
}
}
a.mu.Unlock()
if found == nil {
return
}
a.hub.broadcast(interactionResolvedEvent(a.ID, found, by))
}
func interactionResolvedEvent(agentID string, in *Interaction, by string) Event {
return Event{
Type: EventInteractionResolved,
Agent: agentID,
ID: string(in.ID),
ToolCallID: in.ToolCallID,
By: by,
}
}
type updateKind int
const (
updateOther updateKind = iota
updateInTurn
updateTurnEnd
updateInteractionResolved
)
// classifyUpdate reads just enough of a notification to keep the UI's turn
// indicator honest.
//
// Both rails share the `{sessionId, update: {sessionUpdate: "...", ...}}` shape,
// so one probe covers them. Everything else in the payload stays opaque: the
// xAI rail's ~60 variants are internal to grok and drift with every upstream
// sync, so nothing load-bearing is keyed off them.
func classifyUpdate(params json.RawMessage) (updateKind, string) {
if len(params) == 0 {
return updateOther, ""
}
var probe struct {
Update struct {
SessionUpdate string `json:"sessionUpdate"`
// The stable rail spells it camelCase; the xAI rail keeps Rust's
// snake_case field names. Accept both rather than guess.
ToolCallIDCamel string `json:"toolCallId"`
ToolCallIDSnake string `json:"tool_call_id"`
} `json:"update"`
}
if err := json.Unmarshal(params, &probe); err != nil {
return updateOther, ""
}
toolCallID := probe.Update.ToolCallIDCamel
if toolCallID == "" {
toolCallID = probe.Update.ToolCallIDSnake
}
switch probe.Update.SessionUpdate {
case "turn_completed":
return updateTurnEnd, toolCallID
case "interaction_resolved":
return updateInteractionResolved, toolCallID
case "agent_message_chunk", "agent_thought_chunk", "tool_call", "tool_call_update",
"user_message_chunk", "plan", "pending_interaction":
return updateInTurn, toolCallID
}
return updateOther, toolCallID
}
+285
View File
@@ -0,0 +1,285 @@
package hub
import (
"context"
"encoding/json"
"errors"
"log/slog"
"strings"
"sync"
"time"
"github.com/coder/websocket"
)
// browserSendQueue bounds how far behind a viewer may fall.
//
// A turn can emit thousands of frames faster than a slow link drains them. Once
// this fills, the viewer is dropped and reconnects into a fresh snapshot --
// which is strictly better than an unbounded queue that turns one slow browser
// into the server's memory problem.
const browserSendQueue = 256
// Browser is one connected web client.
type Browser struct {
hub *Hub
log *slog.Logger
conn *websocket.Conn
outbound chan []byte
done chan struct{}
closeOne sync.Once
}
// command is what a browser sends.
type command struct {
Type string `json:"type"`
Agent string `json:"agent,omitempty"`
// Prompt.
Text string `json:"text,omitempty"`
// Interaction answer.
ID string `json:"id,omitempty"`
Result json.RawMessage `json:"result,omitempty"`
Reason string `json:"reason,omitempty"`
}
// ServeBrowser runs a browser connection to completion.
func (h *Hub) ServeBrowser(ctx context.Context, conn *websocket.Conn) {
b := &Browser{
hub: h,
log: h.log,
conn: conn,
outbound: make(chan []byte, browserSendQueue),
done: make(chan struct{}),
}
h.addBrowser(b)
defer func() {
h.removeBrowser(b)
b.close()
}()
ctx, cancel := context.WithCancel(ctx)
defer cancel()
go b.writeLoop(ctx)
b.send(Event{Type: EventAgents, Agents: h.Summaries()})
for {
typ, data, err := conn.Read(ctx)
if err != nil {
return
}
if typ != websocket.MessageText {
continue
}
var cmd command
if err := json.Unmarshal(data, &cmd); err != nil {
b.send(Event{Type: EventError, Message: "could not parse command"})
continue
}
b.handle(ctx, cmd)
}
}
func (b *Browser) handle(ctx context.Context, cmd command) {
switch cmd.Type {
case "list":
b.send(Event{Type: EventAgents, Agents: b.hub.Summaries()})
case "subscribe":
b.subscribe(cmd.Agent)
case "prompt":
b.prompt(ctx, cmd)
case "cancel":
b.cancel(ctx, cmd)
case "answer":
b.answer(cmd)
case "decline":
b.decline(cmd)
default:
b.send(Event{Type: EventError, Message: "unknown command: " + cmd.Type})
}
}
// subscribe sends the full current state of one agent.
//
// Every browser receives every agent's frames regardless -- fan-out is cheap at
// this scale and per-browser filtering would be one more thing to get wrong.
// Subscribing is how a browser gets *history*: the ring, the open interactions
// and the session metadata, in one message, so a reload or a mid-session open
// renders immediately instead of waiting for the next frame.
func (b *Browser) subscribe(agentID string) {
agent, ok := b.hub.Agent(agentID)
if !ok {
b.send(Event{Type: EventError, Agent: agentID, Message: "no such agent connected"})
return
}
frames, dropped := agent.Transcript()
summary := agent.Summary()
session := summary.Session
b.send(Event{
Type: EventSnapshot,
Agent: agentID,
Agents: b.hub.Summaries(),
Frames: frames,
Dropped: dropped,
Open: agent.OpenInteractions(),
Session: &session,
TurnActive: summary.TurnActive,
})
}
func (b *Browser) prompt(ctx context.Context, cmd command) {
agent, ok := b.hub.Agent(cmd.Agent)
if !ok {
b.send(Event{Type: EventError, Agent: cmd.Agent, Message: "no such agent connected"})
return
}
// Whitespace counts as empty: the UI trims before sending, but the server is
// the boundary, and a stray Enter in a box holding a space should not start a
// turn. The text itself goes on unmodified -- refusing an accident is the
// server's business, editing someone's prompt is not.
if strings.TrimSpace(cmd.Text) == "" {
b.send(Event{Type: EventError, Agent: cmd.Agent, Message: "prompt is empty"})
return
}
// A prompt does not return until the turn ends, which can be many minutes.
// Waiting here would stall this browser's whole command stream -- including
// the Stop button it might need next -- so the turn runs detached and its
// progress arrives as mirrored frames like any other.
go func() {
if _, err := agent.Prompt(context.WithoutCancel(ctx), cmd.Text); err != nil {
if errors.Is(err, ErrAgentGone) {
b.hub.Notice(cmd.Agent, "the session disconnected before the turn finished")
return
}
b.hub.Notice(cmd.Agent, "prompt failed: "+err.Error())
}
}()
}
func (b *Browser) cancel(ctx context.Context, cmd command) {
agent, ok := b.hub.Agent(cmd.Agent)
if !ok {
b.send(Event{Type: EventError, Agent: cmd.Agent, Message: "no such agent connected"})
return
}
go func() {
if err := agent.Cancel(context.WithoutCancel(ctx)); err != nil {
b.hub.Notice(cmd.Agent, "interrupt failed: "+err.Error())
return
}
b.hub.Notice(cmd.Agent, "turn interrupted from the web UI")
}()
}
func (b *Browser) answer(cmd command) {
agent, ok := b.hub.Agent(cmd.Agent)
if !ok {
b.send(Event{Type: EventError, Agent: cmd.Agent, Message: "no such agent connected"})
return
}
if len(cmd.Result) == 0 {
b.send(Event{Type: EventError, Agent: cmd.Agent, Message: "answer has no result"})
return
}
if !agent.Answer(cmd.ID, cmd.Result) {
// Losing the race is the expected outcome half the time, not an error:
// the terminal answered first, or another browser did.
b.send(Event{
Type: EventInteractionResolved,
Agent: cmd.Agent,
ID: cmd.ID,
By: "elsewhere",
Message: "already handled elsewhere",
})
}
}
func (b *Browser) decline(cmd command) {
agent, ok := b.hub.Agent(cmd.Agent)
if !ok {
b.send(Event{Type: EventError, Agent: cmd.Agent, Message: "no such agent connected"})
return
}
reason := cmd.Reason
if reason == "" {
reason = "declined in the web UI; answer in the terminal"
}
if !agent.Decline(cmd.ID, reason) {
b.send(Event{
Type: EventInteractionResolved,
Agent: cmd.Agent,
ID: cmd.ID,
By: "elsewhere",
Message: "already handled elsewhere",
})
}
}
func (b *Browser) send(ev Event) {
raw, err := json.Marshal(ev)
if err != nil {
b.log.Warn("could not encode event for browser", "err", err)
return
}
b.deliver(raw)
}
func (b *Browser) deliver(raw []byte) {
select {
case b.outbound <- raw:
case <-b.done:
default:
b.log.Info("browser too slow; dropping it to reconnect")
b.close()
}
}
func (b *Browser) writeLoop(ctx context.Context) {
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-b.done:
return
case <-ticker.C:
pingCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
err := b.conn.Ping(pingCtx)
cancel()
if err != nil {
b.close()
return
}
case raw := <-b.outbound:
writeCtx, cancel := context.WithTimeout(ctx, 15*time.Second)
err := b.conn.Write(writeCtx, websocket.MessageText, raw)
cancel()
if err != nil {
b.close()
return
}
}
}
}
func (b *Browser) close() {
b.closeOne.Do(func() {
close(b.done)
b.conn.CloseNow()
})
}
+270
View File
@@ -0,0 +1,270 @@
// Package hub is the live half of grok-glance: connected grok instances,
// connected browsers, and the routing between them.
//
// Nothing here is persisted. A restart drops every connection; the bridges
// reconnect on their own and browsers reconnect on their own, so the cost of a
// restart is the transcript history and nothing else.
//
// # Two protocols, deliberately
//
// grok speaks ACP over `/api/acp/agent`. Browsers speak a small glance envelope
// over `/api/ws`. Making the browser a real ACP peer was possible and rejected:
// it would push JSON-RPC correlation, request ids and the agent/client role
// inversion into the frontend, in exchange for nothing the UI actually needs.
// So the hub translates, and the frontend sees events with a `type`.
package hub
import (
"context"
"encoding/json"
"log/slog"
"sync"
"time"
"github.com/coder/websocket"
"github.com/user/grok-glance/internal/acp"
)
// EventType tags a message from glance to a browser.
type EventType string
const (
// EventSnapshot is the first message on a browser socket: everything needed
// to render without further round trips.
EventSnapshot EventType = "snapshot"
// EventAgents means the set of connected agents (or their metadata) changed.
EventAgents EventType = "agents"
// EventFrame is one mirrored ACP notification, passed through verbatim.
EventFrame EventType = "frame"
// EventInteraction is a request awaiting a human answer.
EventInteraction EventType = "interaction"
// EventInteractionResolved means it was answered -- possibly elsewhere.
EventInteractionResolved EventType = "interaction_resolved"
// EventNotice is a human-readable line for the UI to surface.
EventNotice EventType = "notice"
// EventError reports that a browser's own action failed.
EventError EventType = "error"
)
// Event is what a browser receives.
type Event struct {
Type EventType `json:"type"`
Agent string `json:"agent,omitempty"`
// Snapshot payload.
Agents []AgentSummary `json:"agents,omitempty"`
Frames []json.RawMessage `json:"frames,omitempty"`
Dropped int `json:"dropped,omitempty"`
Open []*Interaction `json:"open,omitempty"`
Session *acp.SessionMeta `json:"session,omitempty"`
TurnActive bool `json:"turnActive,omitempty"`
// Streaming payload.
Frame json.RawMessage `json:"frame,omitempty"`
Interaction *Interaction `json:"interaction,omitempty"`
// Resolution payload.
ID string `json:"id,omitempty"`
ToolCallID string `json:"toolCallId,omitempty"`
// By is "browser", "terminal", or "declined" -- what the UI shows when a
// dialog closes without this user having answered it.
By string `json:"by,omitempty"`
Message string `json:"message,omitempty"`
}
// Hub owns every live connection.
type Hub struct {
log *slog.Logger
mu sync.RWMutex
agents map[string]*Agent
browsers map[*Browser]struct{}
}
// New builds an empty hub.
func New(log *slog.Logger) *Hub {
return &Hub{
log: log,
agents: make(map[string]*Agent),
browsers: make(map[*Browser]struct{}),
}
}
// ServeAgent runs a grok connection to completion.
//
// agentID identifies the connection for the lifetime of the socket; keyName is
// the API key's label, shown in the UI so several machines can be told apart.
func (h *Hub) ServeAgent(ctx context.Context, conn *websocket.Conn, agentID, keyName string) {
now := time.Now()
agent := &Agent{
ID: agentID,
KeyName: keyName,
hub: h,
conn: conn,
log: h.log.With("agent", agentID),
outbound: make(chan acp.Frame, agentSendQueue),
done: make(chan struct{}),
connectedAt: now,
lastActivity: now,
ring: newRing(defaultRingCapacity),
interactions: make(map[string]*Interaction),
calls: make(map[uint64]chan acp.Frame),
}
h.mu.Lock()
// A reconnect from the same key replaces the old connection rather than
// accumulating a ghost: the bridge reconnects after every network blip, and
// a stale entry would show as a second session that never updates.
if old, ok := h.agents[agentID]; ok {
go old.close()
}
h.agents[agentID] = agent
h.mu.Unlock()
h.log.Info("agent connected", "agent", agentID, "key", keyName)
h.broadcast(Event{Type: EventAgents, Agents: h.Summaries()})
// Ask who this is. The bridge also volunteers it in `x.ai/rc/status` on
// connect, but asking means the UI is correct even if that frame is missed.
go h.initialize(ctx, agent)
agent.serve(ctx)
agent.close()
h.mu.Lock()
if h.agents[agentID] == agent {
delete(h.agents, agentID)
}
h.mu.Unlock()
h.log.Info("agent gone", "agent", agentID)
h.broadcast(Event{Type: EventAgents, Agents: h.Summaries()})
}
func (h *Hub) initialize(ctx context.Context, agent *Agent) {
ctx, cancel := context.WithTimeout(ctx, callTimeout)
defer cancel()
frame, err := agent.call(ctx, acp.MethodInitialize, map[string]any{
"protocolVersion": 1,
"clientCapabilities": map[string]any{
// glance is a viewer and a controller, not a workspace: it does not
// offer grok a filesystem or a terminal, and says so up front.
"fs": map[string]any{"readTextFile": false, "writeTextFile": false},
"terminal": false,
},
})
if err != nil {
agent.log.Info("initialize failed", "err", err)
return
}
if frame.Error != nil {
agent.log.Warn("agent refused initialize", "err", frame.Error)
return
}
var result acp.InitializeResult
if err := json.Unmarshal(frame.Result, &result); err != nil {
agent.log.Warn("could not read initialize result", "err", err)
return
}
if result.Meta != nil {
agent.mu.Lock()
agent.meta = result.Meta.Session
agent.mu.Unlock()
}
h.broadcast(Event{Type: EventAgents, Agents: h.Summaries()})
h.syncViewers(agent)
}
// Agent looks up a connected instance.
func (h *Hub) Agent(id string) (*Agent, bool) {
h.mu.RLock()
defer h.mu.RUnlock()
agent, ok := h.agents[id]
return agent, ok
}
// Summaries lists connected agents, for the sessions page.
func (h *Hub) Summaries() []AgentSummary {
h.mu.RLock()
agents := make([]*Agent, 0, len(h.agents))
for _, a := range h.agents {
agents = append(agents, a)
}
h.mu.RUnlock()
out := make([]AgentSummary, 0, len(agents))
for _, a := range agents {
out = append(out, a.Summary())
}
return out
}
// broadcast fans an event out to every browser.
//
// Delivery is best-effort per browser: a viewer that cannot keep up is
// disconnected and reconnects into a fresh snapshot, which is both simpler and
// more correct than letting it fall arbitrarily far behind.
func (h *Hub) broadcast(ev Event) {
raw, err := json.Marshal(ev)
if err != nil {
h.log.Warn("could not encode event", "type", ev.Type, "err", err)
return
}
h.mu.RLock()
browsers := make([]*Browser, 0, len(h.browsers))
for b := range h.browsers {
browsers = append(browsers, b)
}
h.mu.RUnlock()
for _, b := range browsers {
b.deliver(raw)
}
}
// Notice pushes a human-readable line to every browser.
func (h *Hub) Notice(agentID, message string) {
h.broadcast(Event{Type: EventNotice, Agent: agentID, Message: message})
}
func (h *Hub) addBrowser(b *Browser) {
h.mu.Lock()
h.browsers[b] = struct{}{}
h.mu.Unlock()
h.syncAllViewers()
}
func (h *Hub) removeBrowser(b *Browser) {
h.mu.Lock()
delete(h.browsers, b)
h.mu.Unlock()
h.syncAllViewers()
}
// syncAllViewers tells every bridge how many browsers are attached, so `/rc
// status` in the terminal reflects reality.
func (h *Hub) syncAllViewers() {
h.mu.RLock()
count := len(h.browsers)
agents := make([]*Agent, 0, len(h.agents))
for _, a := range h.agents {
agents = append(agents, a)
}
h.mu.RUnlock()
for _, a := range agents {
a.NotifyViewers(count)
}
}
func (h *Hub) syncViewers(agent *Agent) {
h.mu.RLock()
count := len(h.browsers)
h.mu.RUnlock()
agent.NotifyViewers(count)
}
+663
View File
@@ -0,0 +1,663 @@
package hub
import (
"context"
"encoding/json"
"log/slog"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/coder/websocket"
"github.com/user/grok-glance/internal/acp"
)
// These tests drive the hub over real WebSocket connections rather than mocking
// the transport. The behaviour that matters here -- an interaction reaching a
// browser, an answer reaching grok, and the two sides racing -- lives in the
// interleaving of three goroutines, and a mocked conn would test the mock.
const testTimeout = 5 * time.Second
// link is a hub with an HTTP front door for both socket kinds.
type link struct {
hub *Hub
server *httptest.Server
url string
}
func newLink(t *testing.T) *link {
t.Helper()
l := &link{hub: New(slog.New(slog.DiscardHandler))}
l.server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{InsecureSkipVerify: true})
if err != nil {
return
}
conn.SetReadLimit(8 << 20)
if strings.HasPrefix(r.URL.Path, "/agent") {
l.hub.ServeAgent(r.Context(), conn, r.URL.Query().Get("id"), "laptop")
return
}
l.hub.ServeBrowser(r.Context(), conn)
}))
t.Cleanup(l.server.Close)
l.url = "ws" + strings.TrimPrefix(l.server.URL, "http")
return l
}
// fakeAgent stands in for the grok bridge.
//
// Its reader answers `initialize` by itself, the way the real bridge does, so
// tests do not have to step through a handshake they are not about.
type fakeAgent struct {
t *testing.T
conn *websocket.Conn
frames chan acp.Frame
ready chan struct{}
}
func (l *link) dialAgent(t *testing.T, id string, meta acp.SessionMeta) *fakeAgent {
t.Helper()
ctx, cancel := context.WithTimeout(t.Context(), testTimeout)
defer cancel()
conn, _, err := websocket.Dial(ctx, l.url+"/agent?id="+id, nil)
if err != nil {
t.Fatalf("dial agent: %v", err)
}
t.Cleanup(func() { conn.CloseNow() })
a := &fakeAgent{t: t, conn: conn, frames: make(chan acp.Frame, 64), ready: make(chan struct{})}
go a.read(meta)
// Wait for the handshake so that tests which read the agent list do not race
// the metadata that names the session.
select {
case <-a.ready:
case <-time.After(testTimeout):
t.Fatal("hub never sent initialize")
}
return a
}
func (a *fakeAgent) read(meta acp.SessionMeta) {
ctx := context.Background()
handshake := false
for {
_, data, err := a.conn.Read(ctx)
if err != nil {
close(a.frames)
return
}
var frame acp.Frame
if err := json.Unmarshal(data, &frame); err != nil {
continue
}
if frame.Method == acp.MethodInitialize {
reply, err := acp.NewResponse(frame.ID, acp.InitializeResult{
ProtocolVersion: 1,
Meta: &acp.InitializeMeta{Session: meta},
})
if err == nil {
a.write(reply)
}
if !handshake {
handshake = true
close(a.ready)
}
continue
}
select {
case a.frames <- frame:
default:
}
}
}
func (a *fakeAgent) write(frame any) {
a.t.Helper()
raw, err := json.Marshal(frame)
if err != nil {
a.t.Errorf("encode frame: %v", err)
return
}
ctx, cancel := context.WithTimeout(context.Background(), testTimeout)
defer cancel()
if err := a.conn.Write(ctx, websocket.MessageText, raw); err != nil {
a.t.Errorf("write frame: %v", err)
}
}
// raw writes a frame written out as JSON, for shapes the Go types do not model.
func (a *fakeAgent) raw(body string) {
a.t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), testTimeout)
defer cancel()
if err := a.conn.Write(ctx, websocket.MessageText, []byte(body)); err != nil {
a.t.Errorf("write raw: %v", err)
}
}
// expect waits for the next frame satisfying match.
func (a *fakeAgent) expect(match func(acp.Frame) bool, what string) acp.Frame {
a.t.Helper()
deadline := time.After(testTimeout)
for {
select {
case frame, ok := <-a.frames:
if !ok {
a.t.Fatalf("agent socket closed while waiting for %s", what)
}
if match(frame) {
return frame
}
case <-deadline:
a.t.Fatalf("timed out waiting for %s", what)
}
}
}
// fakeBrowser stands in for the web UI.
type fakeBrowser struct {
t *testing.T
conn *websocket.Conn
events chan Event
}
func (l *link) dialBrowser(t *testing.T) *fakeBrowser {
t.Helper()
ctx, cancel := context.WithTimeout(t.Context(), testTimeout)
defer cancel()
conn, _, err := websocket.Dial(ctx, l.url+"/browser", nil)
if err != nil {
t.Fatalf("dial browser: %v", err)
}
t.Cleanup(func() { conn.CloseNow() })
b := &fakeBrowser{t: t, conn: conn, events: make(chan Event, 256)}
go b.read()
return b
}
func (b *fakeBrowser) read() {
ctx := context.Background()
for {
_, data, err := b.conn.Read(ctx)
if err != nil {
close(b.events)
return
}
var ev Event
if err := json.Unmarshal(data, &ev); err != nil {
continue
}
select {
case b.events <- ev:
default:
}
}
}
func (b *fakeBrowser) send(cmd command) {
b.t.Helper()
raw, err := json.Marshal(cmd)
if err != nil {
b.t.Fatalf("encode command: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), testTimeout)
defer cancel()
if err := b.conn.Write(ctx, websocket.MessageText, raw); err != nil {
b.t.Fatalf("write command: %v", err)
}
}
// expect waits for the next event of the given type, skipping the agent-list
// churn that connects and disconnects produce.
func (b *fakeBrowser) expect(kind EventType) Event {
b.t.Helper()
deadline := time.After(testTimeout)
for {
select {
case ev, ok := <-b.events:
if !ok {
b.t.Fatalf("browser socket closed while waiting for %s", kind)
}
if ev.Type == kind {
return ev
}
case <-deadline:
b.t.Fatalf("timed out waiting for a %s event", kind)
}
}
}
// permissionRequest is what grok asks when a tool needs approval.
func permissionRequest(id int, toolCallID string) map[string]any {
return map[string]any{
"jsonrpc": "2.0",
"id": id,
"method": acp.MethodRequestPermission,
"params": map[string]any{
"sessionId": "s-1",
"toolCallId": toolCallID,
"options": []map[string]string{
{"optionId": "allow", "name": "Allow", "kind": "allow_once"},
{"optionId": "deny", "name": "Deny", "kind": "reject_once"},
},
},
}
}
func TestAgentConnectAnnouncesItselfWithSessionMetadata(t *testing.T) {
l := newLink(t)
browser := l.dialBrowser(t)
browser.expect(EventAgents) // the empty greeting
l.dialAgent(t, "key-1", acp.SessionMeta{
SessionID: "s-1",
CWD: "/home/user/project",
Model: "grok-4",
Hostname: "workstation",
})
// The list is broadcast on connect and again when initialize answers, so the
// named entry may be the second event, not the first.
deadline := time.After(testTimeout)
for {
ev := browser.expect(EventAgents)
if len(ev.Agents) == 1 && ev.Agents[0].Session.Model == "grok-4" {
if got := ev.Agents[0].Label; got != "/home/user/project" {
t.Fatalf("label = %q, want the cwd when there is no title", got)
}
return
}
select {
case <-deadline:
t.Fatal("agent never appeared with its session metadata")
default:
}
}
}
func TestBrowserAnswerReachesGrokAndClosesEveryDialog(t *testing.T) {
l := newLink(t)
agent := l.dialAgent(t, "key-1", acp.SessionMeta{SessionID: "s-1"})
browser := l.dialBrowser(t)
browser.expect(EventAgents)
agent.write(permissionRequest(7, "call-1"))
opened := browser.expect(EventInteraction)
if opened.Interaction == nil || opened.Interaction.Method != acp.MethodRequestPermission {
t.Fatalf("interaction = %+v, want a permission request", opened.Interaction)
}
if opened.Interaction.ToolCallID != "call-1" {
t.Fatalf("toolCallId = %q, want call-1", opened.Interaction.ToolCallID)
}
// The params are passed through untouched: glance renders nothing from them
// itself, so anything it rewrote would be a bug the UI inherits.
if !strings.Contains(string(opened.Interaction.Params), `"allow_once"`) {
t.Fatalf("params were not passed through: %s", opened.Interaction.Params)
}
id := string(opened.Interaction.ID)
browser.send(command{
Type: "answer",
Agent: "key-1",
ID: id,
Result: json.RawMessage(`{"outcome":{"outcome":"selected","optionId":"allow"}}`),
})
// grok gets a JSON-RPC response on the id it asked with.
reply := agent.expect(func(f acp.Frame) bool {
return f.Kind() == acp.KindResponse && string(f.ID) == "7"
}, "the permission response")
if !strings.Contains(string(reply.Result), `"optionId":"allow"`) {
t.Fatalf("result = %s, want the browser's choice", reply.Result)
}
resolved := browser.expect(EventInteractionResolved)
if resolved.ID != id || resolved.By != "browser" {
t.Fatalf("resolution = %+v, want id %s by browser", resolved, id)
}
// The interaction is gone, so a reload does not show a dialog grok is no
// longer waiting on.
if pending := l.hub.Summaries()[0].Pending; pending != 0 {
t.Fatalf("pending = %d after answering, want 0", pending)
}
}
func TestTerminalWinningRetractsTheBrowserDialog(t *testing.T) {
l := newLink(t)
agent := l.dialAgent(t, "key-1", acp.SessionMeta{SessionID: "s-1"})
browser := l.dialBrowser(t)
browser.expect(EventAgents)
agent.write(permissionRequest(11, "call-2"))
opened := browser.expect(EventInteraction)
// The user approved in the terminal, so the bridge tells glance to take the
// card down. glance must not answer grok afterwards: grok already has it.
agent.write(map[string]any{
"jsonrpc": "2.0",
"method": acp.MethodRCInteractionCancelled,
"params": acp.InteractionCancelledParams{ID: 11, ToolCallID: "call-2"},
})
resolved := browser.expect(EventInteractionResolved)
if resolved.By != "terminal" {
t.Fatalf("resolved by %q, want terminal", resolved.By)
}
if resolved.ID != string(opened.Interaction.ID) {
t.Fatalf("resolved id = %q, want %q", resolved.ID, opened.Interaction.ID)
}
// Answering now is the losing half of the race and must say so rather than
// fail: the browser had the card open when the terminal won.
browser.send(command{
Type: "answer",
Agent: "key-1",
ID: resolved.ID,
Result: json.RawMessage(`{"outcome":{"outcome":"selected","optionId":"allow"}}`),
})
late := browser.expect(EventInteractionResolved)
if late.By != "elsewhere" || late.Message == "" {
t.Fatalf("late answer = %+v, want by=elsewhere with an explanation", late)
}
}
func TestOnlyTheFirstBrowserToAnswerWins(t *testing.T) {
l := newLink(t)
agent := l.dialAgent(t, "key-1", acp.SessionMeta{SessionID: "s-1"})
first := l.dialBrowser(t)
second := l.dialBrowser(t)
first.expect(EventAgents)
second.expect(EventAgents)
agent.write(permissionRequest(13, "call-3"))
opened := first.expect(EventInteraction)
second.expect(EventInteraction)
id := string(opened.Interaction.ID)
answer := command{
Type: "answer",
Agent: "key-1",
ID: id,
Result: json.RawMessage(`{"outcome":{"outcome":"selected","optionId":"allow"}}`),
}
first.send(answer)
if got := first.expect(EventInteractionResolved); got.By != "browser" {
t.Fatalf("first answer resolved by %q, want browser", got.By)
}
// The resolution is broadcast, so the other browser's card closes on its own
// without anyone touching it.
if got := second.expect(EventInteractionResolved); got.By != "browser" || got.ID != id {
t.Fatalf("second browser saw %+v, want the first browser's resolution", got)
}
// Answering anyway -- the click that was already in flight -- is told what
// happened rather than failing.
second.send(answer)
if got := second.expect(EventInteractionResolved); got.By != "elsewhere" {
t.Fatalf("second answer resolved by %q, want elsewhere", got.By)
}
// One response, not two: grok is waiting on a single id and a duplicate
// would be an unsolicited frame.
agent.expect(func(f acp.Frame) bool {
return f.Kind() == acp.KindResponse && string(f.ID) == "13"
}, "the permission response")
select {
case frame, ok := <-agent.frames:
if ok && frame.Kind() == acp.KindResponse && string(frame.ID) == "13" {
t.Fatal("the losing browser's answer was also sent to grok")
}
case <-time.After(200 * time.Millisecond):
}
}
func TestDeclineHandsTheInteractionBackToTheTerminal(t *testing.T) {
l := newLink(t)
agent := l.dialAgent(t, "key-1", acp.SessionMeta{SessionID: "s-1"})
browser := l.dialBrowser(t)
browser.expect(EventAgents)
agent.write(permissionRequest(17, "call-4"))
opened := browser.expect(EventInteraction)
browser.send(command{
Type: "decline",
Agent: "key-1",
ID: string(opened.Interaction.ID),
Reason: "answering in the terminal",
})
// A JSON-RPC error, not a fabricated outcome: grok reads it as "glance is
// not answering this" and leaves the terminal's dialog up.
reply := agent.expect(func(f acp.Frame) bool {
return f.Kind() == acp.KindResponse && string(f.ID) == "17"
}, "the decline")
if reply.Error == nil {
t.Fatalf("decline produced result %s, want a JSON-RPC error", reply.Result)
}
if !strings.Contains(reply.Error.Message, "answering in the terminal") {
t.Fatalf("error message = %q, want the browser's reason", reply.Error.Message)
}
}
func TestTranscriptIsRingedAndReplayedOnSubscribe(t *testing.T) {
l := newLink(t)
agent := l.dialAgent(t, "key-1", acp.SessionMeta{SessionID: "s-1"})
// One frame from each rail. The xAI rail is opaque to glance but must still
// reach the browser, or the transcript loses its streaming detail.
agent.raw(`{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"s-1",
"update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"hello"}},
"_meta":{"eventId":"e-1"}}}`)
agent.raw(`{"jsonrpc":"2.0","method":"x.ai/session_notification","params":{"sessionId":"s-1",
"update":{"sessionUpdate":"tool_call_update","tool_call_id":"call-9","status":"in_progress"}}}`)
browser := l.dialBrowser(t)
browser.expect(EventAgents)
// Subscribing late still renders the turn so far -- that is what the ring is
// for, and what makes a page reload mid-turn survivable.
waitFor(t, func() bool { return l.hub.Summaries()[0].Frames == 2 })
browser.send(command{Type: "subscribe", Agent: "key-1"})
snapshot := browser.expect(EventSnapshot)
if len(snapshot.Frames) != 2 {
t.Fatalf("snapshot has %d frames, want 2", len(snapshot.Frames))
}
if snapshot.Dropped != 0 {
t.Fatalf("dropped = %d, want 0", snapshot.Dropped)
}
// A chunk means a turn is running; the UI's spinner is driven by this.
if !snapshot.TurnActive {
t.Fatal("turnActive = false after a message chunk")
}
// `_meta` is what lets a viewer dedup and order the stream, so it has to
// survive the round trip verbatim.
if !strings.Contains(string(snapshot.Frames[0]), `"eventId":"e-1"`) {
t.Fatalf("frame lost its _meta: %s", snapshot.Frames[0])
}
// A live frame arrives on the same socket after the snapshot.
agent.raw(`{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"s-1",
"update":{"sessionUpdate":"turn_completed"}}}`)
live := browser.expect(EventFrame)
if !strings.Contains(string(live.Frame), "turn_completed") {
t.Fatalf("live frame = %s, want the turn_completed update", live.Frame)
}
waitFor(t, func() bool { return !l.hub.Summaries()[0].TurnActive })
}
func TestUnsupportedRequestsGetMethodNotFound(t *testing.T) {
l := newLink(t)
agent := l.dialAgent(t, "key-1", acp.SessionMeta{SessionID: "s-1"})
// glance drives grok, not the other way round: it has no filesystem to
// offer. Saying so beats a fabricated success grok would then act on.
agent.write(map[string]any{
"jsonrpc": "2.0",
"id": 21,
"method": "fs/read_text_file",
"params": map[string]any{"path": "/etc/passwd"},
})
reply := agent.expect(func(f acp.Frame) bool {
return f.Kind() == acp.KindResponse && string(f.ID) == "21"
}, "the method-not-found reply")
if reply.Error == nil || reply.Error.Code != acp.CodeMethodNotFound {
t.Fatalf("reply = %+v, want a method-not-found error", reply)
}
}
func TestCommandsForAMissingAgentAreReportedNotDropped(t *testing.T) {
l := newLink(t)
browser := l.dialBrowser(t)
browser.expect(EventAgents)
for _, cmd := range []command{
{Type: "subscribe", Agent: "ghost"},
{Type: "prompt", Agent: "ghost", Text: "hi"},
{Type: "cancel", Agent: "ghost"},
{Type: "answer", Agent: "ghost", ID: "1", Result: json.RawMessage(`{}`)},
} {
browser.send(cmd)
if ev := browser.expect(EventError); !strings.Contains(ev.Message, "no such agent") {
t.Fatalf("%s: message = %q, want a missing-agent error", cmd.Type, ev.Message)
}
}
browser.send(command{Type: "nonsense"})
if ev := browser.expect(EventError); !strings.Contains(ev.Message, "unknown command") {
t.Fatalf("message = %q, want an unknown-command error", ev.Message)
}
}
func TestAgentDisconnectLeavesNoGhost(t *testing.T) {
l := newLink(t)
agent := l.dialAgent(t, "key-1", acp.SessionMeta{SessionID: "s-1"})
waitFor(t, func() bool { return len(l.hub.Summaries()) == 1 })
agent.conn.Close(websocket.StatusNormalClosure, "bye")
// A stale entry would show in the UI as a session that never updates again.
waitFor(t, func() bool { return len(l.hub.Summaries()) == 0 })
}
func TestReconnectWithTheSameKeyReplacesTheOldConnection(t *testing.T) {
l := newLink(t)
l.dialAgent(t, "key-1", acp.SessionMeta{SessionID: "s-1"})
waitFor(t, func() bool { return len(l.hub.Summaries()) == 1 })
// The bridge reconnects after every network blip. Accumulating a second
// entry per blip would fill the session list with dead sessions.
l.dialAgent(t, "key-1", acp.SessionMeta{SessionID: "s-2"})
waitFor(t, func() bool {
summaries := l.hub.Summaries()
return len(summaries) == 1 && summaries[0].Session.SessionID == "s-2"
})
}
func waitFor(t *testing.T, cond func() bool) {
t.Helper()
deadline := time.Now().Add(testTimeout)
for time.Now().Before(deadline) {
if cond() {
return
}
time.Sleep(5 * time.Millisecond)
}
t.Fatal("condition never held")
}
// The prompt box and the Stop button, which are the two things the browser can
// do to a turn. Both are dispatched on their own goroutine -- a prompt does not
// return until the turn ends -- so this also checks that a browser can still be
// heard while one is outstanding.
func TestPromptAndStopReachGrok(t *testing.T) {
l := newLink(t)
agent := l.dialAgent(t, "key-1", acp.SessionMeta{SessionID: "s-1", CWD: "/repo"})
browser := l.dialBrowser(t)
browser.send(command{Type: "prompt", Agent: "key-1", Text: "summarise the diff"})
prompt := agent.expect(func(f acp.Frame) bool {
return f.Kind() == acp.KindRequest && f.Method == acp.MethodSessionPrompt
}, "the prompt")
var got acp.PromptParams
if err := json.Unmarshal(prompt.Params, &got); err != nil {
t.Fatalf("decode prompt params: %v", err)
}
if got.Text != "summarise the diff" {
t.Fatalf("prompt text = %q, want the browser's text", got.Text)
}
if got.SessionID != "s-1" {
t.Fatalf("prompt sessionId = %q, want the mirrored session", got.SessionID)
}
// The turn is now running and grok has not answered the prompt. Stop must
// still get through rather than queueing behind it.
browser.send(command{Type: "cancel", Agent: "key-1"})
cancel := agent.expect(func(f acp.Frame) bool {
return f.Kind() == acp.KindRequest && f.Method == acp.MethodSessionCancel
}, "the cancel")
var cancelled acp.CancelParams
if err := json.Unmarshal(cancel.Params, &cancelled); err != nil {
t.Fatalf("decode cancel params: %v", err)
}
if cancelled.SessionID != "s-1" {
t.Fatalf("cancel sessionId = %q, want the mirrored session", cancelled.SessionID)
}
// Answering both keeps the agent's call table clean, which is what the next
// prompt depends on.
agent.write(map[string]any{"jsonrpc": "2.0", "id": cancel.ID, "result": map[string]any{}})
agent.write(map[string]any{
"jsonrpc": "2.0", "id": prompt.ID,
"result": map[string]any{"stopReason": "cancelled"},
})
browser.send(command{Type: "prompt", Agent: "key-1", Text: "again"})
agent.expect(func(f acp.Frame) bool {
return f.Kind() == acp.KindRequest && f.Method == acp.MethodSessionPrompt &&
string(f.ID) != string(prompt.ID)
}, "a second prompt")
}
// An empty prompt is the accidental Enter in an empty box. It must not reach
// grok and start a turn nobody asked for.
func TestEmptyPromptsAreRefusedLocally(t *testing.T) {
l := newLink(t)
agent := l.dialAgent(t, "key-1", acp.SessionMeta{SessionID: "s-1"})
browser := l.dialBrowser(t)
browser.send(command{Type: "prompt", Agent: "key-1", Text: " "})
if ev := browser.expect(EventError); ev.Message == "" {
t.Fatal("an empty prompt should be reported to the browser")
}
// Nothing reached grok: a real prompt afterwards is the first one it sees.
browser.send(command{Type: "prompt", Agent: "key-1", Text: "real"})
prompt := agent.expect(func(f acp.Frame) bool {
return f.Kind() == acp.KindRequest && f.Method == acp.MethodSessionPrompt
}, "the prompt")
var got acp.PromptParams
if err := json.Unmarshal(prompt.Params, &got); err != nil {
t.Fatalf("decode prompt params: %v", err)
}
if got.Text != "real" {
t.Fatalf("prompt text = %q, want the first prompt grok sees to be the real one", got.Text)
}
}
+64
View File
@@ -0,0 +1,64 @@
package hub
import "encoding/json"
// ring is a bounded transcript buffer.
//
// The whole persistence story of glance is this type: a browser that attaches
// mid-session, or reloads, sees the last `capacity` frames and nothing older.
// Frames are kept as the raw bytes that arrived, so replay is byte-identical to
// what the terminal saw and costs no re-encoding.
//
// Bounded and in-memory is a deliberate choice, not a shortcut. A control plane
// that durably recorded everything an agent ever said -- file contents, diffs,
// command output -- would be a far larger secret to keep than the one this
// server is built to keep.
type ring struct {
frames []json.RawMessage
start int
size int
dropped int
}
func newRing(capacity int) *ring {
if capacity < 1 {
capacity = 1
}
return &ring{frames: make([]json.RawMessage, capacity)}
}
func (r *ring) push(frame json.RawMessage) {
n := len(r.frames)
if r.size < n {
r.frames[(r.start+r.size)%n] = frame
r.size++
return
}
// Full: overwrite the oldest and remember that history was lost, so the UI
// can say "history truncated" rather than implying the session began here.
r.frames[r.start] = frame
r.start = (r.start + 1) % n
r.dropped++
}
// snapshot returns the buffered frames oldest-first.
//
// The slice is fresh but the frames are shared: they are never mutated after
// being pushed, so readers may hold them without copying.
func (r *ring) snapshot() []json.RawMessage {
out := make([]json.RawMessage, 0, r.size)
n := len(r.frames)
for i := 0; i < r.size; i++ {
out = append(out, r.frames[(r.start+i)%n])
}
return out
}
func (r *ring) reset() {
r.start = 0
r.size = 0
r.dropped = 0
for i := range r.frames {
r.frames[i] = nil
}
}
+80
View File
@@ -0,0 +1,80 @@
package hub
import (
"encoding/json"
"fmt"
"testing"
)
func frames(r *ring) []string {
out := make([]string, 0, r.size)
for _, f := range r.snapshot() {
out = append(out, string(f))
}
return out
}
func TestRingKeepsTheMostRecentFrames(t *testing.T) {
r := newRing(3)
if got := r.snapshot(); len(got) != 0 {
t.Fatalf("empty ring snapshot = %v", got)
}
for i := 1; i <= 5; i++ {
r.push(json.RawMessage(fmt.Sprintf(`{"n":%d}`, i)))
}
// A browser opening mid-session wants the end of the transcript, not the
// beginning, so the oldest frames are what fall off.
want := []string{`{"n":3}`, `{"n":4}`, `{"n":5}`}
got := frames(r)
if len(got) != len(want) {
t.Fatalf("snapshot = %v, want %v", got, want)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("snapshot = %v, want %v", got, want)
}
}
// The count of dropped frames is shown in the UI, so a viewer knows the
// transcript starts mid-stream rather than at the beginning of the session.
if r.dropped != 2 {
t.Fatalf("dropped = %d, want 2", r.dropped)
}
if r.size != 3 {
t.Fatalf("size = %d, want 3", r.size)
}
}
func TestRingSnapshotIsOrderedAcrossTheWrap(t *testing.T) {
r := newRing(4)
for i := 1; i <= 4; i++ {
r.push(json.RawMessage(fmt.Sprintf(`%d`, i)))
}
// Exactly full: no wrap yet.
if got := frames(r); got[0] != "1" || got[3] != "4" {
t.Fatalf("full ring = %v", got)
}
r.push(json.RawMessage(`5`))
got := frames(r)
// Replay is only useful if it is in order; an off-by-one at the wrap point
// would show the transcript scrambled rather than truncated.
for i, want := range []string{"2", "3", "4", "5"} {
if got[i] != want {
t.Fatalf("after wrap = %v, want [2 3 4 5]", got)
}
}
}
func TestRingReset(t *testing.T) {
r := newRing(2)
r.push(json.RawMessage(`1`))
r.push(json.RawMessage(`2`))
r.push(json.RawMessage(`3`))
r.reset()
if r.size != 0 || r.dropped != 0 || len(r.snapshot()) != 0 {
t.Fatalf("reset left size=%d dropped=%d len=%d", r.size, r.dropped, len(r.snapshot()))
}
}