Skip to content

Commit 6e21b2d

Browse files
committed
Fix engagement roster filters
1 parent baed54d commit 6e21b2d

6 files changed

Lines changed: 168 additions & 20 deletions

File tree

.planning/phases/96-agent-memory-continuity/96-01-SUMMARY.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
- Added operator-gated APIs:
1616
- `POST /api/agent-memory/capture`
1717
- `POST /api/agent-memory/handoff`
18+
- Added proxy allowlist coverage so the new machine-facing routes can reach their own local/operator authorization logic.
1819
- Added tests for library behavior and API flow.
1920

2021
## Product Meaning
@@ -23,8 +24,9 @@ AgentMemory's useful behavior is now a MemRoOS requirement and implementation pa
2324

2425
## Verification
2526

26-
- `npm --prefix apps/memroos run test -- agent-memory`
27+
- `npm --prefix apps/memroos run test -- agent-memory proxy`
2728
- `npm --prefix apps/memroos run typecheck`
29+
- `npm --prefix apps/memroos run build`
2830

2931
## Follow-Ups
3032

.planning/phases/96-agent-memory-continuity/96-VERIFICATION.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,16 @@
33
## Commands
44

55
```bash
6-
npm --prefix apps/memroos run test -- agent-memory
6+
npm --prefix apps/memroos run test -- agent-memory proxy
77
npm --prefix apps/memroos run typecheck
8+
npm --prefix apps/memroos run build
89
```
910

1011
## Results
1112

12-
- Agent-memory test slice: passed, 2 files, 4 tests.
13+
- Agent-memory and proxy test slice: passed, 3 files, 13 tests.
1314
- TypeScript typecheck: passed.
15+
- Next.js production build: passed.
1416

1517
## Coverage
1618

@@ -19,3 +21,4 @@ npm --prefix apps/memroos run typecheck
1921
- Secret-like tokens are redacted from handoff-visible fields.
2022
- Duplicate captures are suppressed by stable capture hash.
2123
- API routes are operator-gated and verified through loopback route tests.
24+
- Proxy allows the machine-facing capture and handoff routes to reach their own local/operator authorization logic.

apps/memroos/src/__tests__/proxy.test.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,18 @@ describe("proxy", () => {
5151
expect(await response.text()).toBe("");
5252
});
5353

54+
it("lets agent-memory continuity routes handle their own local/operator authorization", async () => {
55+
const response = await proxy(
56+
new NextRequest("http://localhost:3002/api/agent-memory/capture", {
57+
method: "POST",
58+
headers: { host: "localhost:3002", "x-memroos-operator-key": "operator-key" },
59+
})
60+
);
61+
62+
expect(response.status).toBe(200);
63+
expect(await response.text()).toBe("");
64+
});
65+
5466
it("lets ChatGPT Action routes handle their own API key authorization", async () => {
5567
const response = await proxy(
5668
new NextRequest("https://app.memroos.test/api/chatgpt/actions/search", {

apps/memroos/src/components/engagement/__tests__/agent-engagement-console.test.tsx

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,30 @@ const mockAgents = [
8080
updatedAt: "2026-05-11T00:00:00Z",
8181
deregisteredAt: null,
8282
},
83+
{
84+
id: "ceo",
85+
name: "CEO",
86+
role: "CEO Agent",
87+
platform: "codex",
88+
protocol: "local",
89+
status: "dormant",
90+
lastHeartbeat: null,
91+
currentTask: null,
92+
lessonsCount: 0,
93+
todayMemoryCount: 0,
94+
location: "local",
95+
isRemote: false,
96+
latencyMs: null,
97+
capabilities: [],
98+
metadata: { source: "pmo-agents" },
99+
host: null,
100+
port: null,
101+
healthEndpoint: null,
102+
tunnelUrl: null,
103+
createdAt: "2026-05-11T00:00:00Z",
104+
updatedAt: "2026-05-11T00:00:00Z",
105+
deregisteredAt: null,
106+
},
83107
];
84108

85109
vi.mock("@/lib/api-client", () => ({
@@ -127,9 +151,34 @@ describe("AgentEngagementConsole", () => {
127151
expect(screen.getAllByText("Claude Sonnet Engineer").length).toBeGreaterThan(0);
128152
expect(screen.getAllByText("Codex CLI").length).toBeGreaterThan(0);
129153
expect(screen.getByText("1 active / 2 registered")).toBeInTheDocument();
154+
expect(screen.getByRole("button", { name: /Show system \(2\)/i })).toBeInTheDocument();
130155
expect(screen.queryByRole("button", { name: /Paperclip support agents/i })).not.toBeInTheDocument();
131156
expect(screen.queryByText("Paperclip")).not.toBeInTheDocument();
132157
expect(screen.queryByText("Paperclip orchestrator")).not.toBeInTheDocument();
158+
expect(screen.queryByText("CEO")).not.toBeInTheDocument();
159+
expect(screen.queryByText("CEO Agent")).not.toBeInTheDocument();
160+
});
161+
162+
it("can explicitly show hidden Paperclip and PMO system agents", () => {
163+
render(<AgentEngagementConsole />);
164+
165+
fireEvent.click(screen.getByRole("button", { name: /Show system \(2\)/i }));
166+
167+
expect(screen.getByText("System / Paperclip")).toBeInTheDocument();
168+
expect(screen.getByText("Paperclip")).toBeInTheDocument();
169+
expect(screen.getByText("CEO")).toBeInTheDocument();
170+
expect(screen.getByRole("button", { name: /Hide system/i })).toBeInTheDocument();
171+
});
172+
173+
it("filters the agent roster by search text and status", () => {
174+
render(<AgentEngagementConsole />);
175+
176+
fireEvent.change(screen.getByLabelText("Filter agents"), { target: { value: "codex" } });
177+
expect(screen.queryByText("Claude Sonnet Engineer")).not.toBeInTheDocument();
178+
expect(screen.getAllByText("Codex CLI").length).toBeGreaterThan(0);
179+
180+
fireEvent.change(screen.getByLabelText("Filter by agent status"), { target: { value: "active" } });
181+
expect(screen.getByText("No agents match the current filters.")).toBeInTheDocument();
133182
});
134183

135184
it("runs diagnostics for primary agents without testing hidden Paperclip agents", async () => {

apps/memroos/src/components/engagement/agent-engagement-console.tsx

Lines changed: 97 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
MessageSquare,
99
PhoneCall,
1010
RefreshCw,
11+
Search,
1112
Send,
1213
TestTube2,
1314
Video,
@@ -118,9 +119,22 @@ function metadataSource(agent: RegisteredAgent): string {
118119
return typeof source === "string" ? source : "";
119120
}
120121

122+
function metadataText(agent: RegisteredAgent): string {
123+
try {
124+
return JSON.stringify(agent.metadata ?? {}).toLowerCase();
125+
} catch {
126+
return "";
127+
}
128+
}
129+
121130
function isPaperclipAgent(agent: RegisteredAgent): boolean {
122-
const haystack = `${agent.id} ${agent.name} ${agent.role} ${metadataSource(agent)}`.toLowerCase();
123-
return haystack.includes("paperclip");
131+
const haystack = `${agent.id} ${agent.name} ${agent.role} ${metadataSource(agent)} ${metadataText(agent)}`.toLowerCase();
132+
return (
133+
haystack.includes("paperclip") ||
134+
haystack.includes("pmo-agent") ||
135+
haystack.includes("pmo_agents") ||
136+
haystack.includes("pmo agents")
137+
);
124138
}
125139

126140
function isPrimaryAgent(agent: RegisteredAgent): boolean {
@@ -231,20 +245,13 @@ export function AgentEngagementConsole() {
231245
const { data: agentsData, isLoading: agentsLoading } = useAgents();
232246
const { data: delegationsData } = useDelegations(8);
233247
const agents = useMemo(() => (agentsData?.agents ?? []) as RegisteredAgent[], [agentsData?.agents]);
234-
const rosterAgents = useMemo(() => agents.filter((agent) => !isPaperclipAgent(agent)), [agents]);
235-
const primaryAgents = useMemo(() => rosterAgents.filter((agent) => agentGroup(agent) === "primary").sort(sortAgents), [rosterAgents]);
236-
const directoryAgents = useMemo(() => rosterAgents.filter((agent) => agentGroup(agent) === "directory").sort(sortAgents), [rosterAgents]);
237-
const activeAgents = useMemo(() => primaryAgents.filter((agent) => agent.status === "active"), [primaryAgents]);
238-
const roster = useMemo(
239-
() => [...primaryAgents, ...directoryAgents],
240-
[directoryAgents, primaryAgents]
241-
);
242-
const defaultRoomIds = activeAgents.length > 0 ? activeAgents.map((agent) => agent.id) : roster.map((agent) => agent.id);
243-
const defaultAgentId = activeAgents[0]?.id ?? roster[0]?.id ?? "";
244248

245249
const [mode, setMode] = useState<Mode>("chat");
246250
const [selectedAgentId, setSelectedAgentId] = useState<string | null>(null);
247251
const [participants, setParticipants] = useState<string[] | null>(null);
252+
const [showSupportAgents, setShowSupportAgents] = useState(false);
253+
const [rosterQuery, setRosterQuery] = useState("");
254+
const [statusFilter, setStatusFilter] = useState<RegisteredAgent["status"] | "all">("all");
248255
const [message, setMessage] = useState("");
249256
const [standupFocus, setStandupFocus] = useState("");
250257
const [standupBlockers, setStandupBlockers] = useState("");
@@ -260,6 +267,28 @@ export function AgentEngagementConsole() {
260267
const audioRef = useRef<HTMLAudioElement | null>(null);
261268
const recognitionRef = useRef<SpeechRecognitionLike | null>(null);
262269

270+
const supportAgents = useMemo(() => agents.filter(isPaperclipAgent), [agents]);
271+
const rosterAgents = useMemo(() => {
272+
const query = rosterQuery.trim().toLowerCase();
273+
return agents
274+
.filter((agent) => showSupportAgents || !isPaperclipAgent(agent))
275+
.filter((agent) => statusFilter === "all" || agent.status === statusFilter)
276+
.filter((agent) => {
277+
if (!query) return true;
278+
return `${agent.id} ${agent.name} ${agent.role} ${agent.platform} ${metadataSource(agent)}`.toLowerCase().includes(query);
279+
});
280+
}, [agents, rosterQuery, showSupportAgents, statusFilter]);
281+
const primaryAgents = useMemo(() => rosterAgents.filter((agent) => agentGroup(agent) === "primary" && !isPaperclipAgent(agent)).sort(sortAgents), [rosterAgents]);
282+
const directoryAgents = useMemo(() => rosterAgents.filter((agent) => agentGroup(agent) === "directory" && !isPaperclipAgent(agent)).sort(sortAgents), [rosterAgents]);
283+
const supportRosterAgents = useMemo(() => rosterAgents.filter(isPaperclipAgent).sort(sortAgents), [rosterAgents]);
284+
const activeAgents = useMemo(() => primaryAgents.filter((agent) => agent.status === "active"), [primaryAgents]);
285+
const roster = useMemo(
286+
() => [...primaryAgents, ...directoryAgents, ...supportRosterAgents],
287+
[directoryAgents, primaryAgents, supportRosterAgents]
288+
);
289+
const defaultRoomIds = activeAgents.length > 0 ? activeAgents.map((agent) => agent.id) : roster.map((agent) => agent.id);
290+
const defaultAgentId = activeAgents[0]?.id ?? roster[0]?.id ?? "";
291+
263292
const selectedAgent = useMemo(
264293
() => roster.find((agent) => agent.id === (selectedAgentId || defaultAgentId)) ?? roster[0],
265294
[defaultAgentId, roster, selectedAgentId]
@@ -617,22 +646,73 @@ export function AgentEngagementConsole() {
617646
<div className="mb-3 flex items-center justify-between">
618647
<h2 className="flex items-center text-sm font-semibold text-slate-950">
619648
Agent roster
620-
<InfoTip text="Primary working agents are shown first. Paperclip support agents are excluded from this engagement roster and belong in the workflow map." />
649+
<InfoTip text="Primary working agents are shown first. Paperclip and PMO support agents are hidden by default and belong in the workflow map unless you explicitly show system agents." />
621650
</h2>
622651
<Pill value={`${activeAgents.length} active / ${roster.length} registered`} />
623652
</div>
653+
<div className="mb-3 space-y-2">
654+
<label className="flex items-center gap-2 rounded-md border border-slate-200 bg-white px-2 py-1.5 text-xs text-stone-600">
655+
<Search className="h-3.5 w-3.5 flex-shrink-0 text-stone-400" />
656+
<input
657+
type="search"
658+
value={rosterQuery}
659+
onChange={(event) => setRosterQuery(event.target.value)}
660+
placeholder="Filter agents..."
661+
className="min-w-0 flex-1 bg-transparent text-sm outline-none placeholder:text-stone-400"
662+
aria-label="Filter agents"
663+
/>
664+
</label>
665+
<div className="grid grid-cols-[1fr_auto] gap-2">
666+
<select
667+
value={statusFilter}
668+
onChange={(event) => setStatusFilter(event.target.value as RegisteredAgent["status"] | "all")}
669+
className="h-8 rounded-md border border-slate-200 bg-white px-2 text-xs font-semibold text-stone-600 outline-none"
670+
aria-label="Filter by agent status"
671+
>
672+
<option value="all">All statuses</option>
673+
<option value="active">Active</option>
674+
<option value="idle">Idle</option>
675+
<option value="dormant">Dormant</option>
676+
<option value="error">Error</option>
677+
</select>
678+
<button
679+
type="button"
680+
onClick={() => setShowSupportAgents((value) => !value)}
681+
className={`h-8 rounded-md border px-2 text-xs font-semibold transition ${
682+
showSupportAgents
683+
? "border-amber-300 bg-amber-50 text-amber-800"
684+
: "border-slate-200 bg-white text-stone-600 hover:bg-slate-50"
685+
}`}
686+
>
687+
{showSupportAgents ? "Hide system" : `Show system (${supportAgents.length})`}
688+
</button>
689+
</div>
690+
</div>
624691
{agentsLoading && <p className="text-sm text-stone-500">Loading agents...</p>}
625692
<div className="max-h-[34rem] space-y-2 overflow-y-auto pr-1">
626-
<div className="space-y-2">
627-
<p className="px-1 text-[11px] font-semibold uppercase tracking-[0.12em] text-stone-500">Primary agents</p>
628-
{primaryAgents.map(renderAgentCard)}
629-
</div>
693+
{primaryAgents.length > 0 && (
694+
<div className="space-y-2">
695+
<p className="px-1 text-[11px] font-semibold uppercase tracking-[0.12em] text-stone-500">Primary agents</p>
696+
{primaryAgents.map(renderAgentCard)}
697+
</div>
698+
)}
630699
{directoryAgents.length > 0 && (
631700
<div className="space-y-2 pt-2">
632701
<p className="px-1 text-[11px] font-semibold uppercase tracking-[0.12em] text-stone-500">Registered directory</p>
633702
{directoryAgents.map(renderAgentCard)}
634703
</div>
635704
)}
705+
{showSupportAgents && supportRosterAgents.length > 0 && (
706+
<div className="space-y-2 pt-2">
707+
<p className="px-1 text-[11px] font-semibold uppercase tracking-[0.12em] text-amber-700">System / Paperclip</p>
708+
{supportRosterAgents.map(renderAgentCard)}
709+
</div>
710+
)}
711+
{!agentsLoading && roster.length === 0 && (
712+
<p className="rounded-md border border-slate-200 bg-slate-50 p-3 text-sm text-stone-500">
713+
No agents match the current filters.
714+
</p>
715+
)}
636716
</div>
637717
</aside>
638718

apps/memroos/src/proxy.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,8 @@ const ADMIN_ROUTES: Array<{ method?: string; pattern: RegExp }> = [
6868

6969
const ROUTE_LOCAL_AUTH_API_ROUTES: Array<{ method?: string; pattern: RegExp }> = [
7070
{ pattern: /^\/api\/chatgpt\/actions\// },
71+
{ method: "POST", pattern: /^\/api\/agent-memory\/capture$/ },
72+
{ method: "POST", pattern: /^\/api\/agent-memory\/handoff$/ },
7173
{ method: "POST", pattern: /^\/api\/agents\/register$/ },
7274
{ method: "POST", pattern: /^\/api\/dispatch$/ },
7375
{ method: "POST", pattern: /^\/api\/heartbeat$/ },

0 commit comments

Comments
 (0)