Skip to content

Commit 7a8f312

Browse files
Shawclaude
andcommitted
fix(app-control): bridge reads validator params from verification payload
The orchestrator emits validator.params at the verification level (sibling of the validator descriptor), not nested under validator. The bridge's decoder previously looked at validator.params and so silently dropped every event. Fixed and added unit coverage that mirrors the real broadcast shape so future drift gets caught. Also lands biome-formatting fixups. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent c9a2138 commit 7a8f312

3 files changed

Lines changed: 292 additions & 9 deletions

File tree

plugins/plugin-app-control/typescript/src/actions/app-create.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -907,4 +907,3 @@ export async function hasPendingIntent(
907907
const existing = await findExistingIntentTask(runtime, roomId);
908908
return existing !== null;
909909
}
910-
Lines changed: 275 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,275 @@
1+
/**
2+
* @module plugin-app-control/services/__tests__/verification-room-bridge
3+
*
4+
* Unit tests for VerificationRoomBridgeService.
5+
*
6+
* Covers:
7+
* - subscribes to SwarmCoordinator.subscribe on start
8+
* - filters out events with the wrong validator service or method
9+
* - filters out events without a verification.source === "custom-validator"
10+
* - filters out events without an originRoomId on the data payload
11+
* - posts a memory back into originRoomId for app pass / fail
12+
* - posts a memory back into originRoomId for plugin pass / fail
13+
* - logs and does not throw when the orchestrator service is missing
14+
* - unsubscribes on stop()
15+
*/
16+
17+
import type { IAgentRuntime } from "@elizaos/core";
18+
import { beforeEach, describe, expect, it, vi } from "vitest";
19+
import { VerificationRoomBridgeService } from "../verification-room-bridge.js";
20+
21+
interface SwarmEventLike {
22+
type: string;
23+
sessionId: string;
24+
timestamp: number;
25+
data: unknown;
26+
}
27+
28+
type Listener = (event: SwarmEventLike) => void;
29+
30+
interface FakeCoordinator {
31+
subscribe: ReturnType<typeof vi.fn>;
32+
emit: (event: SwarmEventLike) => void;
33+
listeners: Listener[];
34+
unsubscribed: number;
35+
}
36+
37+
/** Drain pending microtasks so async listener handlers run before assertions. */
38+
async function flushMicrotasks(): Promise<void> {
39+
for (let i = 0; i < 8; i += 1) {
40+
await Promise.resolve();
41+
}
42+
}
43+
44+
function createFakeCoordinator(): FakeCoordinator {
45+
const listeners: Listener[] = [];
46+
const coord: FakeCoordinator = {
47+
subscribe: vi.fn((listener: Listener) => {
48+
listeners.push(listener);
49+
return () => {
50+
const idx = listeners.indexOf(listener);
51+
if (idx !== -1) listeners.splice(idx, 1);
52+
coord.unsubscribed += 1;
53+
};
54+
}),
55+
emit: (event: SwarmEventLike) => {
56+
for (const l of listeners) l(event);
57+
},
58+
listeners,
59+
unsubscribed: 0,
60+
};
61+
return coord;
62+
}
63+
64+
function createRuntime(coordinator: FakeCoordinator | null): {
65+
runtime: IAgentRuntime;
66+
createMemory: ReturnType<typeof vi.fn>;
67+
} {
68+
const createMemory = vi.fn(
69+
async () => "00000000-0000-0000-0000-000000000000",
70+
);
71+
const runtime = {
72+
agentId: "agent-1",
73+
getService: vi.fn((name: string) =>
74+
name === "SWARM_COORDINATOR" ? coordinator : null,
75+
),
76+
createMemory,
77+
} as unknown as IAgentRuntime;
78+
return { runtime, createMemory };
79+
}
80+
81+
function passEvent(
82+
originRoomId: string | undefined,
83+
overrides?: Partial<{ method: string; appName: string; pluginName: string }>,
84+
): SwarmEventLike {
85+
const method = overrides?.method ?? "verifyApp";
86+
const params: Record<string, string> = {
87+
workdir: "/tmp/wd",
88+
profile: "full",
89+
};
90+
if (method === "verifyApp")
91+
params.appName = overrides?.appName ?? "notes-app";
92+
else params.pluginName = overrides?.pluginName ?? "plugin-notes";
93+
return {
94+
type: "task_complete",
95+
sessionId: "s1",
96+
timestamp: Date.now(),
97+
data: {
98+
reasoning: "validator pass",
99+
verification: {
100+
source: "custom-validator",
101+
verdict: "pass",
102+
validator: { service: "app-verification", method },
103+
params,
104+
},
105+
originRoomId,
106+
label: "create-app:notes-app",
107+
workdir: "/tmp/wd",
108+
},
109+
};
110+
}
111+
112+
function failEvent(originRoomId: string): SwarmEventLike {
113+
return {
114+
type: "escalation",
115+
sessionId: "s1",
116+
timestamp: Date.now(),
117+
data: {
118+
reason: "verification_failed",
119+
summary: "Verification failed: tests=2 failed",
120+
verifier: { service: "app-verification", method: "verifyApp" },
121+
verification: {
122+
source: "custom-validator",
123+
verdict: "fail",
124+
validator: { service: "app-verification", method: "verifyApp" },
125+
params: { workdir: "/tmp/wd", appName: "notes-app", profile: "full" },
126+
},
127+
details: null,
128+
retryCount: 3,
129+
maxRetries: 3,
130+
originRoomId,
131+
label: "create-app:notes-app",
132+
workdir: "/tmp/wd",
133+
},
134+
};
135+
}
136+
137+
describe("VerificationRoomBridgeService", () => {
138+
let coord: FakeCoordinator;
139+
140+
beforeEach(() => {
141+
coord = createFakeCoordinator();
142+
});
143+
144+
it("subscribes on start", async () => {
145+
const { runtime } = createRuntime(coord);
146+
await VerificationRoomBridgeService.start(runtime);
147+
expect(coord.subscribe).toHaveBeenCalledTimes(1);
148+
});
149+
150+
it("posts a memory into originRoomId on app pass", async () => {
151+
const { runtime, createMemory } = createRuntime(coord);
152+
await VerificationRoomBridgeService.start(runtime);
153+
coord.emit(passEvent("room-42"));
154+
await flushMicrotasks();
155+
expect(createMemory).toHaveBeenCalledTimes(1);
156+
const [memory, table] = createMemory.mock.calls[0] as [
157+
{ roomId: string; content: { text: string; source: string } },
158+
string,
159+
];
160+
expect(table).toBe("messages");
161+
expect(memory.roomId).toBe("room-42");
162+
expect(memory.content.source).toBe("verification-room-bridge");
163+
expect(memory.content.text).toContain("notes-app");
164+
expect(memory.content.text).toContain("built and verified");
165+
});
166+
167+
it("posts a memory into originRoomId on plugin pass with reinject hint", async () => {
168+
const { runtime, createMemory } = createRuntime(coord);
169+
await VerificationRoomBridgeService.start(runtime);
170+
coord.emit(
171+
passEvent("room-42", {
172+
method: "verifyPlugin",
173+
pluginName: "plugin-notes",
174+
}),
175+
);
176+
await flushMicrotasks();
177+
expect(createMemory).toHaveBeenCalledTimes(1);
178+
const [memory] = createMemory.mock.calls[0] as [
179+
{ roomId: string; content: { text: string } },
180+
string,
181+
];
182+
expect(memory.roomId).toBe("room-42");
183+
expect(memory.content.text).toContain("plugin-notes");
184+
expect(memory.content.text).toContain("reinject");
185+
});
186+
187+
it("posts a fail message into originRoomId on escalation", async () => {
188+
const { runtime, createMemory } = createRuntime(coord);
189+
await VerificationRoomBridgeService.start(runtime);
190+
coord.emit(failEvent("room-42"));
191+
await flushMicrotasks();
192+
expect(createMemory).toHaveBeenCalledTimes(1);
193+
const [memory] = createMemory.mock.calls[0] as [
194+
{ roomId: string; content: { text: string } },
195+
string,
196+
];
197+
expect(memory.roomId).toBe("room-42");
198+
expect(memory.content.text).toContain("verification failure");
199+
expect(memory.content.text).toContain("3");
200+
});
201+
202+
it("ignores events with no originRoomId", async () => {
203+
const { runtime, createMemory } = createRuntime(coord);
204+
await VerificationRoomBridgeService.start(runtime);
205+
coord.emit(passEvent(undefined));
206+
await flushMicrotasks();
207+
expect(createMemory).not.toHaveBeenCalled();
208+
});
209+
210+
it("ignores events from a different validator service", async () => {
211+
const { runtime, createMemory } = createRuntime(coord);
212+
await VerificationRoomBridgeService.start(runtime);
213+
const evt: SwarmEventLike = {
214+
type: "task_complete",
215+
sessionId: "s1",
216+
timestamp: Date.now(),
217+
data: {
218+
verification: {
219+
source: "custom-validator",
220+
verdict: "pass",
221+
validator: { service: "some-other-service", method: "verifyApp" },
222+
params: { workdir: "/tmp/wd", appName: "notes-app" },
223+
},
224+
originRoomId: "room-42",
225+
},
226+
};
227+
coord.emit(evt);
228+
await flushMicrotasks();
229+
expect(createMemory).not.toHaveBeenCalled();
230+
});
231+
232+
it("ignores task_complete events without verification metadata", async () => {
233+
const { runtime, createMemory } = createRuntime(coord);
234+
await VerificationRoomBridgeService.start(runtime);
235+
const evt: SwarmEventLike = {
236+
type: "task_complete",
237+
sessionId: "s1",
238+
timestamp: Date.now(),
239+
data: { reasoning: "ok", originRoomId: "room-42" },
240+
};
241+
coord.emit(evt);
242+
await flushMicrotasks();
243+
expect(createMemory).not.toHaveBeenCalled();
244+
});
245+
246+
it("ignores unrelated event types", async () => {
247+
const { runtime, createMemory } = createRuntime(coord);
248+
await VerificationRoomBridgeService.start(runtime);
249+
const evt: SwarmEventLike = {
250+
type: "task_status_changed",
251+
sessionId: "s1",
252+
timestamp: Date.now(),
253+
data: { originRoomId: "room-42" },
254+
};
255+
coord.emit(evt);
256+
await flushMicrotasks();
257+
expect(createMemory).not.toHaveBeenCalled();
258+
});
259+
260+
it("stays inert when SWARM_COORDINATOR is missing", async () => {
261+
const { runtime, createMemory } = createRuntime(null);
262+
const service = await VerificationRoomBridgeService.start(runtime);
263+
expect(service).toBeInstanceOf(VerificationRoomBridgeService);
264+
expect(createMemory).not.toHaveBeenCalled();
265+
});
266+
267+
it("unsubscribes on stop()", async () => {
268+
const { runtime } = createRuntime(coord);
269+
const service = await VerificationRoomBridgeService.start(runtime);
270+
expect(coord.listeners.length).toBe(1);
271+
await service.stop();
272+
expect(coord.listeners.length).toBe(0);
273+
expect(coord.unsubscribed).toBe(1);
274+
});
275+
});

plugins/plugin-app-control/typescript/src/services/verification-room-bridge.ts

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,7 @@ import { randomUUID } from "node:crypto";
3636
import type { IAgentRuntime, Memory, UUID } from "@elizaos/core";
3737
import { logger, Service } from "@elizaos/core";
3838

39-
export const VERIFICATION_ROOM_BRIDGE_SERVICE_TYPE =
40-
"verification-room-bridge";
39+
export const VERIFICATION_ROOM_BRIDGE_SERVICE_TYPE = "verification-room-bridge";
4140

4241
const APP_VERIFICATION_SERVICE = "app-verification";
4342
const VERIFY_APP_METHOD = "verifyApp";
@@ -76,17 +75,24 @@ function isRecord(value: unknown): value is Record<string, unknown> {
7675
return typeof value === "object" && value !== null && !Array.isArray(value);
7776
}
7877

79-
function readString(record: Record<string, unknown>, key: string): string | undefined {
78+
function readString(
79+
record: Record<string, unknown>,
80+
key: string,
81+
): string | undefined {
8082
const value = record[key];
81-
return typeof value === "string" && value.trim().length > 0 ? value : undefined;
83+
return typeof value === "string" && value.trim().length > 0
84+
? value
85+
: undefined;
8286
}
8387

8488
function readNumber(
8589
record: Record<string, unknown>,
8690
key: string,
8791
): number | undefined {
8892
const value = record[key];
89-
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
93+
return typeof value === "number" && Number.isFinite(value)
94+
? value
95+
: undefined;
9096
}
9197

9298
/**
@@ -118,10 +124,13 @@ function decodeEvent(event: SwarmEventLike): BridgeEventPayload | null {
118124
return null;
119125
}
120126

121-
const params = isRecord(validator.params) ? validator.params : null;
127+
// Validator params live on the `verification` payload (sibling of the
128+
// `validator` descriptor) — that's how swarm-decision-loop.ts emits them.
129+
const params = isRecord(verification.params) ? verification.params : null;
122130
if (!params) return null;
131+
const method = validator.method;
123132
const targetName =
124-
validator.method === VERIFY_APP_METHOD
133+
method === VERIFY_APP_METHOD
125134
? readString(params, "appName")
126135
: readString(params, "pluginName");
127136
if (!targetName) return null;
@@ -135,7 +144,7 @@ function decodeEvent(event: SwarmEventLike): BridgeEventPayload | null {
135144
return {
136145
originRoomId,
137146
verdict,
138-
method: validator.method,
147+
method,
139148
targetName,
140149
label: readString(event.data, "label"),
141150
workdir: readString(event.data, "workdir"),

0 commit comments

Comments
 (0)