skillscript-runtime 0.9.5 → 0.9.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/dist/composition.d.ts.map +1 -1
- package/dist/composition.js +10 -0
- package/dist/composition.js.map +1 -1
- package/dist/connectors/agent-noop.d.ts +12 -7
- package/dist/connectors/agent-noop.d.ts.map +1 -1
- package/dist/connectors/agent-noop.js +31 -12
- package/dist/connectors/agent-noop.js.map +1 -1
- package/dist/connectors/agent.d.ts +150 -28
- package/dist/connectors/agent.d.ts.map +1 -1
- package/dist/connectors/agent.js +13 -10
- package/dist/connectors/agent.js.map +1 -1
- package/dist/connectors/registry.d.ts +8 -1
- package/dist/connectors/registry.d.ts.map +1 -1
- package/dist/connectors/registry.js +14 -1
- package/dist/connectors/registry.js.map +1 -1
- package/dist/help-content.d.ts.map +1 -1
- package/dist/help-content.js +10 -7
- package/dist/help-content.js.map +1 -1
- package/dist/lint.d.ts.map +1 -1
- package/dist/lint.js +37 -11
- package/dist/lint.js.map +1 -1
- package/dist/parser.d.ts +13 -6
- package/dist/parser.d.ts.map +1 -1
- package/dist/parser.js +17 -7
- package/dist/parser.js.map +1 -1
- package/dist/runtime.d.ts +22 -2
- package/dist/runtime.d.ts.map +1 -1
- package/dist/runtime.js +73 -21
- package/dist/runtime.js.map +1 -1
- package/dist/scheduler.d.ts.map +1 -1
- package/dist/scheduler.js +6 -2
- package/dist/scheduler.js.map +1 -1
- package/dist/testing/conformance.d.ts.map +1 -1
- package/dist/testing/conformance.js +16 -6
- package/dist/testing/conformance.js.map +1 -1
- package/examples/connectors/HttpWebhookAgentConnector/.env.example +34 -0
- package/examples/connectors/HttpWebhookAgentConnector/HttpWebhookAgentConnector.ts +320 -0
- package/examples/connectors/HttpWebhookAgentConnector/README.md +266 -0
- package/examples/connectors/HttpWebhookAgentConnector/receiver-example/express.js +72 -0
- package/examples/connectors/HttpWebhookAgentConnector/receiver-example/flask.py +77 -0
- package/examples/connectors/HttpWebhookAgentConnector/tests/HttpWebhookAgentConnector.test.ts +378 -0
- package/examples/hello.skill.provenance.json +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Reference receiver for HttpWebhookAgentConnector — Python + Flask.
|
|
3
|
+
Copy + customize per your substrate. ~30 LOC; readable in 60 seconds.
|
|
4
|
+
|
|
5
|
+
What this demonstrates:
|
|
6
|
+
- Accepts POST with JSON body matching the canonical DeliveryPayload shape
|
|
7
|
+
(kind + content + meta + agent_id at top-level for routing).
|
|
8
|
+
- Translates skillscript envelope -> your substrate's routing layer.
|
|
9
|
+
- Returns canonical DeliveryReceipt shape (delivered_at + optional delivery_id).
|
|
10
|
+
|
|
11
|
+
HMAC validation note: if you use HTTP_WEBHOOK_HMAC_SECRET on the sender,
|
|
12
|
+
validate the X-Signature header against the RAW HTTP body BEFORE parsing
|
|
13
|
+
JSON. Use `request.get_data()` to get the raw bytes; only `request.get_json()`
|
|
14
|
+
after signature validates.
|
|
15
|
+
"""
|
|
16
|
+
import hashlib
|
|
17
|
+
import hmac
|
|
18
|
+
import json
|
|
19
|
+
import os
|
|
20
|
+
import time
|
|
21
|
+
from flask import Flask, request, jsonify
|
|
22
|
+
|
|
23
|
+
app = Flask(__name__)
|
|
24
|
+
HMAC_SECRET = os.environ.get("HMAC_SECRET") # optional
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@app.route("/webhook", methods=["POST"])
|
|
28
|
+
@app.route("/webhook/<channel>", methods=["POST"])
|
|
29
|
+
def webhook(channel=None):
|
|
30
|
+
raw_body = request.get_data()
|
|
31
|
+
|
|
32
|
+
# 1) Validate HMAC signature against raw body (BEFORE parsing).
|
|
33
|
+
if HMAC_SECRET is not None:
|
|
34
|
+
expected = "sha256=" + hmac.new(HMAC_SECRET.encode(), raw_body, hashlib.sha256).hexdigest()
|
|
35
|
+
if request.headers.get("X-Signature") != expected:
|
|
36
|
+
return jsonify({"error": "invalid signature"}), 401
|
|
37
|
+
|
|
38
|
+
# 2) Parse JSON body. Now safe — signature validated against raw bytes.
|
|
39
|
+
try:
|
|
40
|
+
payload = json.loads(raw_body.decode("utf-8"))
|
|
41
|
+
except json.JSONDecodeError:
|
|
42
|
+
return jsonify({"error": "malformed JSON"}), 400
|
|
43
|
+
|
|
44
|
+
# 3) Translate skillscript envelope -> your routing layer.
|
|
45
|
+
skill_name = payload.get("meta", {}).get("origin", {}).get("skill_name", "skill")
|
|
46
|
+
content = payload.get("content") or payload.get("prompt", "")
|
|
47
|
+
text = f"[{skill_name}] {content}"
|
|
48
|
+
sender_name = f"skillscript:{skill_name}"
|
|
49
|
+
|
|
50
|
+
# your-substrate-here:
|
|
51
|
+
print(f"[{channel or payload.get('agent_id')}] {sender_name}: {text}")
|
|
52
|
+
# e.g., NanoClaw-style:
|
|
53
|
+
# route_inbound(channel_type, platform_id, message={
|
|
54
|
+
# "id": payload["meta"]["dispatch_id"], "body": text, "sender": sender_name})
|
|
55
|
+
|
|
56
|
+
# 4) Return canonical DeliveryReceipt. delivery_id echoes dispatch_id
|
|
57
|
+
# so sender can correlate. Set delivery_skipped: true if substrate
|
|
58
|
+
# accepts but won't actually deliver (agent offline, rate-limit, etc.).
|
|
59
|
+
return jsonify({
|
|
60
|
+
"delivered_at": int(time.time() * 1000),
|
|
61
|
+
"delivery_id": payload.get("meta", {}).get("dispatch_id"),
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@app.route("/health", methods=["GET"])
|
|
66
|
+
def health():
|
|
67
|
+
return jsonify({"status": "ok"})
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
if __name__ == "__main__":
|
|
71
|
+
port = int(os.environ.get("PORT", "3200"))
|
|
72
|
+
app.run(host="0.0.0.0", port=port)
|
|
73
|
+
|
|
74
|
+
# Clock-skew note: payload.meta.sent_at is the SENDER's emit-clock; your
|
|
75
|
+
# own clock when this request arrives may drift. If you compute staleness
|
|
76
|
+
# as `now - payload['meta']['sent_at']`, use `max(0, delta)` to avoid
|
|
77
|
+
# negative values when receiver clock runs ahead.
|
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the HttpWebhookAgentConnector example.
|
|
3
|
+
*
|
|
4
|
+
* Uses Node's built-in `http.createServer` as a mock receiver — no
|
|
5
|
+
* external dependencies. Each test spins up a fresh server bound to
|
|
6
|
+
* port 0 (kernel-assigned) so tests can run in parallel.
|
|
7
|
+
*/
|
|
8
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
9
|
+
import { createServer, type IncomingMessage, type ServerResponse, type Server } from "node:http";
|
|
10
|
+
import { AddressInfo } from "node:net";
|
|
11
|
+
import { HttpWebhookAgentConnector, DeliveryFailedError } from "../HttpWebhookAgentConnector.js";
|
|
12
|
+
import type { DeliveryPayload } from "../../../../src/connectors/agent.js";
|
|
13
|
+
|
|
14
|
+
interface RecordedRequest {
|
|
15
|
+
method: string;
|
|
16
|
+
url: string;
|
|
17
|
+
headers: Record<string, string | string[] | undefined>;
|
|
18
|
+
body: Buffer;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface MockServer {
|
|
22
|
+
server: Server;
|
|
23
|
+
url: string;
|
|
24
|
+
port: number;
|
|
25
|
+
received: RecordedRequest[];
|
|
26
|
+
setResponse: (status: number, body: object | string) => void;
|
|
27
|
+
close: () => Promise<void>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function startMock(): Promise<MockServer> {
|
|
31
|
+
let responseStatus = 200;
|
|
32
|
+
let responseBody: object | string = { delivered_at: 1700000000000 };
|
|
33
|
+
const received: RecordedRequest[] = [];
|
|
34
|
+
|
|
35
|
+
const server = createServer((req: IncomingMessage, res: ServerResponse) => {
|
|
36
|
+
const chunks: Buffer[] = [];
|
|
37
|
+
req.on("data", (c) => chunks.push(c as Buffer));
|
|
38
|
+
req.on("end", () => {
|
|
39
|
+
received.push({
|
|
40
|
+
method: req.method ?? "",
|
|
41
|
+
url: req.url ?? "",
|
|
42
|
+
headers: req.headers,
|
|
43
|
+
body: Buffer.concat(chunks),
|
|
44
|
+
});
|
|
45
|
+
res.statusCode = responseStatus;
|
|
46
|
+
res.setHeader("content-type", "application/json");
|
|
47
|
+
res.end(typeof responseBody === "string" ? responseBody : JSON.stringify(responseBody));
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
52
|
+
const port = (server.address() as AddressInfo).port;
|
|
53
|
+
return {
|
|
54
|
+
server,
|
|
55
|
+
url: `http://127.0.0.1:${port}`,
|
|
56
|
+
port,
|
|
57
|
+
received,
|
|
58
|
+
setResponse: (status, body) => { responseStatus = status; responseBody = body; },
|
|
59
|
+
close: () => new Promise<void>((resolve) => server.close(() => resolve())),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const META_FIXTURE: DeliveryPayload["meta"] = {
|
|
64
|
+
dispatch_id: "test-dispatch-id",
|
|
65
|
+
sent_at: 1700000000000,
|
|
66
|
+
origin: { skill_name: "test-skill", trigger_kind: "inline" },
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
describe("HttpWebhookAgentConnector — deliver()", () => {
|
|
70
|
+
let mock: MockServer;
|
|
71
|
+
beforeEach(async () => { mock = await startMock(); });
|
|
72
|
+
afterEach(async () => { await mock.close(); });
|
|
73
|
+
|
|
74
|
+
it("POSTs canonical envelope with agent_id at top-level", async () => {
|
|
75
|
+
const conn = new HttpWebhookAgentConnector({
|
|
76
|
+
agents: { "agent-x": { url: mock.url } },
|
|
77
|
+
});
|
|
78
|
+
await conn.deliver("agent-x", { kind: "augment", content: "hello", meta: META_FIXTURE });
|
|
79
|
+
|
|
80
|
+
expect(mock.received).toHaveLength(1);
|
|
81
|
+
const body = JSON.parse(mock.received[0]!.body.toString("utf8"));
|
|
82
|
+
expect(body.agent_id).toBe("agent-x");
|
|
83
|
+
expect(body.kind).toBe("augment");
|
|
84
|
+
expect(body.content).toBe("hello");
|
|
85
|
+
expect(body.meta.dispatch_id).toBe("test-dispatch-id");
|
|
86
|
+
expect(body.meta.origin.skill_name).toBe("test-skill");
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("POSTs template kind round-trips", async () => {
|
|
90
|
+
const conn = new HttpWebhookAgentConnector({ agents: { "agent-x": { url: mock.url } } });
|
|
91
|
+
await conn.deliver("agent-x", { kind: "template", prompt: "playbook body", meta: META_FIXTURE });
|
|
92
|
+
const body = JSON.parse(mock.received[0]!.body.toString("utf8"));
|
|
93
|
+
expect(body.kind).toBe("template");
|
|
94
|
+
expect(body.prompt).toBe("playbook body");
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("sends Content-Type: application/json", async () => {
|
|
98
|
+
const conn = new HttpWebhookAgentConnector({ agents: { "agent-x": { url: mock.url } } });
|
|
99
|
+
await conn.deliver("agent-x", { kind: "augment", content: "hi", meta: META_FIXTURE });
|
|
100
|
+
expect(mock.received[0]!.headers["content-type"]).toBe("application/json");
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("parses canonical receipt response", async () => {
|
|
104
|
+
mock.setResponse(200, { delivered_at: 1700000000999, delivery_id: "receiver-id-42", delivery_skipped: false });
|
|
105
|
+
const conn = new HttpWebhookAgentConnector({ agents: { "agent-x": { url: mock.url } } });
|
|
106
|
+
const receipt = await conn.deliver("agent-x", { kind: "augment", content: "hi", meta: META_FIXTURE });
|
|
107
|
+
expect(receipt.delivered_at).toBe(1700000000999);
|
|
108
|
+
expect(receipt.delivery_id).toBe("receiver-id-42");
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("synthesizes receipt from substrate-shaped response (NanoClaw-style { status, id })", async () => {
|
|
112
|
+
mock.setResponse(202, { status: "accepted", id: "wh-1234-abc" });
|
|
113
|
+
const conn = new HttpWebhookAgentConnector({ agents: { "agent-x": { url: mock.url } } });
|
|
114
|
+
const receipt = await conn.deliver("agent-x", { kind: "augment", content: "hi", meta: META_FIXTURE });
|
|
115
|
+
expect(typeof receipt.delivered_at).toBe("number");
|
|
116
|
+
expect(receipt.delivery_id).toBe("wh-1234-abc");
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("honors delivery_skipped: true from receiver", async () => {
|
|
120
|
+
mock.setResponse(200, { delivered_at: 1700000000999, delivery_skipped: true });
|
|
121
|
+
const conn = new HttpWebhookAgentConnector({ agents: { "agent-x": { url: mock.url } } });
|
|
122
|
+
const receipt = await conn.deliver("agent-x", { kind: "augment", content: "hi", meta: META_FIXTURE });
|
|
123
|
+
expect(receipt.delivery_skipped).toBe(true);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("synthesizes minimal receipt from empty body (no fields available)", async () => {
|
|
127
|
+
mock.setResponse(200, "");
|
|
128
|
+
const conn = new HttpWebhookAgentConnector({ agents: { "agent-x": { url: mock.url } } });
|
|
129
|
+
const receipt = await conn.deliver("agent-x", { kind: "augment", content: "hi", meta: META_FIXTURE });
|
|
130
|
+
expect(typeof receipt.delivered_at).toBe("number");
|
|
131
|
+
expect(receipt.delivery_id).toBeUndefined();
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("throws DeliveryFailedError on 4xx", async () => {
|
|
135
|
+
mock.setResponse(400, { error: "bad request" });
|
|
136
|
+
const conn = new HttpWebhookAgentConnector({ agents: { "agent-x": { url: mock.url } } });
|
|
137
|
+
await expect(
|
|
138
|
+
conn.deliver("agent-x", { kind: "augment", content: "hi", meta: META_FIXTURE }),
|
|
139
|
+
).rejects.toThrow(DeliveryFailedError);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("throws DeliveryFailedError on 5xx", async () => {
|
|
143
|
+
mock.setResponse(503, { error: "service unavailable" });
|
|
144
|
+
const conn = new HttpWebhookAgentConnector({ agents: { "agent-x": { url: mock.url } } });
|
|
145
|
+
const err = await conn.deliver("agent-x", { kind: "augment", content: "hi", meta: META_FIXTURE }).catch((e: unknown) => e);
|
|
146
|
+
expect(err).toBeInstanceOf(DeliveryFailedError);
|
|
147
|
+
expect((err as DeliveryFailedError).http_status).toBe(503);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("throws DeliveryFailedError when agent_id is not configured", async () => {
|
|
151
|
+
const conn = new HttpWebhookAgentConnector({ agents: { "agent-x": { url: mock.url } } });
|
|
152
|
+
await expect(
|
|
153
|
+
conn.deliver("not-wired", { kind: "augment", content: "hi", meta: META_FIXTURE }),
|
|
154
|
+
).rejects.toThrow(/not configured/);
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
describe("HttpWebhookAgentConnector — Model A + Model B routing", () => {
|
|
159
|
+
it("Model A: multi-agent_id with distinct URLs → POSTs to the matching URL", async () => {
|
|
160
|
+
const mockA = await startMock();
|
|
161
|
+
const mockB = await startMock();
|
|
162
|
+
try {
|
|
163
|
+
const conn = new HttpWebhookAgentConnector({
|
|
164
|
+
agents: {
|
|
165
|
+
"agent-slack": { url: mockA.url },
|
|
166
|
+
"agent-whatsapp": { url: mockB.url },
|
|
167
|
+
},
|
|
168
|
+
});
|
|
169
|
+
await conn.deliver("agent-slack", { kind: "augment", content: "via slack", meta: META_FIXTURE });
|
|
170
|
+
await conn.deliver("agent-whatsapp", { kind: "augment", content: "via whatsapp", meta: META_FIXTURE });
|
|
171
|
+
|
|
172
|
+
expect(mockA.received).toHaveLength(1);
|
|
173
|
+
expect(mockB.received).toHaveLength(1);
|
|
174
|
+
const bodyA = JSON.parse(mockA.received[0]!.body.toString("utf8"));
|
|
175
|
+
const bodyB = JSON.parse(mockB.received[0]!.body.toString("utf8"));
|
|
176
|
+
expect(bodyA.content).toBe("via slack");
|
|
177
|
+
expect(bodyB.content).toBe("via whatsapp");
|
|
178
|
+
} finally {
|
|
179
|
+
await mockA.close();
|
|
180
|
+
await mockB.close();
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it("Model B: multi-agent_id with same URL → agent_id in body distinguishes", async () => {
|
|
185
|
+
const mock = await startMock();
|
|
186
|
+
try {
|
|
187
|
+
const conn = new HttpWebhookAgentConnector({
|
|
188
|
+
agents: {
|
|
189
|
+
"agent-slack": { url: mock.url },
|
|
190
|
+
"agent-whatsapp": { url: mock.url },
|
|
191
|
+
},
|
|
192
|
+
});
|
|
193
|
+
await conn.deliver("agent-slack", { kind: "augment", content: "x", meta: META_FIXTURE });
|
|
194
|
+
await conn.deliver("agent-whatsapp", { kind: "augment", content: "y", meta: META_FIXTURE });
|
|
195
|
+
|
|
196
|
+
expect(mock.received).toHaveLength(2);
|
|
197
|
+
const ids = mock.received.map((r) => JSON.parse(r.body.toString("utf8")).agent_id);
|
|
198
|
+
expect(ids).toEqual(["agent-slack", "agent-whatsapp"]);
|
|
199
|
+
} finally {
|
|
200
|
+
await mock.close();
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
describe("HttpWebhookAgentConnector — auth", () => {
|
|
206
|
+
let mock: MockServer;
|
|
207
|
+
beforeEach(async () => { mock = await startMock(); });
|
|
208
|
+
afterEach(async () => { await mock.close(); });
|
|
209
|
+
|
|
210
|
+
it("bearer-token sets Authorization header", async () => {
|
|
211
|
+
const conn = new HttpWebhookAgentConnector({
|
|
212
|
+
agents: { "agent-x": { url: mock.url } },
|
|
213
|
+
authorization: "Bearer abc123",
|
|
214
|
+
});
|
|
215
|
+
await conn.deliver("agent-x", { kind: "augment", content: "hi", meta: META_FIXTURE });
|
|
216
|
+
expect(mock.received[0]!.headers["authorization"]).toBe("Bearer abc123");
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it("bearer + HMAC combinable — both headers set when both options provided", async () => {
|
|
220
|
+
const conn = new HttpWebhookAgentConnector({
|
|
221
|
+
agents: { "agent-x": { url: mock.url } },
|
|
222
|
+
authorization: "Bearer combined-test",
|
|
223
|
+
hmac_secret: "combined-secret",
|
|
224
|
+
});
|
|
225
|
+
await conn.deliver("agent-x", { kind: "augment", content: "hi", meta: META_FIXTURE });
|
|
226
|
+
expect(mock.received[0]!.headers["authorization"]).toBe("Bearer combined-test");
|
|
227
|
+
expect(typeof mock.received[0]!.headers["x-signature"]).toBe("string");
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it("HMAC signing sets X-Signature header with correct value", async () => {
|
|
231
|
+
const secret = "test-secret-key";
|
|
232
|
+
const conn = new HttpWebhookAgentConnector({
|
|
233
|
+
agents: { "agent-x": { url: mock.url } },
|
|
234
|
+
hmac_secret: secret,
|
|
235
|
+
});
|
|
236
|
+
await conn.deliver("agent-x", { kind: "augment", content: "hi", meta: META_FIXTURE });
|
|
237
|
+
|
|
238
|
+
const rawBody = mock.received[0]!.body.toString("utf8");
|
|
239
|
+
const { createHmac } = await import("node:crypto");
|
|
240
|
+
const expected = `sha256=${createHmac("sha256", secret).update(rawBody, "utf8").digest("hex")}`;
|
|
241
|
+
expect(mock.received[0]!.headers["x-signature"]).toBe(expected);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it("no auth headers set when neither auth_header nor hmac_secret configured", async () => {
|
|
245
|
+
const conn = new HttpWebhookAgentConnector({ agents: { "agent-x": { url: mock.url } } });
|
|
246
|
+
await conn.deliver("agent-x", { kind: "augment", content: "hi", meta: META_FIXTURE });
|
|
247
|
+
expect(mock.received[0]!.headers["authorization"]).toBeUndefined();
|
|
248
|
+
expect(mock.received[0]!.headers["x-signature"]).toBeUndefined();
|
|
249
|
+
});
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
describe("HttpWebhookAgentConnector — list_agents / health_check / wake / request_response", () => {
|
|
253
|
+
let mock: MockServer;
|
|
254
|
+
beforeEach(async () => { mock = await startMock(); });
|
|
255
|
+
afterEach(async () => { await mock.close(); });
|
|
256
|
+
|
|
257
|
+
it("list_agents returns all configured agent_ids", async () => {
|
|
258
|
+
const conn = new HttpWebhookAgentConnector({
|
|
259
|
+
agents: {
|
|
260
|
+
"agent-slack": { url: mock.url },
|
|
261
|
+
"agent-whatsapp": { url: mock.url },
|
|
262
|
+
},
|
|
263
|
+
});
|
|
264
|
+
const agents = await conn.list_agents();
|
|
265
|
+
expect(agents.map((a) => a.agent_id).sort()).toEqual(["agent-slack", "agent-whatsapp"]);
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it("health_check returns true when no status_urls configured", async () => {
|
|
269
|
+
const conn = new HttpWebhookAgentConnector({ agents: { "agent-x": { url: mock.url } } });
|
|
270
|
+
expect(await conn.health_check()).toBe(true);
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
it("health_check pings status_urls when configured + returns true on 2xx", async () => {
|
|
274
|
+
const conn = new HttpWebhookAgentConnector({
|
|
275
|
+
agents: { "agent-x": { url: mock.url, status_url: `${mock.url}/status` } },
|
|
276
|
+
});
|
|
277
|
+
mock.setResponse(200, { status: "ok" });
|
|
278
|
+
expect(await conn.health_check()).toBe(true);
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
it("wake throws when wake_url not configured for the agent", async () => {
|
|
282
|
+
const conn = new HttpWebhookAgentConnector({ agents: { "agent-x": { url: mock.url } } });
|
|
283
|
+
await expect(conn.wake("agent-x")).rejects.toThrow(/no wake_url configured/);
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
it("wake POSTs to wake_url when configured", async () => {
|
|
287
|
+
const conn = new HttpWebhookAgentConnector({
|
|
288
|
+
agents: { "agent-x": { url: mock.url, wake_url: `${mock.url}/wake` } },
|
|
289
|
+
});
|
|
290
|
+
const receipt = await conn.wake("agent-x");
|
|
291
|
+
expect(typeof receipt.woken_at).toBe("number");
|
|
292
|
+
expect(mock.received).toHaveLength(1);
|
|
293
|
+
expect(mock.received[0]!.url).toBe("/wake");
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
it("request_response throws NotImplementedError (v0.10 deferred)", async () => {
|
|
297
|
+
const conn = new HttpWebhookAgentConnector({ agents: { "agent-x": { url: mock.url } } });
|
|
298
|
+
await expect(
|
|
299
|
+
conn.request_response("agent-x", { kind: "augment", content: "ping", meta: META_FIXTURE }, { timeout_ms: 1000 }),
|
|
300
|
+
).rejects.toThrow(/not implemented/i);
|
|
301
|
+
});
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
describe("HttpWebhookAgentConnector — fromEnv()", () => {
|
|
305
|
+
it("constructs from process.env-shaped object", () => {
|
|
306
|
+
const env = {
|
|
307
|
+
HTTP_WEBHOOK_AGENTS: JSON.stringify({ "agent-x": { url: "http://example.com" } }),
|
|
308
|
+
HTTP_WEBHOOK_TIMEOUT_MS: "8000",
|
|
309
|
+
HTTP_WEBHOOK_AUTH: "Bearer xyz",
|
|
310
|
+
};
|
|
311
|
+
const conn = HttpWebhookAgentConnector.fromEnv(env as NodeJS.ProcessEnv);
|
|
312
|
+
expect(conn).toBeInstanceOf(HttpWebhookAgentConnector);
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
it("throws when HTTP_WEBHOOK_AGENTS missing", () => {
|
|
316
|
+
expect(() => HttpWebhookAgentConnector.fromEnv({} as NodeJS.ProcessEnv)).toThrow(/required/);
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
it("throws when HTTP_WEBHOOK_AGENTS is invalid JSON", () => {
|
|
320
|
+
expect(() => HttpWebhookAgentConnector.fromEnv({ HTTP_WEBHOOK_AGENTS: "not-json" } as NodeJS.ProcessEnv)).toThrow(/valid JSON/);
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
it("throws when HTTP_WEBHOOK_AGENTS parses to non-object (string)", () => {
|
|
324
|
+
expect(() => HttpWebhookAgentConnector.fromEnv({ HTTP_WEBHOOK_AGENTS: JSON.stringify("just-a-string") } as NodeJS.ProcessEnv))
|
|
325
|
+
.toThrow(/must be a JSON object/);
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
it("throws when HTTP_WEBHOOK_AGENTS parses to array", () => {
|
|
329
|
+
expect(() => HttpWebhookAgentConnector.fromEnv({ HTTP_WEBHOOK_AGENTS: JSON.stringify(["a", "b"]) } as NodeJS.ProcessEnv))
|
|
330
|
+
.toThrow(/must be a JSON object/);
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
it("throws when an agent config is missing url", () => {
|
|
334
|
+
const env = { HTTP_WEBHOOK_AGENTS: JSON.stringify({ "agent-x": { wake_url: "http://example.com" } }) };
|
|
335
|
+
expect(() => HttpWebhookAgentConnector.fromEnv(env as NodeJS.ProcessEnv))
|
|
336
|
+
.toThrow(/missing required "url"/);
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
it("throws when HTTP_WEBHOOK_TIMEOUT_MS is non-numeric (NaN guard)", () => {
|
|
340
|
+
const env = {
|
|
341
|
+
HTTP_WEBHOOK_AGENTS: JSON.stringify({ "agent-x": { url: "http://example.com" } }),
|
|
342
|
+
HTTP_WEBHOOK_TIMEOUT_MS: "abc",
|
|
343
|
+
};
|
|
344
|
+
expect(() => HttpWebhookAgentConnector.fromEnv(env as NodeJS.ProcessEnv))
|
|
345
|
+
.toThrow(/positive integer/);
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
it("throws when HTTP_WEBHOOK_TIMEOUT_MS is zero or negative", () => {
|
|
349
|
+
const env = {
|
|
350
|
+
HTTP_WEBHOOK_AGENTS: JSON.stringify({ "agent-x": { url: "http://example.com" } }),
|
|
351
|
+
HTTP_WEBHOOK_TIMEOUT_MS: "0",
|
|
352
|
+
};
|
|
353
|
+
expect(() => HttpWebhookAgentConnector.fromEnv(env as NodeJS.ProcessEnv))
|
|
354
|
+
.toThrow(/positive integer/);
|
|
355
|
+
});
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
describe("HttpWebhookAgentConnector — defensive paths", () => {
|
|
359
|
+
let mock: MockServer;
|
|
360
|
+
beforeEach(async () => { mock = await startMock(); });
|
|
361
|
+
afterEach(async () => { await mock.close(); });
|
|
362
|
+
|
|
363
|
+
it("synthesizes minimal receipt when receiver returns malformed JSON", async () => {
|
|
364
|
+
mock.setResponse(200, "{not-valid-json");
|
|
365
|
+
const conn = new HttpWebhookAgentConnector({ agents: { "agent-x": { url: mock.url } } });
|
|
366
|
+
const receipt = await conn.deliver("agent-x", { kind: "augment", content: "hi", meta: META_FIXTURE });
|
|
367
|
+
expect(typeof receipt.delivered_at).toBe("number");
|
|
368
|
+
expect(receipt.delivery_id).toBeUndefined();
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
it("unconfigured agent throws DeliveryFailedError with cause_kind=client_validation", async () => {
|
|
372
|
+
const conn = new HttpWebhookAgentConnector({ agents: { "agent-x": { url: mock.url } } });
|
|
373
|
+
const err = await conn.deliver("not-wired", { kind: "augment", content: "hi", meta: META_FIXTURE }).catch((e: unknown) => e);
|
|
374
|
+
expect(err).toBeInstanceOf(DeliveryFailedError);
|
|
375
|
+
expect((err as DeliveryFailedError).cause_kind).toBe("client_validation");
|
|
376
|
+
expect((err as DeliveryFailedError).http_status).toBeNull();
|
|
377
|
+
});
|
|
378
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "skillscript-runtime",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.7",
|
|
4
4
|
"description": "Runtime, compiler, lint, CLI, and dashboard for Skillscript — a small declarative language for authoring agent workflows.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Scott Shwarts <scotts@pobox.com>",
|