diff --git a/cmd/cyrene/wire.go b/cmd/cyrene/wire.go index 5791e7d..72537d9 100644 --- a/cmd/cyrene/wire.go +++ b/cmd/cyrene/wire.go @@ -101,6 +101,16 @@ type app struct { gw *gateway.Server channelBridge *channelbridge.Bridge channels gateway.ChannelConfigStore + // capacitySink broadcasts capacity_awaken transitions to the Web UI (design/14 + // §5). It is satisfied by *gateway.Server (BroadcastCapacityAwaken); held as an + // interface so the composition root is unit-testable with a fake sink. + capacitySink capacityEmitter +} + +// capacityEmitter is the gateway-facing sink for capacity_awaken broadcasts. +// *gateway.Server implements it; tests substitute a fake to capture the args. +type capacityEmitter interface { + BroadcastCapacityAwaken(sessionID, fromState, toState, tool string) } // newApp assembles the full Cyrene runtime from cfg and log. It opens the @@ -330,6 +340,7 @@ func (a *app) wireRuntime() error { Tools: a.dispatcher, Capacity: capDecider, OnCapacity: a.onCapacity, + OnAudit: a.onToolAudit, Memory: store, Retriever: a.retriever, Extractor: memory.NewManualExtractor(personaID, "owner"), @@ -379,12 +390,33 @@ func (a *app) buildCapacity() *capacity.Decider { } // onCapacity is the loop's OnCapacity sink: it observes a capacity_awaken -// transition for observability. A future frontend wire (design/14 §5) will render -// the "能力点亮" progression from these events. +// transition and pushes it to the Web UI (design/14 §5) as a `capacity_awaken` +// frame. The loop only emits on a real state transition (never every round), and +// the event carries the originating session id so it routes to that session, not +// globally. The payload is the frontend AwakenPayload {fromState,toState,tool}. func (a *app) onCapacity(ev capacity.Event) { - a.log.Debug("capacity event", + logger.WithSource(a.log, "cmd/cyrene").Debug("capacity event", "type", ev.Type, "from", ev.FromState, "to", ev.ToState, - "tool", ev.Tool, "at", ev.At) + "tool", ev.Tool, "at", ev.At, "session", ev.SessionID) + if a.capacitySink == nil { + return + } + a.capacitySink.BroadcastCapacityAwaken(ev.SessionID, string(ev.FromState), string(ev.ToState), ev.Tool) +} + +// onToolAudit is the loop's OnAudit sink: it forwards a tool call denied by the +// design/11 §3 security gate in a group/stranger environment into the gateway +// audit trail, so the rejection is "audit 可查" and the frontend can render the +// small "xx 工具在群聊被拒" note (design/11 §8). The gateway is built after the +// loop (wireFrontend), so the sink reads a.gw at call time — by then the app is +// fully assembled and any Run is served through the gateway. +func (a *app) onToolAudit(ev loop.AuditEvent) { + if a.gw == nil { + logger.WithSource(a.log, "cmd/cyrene").Warn("loop audit dropped: gateway not ready", + "who", ev.Who, "what", ev.What, "result", ev.Result) + return + } + a.gw.AuditEvent(ev.Who, ev.What, ev.Result) } // parseCapacityToolSets decodes the CAPACITY_TOOL_SETS JSON map of @@ -606,6 +638,7 @@ func (a *app) wireFrontend() error { }) } a.gw = gw + a.capacitySink = gw return nil } diff --git a/cmd/cyrene/wire_test.go b/cmd/cyrene/wire_test.go new file mode 100644 index 0000000..b37d860 --- /dev/null +++ b/cmd/cyrene/wire_test.go @@ -0,0 +1,63 @@ +package main + +import ( + "testing" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/capacity" +) + +// fakeCapacityEmitter captures the capacity_awaken broadcast args so the +// composition-root test can assert the wire forwards the transition to the sink. +type fakeCapacityEmitter struct { + callCount int + sessionID string + fromState string + toState string + tool string +} + +func (f *fakeCapacityEmitter) BroadcastCapacityAwaken(sessionID, fromState, toState, tool string) { + f.callCount++ + f.sessionID = sessionID + f.fromState = fromState + f.toState = toState + f.tool = tool +} + +// TestOnCapacityBroadcastsAwaken exercises the audit-gap fix (#6): a capacity +// state transition received by the loop's OnCapacity sink is forwarded to the +// gateway as a capacity_awaken broadcast carrying the originating session and the +// frontend AwakenPayload fields (fromState, toState, tool). +func TestOnCapacityBroadcastsAwaken(t *testing.T) { + fake := &fakeCapacityEmitter{} + a := &app{log: testLog(), capacitySink: fake} + + a.onCapacity(capacity.Event{ + Type: capacity.EventAwaken, + FromState: capacity.StateQuiet, + ToState: capacity.StateAwake, + Tool: "web_search", + SessionID: "web", + }) + + if fake.callCount != 1 { + t.Fatalf("broadcast call count = %d, want 1", fake.callCount) + } + if fake.sessionID != "web" || fake.fromState != "quiet" || fake.toState != "awake" || fake.tool != "web_search" { + t.Fatalf("broadcast args = {sessionID:%q from:%q to:%q tool:%q}, want {web,quiet,awake,web_search}", + fake.sessionID, fake.fromState, fake.toState, fake.tool) + } +} + +// TestOnCapacityNilSinkNoPanic locks the guard: a boot/degraded path where the +// gateway is not yet wired must not panic — the sink is optional. +func TestOnCapacityNilSinkNoPanic(t *testing.T) { + a := &app{log: testLog()} + a.onCapacity(capacity.Event{ + Type: capacity.EventAwaken, + FromState: capacity.StateQuiet, + ToState: capacity.StateFull, + Tool: "code", + SessionID: "web", + }) +} diff --git a/frontend/web/src/admin/AdminApp.tsx b/frontend/web/src/admin/AdminApp.tsx index 7f486e9..0128f92 100644 --- a/frontend/web/src/admin/AdminApp.tsx +++ b/frontend/web/src/admin/AdminApp.tsx @@ -42,16 +42,22 @@ function renderSection(section: AdminSectionId): React.ReactNode { } } -function AdminSidebar({ active }: { active: AdminSectionId }): React.ReactNode { +/** + * Sidebar content shared by the desktop rail and the mobile drawer (design/10). + * Both render ADMIN_GROUPS with the same `onNavigate` hook — the drawer passes + * its close callback (tap an item → close), the desktop rail passes a no-op — so + * desktop and mobile never diverge. + */ +function adminNavContent({ active, onNavigate }: { active: AdminSectionId; onNavigate: () => void }): React.ReactNode { return ( -