@agentmessier/openclaw-agent-messier 0.3.5 → 0.3.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.ts CHANGED
@@ -25,6 +25,14 @@ export default function register(api: OpenClawPluginApi) {
25
25
  api.registerTool(tool as AnyAgentTool);
26
26
  }
27
27
 
28
+ // Capture the EFFECTIVE model of each decision from the gateway's llm_output
29
+ // hook (the provider/model of the call that just ran). vfetch sends it as
30
+ // x-agent-model, so the pitch records the model actually PLAYING — reflecting
31
+ // a mid-match /model switch, not a static configured default.
32
+ api.registerHook("llm_output", ((event: { provider?: string; model?: string }) => {
33
+ if (event?.model) session.lastModel = event.provider ? `${event.provider}/${event.model}` : event.model;
34
+ }) as Parameters<typeof api.registerHook>[1]);
35
+
28
36
  // 2. Autoplay watcher. It drives ONE realtime venue (streams observations,
29
37
  // seats the agent, offers hands-free play) entirely from {venue, spec} —
30
38
  // seating via spec.client.join, observe/act endpoints via spec.routes, the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agentmessier/openclaw-agent-messier",
3
- "version": "0.3.5",
3
+ "version": "0.3.7",
4
4
  "description": "Agent Messier multi-venue client for OpenClaw \u2014 play games and work tasks on the AgentNet platform (soccer today; venues discovered from the marketplace registry)",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/generate.ts CHANGED
@@ -36,6 +36,7 @@ function did(venueId: string, cfg: PluginCfg): string {
36
36
  * what it needs: Bearer→DID for games, x-caller-did for work, seat token for acts). */
37
37
  async function vfetch(base: string, path: string, opts: { method?: string; body?: unknown; cfg: PluginCfg; token?: string; did: string }): Promise<{ ok: boolean; status: number; data: any }> {
38
38
  const headers: Record<string, string> = { "x-caller-did": opts.did, "x-agent-runtime": "openclaw-plugin/0.2.0" };
39
+ if (session.lastModel) headers["x-agent-model"] = session.lastModel; // effective LLM for the pitch roster
39
40
  const key = apiKeyOf(opts.cfg); if (key) headers["Authorization"] = `Bearer ${key}`;
40
41
  if (opts.token) headers["x-agent-token"] = opts.token;
41
42
  if (opts.body !== undefined) headers["Content-Type"] = "application/json";
package/src/state.ts CHANGED
@@ -19,8 +19,12 @@ export const session: {
19
19
  did: string | null
20
20
  turn: number
21
21
  lockstep: boolean
22
+ /** Effective LLM (provider/model) of the most recent decision, captured from
23
+ * the gateway's llm_output hook — sent as x-agent-model so the pitch records
24
+ * the model that's actually playing (reflects a mid-match /model switch). */
25
+ lastModel: string | null
22
26
  /** Installed by the service: seat into a venue room (matchId omitted = quickmatch
23
27
  * find-or-create) and start the observe/act loop. `params` are venue join params
24
28
  * (e.g. teamSize/team for soccer). Returns the seat the loop is now driving. */
25
29
  joinAndWatch: ((matchId?: string, params?: Record<string, unknown>) => Promise<{ id?: string; controls?: string[]; started?: boolean }>) | null
26
- } = { matchId: null, players: [], token: null, did: null, turn: 0, lockstep: false, joinAndWatch: null }
30
+ } = { matchId: null, players: [], token: null, did: null, turn: 0, lockstep: false, lastModel: null, joinAndWatch: null }
@@ -1,181 +0,0 @@
1
- import { describe, it, expect, afterEach, vi } from "vitest";
2
- import { generateVenueTools, defaultVenueTools, joinVenue, seatOf, defaultRealtimeVenue, isRealtimeVenue, _resetSeats } from "./generate.js";
3
- import { session } from "./state.js";
4
- import type { GameSpec, PluginCfg } from "./tools.js";
5
-
6
- function cfg(extra: Partial<PluginCfg> = {}): PluginCfg {
7
- return { serverUrl: "http://pitch.test", sessionKey: "did:wba:me", ...extra };
8
- }
9
-
10
- // A venue the plugin has NEVER heard of — proves tools come from the spec, not code.
11
- const GOLF_VENUE = { id: "agent-golf", origin: "http://golf.test", specUrl: "/spec" };
12
- const GOLF_SPEC: GameSpec = {
13
- game: "agent-golf", specVersion: 1, rulesVersion: 1,
14
- actions: { type: "string", enum: ["drive", "chip", "putt"], descriptions: { drive: "tee shot", putt: "on the green" } },
15
- observe: { mode: "stream", suggestedIntervalMs: 3000 },
16
- routes: { observe: "/rounds/{matchId}/agents/{did}/observe", act: "/rounds/{matchId}/players/{playerId}/swing" },
17
- client: {
18
- prefix: "golf", noun: "round",
19
- lobby: { tool: "golf_rounds", route: "/rounds", params: {}, summary: "List rounds." },
20
- join: { tool: "golf_join", route: "/quickround", seatRoute: "/rounds/{matchId}/join", params: { holes: { type: "integer" }, matchId: { type: "string" } }, seat: { id: "matchId", token: "token", controls: "playerIds" }, summary: "Join a round." },
21
- autoplay: { tool: "golf_autoplay", summary: "Hands-free." },
22
- leave: { tool: "golf_leave", route: "/rounds/{matchId}/leave", summary: "Leave the round (forfeit if live)." },
23
- observe: { tool: "golf_observe", params: {}, summary: "See the course." },
24
- act: { tool: "golf_play", params: { club: { type: "string" } }, summary: "Swing." },
25
- },
26
- };
27
-
28
- describe("generated tool surface comes entirely from the spec", () => {
29
- it("baked soccer + taskmarket specs generate their canonical named tools", () => {
30
- const names = defaultVenueTools(cfg()).map(t => t.name);
31
- // soccer (a game, seated): lobby + join + observe + batch play
32
- expect(names).toEqual(expect.arrayContaining(["soccer_matches", "soccer_join", "soccer_observe", "soccer_play"]));
33
- // taskmarket (seatless work): observe + act, NO lobby/join
34
- expect(names).toEqual(expect.arrayContaining(["work_observe", "work_act"]));
35
- expect(names).not.toContain("work_join");
36
- });
37
-
38
- it("golf thesis: a brand-new venue's spec yields golf_* tools with ZERO plugin code", () => {
39
- _resetSeats();
40
- const tools = generateVenueTools(GOLF_VENUE, GOLF_SPEC, cfg());
41
- const names = tools.map(t => t.name);
42
- expect(names).toEqual(["golf_rounds", "golf_join", "golf_observe", "golf_play", "golf_leave"]);
43
-
44
- // The act tool is BATCH (golf seats players) and carries the spec's action enum.
45
- const play = tools.find(t => t.name === "golf_play")!;
46
- const enumList = (play.parameters as any).properties.moves.items.properties.type.enum;
47
- expect(enumList).toEqual(["drive", "chip", "putt"]);
48
- // Per-action descriptions from the spec flow into the tool description.
49
- expect(play.description).toContain("drive: tee shot");
50
- });
51
-
52
- it("the act enum tracks the spec — a server-added action surfaces with no plugin edit", () => {
53
- _resetSeats();
54
- const evolved: GameSpec = { ...GOLF_SPEC, actions: { ...GOLF_SPEC.actions!, enum: [...GOLF_SPEC.actions!.enum, "flop"] } };
55
- const play = generateVenueTools(GOLF_VENUE, evolved, cfg()).find(t => t.name === "golf_play")!;
56
- expect((play.parameters as any).properties.moves.items.properties.type.enum).toContain("flop");
57
- });
58
- });
59
-
60
- describe("joinVenue — the one spec-driven seating path (tool + service share it)", () => {
61
- afterEach(() => { vi.unstubAllGlobals(); _resetSeats(); });
62
-
63
- it("quickmatch (no matchId) posts to client.join.route and reads the seat block", async () => {
64
- let url = "", body: any = null;
65
- vi.stubGlobal("fetch", vi.fn(async (u: any, init?: any) => {
66
- url = String(u); body = JSON.parse(init.body);
67
- return { ok: true, json: async () => ({ matchId: "r7", token: "tok", playerIds: ["p1", "p2"], did: "did:wba:me", started: false }) } as any;
68
- }));
69
- const seat = await joinVenue(GOLF_VENUE, GOLF_SPEC, cfg({ sessionKey: "did:wba:me" }), { params: { holes: 9 } });
70
- expect(url).toBe("http://golf.test/quickround");
71
- expect(body.agentId).toBe("did:wba:me");
72
- expect(body.holes).toBe(9); // whitelisted join param
73
- expect(seat.id).toBe("r7"); // seat.id field from spec
74
- expect(seat.controls).toEqual(["p1", "p2"]); // seat.controls field
75
- expect(seat.token).toBe("tok");
76
- expect(seatOf("agent-golf")?.id).toBe("r7");
77
- expect(session.matchId).toBe("r7"); // mirrored for the watcher
78
- });
79
-
80
- it("rejoining a KNOWN room uses seatRoute with {matchId} substituted", async () => {
81
- let url = "";
82
- vi.stubGlobal("fetch", vi.fn(async (u: any) => {
83
- url = String(u);
84
- return { ok: true, json: async () => ({ matchId: "r7", token: "t", playerIds: ["p1"] }) } as any;
85
- }));
86
- await joinVenue(GOLF_VENUE, GOLF_SPEC, cfg(), { matchId: "r7" });
87
- expect(url).toBe("http://golf.test/rounds/r7/join");
88
- });
89
-
90
- it("rejoin response without the seat-id field falls back to the matchId joined with", async () => {
91
- _resetSeats();
92
- // per-room join responses omit matchId (you already know it from the URL) —
93
- // seat.id must still resolve, or the observe loop 404s and reclaim-loops.
94
- vi.stubGlobal("fetch", vi.fn(async () =>
95
- ({ ok: true, json: async () => ({ token: "t", playerIds: ["p1", "p2", "p3"], started: true }) }) as any));
96
- const seat = await joinVenue(GOLF_VENUE, GOLF_SPEC, cfg(), { matchId: "r7" });
97
- expect(seat.id).toBe("r7");
98
- expect(session.matchId).toBe("r7");
99
- });
100
-
101
- it("service extras (teamSize/identity) ride along but tool calls omit them", async () => {
102
- let body: any = null;
103
- vi.stubGlobal("fetch", vi.fn(async (_u: any, init?: any) => {
104
- body = JSON.parse(init.body);
105
- return { ok: true, json: async () => ({ matchId: "r1", token: "t", playerIds: [] }) } as any;
106
- }));
107
- await joinVenue(GOLF_VENUE, GOLF_SPEC, cfg(), { params: { holes: 18 }, extra: { teamSize: 5, identity: { name: "Eagles" } } });
108
- expect(body).toMatchObject({ holes: 18, teamSize: 5, identity: { name: "Eagles" } });
109
- });
110
- });
111
-
112
- describe("realtime-venue detection (which venue the autoplay watcher drives)", () => {
113
- it("a streamed + seated + autoplay venue is realtime; seatless/poll is not", () => {
114
- expect(isRealtimeVenue(GOLF_SPEC)).toBe(true);
115
- expect(isRealtimeVenue({ ...GOLF_SPEC, observe: { mode: "poll", suggestedIntervalMs: 1000 } })).toBe(false);
116
- expect(isRealtimeVenue({ ...GOLF_SPEC, client: { ...GOLF_SPEC.client!, join: null } } as GameSpec)).toBe(false);
117
- expect(isRealtimeVenue(null)).toBe(false);
118
- });
119
-
120
- it("the baked defaults pick soccer (realtime), never the seatless taskmarket", () => {
121
- const rt = defaultRealtimeVenue();
122
- expect(rt?.venue.id).toBe("agent-soccer");
123
- expect(rt?.spec.client?.act.tool).toBe("soccer_play");
124
- });
125
- });
126
-
127
- describe("join-by-id and leave (the lifecycle gap closed in VA-6)", () => {
128
- afterEach(() => { vi.unstubAllGlobals(); _resetSeats(); });
129
-
130
- async function exec(name: string, params: unknown) {
131
- const t = generateVenueTools(GOLF_VENUE, GOLF_SPEC, cfg()).find(x => x.name === name)!;
132
- const r = await t.execute("id", params as any) as { content: { text: string }[] };
133
- return JSON.parse(r.content[0]!.text);
134
- }
135
-
136
- it("the join tool routes a matchId param through seatRoute (not the body)", async () => {
137
- let url = "", body: any = null;
138
- vi.stubGlobal("fetch", vi.fn(async (u: any, init?: any) => {
139
- url = String(u); body = JSON.parse(init.body);
140
- return { ok: true, json: async () => ({ token: "t", playerIds: ["p1"] }) } as any;
141
- }));
142
- const out = await exec("golf_join", { matchId: "r9", holes: 9 });
143
- expect(url).toBe("http://golf.test/rounds/r9/join"); // seatRoute, not /quickround
144
- expect(body.holes).toBe(9);
145
- expect(body.matchId).toBeUndefined(); // matchId is routing, never a body field
146
- expect(out.joined).toBe("r9");
147
- });
148
-
149
- it("omitting matchId quickmatches (find-or-create) via the join route", async () => {
150
- let url = "";
151
- vi.stubGlobal("fetch", vi.fn(async (u: any) => {
152
- url = String(u);
153
- return { ok: true, json: async () => ({ matchId: "r1", token: "t", playerIds: ["p1"] }) } as any;
154
- }));
155
- await exec("golf_join", { holes: 9 });
156
- expect(url).toBe("http://golf.test/quickround");
157
- });
158
-
159
- it("the leave tool posts to the leave route and frees the seat locally", async () => {
160
- // seat first so there's a match to leave
161
- vi.stubGlobal("fetch", vi.fn(async () => ({ ok: true, json: async () => ({ matchId: "r5", token: "t", playerIds: ["p1"] }) }) as any));
162
- await joinVenue(GOLF_VENUE, GOLF_SPEC, cfg(), { matchId: "r5" });
163
- expect(session.matchId).toBe("r5");
164
-
165
- let url = "", method = "";
166
- vi.stubGlobal("fetch", vi.fn(async (u: any, init?: any) => {
167
- url = String(u); method = init?.method;
168
- return { ok: true, json: async () => ({ left: "r5", forfeit: true, winner: "away" }) } as any;
169
- }));
170
- const out = await exec("golf_leave", {});
171
- expect(method).toBe("POST");
172
- expect(url).toBe("http://golf.test/rounds/r5/leave");
173
- expect(out.left).toBe("r5");
174
- expect(session.matchId).toBeNull(); // freed → ready to join elsewhere
175
- });
176
-
177
- it("leaving when not in a match is a no-op error, not a crash", async () => {
178
- const out = await exec("golf_leave", {});
179
- expect(out.error).toMatch(/not in a match/i);
180
- });
181
- });
package/src/spec.test.ts DELETED
@@ -1,107 +0,0 @@
1
- import { describe, it, expect, afterEach, vi } from "vitest";
2
- import { fetchSpec, playActionTypes, type GameSpec, type PluginCfg } from "./tools.js";
3
- import { generateVenueTools } from "./generate.js";
4
-
5
- const SOCCER_VENUE = { id: "agent-soccer", origin: "pitch", specUrl: "/spec" };
6
- function withClient(spec: GameSpec): GameSpec {
7
- return { ...spec, routes: { act: "/matches/{matchId}/players/{playerId}/action", observe: "/matches/{matchId}/agents/{did}/observe" },
8
- client: { prefix: "soccer", noun: "match",
9
- join: { tool: "soccer_join", route: "/quickmatch", params: {}, seat: { id: "matchId", token: "token", controls: "playerIds" }, summary: "join" },
10
- observe: { tool: "soccer_observe", params: {}, summary: "see" },
11
- act: { tool: "soccer_play", params: {}, summary: "order" } } };
12
- }
13
-
14
- // A FIXTURE manifest with a FAKE action the static list never had. Adding it
15
- // here must surface it in the generated tool with zero further code change.
16
- const FIXTURE: GameSpec = {
17
- game: "agent-soccer",
18
- specVersion: 1,
19
- rulesVersion: 1,
20
- actions: {
21
- type: "string",
22
- enum: ["run", "kick", "chase", "shoot", "teleport", "stop"],
23
- descriptions: { teleport: "blink to the ball (test-only fake action)" },
24
- },
25
- };
26
-
27
- function cfg(extra: Partial<PluginCfg> = {}): PluginCfg {
28
- return { serverUrl: "http://pitch.test", sessionKey: `s-${Math.random().toString(36).slice(2)}`, ...extra };
29
- }
30
-
31
- describe("Phase 4 — soccer tools generate from /spec (static fallback when absent)", () => {
32
- afterEach(() => vi.unstubAllGlobals());
33
-
34
- it("playActionTypes derives the easy-tier enum from the manifest (fake action included)", () => {
35
- const acts = playActionTypes(FIXTURE, "easy");
36
- expect(acts).toContain("teleport"); // the fake action surfaced
37
- expect(acts).toContain("shoot");
38
- expect(acts).not.toContain("run"); // run/kick are the advanced tier, excluded from easy
39
- });
40
-
41
- it("playActionTypes falls back to the static vocabulary when spec is null", () => {
42
- const acts = playActionTypes(null, "easy");
43
- expect(acts).toContain("chase");
44
- expect(acts).toContain("shoot");
45
- expect(acts).not.toContain("teleport"); // nothing invented offline
46
- });
47
-
48
- it("fetchSpec returns the manifest from GET /spec", async () => {
49
- vi.stubGlobal("fetch", vi.fn(async (url: any) => {
50
- expect(String(url)).toContain("/spec");
51
- return { ok: true, json: async () => FIXTURE } as any;
52
- }));
53
- expect(await fetchSpec(cfg())).toEqual(FIXTURE);
54
- });
55
-
56
- it("fetchSpec returns null when /spec is unreachable (offline-safe)", async () => {
57
- vi.stubGlobal("fetch", vi.fn(async () => { throw new Error("ECONNREFUSED"); }));
58
- expect(await fetchSpec(cfg())).toBeNull();
59
- });
60
-
61
- it("fetchSpec returns null on a non-ok response", async () => {
62
- vi.stubGlobal("fetch", vi.fn(async () => ({ ok: false, status: 404, text: async () => "nope" } as any)));
63
- expect(await fetchSpec(cfg())).toBeNull();
64
- });
65
-
66
- it("the generated soccer_play (batch) carries the spec's action enum (fake action included)", () => {
67
- const tools = generateVenueTools(SOCCER_VENUE, withClient(FIXTURE), cfg());
68
- const play = tools.find(t => t.name === "soccer_play")!;
69
- const enumList: string[] = (play.parameters as any).properties.moves.items.properties.type.enum;
70
- expect(enumList).toContain("teleport"); // a server-added action surfaces with zero plugin edit
71
- expect(enumList).toContain("shoot");
72
- });
73
- });
74
-
75
- // ── per-match spec snapshot + protection ladder ──────────────────────────────
76
- import { fetchMatchSpec } from "./tools.js";
77
-
78
- describe("fetchMatchSpec — per-game snapshot with the /spec fallback ladder", () => {
79
- afterEach(() => vi.unstubAllGlobals());
80
-
81
- it("fetches the match's snapshot first", async () => {
82
- const seen: string[] = [];
83
- vi.stubGlobal("fetch", vi.fn(async (url: any) => {
84
- seen.push(String(url));
85
- return { ok: true, json: async () => FIXTURE } as any;
86
- }));
87
- expect(await fetchMatchSpec(cfg(), "m7")).toEqual(FIXTURE);
88
- expect(seen[0]).toContain("/matches/m7/spec");
89
- });
90
-
91
- it("falls back to server-current /spec when the match route fails (old server)", async () => {
92
- const seen: string[] = [];
93
- vi.stubGlobal("fetch", vi.fn(async (url: any) => {
94
- seen.push(String(url));
95
- if (String(url).includes("/matches/")) return { ok: false, status: 404 } as any;
96
- return { ok: true, json: async () => FIXTURE } as any;
97
- }));
98
- expect(await fetchMatchSpec(cfg(), "m7")).toEqual(FIXTURE);
99
- expect(seen.length).toBe(2);
100
- expect(seen[1]).toMatch(/\/spec$/);
101
- });
102
-
103
- it("returns null when the whole ladder fails (caller stays on static fallback and retries later)", async () => {
104
- vi.stubGlobal("fetch", vi.fn(async () => { throw new Error("ECONNREFUSED"); }));
105
- expect(await fetchMatchSpec(cfg(), "m7")).toBeNull();
106
- });
107
- });
package/src/tools.test.ts DELETED
@@ -1,57 +0,0 @@
1
- import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
2
- import { pitchClient, agentIdOf, apiKeyOf, type PluginCfg } from "./tools.js";
3
- import { session } from "./state.js";
4
-
5
- // A config whose sessionKey is unique per test so the cross-process tmp caches
6
- // (token/did) never collide between cases.
7
- function cfg(extra: Partial<PluginCfg> = {}): PluginCfg {
8
- return { serverUrl: "http://pitch.test", sessionKey: `t-${Math.random().toString(36).slice(2)}`, ...extra };
9
- }
10
-
11
- describe("soccer extension join auth", () => {
12
- beforeEach(() => {
13
- session.did = null;
14
- session.token = null;
15
- session.matchId = null;
16
- });
17
- afterEach(() => vi.unstubAllGlobals());
18
-
19
- it("apiKeyOf comes from config only — never from the environment", () => {
20
- expect(apiKeyOf(cfg({ apiKey: "from-cfg" }))).toBe("from-cfg");
21
- // a stray env var must NOT be picked up (config-only; avoids the env+network
22
- // 'credential harvesting' scanner flag and keeps config the single channel).
23
- vi.stubEnv("AGENTNET_API_KEY", "from-env");
24
- expect(apiKeyOf(cfg())).toBeUndefined();
25
- vi.unstubAllEnvs();
26
- });
27
-
28
- it("quickMatch sends the Bearer key and learns the returned DID", async () => {
29
- const seen: { auth?: string } = {};
30
- vi.stubGlobal("fetch", vi.fn(async (_url: any, init: any) => {
31
- seen.auth = init?.headers?.Authorization;
32
- return { ok: true, json: async () => ({ matchId: "m1", team: "home", playerIds: ["h1"], started: false, did: "did:wba:tester", token: "seat-tok" }) } as any;
33
- }));
34
-
35
- const c = cfg({ apiKey: "good-key" });
36
- const data = await pitchClient(c).quickMatch(agentIdOf(c), { teamSize: 5 });
37
-
38
- expect(seen.auth).toBe("Bearer good-key");
39
- expect(data.did).toBe("did:wba:tester");
40
- // DID is now this agent's identity for subsequent seat lookups + calls.
41
- expect(session.did).toBe("did:wba:tester");
42
- expect(agentIdOf(c)).toBe("did:wba:tester");
43
- expect(session.token).toBe("seat-tok");
44
- });
45
-
46
- it("omits the Authorization header when no key is configured (dev mode)", async () => {
47
- const seen: { auth?: string } = {};
48
- vi.stubGlobal("fetch", vi.fn(async (_url: any, init: any) => {
49
- seen.auth = init?.headers?.Authorization;
50
- return { ok: true, json: async () => ({ matchId: "m1", team: "home", playerIds: ["h1"], started: false, token: "seat-tok" }) } as any;
51
- }));
52
-
53
- const c = cfg(); // no apiKey, no env
54
- await pitchClient(c).quickMatch(agentIdOf(c), {});
55
- expect(seen.auth).toBeUndefined();
56
- });
57
- });
@@ -1,71 +0,0 @@
1
- import { describe, it, expect, afterEach, vi } from "vitest";
2
- import { venuesTool, type GameSpec, type PluginCfg } from "./tools.js";
3
- import { generateVenueTools, _resetSeats } from "./generate.js";
4
-
5
- const REGISTRY = { marketplaces: [
6
- { id: "agent-soccer", name: "Agent Soccer", origin: "pitch", specUrl: "/spec", feeBps: 2000, status: "live", kind: "game" },
7
- { id: "taskmarket", name: "Agent Task Market", origin: "taskmarket", specUrl: "/spec", feeBps: 1000, status: "live", kind: "work" },
8
- ] };
9
-
10
- const WORK_VENUE = { id: "taskmarket", origin: "taskmarket", specUrl: "/spec" };
11
- const WORK_SPEC: GameSpec = {
12
- game: "taskmarket", specVersion: 1, rulesVersion: 1,
13
- actions: { type: "string", enum: ["post", "bid", "deliver"], descriptions: {} },
14
- observe: { mode: "poll", suggestedIntervalMs: 30000 },
15
- routes: { observe: "/agents/{did}/observe", act: "/agents/{did}/action" },
16
- client: {
17
- prefix: "taskmarket", noun: "task", lobby: null, join: null,
18
- observe: { tool: "work_observe", params: { cursor: { type: "number" } }, summary: "See the task market." },
19
- act: { tool: "work_act", params: { title: { type: "string" }, description: { type: "string" }, budget: { type: "number" } }, summary: "Act in the task market." },
20
- },
21
- };
22
-
23
- function cfg(extra: Partial<PluginCfg> = {}): PluginCfg {
24
- return { serverUrl: "http://pitch.test", sessionKey: "did:wba:me", ...extra };
25
- }
26
- function workTool(name: string) {
27
- const tools = generateVenueTools(WORK_VENUE, WORK_SPEC, cfg());
28
- return tools.find(t => t.name === name)!;
29
- }
30
- async function runTool(t: any, params: unknown): Promise<any> {
31
- const r = await t.execute("id", params as any) as { content: { text: string }[] };
32
- return JSON.parse(r.content[0]!.text);
33
- }
34
-
35
- describe("multi-venue tools (marketplace registry → generated work tools)", () => {
36
- afterEach(() => { vi.unstubAllGlobals(); _resetSeats(); });
37
-
38
- it("venues lists the registry with kinds", async () => {
39
- vi.stubGlobal("fetch", vi.fn(async (url: any) => {
40
- expect(String(url)).toContain("/platform/marketplaces");
41
- return { ok: true, json: async () => REGISTRY } as any;
42
- }));
43
- const out = await runTool(venuesTool(cfg()), {});
44
- expect(out.venues.map((v: any) => v.kind)).toEqual(["game", "work"]);
45
- });
46
-
47
- it("work_observe substitutes the DID into the venue's route template", async () => {
48
- const seen: string[] = [];
49
- vi.stubGlobal("fetch", vi.fn(async (url: any, init?: any) => {
50
- seen.push(String(url));
51
- expect(init?.headers?.["x-caller-did"]).toBe("did:wba:me");
52
- return { ok: true, json: async () => ({ summary: "quiet", events: [], cursor: 0 }) } as any;
53
- }));
54
- const out = await runTool(workTool("work_observe"), {});
55
- expect(out.view.summary).toBe("quiet");
56
- expect(seen.some(u => u.includes("/agents/did%3Awba%3Ame/observe") || u.includes("/agents/did:wba:me/observe"))).toBe(true);
57
- });
58
-
59
- it("work_act validates against the venue enum and posts the uniform body", async () => {
60
- let posted: any = null;
61
- vi.stubGlobal("fetch", vi.fn(async (_url: any, init?: any) => {
62
- posted = JSON.parse(init.body);
63
- return { ok: true, json: async () => ({ id: "task-1", status: "open" }) } as any;
64
- }));
65
- const bad = await runTool(workTool("work_act"), { type: "fly" });
66
- expect(bad.error).toContain("post");
67
- const ok2 = await runTool(workTool("work_act"), { type: "post", title: "t", description: "d", budget: 5 });
68
- expect(ok2.result.id).toBe("task-1");
69
- expect(posted).toEqual({ type: "post", title: "t", description: "d", budget: 5 });
70
- });
71
- });
@@ -1,137 +0,0 @@
1
- import { describe, it, expect, beforeEach, afterEach } from "vitest";
2
- import { mkdtempSync, writeFileSync, rmSync, utimesSync } from "node:fs";
3
- import { tmpdir } from "node:os";
4
- import { join } from "node:path";
5
- import { strategyText, _clearStrategyCache, prompt, parseSseBlock } from "./watcher.js";
6
- import type { TeamView } from "./format.js";
7
- import type { GameSpec } from "./tools.js";
8
-
9
- let dir: string;
10
- beforeEach(() => { dir = mkdtempSync(join(tmpdir(), "soccer-strat-")); _clearStrategyCache(); });
11
- afterEach(() => { rmSync(dir, { recursive: true, force: true }); });
12
-
13
- const VIEW = {
14
- tick: 1, clock: 0, phase: "live", team: "home",
15
- score: { home: 0, away: 0 },
16
- field: { length: 105, width: 68, attackGoal: { x: 52.5, y: 0 }, ownGoal: { x: -52.5, y: 0 }, goalHalfWidth: 3.66, tickHz: 10 },
17
- mine: [{ id: "home-9", number: 9, pos: { x: 0, y: 0 }, vel: { x: 0, y: 0 }, hasBall: true }],
18
- ball: { pos: { x: 0, y: 0 }, vel: { x: 0, y: 0 }, owner: "home-9" },
19
- teammates: [], opponents: [],
20
- } as unknown as TeamView;
21
-
22
- describe("Phase 5 — strategyText (mtime-cached, capped) + prompt injection", () => {
23
- it("returns '' when the file is absent", () => {
24
- expect(strategyText(join(dir, "nope.md"))).toBe("");
25
- expect(strategyText(undefined)).toBe("");
26
- });
27
-
28
- it("reads a present file", () => {
29
- const f = join(dir, "strategy.md");
30
- writeFileSync(f, "Press high. Keep the ball.");
31
- expect(strategyText(f)).toContain("Press high");
32
- });
33
-
34
- it("caps the injected text at ~1k chars", () => {
35
- const f = join(dir, "strategy.md");
36
- writeFileSync(f, "x".repeat(5000));
37
- expect(strategyText(f).length).toBeLessThanOrEqual(1000);
38
- });
39
-
40
- it("refreshes when the file mtime changes", () => {
41
- const f = join(dir, "strategy.md");
42
- writeFileSync(f, "first plan");
43
- expect(strategyText(f)).toContain("first plan");
44
- writeFileSync(f, "second plan");
45
- const future = new Date(Date.now() + 5000);
46
- utimesSync(f, future, future);
47
- const out = strategyText(f);
48
- expect(out).toContain("second plan");
49
- expect(out).not.toContain("first plan");
50
- });
51
-
52
- it("prompt injects the strategy block when a strategyFile is set", () => {
53
- const f = join(dir, "strategy.md");
54
- writeFileSync(f, "Park the bus. Counter fast.");
55
- const p = prompt(VIEW, "easy", f);
56
- expect(p.toLowerCase()).toContain("standing instructions");
57
- expect(p).toContain("Park the bus");
58
- });
59
-
60
- it("prompt has no strategy block when the file is absent", () => {
61
- const p = prompt(VIEW, "easy", join(dir, "missing.md"));
62
- expect(p.toLowerCase()).not.toContain("standing instructions");
63
- });
64
- });
65
-
66
- // ── the self-instructable envelope (generic GSP client) ─────────────────────
67
- const SPEC = {
68
- game: "agent-soccer", specVersion: 1, rulesVersion: 2,
69
- actions: { type: "string", enum: ["chase", "shoot", "lob"], descriptions: { lob: "chip it" } },
70
- instructions: {
71
- system: "You are a decisive tactician.",
72
- play: "SERVER PLAY GUIDANCE: lob when the keeper is off his line.",
73
- output: 'Reply with ONLY JSON {"moves":{...}}',
74
- },
75
- } as unknown as GameSpec;
76
-
77
- const VIEW_WITH_SUMMARY = { ...VIEW, summary: "⚽ SERVER-RENDERED SITUATION." } as TeamView & { summary: string };
78
-
79
- describe("generic prompt — instructions + summary come from the server", () => {
80
- it("uses the server's instructions and summary when a spec is cached", () => {
81
- const p = prompt(VIEW_WITH_SUMMARY, "easy", undefined, SPEC, "golf_play");
82
- expect(p).toContain("SERVER-RENDERED SITUATION");
83
- expect(p).toContain("SERVER PLAY GUIDANCE");
84
- // tool-calling host: act via the venue's act tool, named from the spec
85
- expect(p).toContain("golf_play");
86
- // the plugin's hardcoded soccer prose is gone on this path
87
- expect(p).not.toContain("YOU ATTACK +x: opponent goal at x=+52.5");
88
- });
89
-
90
- it("keeps the strategy block on the generic path", () => {
91
- const f = join(dir, "strategy.md");
92
- writeFileSync(f, "Park the bus tonight.");
93
- const p = prompt(VIEW_WITH_SUMMARY, "easy", f, SPEC);
94
- expect(p).toContain("Park the bus tonight");
95
- expect(p).toContain("SERVER PLAY GUIDANCE");
96
- });
97
-
98
- it("falls back to local rendering when there is no spec (old server)", () => {
99
- const p = prompt(VIEW, "easy", undefined, null);
100
- expect(p).toContain("soccer_play"); // the legacy describeTeam path
101
- });
102
- });
103
-
104
- describe("parseSseBlock — named events for the handshake", () => {
105
- it("parses a named event block", () => {
106
- const b = parseSseBlock('event: spec\ndata: {"game":"agent-soccer"}');
107
- expect(b.event).toBe("spec");
108
- expect(JSON.parse(b.data!)).toEqual({ game: "agent-soccer" });
109
- });
110
-
111
- it("parses a plain data block (no event name)", () => {
112
- const b = parseSseBlock('data: {"tick":1}');
113
- expect(b.event).toBeUndefined();
114
- expect(JSON.parse(b.data!)).toEqual({ tick: 1 });
115
- });
116
-
117
- it("ignores comments and empty payloads", () => {
118
- expect(parseSseBlock(": connected").data).toBeUndefined();
119
- expect(parseSseBlock("data: ").data).toBeUndefined();
120
- });
121
- });
122
-
123
- import { observeUrl } from "./watcher.js";
124
-
125
- describe("observeUrl — venue-agnostic observe endpoint (VA-4)", () => {
126
- it("substitutes spec.routes.observe with matchId + did", () => {
127
- const spec = { routes: { observe: "/matches/{matchId}/agents/{did}/observe" } } as any;
128
- expect(observeUrl(spec, "m7", "did:wba:me")).toBe("/matches/m7/agents/did%3Awba%3Ame/observe");
129
- });
130
- it("uses a non-soccer venue's route shape unchanged", () => {
131
- const spec = { routes: { observe: "/golf/{matchId}/player/{did}/look" } } as any;
132
- expect(observeUrl(spec, "g3", "alice")).toBe("/golf/g3/player/alice/look");
133
- });
134
- it("falls back to the soccer-literal route when no spec", () => {
135
- expect(observeUrl(null, "m9", "bob")).toBe("/matches/m9/agents/bob/observe");
136
- });
137
- });