@apa-network/agent-sdk 0.1.0 → 0.2.0-beta.10
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/README.md +109 -14
- package/bin/apa-bot.js +6 -4
- package/dist/cli.d.ts +1 -1
- package/dist/cli.js +491 -74
- package/dist/cli.next_decision.e2e.test.js +468 -0
- package/dist/commands/register.d.ts +2 -0
- package/dist/commands/register.js +18 -0
- package/dist/commands/register.test.js +26 -0
- package/dist/commands/session_recovery.d.ts +6 -0
- package/dist/commands/session_recovery.js +25 -0
- package/dist/commands/session_recovery.test.js +27 -0
- package/dist/http/client.d.ts +30 -1
- package/dist/http/client.js +50 -7
- package/dist/index.d.ts +0 -3
- package/dist/index.js +0 -1
- package/dist/loop/callback.d.ts +20 -0
- package/dist/loop/callback.js +105 -0
- package/dist/loop/callback.test.js +33 -0
- package/dist/loop/credentials.d.ts +10 -0
- package/dist/loop/credentials.js +60 -0
- package/dist/loop/credentials.test.d.ts +1 -0
- package/dist/loop/credentials.test.js +33 -0
- package/dist/loop/decision_state.d.ts +30 -0
- package/dist/loop/decision_state.js +43 -0
- package/dist/loop/state.d.ts +9 -0
- package/dist/loop/state.js +16 -0
- package/dist/loop/state.test.d.ts +1 -0
- package/dist/loop/state.test.js +14 -0
- package/dist/utils/config.d.ts +1 -2
- package/dist/utils/config.js +8 -5
- package/dist/utils/config.test.js +7 -1
- package/package.json +7 -2
- package/dist/bot/createBot.d.ts +0 -14
- package/dist/bot/createBot.js +0 -107
- package/dist/types/bot.d.ts +0 -42
- package/dist/types/messages.d.ts +0 -85
- package/dist/utils/action.d.ts +0 -3
- package/dist/utils/action.js +0 -10
- package/dist/utils/action.test.js +0 -10
- package/dist/utils/backoff.d.ts +0 -2
- package/dist/utils/backoff.js +0 -11
- package/dist/utils/backoff.test.js +0 -8
- package/dist/ws/client.d.ts +0 -32
- package/dist/ws/client.js +0 -116
- /package/dist/{types/bot.js → cli.next_decision.e2e.test.d.ts} +0 -0
- /package/dist/{types/messages.js → commands/register.test.d.ts} +0 -0
- /package/dist/{utils/action.test.d.ts → commands/session_recovery.test.d.ts} +0 -0
- /package/dist/{utils/backoff.test.d.ts → loop/callback.test.d.ts} +0 -0
|
@@ -0,0 +1,468 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { promises as fs } from "node:fs";
|
|
6
|
+
import { runCLI } from "./cli.js";
|
|
7
|
+
import { saveCredential } from "./loop/credentials.js";
|
|
8
|
+
import { loadDecisionState } from "./loop/decision_state.js";
|
|
9
|
+
function jsonResponse(body, status = 200) {
|
|
10
|
+
return new Response(JSON.stringify(body), {
|
|
11
|
+
status,
|
|
12
|
+
headers: { "content-type": "application/json" }
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
function sseResponse(chunks) {
|
|
16
|
+
const encoder = new TextEncoder();
|
|
17
|
+
const stream = new ReadableStream({
|
|
18
|
+
start(controller) {
|
|
19
|
+
for (const chunk of chunks) {
|
|
20
|
+
controller.enqueue(encoder.encode(chunk));
|
|
21
|
+
}
|
|
22
|
+
controller.close();
|
|
23
|
+
}
|
|
24
|
+
});
|
|
25
|
+
return new Response(stream, {
|
|
26
|
+
status: 200,
|
|
27
|
+
headers: { "content-type": "text/event-stream" }
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
function captureStdout() {
|
|
31
|
+
const writes = [];
|
|
32
|
+
const original = process.stdout.write.bind(process.stdout);
|
|
33
|
+
process.stdout.write = ((chunk) => {
|
|
34
|
+
writes.push(typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8"));
|
|
35
|
+
return true;
|
|
36
|
+
});
|
|
37
|
+
return {
|
|
38
|
+
writes,
|
|
39
|
+
restore() {
|
|
40
|
+
process.stdout.write = original;
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
async function withTempCwd(fn) {
|
|
45
|
+
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "apa-sdk-next-decision-"));
|
|
46
|
+
const prev = process.cwd();
|
|
47
|
+
process.chdir(dir);
|
|
48
|
+
try {
|
|
49
|
+
return await fn();
|
|
50
|
+
}
|
|
51
|
+
finally {
|
|
52
|
+
process.chdir(prev);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
test("next-decision creates session, emits decision_request (without protocol fields), and updates state", async () => {
|
|
56
|
+
await withTempCwd(async () => {
|
|
57
|
+
const apiBase = "http://mock.local/api";
|
|
58
|
+
await saveCredential({
|
|
59
|
+
api_base: apiBase,
|
|
60
|
+
agent_name: "BotA",
|
|
61
|
+
agent_id: "agent_1",
|
|
62
|
+
api_key: "apa_1"
|
|
63
|
+
});
|
|
64
|
+
const calls = [];
|
|
65
|
+
const originalFetch = globalThis.fetch;
|
|
66
|
+
const stdout = captureStdout();
|
|
67
|
+
globalThis.fetch = (async (input, init) => {
|
|
68
|
+
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
|
69
|
+
const method = init?.method || "GET";
|
|
70
|
+
calls.push({
|
|
71
|
+
url,
|
|
72
|
+
method,
|
|
73
|
+
headers: init?.headers,
|
|
74
|
+
body: typeof init?.body === "string" ? init.body : undefined
|
|
75
|
+
});
|
|
76
|
+
if (url === `${apiBase}/agents/me` && method === "GET") {
|
|
77
|
+
return jsonResponse({ status: "claimed", balance_cc: 10000 });
|
|
78
|
+
}
|
|
79
|
+
if (url === `${apiBase}/public/rooms` && method === "GET") {
|
|
80
|
+
return jsonResponse({ items: [{ id: "room_low", min_buyin_cc: 1000, name: "Low" }] });
|
|
81
|
+
}
|
|
82
|
+
if (url === `${apiBase}/agent/sessions` && method === "POST") {
|
|
83
|
+
return jsonResponse({
|
|
84
|
+
session_id: "sess_1",
|
|
85
|
+
stream_url: "/api/agent/sessions/sess_1/events"
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
if (url === `${apiBase}/agent/sessions/sess_1/events` && method === "GET") {
|
|
89
|
+
return sseResponse([
|
|
90
|
+
"id: 101\nevent: message\ndata: {\"event\":\"state_snapshot\",\"data\":{\"turn_id\":\"turn_1\",",
|
|
91
|
+
"\"my_seat\":0,\"current_actor_seat\":0,\"legal_actions\":[\"check\",\"bet\"],",
|
|
92
|
+
"\"action_constraints\":{\"bet\":{\"min\":100,\"max\":1200}}}}\n\n"
|
|
93
|
+
]);
|
|
94
|
+
}
|
|
95
|
+
throw new Error(`unexpected fetch: ${method} ${url}`);
|
|
96
|
+
});
|
|
97
|
+
try {
|
|
98
|
+
await runCLI(["next-decision", "--api-base", apiBase, "--join", "random", "--timeout-ms", "2000"]);
|
|
99
|
+
}
|
|
100
|
+
finally {
|
|
101
|
+
globalThis.fetch = originalFetch;
|
|
102
|
+
stdout.restore();
|
|
103
|
+
}
|
|
104
|
+
const messages = stdout.writes
|
|
105
|
+
.join("")
|
|
106
|
+
.split("\n")
|
|
107
|
+
.map((line) => line.trim())
|
|
108
|
+
.filter(Boolean)
|
|
109
|
+
.map((line) => JSON.parse(line));
|
|
110
|
+
const decision = messages.find((m) => m.type === "decision_request");
|
|
111
|
+
assert.ok(decision);
|
|
112
|
+
assert.equal(decision?.session_id, "sess_1");
|
|
113
|
+
assert.ok(typeof decision?.decision_id === "string");
|
|
114
|
+
assert.equal(decision?.turn_id, undefined);
|
|
115
|
+
assert.equal(decision?.callback_url, undefined);
|
|
116
|
+
assert.deepEqual(decision?.legal_actions, ["check", "bet"]);
|
|
117
|
+
assert.deepEqual(decision?.action_constraints, { bet: { min: 100, max: 1200 } });
|
|
118
|
+
const state = await loadDecisionState();
|
|
119
|
+
assert.equal(state.session_id, "sess_1");
|
|
120
|
+
assert.equal(state.stream_url, `${apiBase}/agent/sessions/sess_1/events`);
|
|
121
|
+
assert.equal(state.last_event_id, "101");
|
|
122
|
+
assert.ok(state.pending_decision?.decision_id);
|
|
123
|
+
assert.equal(state.pending_decision?.session_id, "sess_1");
|
|
124
|
+
assert.equal(state.pending_decision?.callback_url, `${apiBase}/agent/sessions/sess_1/actions`);
|
|
125
|
+
assert.equal(state.pending_decision?.turn_id, "turn_1");
|
|
126
|
+
assert.deepEqual(state.pending_decision?.legal_actions, ["check", "bet"]);
|
|
127
|
+
assert.deepEqual(state.pending_decision?.action_constraints, { bet: { min: 100, max: 1200 } });
|
|
128
|
+
const created = calls.find((c) => c.url === `${apiBase}/agent/sessions` && c.method === "POST");
|
|
129
|
+
assert.ok(created);
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
test("next-decision recovers from 409 and reuses existing session from response body", async () => {
|
|
133
|
+
await withTempCwd(async () => {
|
|
134
|
+
const apiBase = "http://mock.local/api";
|
|
135
|
+
await saveCredential({
|
|
136
|
+
api_base: apiBase,
|
|
137
|
+
agent_name: "BotA",
|
|
138
|
+
agent_id: "agent_1",
|
|
139
|
+
api_key: "apa_1"
|
|
140
|
+
});
|
|
141
|
+
const originalFetch = globalThis.fetch;
|
|
142
|
+
const stdout = captureStdout();
|
|
143
|
+
globalThis.fetch = (async (input, init) => {
|
|
144
|
+
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
|
145
|
+
const method = init?.method || "GET";
|
|
146
|
+
if (url === `${apiBase}/agents/me` && method === "GET") {
|
|
147
|
+
return jsonResponse({ status: "claimed", balance_cc: 10000 });
|
|
148
|
+
}
|
|
149
|
+
if (url === `${apiBase}/public/rooms` && method === "GET") {
|
|
150
|
+
return jsonResponse({ items: [{ id: "room_low", min_buyin_cc: 1000, name: "Low" }] });
|
|
151
|
+
}
|
|
152
|
+
if (url === `${apiBase}/agent/sessions` && method === "POST") {
|
|
153
|
+
return jsonResponse({
|
|
154
|
+
error: "agent_already_in_session",
|
|
155
|
+
session_id: "sess_conflict",
|
|
156
|
+
stream_url: "/api/agent/sessions/sess_conflict/events"
|
|
157
|
+
}, 409);
|
|
158
|
+
}
|
|
159
|
+
if (url === `${apiBase}/agent/sessions/sess_conflict/events` && method === "GET") {
|
|
160
|
+
return sseResponse([
|
|
161
|
+
"id: 202\nevent: message\ndata: {\"event\":\"state_snapshot\",\"data\":{\"turn_id\":\"turn_2\",\"my_seat\":1,\"current_actor_seat\":1,",
|
|
162
|
+
"\"legal_actions\":[\"fold\",\"call\",\"raise\"],\"action_constraints\":{\"raise\":{\"min_to\":300,\"max_to\":1500}}}}\n\n"
|
|
163
|
+
]);
|
|
164
|
+
}
|
|
165
|
+
throw new Error(`unexpected fetch: ${method} ${url}`);
|
|
166
|
+
});
|
|
167
|
+
try {
|
|
168
|
+
await runCLI(["next-decision", "--api-base", apiBase, "--join", "random", "--timeout-ms", "2000"]);
|
|
169
|
+
}
|
|
170
|
+
finally {
|
|
171
|
+
globalThis.fetch = originalFetch;
|
|
172
|
+
stdout.restore();
|
|
173
|
+
}
|
|
174
|
+
const messages = stdout.writes
|
|
175
|
+
.join("")
|
|
176
|
+
.split("\n")
|
|
177
|
+
.map((line) => line.trim())
|
|
178
|
+
.filter(Boolean)
|
|
179
|
+
.map((line) => JSON.parse(line));
|
|
180
|
+
const decision = messages.find((m) => m.type === "decision_request");
|
|
181
|
+
assert.ok(decision);
|
|
182
|
+
assert.equal(decision?.session_id, "sess_conflict");
|
|
183
|
+
assert.ok(typeof decision?.decision_id === "string");
|
|
184
|
+
assert.equal(decision?.callback_url, undefined);
|
|
185
|
+
const state = await loadDecisionState();
|
|
186
|
+
assert.equal(state.session_id, "sess_conflict");
|
|
187
|
+
assert.equal(state.stream_url, `${apiBase}/agent/sessions/sess_conflict/events`);
|
|
188
|
+
assert.equal(state.last_event_id, "202");
|
|
189
|
+
assert.equal(state.pending_decision?.session_id, "sess_conflict");
|
|
190
|
+
assert.equal(state.pending_decision?.turn_id, "turn_2");
|
|
191
|
+
assert.deepEqual(state.pending_decision?.legal_actions, ["fold", "call", "raise"]);
|
|
192
|
+
assert.deepEqual(state.pending_decision?.action_constraints, { raise: { min_to: 300, max_to: 1500 } });
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
test("submit-decision uses pending decision metadata and clears pending entry", async () => {
|
|
196
|
+
await withTempCwd(async () => {
|
|
197
|
+
const apiBase = "http://mock.local/api";
|
|
198
|
+
await saveCredential({
|
|
199
|
+
api_base: apiBase,
|
|
200
|
+
agent_name: "BotA",
|
|
201
|
+
agent_id: "agent_1",
|
|
202
|
+
api_key: "apa_1"
|
|
203
|
+
});
|
|
204
|
+
const calls = [];
|
|
205
|
+
const originalFetch = globalThis.fetch;
|
|
206
|
+
const stdout = captureStdout();
|
|
207
|
+
globalThis.fetch = (async (input, init) => {
|
|
208
|
+
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
|
209
|
+
const method = init?.method || "GET";
|
|
210
|
+
calls.push({
|
|
211
|
+
url,
|
|
212
|
+
method,
|
|
213
|
+
headers: init?.headers,
|
|
214
|
+
body: typeof init?.body === "string" ? init.body : undefined
|
|
215
|
+
});
|
|
216
|
+
if (url === `${apiBase}/agents/me` && method === "GET") {
|
|
217
|
+
return jsonResponse({ status: "claimed", balance_cc: 10000 });
|
|
218
|
+
}
|
|
219
|
+
if (url === `${apiBase}/public/rooms` && method === "GET") {
|
|
220
|
+
return jsonResponse({ items: [{ id: "room_low", min_buyin_cc: 1000, name: "Low" }] });
|
|
221
|
+
}
|
|
222
|
+
if (url === `${apiBase}/agent/sessions` && method === "POST") {
|
|
223
|
+
return jsonResponse({
|
|
224
|
+
session_id: "sess_1",
|
|
225
|
+
stream_url: "/api/agent/sessions/sess_1/events"
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
if (url === `${apiBase}/agent/sessions/sess_1/events` && method === "GET") {
|
|
229
|
+
return sseResponse([
|
|
230
|
+
"id: 301\nevent: message\ndata: {\"event\":\"state_snapshot\",\"data\":{\"turn_id\":\"turn_3\",",
|
|
231
|
+
"\"my_seat\":0,\"current_actor_seat\":0,\"legal_actions\":[\"call\"]}}\n\n"
|
|
232
|
+
]);
|
|
233
|
+
}
|
|
234
|
+
if (url === `${apiBase}/agent/sessions/sess_1/actions` && method === "POST") {
|
|
235
|
+
return jsonResponse({ accepted: true, request_id: "req_ok" });
|
|
236
|
+
}
|
|
237
|
+
throw new Error(`unexpected fetch: ${method} ${url}`);
|
|
238
|
+
});
|
|
239
|
+
try {
|
|
240
|
+
await runCLI(["next-decision", "--api-base", apiBase, "--join", "random", "--timeout-ms", "2000"]);
|
|
241
|
+
const state = await loadDecisionState();
|
|
242
|
+
const decisionID = state.pending_decision?.decision_id;
|
|
243
|
+
assert.ok(decisionID);
|
|
244
|
+
await runCLI(["submit-decision", "--api-base", apiBase, "--decision-id", decisionID, "--action", "call"]);
|
|
245
|
+
}
|
|
246
|
+
finally {
|
|
247
|
+
globalThis.fetch = originalFetch;
|
|
248
|
+
stdout.restore();
|
|
249
|
+
}
|
|
250
|
+
const actionCall = calls.find((c) => c.url === `${apiBase}/agent/sessions/sess_1/actions` && c.method === "POST");
|
|
251
|
+
assert.ok(actionCall);
|
|
252
|
+
const payload = JSON.parse(String(actionCall?.body || "{}"));
|
|
253
|
+
assert.equal(payload.turn_id, "turn_3");
|
|
254
|
+
assert.equal(payload.action, "call");
|
|
255
|
+
const finalState = await loadDecisionState();
|
|
256
|
+
assert.equal(finalState.pending_decision, undefined);
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
test("submit-decision blocks bet/raise without amount locally", async () => {
|
|
260
|
+
await withTempCwd(async () => {
|
|
261
|
+
const apiBase = "http://mock.local/api";
|
|
262
|
+
await saveCredential({
|
|
263
|
+
api_base: apiBase,
|
|
264
|
+
agent_name: "BotA",
|
|
265
|
+
agent_id: "agent_1",
|
|
266
|
+
api_key: "apa_1"
|
|
267
|
+
});
|
|
268
|
+
const calls = [];
|
|
269
|
+
const originalFetch = globalThis.fetch;
|
|
270
|
+
const stdout = captureStdout();
|
|
271
|
+
globalThis.fetch = (async (input, init) => {
|
|
272
|
+
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
|
273
|
+
const method = init?.method || "GET";
|
|
274
|
+
calls.push({ url, method, headers: init?.headers, body: typeof init?.body === "string" ? init.body : undefined });
|
|
275
|
+
if (url === `${apiBase}/agents/me` && method === "GET") {
|
|
276
|
+
return jsonResponse({ status: "claimed", balance_cc: 10000 });
|
|
277
|
+
}
|
|
278
|
+
if (url === `${apiBase}/public/rooms` && method === "GET") {
|
|
279
|
+
return jsonResponse({ items: [{ id: "room_low", min_buyin_cc: 1000, name: "Low" }] });
|
|
280
|
+
}
|
|
281
|
+
if (url === `${apiBase}/agent/sessions` && method === "POST") {
|
|
282
|
+
return jsonResponse({ session_id: "sess_2", stream_url: "/api/agent/sessions/sess_2/events" });
|
|
283
|
+
}
|
|
284
|
+
if (url === `${apiBase}/agent/sessions/sess_2/events` && method === "GET") {
|
|
285
|
+
return sseResponse([
|
|
286
|
+
"id: 401\nevent: message\ndata: {\"event\":\"state_snapshot\",\"data\":{\"turn_id\":\"turn_4\",",
|
|
287
|
+
"\"my_seat\":0,\"current_actor_seat\":0,\"legal_actions\":[\"bet\"],\"action_constraints\":{\"bet\":{\"min\":100,\"max\":500}}}}\n\n"
|
|
288
|
+
]);
|
|
289
|
+
}
|
|
290
|
+
throw new Error(`unexpected fetch: ${method} ${url}`);
|
|
291
|
+
});
|
|
292
|
+
try {
|
|
293
|
+
await runCLI(["next-decision", "--api-base", apiBase, "--join", "random", "--timeout-ms", "2000"]);
|
|
294
|
+
const state = await loadDecisionState();
|
|
295
|
+
const decisionID = state.pending_decision?.decision_id;
|
|
296
|
+
assert.ok(decisionID);
|
|
297
|
+
await assert.rejects(async () => runCLI(["submit-decision", "--api-base", apiBase, "--decision-id", decisionID, "--action", "bet"]), /amount_required_for_bet_or_raise/);
|
|
298
|
+
}
|
|
299
|
+
finally {
|
|
300
|
+
globalThis.fetch = originalFetch;
|
|
301
|
+
stdout.restore();
|
|
302
|
+
}
|
|
303
|
+
const actionCall = calls.find((c) => c.url.includes("/actions") && c.method === "POST");
|
|
304
|
+
assert.equal(actionCall, undefined);
|
|
305
|
+
});
|
|
306
|
+
});
|
|
307
|
+
test("submit-decision blocks out-of-range amount locally", async () => {
|
|
308
|
+
await withTempCwd(async () => {
|
|
309
|
+
const apiBase = "http://mock.local/api";
|
|
310
|
+
await saveCredential({
|
|
311
|
+
api_base: apiBase,
|
|
312
|
+
agent_name: "BotA",
|
|
313
|
+
agent_id: "agent_1",
|
|
314
|
+
api_key: "apa_1"
|
|
315
|
+
});
|
|
316
|
+
const calls = [];
|
|
317
|
+
const originalFetch = globalThis.fetch;
|
|
318
|
+
const stdout = captureStdout();
|
|
319
|
+
globalThis.fetch = (async (input, init) => {
|
|
320
|
+
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
|
321
|
+
const method = init?.method || "GET";
|
|
322
|
+
calls.push({ url, method, headers: init?.headers, body: typeof init?.body === "string" ? init.body : undefined });
|
|
323
|
+
if (url === `${apiBase}/agents/me` && method === "GET") {
|
|
324
|
+
return jsonResponse({ status: "claimed", balance_cc: 10000 });
|
|
325
|
+
}
|
|
326
|
+
if (url === `${apiBase}/public/rooms` && method === "GET") {
|
|
327
|
+
return jsonResponse({ items: [{ id: "room_low", min_buyin_cc: 1000, name: "Low" }] });
|
|
328
|
+
}
|
|
329
|
+
if (url === `${apiBase}/agent/sessions` && method === "POST") {
|
|
330
|
+
return jsonResponse({ session_id: "sess_3", stream_url: "/api/agent/sessions/sess_3/events" });
|
|
331
|
+
}
|
|
332
|
+
if (url === `${apiBase}/agent/sessions/sess_3/events` && method === "GET") {
|
|
333
|
+
return sseResponse([
|
|
334
|
+
"id: 501\nevent: message\ndata: {\"event\":\"state_snapshot\",\"data\":{\"turn_id\":\"turn_5\",",
|
|
335
|
+
"\"my_seat\":0,\"current_actor_seat\":0,\"legal_actions\":[\"raise\"],\"action_constraints\":{\"raise\":{\"min_to\":300,\"max_to\":600}}}}\n\n"
|
|
336
|
+
]);
|
|
337
|
+
}
|
|
338
|
+
throw new Error(`unexpected fetch: ${method} ${url}`);
|
|
339
|
+
});
|
|
340
|
+
try {
|
|
341
|
+
await runCLI(["next-decision", "--api-base", apiBase, "--join", "random", "--timeout-ms", "2000"]);
|
|
342
|
+
const state = await loadDecisionState();
|
|
343
|
+
const decisionID = state.pending_decision?.decision_id;
|
|
344
|
+
assert.ok(decisionID);
|
|
345
|
+
await assert.rejects(async () => runCLI([
|
|
346
|
+
"submit-decision",
|
|
347
|
+
"--api-base",
|
|
348
|
+
apiBase,
|
|
349
|
+
"--decision-id",
|
|
350
|
+
decisionID,
|
|
351
|
+
"--action",
|
|
352
|
+
"raise",
|
|
353
|
+
"--amount",
|
|
354
|
+
"700"
|
|
355
|
+
]), /amount_out_of_range/);
|
|
356
|
+
}
|
|
357
|
+
finally {
|
|
358
|
+
globalThis.fetch = originalFetch;
|
|
359
|
+
stdout.restore();
|
|
360
|
+
}
|
|
361
|
+
const actionCall = calls.find((c) => c.url.includes("/actions") && c.method === "POST");
|
|
362
|
+
assert.equal(actionCall, undefined);
|
|
363
|
+
});
|
|
364
|
+
});
|
|
365
|
+
test("next-decision emits noop when table is closing", async () => {
|
|
366
|
+
await withTempCwd(async () => {
|
|
367
|
+
const apiBase = "http://mock.local/api";
|
|
368
|
+
await saveCredential({
|
|
369
|
+
api_base: apiBase,
|
|
370
|
+
agent_name: "BotA",
|
|
371
|
+
agent_id: "agent_1",
|
|
372
|
+
api_key: "apa_1"
|
|
373
|
+
});
|
|
374
|
+
const originalFetch = globalThis.fetch;
|
|
375
|
+
const stdout = captureStdout();
|
|
376
|
+
globalThis.fetch = (async (input, init) => {
|
|
377
|
+
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
|
378
|
+
const method = init?.method || "GET";
|
|
379
|
+
if (url === `${apiBase}/agents/me` && method === "GET") {
|
|
380
|
+
return jsonResponse({ status: "claimed", balance_cc: 10000 });
|
|
381
|
+
}
|
|
382
|
+
if (url === `${apiBase}/public/rooms` && method === "GET") {
|
|
383
|
+
return jsonResponse({ items: [{ id: "room_low", min_buyin_cc: 1000, name: "Low" }] });
|
|
384
|
+
}
|
|
385
|
+
if (url === `${apiBase}/agent/sessions` && method === "POST") {
|
|
386
|
+
return jsonResponse({ session_id: "sess_4", stream_url: "/api/agent/sessions/sess_4/events" });
|
|
387
|
+
}
|
|
388
|
+
if (url === `${apiBase}/agent/sessions/sess_4/events` && method === "GET") {
|
|
389
|
+
return sseResponse([
|
|
390
|
+
"id: 601\nevent: message\ndata: {\"event\":\"reconnect_grace_started\",\"data\":{\"disconnected_agent_id\":\"agent_x\",\"deadline_ts\":123}}\n\n"
|
|
391
|
+
]);
|
|
392
|
+
}
|
|
393
|
+
throw new Error(`unexpected fetch: ${method} ${url}`);
|
|
394
|
+
});
|
|
395
|
+
try {
|
|
396
|
+
await runCLI(["next-decision", "--api-base", apiBase, "--join", "random", "--timeout-ms", "2000"]);
|
|
397
|
+
}
|
|
398
|
+
finally {
|
|
399
|
+
globalThis.fetch = originalFetch;
|
|
400
|
+
stdout.restore();
|
|
401
|
+
}
|
|
402
|
+
const messages = stdout.writes
|
|
403
|
+
.join("")
|
|
404
|
+
.split("\n")
|
|
405
|
+
.map((line) => line.trim())
|
|
406
|
+
.filter(Boolean)
|
|
407
|
+
.map((line) => JSON.parse(line));
|
|
408
|
+
assert.equal(messages.length, 1);
|
|
409
|
+
assert.equal(messages[0]?.type, "noop");
|
|
410
|
+
assert.equal(messages[0]?.reason, "table_closing");
|
|
411
|
+
});
|
|
412
|
+
});
|
|
413
|
+
test("submit-decision emits table_closing and clears pending decision", async () => {
|
|
414
|
+
await withTempCwd(async () => {
|
|
415
|
+
const apiBase = "http://mock.local/api";
|
|
416
|
+
await saveCredential({
|
|
417
|
+
api_base: apiBase,
|
|
418
|
+
agent_name: "BotA",
|
|
419
|
+
agent_id: "agent_1",
|
|
420
|
+
api_key: "apa_1"
|
|
421
|
+
});
|
|
422
|
+
const originalFetch = globalThis.fetch;
|
|
423
|
+
const stdout = captureStdout();
|
|
424
|
+
globalThis.fetch = (async (input, init) => {
|
|
425
|
+
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
|
426
|
+
const method = init?.method || "GET";
|
|
427
|
+
if (url === `${apiBase}/agents/me` && method === "GET") {
|
|
428
|
+
return jsonResponse({ status: "claimed", balance_cc: 10000 });
|
|
429
|
+
}
|
|
430
|
+
if (url === `${apiBase}/public/rooms` && method === "GET") {
|
|
431
|
+
return jsonResponse({ items: [{ id: "room_low", min_buyin_cc: 1000, name: "Low" }] });
|
|
432
|
+
}
|
|
433
|
+
if (url === `${apiBase}/agent/sessions` && method === "POST") {
|
|
434
|
+
return jsonResponse({ session_id: "sess_5", stream_url: "/api/agent/sessions/sess_5/events" });
|
|
435
|
+
}
|
|
436
|
+
if (url === `${apiBase}/agent/sessions/sess_5/events` && method === "GET") {
|
|
437
|
+
return sseResponse([
|
|
438
|
+
"id: 701\nevent: message\ndata: {\"event\":\"state_snapshot\",\"data\":{\"turn_id\":\"turn_7\",\"my_seat\":0,\"current_actor_seat\":0,\"legal_actions\":[\"call\"]}}\n\n"
|
|
439
|
+
]);
|
|
440
|
+
}
|
|
441
|
+
if (url === `${apiBase}/agent/sessions/sess_5/actions` && method === "POST") {
|
|
442
|
+
return jsonResponse({ error: "table_closing" }, 409);
|
|
443
|
+
}
|
|
444
|
+
throw new Error(`unexpected fetch: ${method} ${url}`);
|
|
445
|
+
});
|
|
446
|
+
try {
|
|
447
|
+
await runCLI(["next-decision", "--api-base", apiBase, "--join", "random", "--timeout-ms", "2000"]);
|
|
448
|
+
const state = await loadDecisionState();
|
|
449
|
+
const decisionID = state.pending_decision?.decision_id;
|
|
450
|
+
assert.ok(decisionID);
|
|
451
|
+
await runCLI(["submit-decision", "--api-base", apiBase, "--decision-id", decisionID, "--action", "call"]);
|
|
452
|
+
}
|
|
453
|
+
finally {
|
|
454
|
+
globalThis.fetch = originalFetch;
|
|
455
|
+
stdout.restore();
|
|
456
|
+
}
|
|
457
|
+
const messages = stdout.writes
|
|
458
|
+
.join("")
|
|
459
|
+
.split("\n")
|
|
460
|
+
.map((line) => line.trim())
|
|
461
|
+
.filter(Boolean)
|
|
462
|
+
.map((line) => JSON.parse(line));
|
|
463
|
+
const tableClosing = messages.find((m) => m.type === "table_closing");
|
|
464
|
+
assert.ok(tableClosing);
|
|
465
|
+
const finalState = await loadDecisionState();
|
|
466
|
+
assert.equal(finalState.pending_decision, undefined);
|
|
467
|
+
});
|
|
468
|
+
});
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export function buildCredentialFromRegisterResult(result, apiBase, fallbackName) {
|
|
2
|
+
const obj = (result && typeof result === "object" ? result : null);
|
|
3
|
+
if (!obj) {
|
|
4
|
+
return null;
|
|
5
|
+
}
|
|
6
|
+
const agentID = typeof obj.agent_id === "string" ? obj.agent_id : "";
|
|
7
|
+
const apiKey = typeof obj.api_key === "string" ? obj.api_key : "";
|
|
8
|
+
const agentName = typeof obj.name === "string" && obj.name.trim() ? obj.name : fallbackName;
|
|
9
|
+
if (!agentID || !apiKey || !agentName) {
|
|
10
|
+
return null;
|
|
11
|
+
}
|
|
12
|
+
return {
|
|
13
|
+
api_base: apiBase,
|
|
14
|
+
agent_name: agentName,
|
|
15
|
+
agent_id: agentID,
|
|
16
|
+
api_key: apiKey
|
|
17
|
+
};
|
|
18
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { buildCredentialFromRegisterResult } from "./register.js";
|
|
4
|
+
test("buildCredentialFromRegisterResult returns credential when response has required fields", () => {
|
|
5
|
+
const out = buildCredentialFromRegisterResult({
|
|
6
|
+
agent_id: "agent_123",
|
|
7
|
+
api_key: "apa_123",
|
|
8
|
+
name: "BotX"
|
|
9
|
+
}, "http://localhost:8080/api", "FallbackBot");
|
|
10
|
+
assert.ok(out);
|
|
11
|
+
assert.equal(out?.agent_id, "agent_123");
|
|
12
|
+
assert.equal(out?.api_key, "apa_123");
|
|
13
|
+
assert.equal(out?.agent_name, "BotX");
|
|
14
|
+
});
|
|
15
|
+
test("buildCredentialFromRegisterResult falls back to CLI name and rejects incomplete payload", () => {
|
|
16
|
+
const fallback = buildCredentialFromRegisterResult({
|
|
17
|
+
agent_id: "agent_abc",
|
|
18
|
+
api_key: "apa_abc"
|
|
19
|
+
}, "http://localhost:8080/api", "FallbackBot");
|
|
20
|
+
assert.ok(fallback);
|
|
21
|
+
assert.equal(fallback?.agent_name, "FallbackBot");
|
|
22
|
+
const bad = buildCredentialFromRegisterResult({
|
|
23
|
+
api_key: "apa_missing_agent"
|
|
24
|
+
}, "http://localhost:8080/api", "FallbackBot");
|
|
25
|
+
assert.equal(bad, null);
|
|
26
|
+
});
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export type ResumableSession = {
|
|
2
|
+
session_id: string;
|
|
3
|
+
stream_url: string;
|
|
4
|
+
};
|
|
5
|
+
export declare function resolveStreamURL(apiBase: string, streamURL: string): string;
|
|
6
|
+
export declare function recoverSessionFromConflict(err: unknown, apiBase: string): ResumableSession | null;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export function resolveStreamURL(apiBase, streamURL) {
|
|
2
|
+
const base = apiBase.replace(/\/api\/?$/, "");
|
|
3
|
+
return streamURL.startsWith("http") ? streamURL : `${base}${streamURL}`;
|
|
4
|
+
}
|
|
5
|
+
export function recoverSessionFromConflict(err, apiBase) {
|
|
6
|
+
const httpErr = err;
|
|
7
|
+
if (!httpErr || httpErr.status !== 409 || httpErr.code !== "agent_already_in_session") {
|
|
8
|
+
return null;
|
|
9
|
+
}
|
|
10
|
+
const body = (httpErr.body && typeof httpErr.body === "object"
|
|
11
|
+
? httpErr.body
|
|
12
|
+
: null);
|
|
13
|
+
if (!body) {
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
const sessionID = typeof body.session_id === "string" ? body.session_id : "";
|
|
17
|
+
const streamURLRaw = typeof body.stream_url === "string" ? body.stream_url : "";
|
|
18
|
+
if (!sessionID || !streamURLRaw) {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
return {
|
|
22
|
+
session_id: sessionID,
|
|
23
|
+
stream_url: resolveStreamURL(apiBase, streamURLRaw)
|
|
24
|
+
};
|
|
25
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { recoverSessionFromConflict } from "./session_recovery.js";
|
|
4
|
+
test("recoverSessionFromConflict returns resumable session for 409 agent_already_in_session", () => {
|
|
5
|
+
const err = {
|
|
6
|
+
status: 409,
|
|
7
|
+
code: "agent_already_in_session",
|
|
8
|
+
body: {
|
|
9
|
+
error: "agent_already_in_session",
|
|
10
|
+
session_id: "sess_1",
|
|
11
|
+
stream_url: "/api/agent/sessions/sess_1/events"
|
|
12
|
+
}
|
|
13
|
+
};
|
|
14
|
+
const recovered = recoverSessionFromConflict(err, "http://localhost:8080/api");
|
|
15
|
+
assert.ok(recovered);
|
|
16
|
+
assert.equal(recovered?.session_id, "sess_1");
|
|
17
|
+
assert.equal(recovered?.stream_url, "http://localhost:8080/api/agent/sessions/sess_1/events");
|
|
18
|
+
});
|
|
19
|
+
test("recoverSessionFromConflict returns null for unrelated errors", () => {
|
|
20
|
+
const err = {
|
|
21
|
+
status: 500,
|
|
22
|
+
code: "internal_error",
|
|
23
|
+
body: { error: "internal_error" }
|
|
24
|
+
};
|
|
25
|
+
const recovered = recoverSessionFromConflict(err, "http://localhost:8080/api");
|
|
26
|
+
assert.equal(recovered, null);
|
|
27
|
+
});
|
package/dist/http/client.d.ts
CHANGED
|
@@ -1,6 +1,25 @@
|
|
|
1
1
|
type HttpClientOptions = {
|
|
2
2
|
apiBase?: string;
|
|
3
3
|
};
|
|
4
|
+
export type APAClientError = Error & {
|
|
5
|
+
status?: number;
|
|
6
|
+
code?: string;
|
|
7
|
+
body?: unknown;
|
|
8
|
+
};
|
|
9
|
+
type CreateSessionInput = {
|
|
10
|
+
agentID: string;
|
|
11
|
+
apiKey: string;
|
|
12
|
+
joinMode: "random" | "select";
|
|
13
|
+
roomID?: string;
|
|
14
|
+
};
|
|
15
|
+
type SubmitActionInput = {
|
|
16
|
+
sessionID: string;
|
|
17
|
+
requestID: string;
|
|
18
|
+
turnID: string;
|
|
19
|
+
action: "fold" | "check" | "call" | "raise" | "bet";
|
|
20
|
+
amount?: number;
|
|
21
|
+
thoughtLog?: string;
|
|
22
|
+
};
|
|
4
23
|
export declare class APAHttpClient {
|
|
5
24
|
private readonly apiBase;
|
|
6
25
|
constructor(opts?: HttpClientOptions);
|
|
@@ -8,7 +27,7 @@ export declare class APAHttpClient {
|
|
|
8
27
|
name: string;
|
|
9
28
|
description: string;
|
|
10
29
|
}): Promise<any>;
|
|
11
|
-
|
|
30
|
+
claimByCode(claimCode: string): Promise<any>;
|
|
12
31
|
getAgentMe(apiKey: string): Promise<any>;
|
|
13
32
|
bindKey(input: {
|
|
14
33
|
apiKey: string;
|
|
@@ -16,6 +35,16 @@ export declare class APAHttpClient {
|
|
|
16
35
|
vendorKey: string;
|
|
17
36
|
budgetUsd: number;
|
|
18
37
|
}): Promise<any>;
|
|
38
|
+
listPublicRooms(): Promise<{
|
|
39
|
+
items: Array<{
|
|
40
|
+
id: string;
|
|
41
|
+
min_buyin_cc: number;
|
|
42
|
+
name: string;
|
|
43
|
+
}>;
|
|
44
|
+
}>;
|
|
45
|
+
createSession(input: CreateSessionInput): Promise<any>;
|
|
46
|
+
submitAction(input: SubmitActionInput): Promise<any>;
|
|
47
|
+
closeSession(sessionID: string): Promise<void>;
|
|
19
48
|
healthz(): Promise<any>;
|
|
20
49
|
}
|
|
21
50
|
export {};
|