@capybara-hq/testing 0.0.0-stage → 0.1.0-rc.0
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/LICENSE +202 -0
- package/NOTICE +2 -0
- package/README.md +34 -2
- package/dist/containers.d.ts +102 -0
- package/dist/containers.js +314 -0
- package/dist/dual-replica.d.ts +121 -0
- package/dist/dual-replica.js +247 -0
- package/dist/extension-config.d.ts +9 -0
- package/dist/extension-config.js +16 -0
- package/dist/fake-channel.d.ts +162 -0
- package/dist/fake-channel.js +325 -0
- package/dist/fake-e2b.d.ts +104 -0
- package/dist/fake-e2b.js +500 -0
- package/dist/fake-git-server.d.ts +57 -0
- package/dist/fake-git-server.js +323 -0
- package/dist/fake-lark.d.ts +234 -0
- package/dist/fake-lark.js +837 -0
- package/dist/fake-marketplace.d.ts +25 -0
- package/dist/fake-marketplace.js +58 -0
- package/dist/fake-mcp-server.d.ts +134 -0
- package/dist/fake-mcp-server.js +308 -0
- package/dist/fake-model-server.d.ts +28 -0
- package/dist/fake-model-server.js +67 -0
- package/dist/fake-npm-registry.d.ts +59 -0
- package/dist/fake-npm-registry.js +174 -0
- package/dist/fake-oauth-server.d.ts +63 -0
- package/dist/fake-oauth-server.js +284 -0
- package/dist/fake-openapi-server.d.ts +50 -0
- package/dist/fake-openapi-server.js +278 -0
- package/dist/fake-slack.d.ts +258 -0
- package/dist/fake-slack.js +654 -0
- package/dist/fixtures.d.ts +47 -0
- package/dist/fixtures.js +90 -0
- package/dist/global-setup.d.ts +15 -0
- package/dist/global-setup.js +58 -0
- package/dist/index.d.ts +25 -0
- package/dist/index.js +25 -0
- package/dist/kernel-helpers.d.ts +125 -0
- package/dist/kernel-helpers.js +336 -0
- package/dist/media-samples.d.ts +10 -0
- package/dist/media-samples.js +76 -0
- package/dist/model-conformance.d.ts +61 -0
- package/dist/model-conformance.js +287 -0
- package/dist/replica-runner.d.ts +1 -0
- package/dist/replica-runner.js +59 -0
- package/dist/sse.d.ts +20 -0
- package/dist/sse.js +87 -0
- package/fixtures/extensions/fake-bootstrap-a/.capybara-plugin/plugin.json +7 -0
- package/fixtures/extensions/fake-bootstrap-a/package.json +12 -0
- package/fixtures/extensions/fake-bootstrap-a/src/index.ts +62 -0
- package/fixtures/extensions/fake-bootstrap-b/.capybara-plugin/plugin.json +7 -0
- package/fixtures/extensions/fake-bootstrap-b/migrations/0001_init.sql +10 -0
- package/fixtures/extensions/fake-bootstrap-b/package.json +12 -0
- package/fixtures/extensions/fake-bootstrap-b/src/index.ts +123 -0
- package/fixtures/extensions/fake-connector/.capybara-plugin/plugin.json +7 -0
- package/fixtures/extensions/fake-connector/connectors/fake-demo.json +12 -0
- package/fixtures/extensions/fake-connector/package.json +13 -0
- package/fixtures/extensions/fake-connector/src/index.ts +477 -0
- package/fixtures/extensions/fake-dep-provider/.capybara-plugin/plugin.json +7 -0
- package/fixtures/extensions/fake-dep-provider/package.json +12 -0
- package/fixtures/extensions/fake-dep-provider/src/index.ts +19 -0
- package/fixtures/extensions/fake-dep-requirer/.capybara-plugin/plugin.json +8 -0
- package/fixtures/extensions/fake-dep-requirer/package.json +12 -0
- package/fixtures/extensions/fake-dep-requirer/src/index.ts +21 -0
- package/fixtures/extensions/fake-embed/.capybara-plugin/plugin.json +7 -0
- package/fixtures/extensions/fake-embed/package.json +12 -0
- package/fixtures/extensions/fake-embed/src/index.ts +207 -0
- package/fixtures/extensions/fake-im/.capybara-plugin/plugin.json +7 -0
- package/fixtures/extensions/fake-im/package.json +12 -0
- package/fixtures/extensions/fake-im/src/channel.ts +224 -0
- package/fixtures/extensions/fake-im/src/directory.ts +25 -0
- package/fixtures/extensions/fake-im/src/identity.ts +66 -0
- package/fixtures/extensions/fake-im/src/index.ts +91 -0
- package/fixtures/extensions/fake-object-store/.capybara-plugin/plugin.json +7 -0
- package/fixtures/extensions/fake-object-store/package.json +15 -0
- package/fixtures/extensions/fake-object-store/src/index.ts +210 -0
- package/fixtures/extensions/fake-openapi/.capybara-plugin/plugin.json +7 -0
- package/fixtures/extensions/fake-openapi/package.json +12 -0
- package/fixtures/extensions/fake-openapi/src/index.ts +258 -0
- package/fixtures/extensions/fake-storage-user/.capybara-plugin/plugin.json +7 -0
- package/fixtures/extensions/fake-storage-user/package.json +12 -0
- package/fixtures/extensions/fake-storage-user/src/index.ts +58 -0
- package/fixtures/extensions/fake-tools-ui/.capybara-plugin/plugin.json +7 -0
- package/fixtures/extensions/fake-tools-ui/connectors/fake-notes.json +11 -0
- package/fixtures/extensions/fake-tools-ui/migrations/0001_init.sql +7 -0
- package/fixtures/extensions/fake-tools-ui/package.json +12 -0
- package/fixtures/extensions/fake-tools-ui/skills/fake-notes/SKILL.md +7 -0
- package/fixtures/extensions/fake-tools-ui/src/index.ts +140 -0
- package/package.json +66 -3
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
import { assertValidDeclarations, EFFORT_VALUES, isModelError, sanitizeParams, validateParams, } from "@capybara-hq/plugin-sdk";
|
|
2
|
+
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
|
3
|
+
import { pdfBase64, redPngBase64 } from "./media-samples.js";
|
|
4
|
+
function streamOf(t, req, signal) {
|
|
5
|
+
const { params, ...rest } = req;
|
|
6
|
+
if (t.contract === 2)
|
|
7
|
+
return t.provider.stream({ ...rest, params: params ?? {} }, { signal });
|
|
8
|
+
return t.provider.stream(rest, { signal });
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* `@2` 参数声明检查:每个模型的 `parameters` 须通过 `assertValidDeclarations`,
|
|
12
|
+
* 每个声明的默认值须通过 `validateParams`。返回问题列表(形如 `<模型 id>: <原因>`),无问题为空。
|
|
13
|
+
*/
|
|
14
|
+
export function checkDeclaredParameters(plugin, models) {
|
|
15
|
+
const problems = [];
|
|
16
|
+
for (const m of models) {
|
|
17
|
+
try {
|
|
18
|
+
assertValidDeclarations(plugin, m.parameters);
|
|
19
|
+
}
|
|
20
|
+
catch (e) {
|
|
21
|
+
problems.push(`${m.id}: ${e instanceof Error ? e.message : String(e)}`);
|
|
22
|
+
continue;
|
|
23
|
+
}
|
|
24
|
+
for (const d of m.parameters) {
|
|
25
|
+
if (d.default === undefined)
|
|
26
|
+
continue;
|
|
27
|
+
const r = validateParams(m.parameters, { [d.key]: d.default });
|
|
28
|
+
if (!r.ok)
|
|
29
|
+
problems.push(`${m.id}: default of ${r.issues.map((i) => i.message).join("; ")}`);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return problems;
|
|
33
|
+
}
|
|
34
|
+
const enumOf = (decl, key) => {
|
|
35
|
+
const s = decl.find((d) => d.key === key)?.schema;
|
|
36
|
+
return s?.type === "string" && "enum" in s ? s.enum : [];
|
|
37
|
+
};
|
|
38
|
+
/**
|
|
39
|
+
* thinking replay 用例的 `@2` 参数:按模型的声明取最高一档 effort、thinking 取 `summarized`(否则 `on`),
|
|
40
|
+
* 未声明的不设置;其余已声明参数补默认值。目的是让思考尽量稳定出现(adaptive 思考在简单问题上会跳过思考)。
|
|
41
|
+
*/
|
|
42
|
+
export function thinkingReplayParams(decl) {
|
|
43
|
+
const want = {};
|
|
44
|
+
const efforts = enumOf(decl, "effort");
|
|
45
|
+
const top = [...EFFORT_VALUES].reverse().find((e) => efforts.includes(e));
|
|
46
|
+
if (top !== undefined)
|
|
47
|
+
want.effort = top;
|
|
48
|
+
const thinking = enumOf(decl, "thinking");
|
|
49
|
+
const mode = ["summarized", "on"].find((m) => thinking.includes(m));
|
|
50
|
+
if (mode !== undefined)
|
|
51
|
+
want.thinking = mode;
|
|
52
|
+
return sanitizeParams(decl, want).value;
|
|
53
|
+
}
|
|
54
|
+
/** thinking replay 的提问:需要多步推理的短应用题(简单算式可能让 adaptive 思考直接跳过)。 */
|
|
55
|
+
const THINKING_PROMPT = "A shop sells pencils at 3 for $2. Maria buys 27 pencils and pays with a $50 bill. " +
|
|
56
|
+
"How much change does she get? Work it out, then answer with just the amount.";
|
|
57
|
+
async function collect(t, req, signal = new AbortController().signal) {
|
|
58
|
+
const events = [];
|
|
59
|
+
for await (const ev of streamOf(t, req, signal))
|
|
60
|
+
events.push(ev);
|
|
61
|
+
const completes = events.filter((e) => e.type === "message_complete");
|
|
62
|
+
expect(completes).toHaveLength(1);
|
|
63
|
+
expect(events[events.length - 1]?.type).toBe("message_complete");
|
|
64
|
+
return { events, done: completes[0] };
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* 媒体用例的样本与期望答案。夹具服务按原生格式认出这些样本后回答对应的词,所以夹具模式也能证明
|
|
68
|
+
* 插件把媒体块映射成了供应商认得的形状。
|
|
69
|
+
*/
|
|
70
|
+
export const MEDIA_SAMPLES = {
|
|
71
|
+
/** 64×64 纯红 PNG;期望回答含 "red"。 */
|
|
72
|
+
image: { mediaType: "image/png", name: "red.png", answer: "red" },
|
|
73
|
+
/** 一页 PDF,只印这个词。 */
|
|
74
|
+
pdf: { mediaType: "application/pdf", name: "fruit.pdf", word: "PINEAPPLE" },
|
|
75
|
+
/** 纯文本文档,内容是 {@link MEDIA_SAMPLES.text.content}。 */
|
|
76
|
+
text: {
|
|
77
|
+
mediaType: "text/plain",
|
|
78
|
+
name: "note.txt",
|
|
79
|
+
content: "The secret word is MANGO.\n",
|
|
80
|
+
word: "MANGO",
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
const textOf = (content) => content.map((b) => (b.type === "text" ? b.text : "")).join("");
|
|
84
|
+
/** model-provider 一致性套件(`@1` 与 `@2`):每个供应商插件都应通过。 */
|
|
85
|
+
export function describeModelProviderConformance(name, setup) {
|
|
86
|
+
describe(`model-provider conformance: ${name}`, () => {
|
|
87
|
+
let t;
|
|
88
|
+
beforeAll(async () => {
|
|
89
|
+
t = await setup();
|
|
90
|
+
});
|
|
91
|
+
afterAll(async () => {
|
|
92
|
+
await t?.teardown();
|
|
93
|
+
});
|
|
94
|
+
const base = (over = {}) => ({
|
|
95
|
+
model: t.model,
|
|
96
|
+
system: [{ text: "You are a concise assistant.", cache: true }],
|
|
97
|
+
messages: [{ role: "user", content: [{ type: "text", text: "Say hello." }] }],
|
|
98
|
+
tools: [],
|
|
99
|
+
maxOutputTokens: 1024,
|
|
100
|
+
...over,
|
|
101
|
+
});
|
|
102
|
+
const weather = {
|
|
103
|
+
name: "get_weather",
|
|
104
|
+
description: "Get the current weather for a city.",
|
|
105
|
+
inputSchema: {
|
|
106
|
+
type: "object",
|
|
107
|
+
properties: { city: { type: "string" } },
|
|
108
|
+
required: ["city"],
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
it("streams text", async () => {
|
|
112
|
+
const { events, done } = await collect(t, base());
|
|
113
|
+
const deltas = events.flatMap((e) => (e.type === "text_delta" ? [e.text] : []));
|
|
114
|
+
expect(deltas.length).toBeGreaterThan(0);
|
|
115
|
+
expect(deltas.join("")).toBe(textOf(done.content));
|
|
116
|
+
expect(done.stopReason).toBe("end_turn");
|
|
117
|
+
});
|
|
118
|
+
it("tool round trip", async () => {
|
|
119
|
+
const first = base({
|
|
120
|
+
tools: [weather],
|
|
121
|
+
messages: [
|
|
122
|
+
{ role: "user", content: [{ type: "text", text: "What is the weather in Paris?" }] },
|
|
123
|
+
],
|
|
124
|
+
});
|
|
125
|
+
const { done } = await collect(t, first);
|
|
126
|
+
expect(done.stopReason).toBe("tool_use");
|
|
127
|
+
const call = done.content.find((b) => b.type === "tool_call");
|
|
128
|
+
expect(call).toBeDefined();
|
|
129
|
+
if (call?.type !== "tool_call")
|
|
130
|
+
return;
|
|
131
|
+
expect(typeof call.input).toBe("object");
|
|
132
|
+
expect(call.input).not.toBeNull();
|
|
133
|
+
const second = await collect(t, base({
|
|
134
|
+
tools: [weather],
|
|
135
|
+
messages: [
|
|
136
|
+
...first.messages,
|
|
137
|
+
{ role: "assistant", content: done.content },
|
|
138
|
+
{
|
|
139
|
+
role: "user",
|
|
140
|
+
content: [
|
|
141
|
+
{ type: "tool_result", callId: call.id, content: "Sunny, 22C", isError: false },
|
|
142
|
+
],
|
|
143
|
+
},
|
|
144
|
+
],
|
|
145
|
+
}));
|
|
146
|
+
expect(second.done.content.some((b) => b.type === "text")).toBe(true);
|
|
147
|
+
});
|
|
148
|
+
/** 思考用例的参数:`@1` 发 `effort: "high"`;`@2` 见 {@link thinkingReplayParams}。 */
|
|
149
|
+
const thinkingOver = async () => {
|
|
150
|
+
if (t.contract !== 2)
|
|
151
|
+
return { effort: "high" };
|
|
152
|
+
const info = (await t.provider.listModels()).find((m) => m.id === t.model);
|
|
153
|
+
return { params: thinkingReplayParams(info?.parameters ?? []) };
|
|
154
|
+
};
|
|
155
|
+
it("declares valid parameters", async (ctx) => {
|
|
156
|
+
if (t.contract !== 2)
|
|
157
|
+
return ctx.skip();
|
|
158
|
+
expect(checkDeclaredParameters(t.plugin, await t.provider.listModels())).toEqual([]);
|
|
159
|
+
});
|
|
160
|
+
it("thinking replay", async (ctx) => {
|
|
161
|
+
if (!t.expect.thinking)
|
|
162
|
+
return ctx.skip();
|
|
163
|
+
// 最高一档 effort 下推理 token 也计入输出上限:1024 不够(gpt-5-nano 第二轮会 max_tokens),放宽
|
|
164
|
+
const over = { ...(await thinkingOver()), maxOutputTokens: 8192 };
|
|
165
|
+
const first = base({
|
|
166
|
+
...over,
|
|
167
|
+
messages: [{ role: "user", content: [{ type: "text", text: THINKING_PROMPT }] }],
|
|
168
|
+
});
|
|
169
|
+
const { done } = await collect(t, first);
|
|
170
|
+
expect(done.content.some((b) => b.type === "thinking")).toBe(true);
|
|
171
|
+
const second = await collect(t, base({
|
|
172
|
+
...over,
|
|
173
|
+
messages: [
|
|
174
|
+
...first.messages,
|
|
175
|
+
{ role: "assistant", content: done.content },
|
|
176
|
+
{ role: "user", content: [{ type: "text", text: "Now double that amount." }] },
|
|
177
|
+
],
|
|
178
|
+
}));
|
|
179
|
+
expect(second.done.stopReason).toBe("end_turn");
|
|
180
|
+
});
|
|
181
|
+
it("caching", async (ctx) => {
|
|
182
|
+
if (!t.expect.caching)
|
|
183
|
+
return ctx.skip();
|
|
184
|
+
const req = base({
|
|
185
|
+
system: [
|
|
186
|
+
{
|
|
187
|
+
text: `Stable prefix. ${"Reference material. ".repeat(Math.ceil((t.cachePrefixTokens ?? 5000) / 4))}`,
|
|
188
|
+
cache: true,
|
|
189
|
+
},
|
|
190
|
+
],
|
|
191
|
+
});
|
|
192
|
+
await collect(t, req);
|
|
193
|
+
const second = await collect(t, req);
|
|
194
|
+
expect(second.done.usage.cacheReadTokens).toBeGreaterThan(0);
|
|
195
|
+
});
|
|
196
|
+
it("usage", async () => {
|
|
197
|
+
const { done } = await collect(t, base());
|
|
198
|
+
// inputTokens 只是未缓存部分:缓存命中时可能为 0,所以看三类输入之和
|
|
199
|
+
const u = done.usage;
|
|
200
|
+
expect(u.inputTokens + u.cacheReadTokens + u.cacheWriteTokens).toBeGreaterThan(0);
|
|
201
|
+
expect(u.outputTokens).toBeGreaterThan(0);
|
|
202
|
+
});
|
|
203
|
+
it("mid-conversation system", async () => {
|
|
204
|
+
const { done } = await collect(t, base({
|
|
205
|
+
messages: [
|
|
206
|
+
{ role: "user", content: [{ type: "text", text: "Say hello." }] },
|
|
207
|
+
{ role: "assistant", content: [{ type: "text", text: "Hello." }] },
|
|
208
|
+
{ role: "system", content: "Reminder: keep answers under ten words." },
|
|
209
|
+
{ role: "user", content: [{ type: "text", text: "Say goodbye." }] },
|
|
210
|
+
],
|
|
211
|
+
}));
|
|
212
|
+
expect(done.content.length).toBeGreaterThan(0);
|
|
213
|
+
});
|
|
214
|
+
/** `@2` 模型的能力声明;`@1` 返回 null(媒体用例只对 `@2` 跑)。 */
|
|
215
|
+
const caps = async () => {
|
|
216
|
+
if (t.contract !== 2)
|
|
217
|
+
return null;
|
|
218
|
+
const info = (await t.provider.listModels()).find((m) => m.id === t.model);
|
|
219
|
+
return info?.capabilities ?? null;
|
|
220
|
+
};
|
|
221
|
+
const ask = (block, question) => base({
|
|
222
|
+
messages: [{ role: "user", content: [block, { type: "text", text: question }] }],
|
|
223
|
+
});
|
|
224
|
+
it("user message with image", async (ctx) => {
|
|
225
|
+
if ((await caps())?.vision !== true)
|
|
226
|
+
return ctx.skip();
|
|
227
|
+
const s = MEDIA_SAMPLES.image;
|
|
228
|
+
const { done } = await collect(t, ask({
|
|
229
|
+
type: "image",
|
|
230
|
+
mediaType: s.mediaType,
|
|
231
|
+
name: s.name,
|
|
232
|
+
source: { kind: "base64", data: redPngBase64() },
|
|
233
|
+
}, "What is the color of this image? Answer with one word."));
|
|
234
|
+
expect(done.stopReason).toBe("end_turn");
|
|
235
|
+
expect(textOf(done.content).toLowerCase()).toContain(s.answer);
|
|
236
|
+
});
|
|
237
|
+
it("user message with PDF document", async (ctx) => {
|
|
238
|
+
if ((await caps())?.documents !== true)
|
|
239
|
+
return ctx.skip();
|
|
240
|
+
const s = MEDIA_SAMPLES.pdf;
|
|
241
|
+
const { done } = await collect(t, ask({
|
|
242
|
+
type: "document",
|
|
243
|
+
mediaType: s.mediaType,
|
|
244
|
+
name: s.name,
|
|
245
|
+
source: { kind: "base64", data: pdfBase64(s.word) },
|
|
246
|
+
}, "What single word is printed in this PDF? Answer with just the word."));
|
|
247
|
+
expect(done.stopReason).toBe("end_turn");
|
|
248
|
+
expect(textOf(done.content).toUpperCase()).toContain(s.word);
|
|
249
|
+
});
|
|
250
|
+
it("user message with text document", async (ctx) => {
|
|
251
|
+
if ((await caps())?.documents !== true)
|
|
252
|
+
return ctx.skip();
|
|
253
|
+
const s = MEDIA_SAMPLES.text;
|
|
254
|
+
const { done } = await collect(t, ask({
|
|
255
|
+
type: "document",
|
|
256
|
+
mediaType: s.mediaType,
|
|
257
|
+
name: s.name,
|
|
258
|
+
source: { kind: "base64", data: Buffer.from(s.content, "utf8").toString("base64") },
|
|
259
|
+
}, "What is the secret word in the attached file? Answer with just the word."));
|
|
260
|
+
expect(done.stopReason).toBe("end_turn");
|
|
261
|
+
expect(textOf(done.content).toUpperCase()).toContain(s.word);
|
|
262
|
+
});
|
|
263
|
+
it("abort", async () => {
|
|
264
|
+
const ac = new AbortController();
|
|
265
|
+
const it = streamOf(t, base(), ac.signal)[Symbol.asyncIterator]();
|
|
266
|
+
const firstEv = await it.next();
|
|
267
|
+
expect(firstEv.done).toBe(false);
|
|
268
|
+
ac.abort();
|
|
269
|
+
let err;
|
|
270
|
+
try {
|
|
271
|
+
// 事件不再继续产出:中止后最多再得到已缓冲的事件,随后必须抛错而不是正常结束
|
|
272
|
+
for (;;) {
|
|
273
|
+
const r = await it.next();
|
|
274
|
+
if (r.done)
|
|
275
|
+
break;
|
|
276
|
+
expect(r.value.type).not.toBe("message_complete");
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
catch (e) {
|
|
280
|
+
err = e;
|
|
281
|
+
}
|
|
282
|
+
expect(isModelError(err)).toBe(true);
|
|
283
|
+
if (isModelError(err))
|
|
284
|
+
expect(err.kind).toBe("aborted");
|
|
285
|
+
});
|
|
286
|
+
});
|
|
287
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { createKernel, KernelConfigSchema } from "@capybara-hq/kernel";
|
|
2
|
+
let kernel;
|
|
3
|
+
const isPlainObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
4
|
+
/** 深合并:普通对象逐键递归,其余(含数组)由 `over` 整体替换。 */
|
|
5
|
+
function deepMerge(base, over) {
|
|
6
|
+
const out = { ...base };
|
|
7
|
+
for (const [k, v] of Object.entries(over)) {
|
|
8
|
+
const b = out[k];
|
|
9
|
+
out[k] = isPlainObject(b) && isPlainObject(v) ? deepMerge(b, v) : v;
|
|
10
|
+
}
|
|
11
|
+
return out;
|
|
12
|
+
}
|
|
13
|
+
/** 启动内核并回报实际监听端口。 */
|
|
14
|
+
async function startKernel(c) {
|
|
15
|
+
const base = {
|
|
16
|
+
db: { url: c.dbUrl, poolMax: 3 },
|
|
17
|
+
redis: { url: c.redisUrl },
|
|
18
|
+
masterKey: c.masterKey,
|
|
19
|
+
http: { port: 0, replicaId: c.replicaId },
|
|
20
|
+
roles: c.roles,
|
|
21
|
+
extensions: c.extensions,
|
|
22
|
+
extensionConfig: c.extensionConfig,
|
|
23
|
+
// 测试默认自动批准插件权限(生产默认关闭);调用方可经 config 覆盖为 false 验证待批准行为
|
|
24
|
+
extensionPermissions: { autoApprove: true },
|
|
25
|
+
// The official marketplace is on by default and on the internet: tests turn it on explicitly.
|
|
26
|
+
marketplaces: { official: { enabled: false } },
|
|
27
|
+
log: { level: "error" },
|
|
28
|
+
};
|
|
29
|
+
const config = KernelConfigSchema.parse(deepMerge(base, c.overrides));
|
|
30
|
+
kernel = await createKernel(config, {
|
|
31
|
+
resolveFrom: c.resolveFrom,
|
|
32
|
+
...(c.bootstrapDir !== undefined && { bootstrapDir: c.bootstrapDir }),
|
|
33
|
+
...(c.bootstrapManifest !== undefined && { bootstrapManifest: c.bootstrapManifest }),
|
|
34
|
+
});
|
|
35
|
+
await kernel.start();
|
|
36
|
+
process.send?.({ type: "ready", port: kernel.port });
|
|
37
|
+
}
|
|
38
|
+
let exiting = false;
|
|
39
|
+
/** 停内核后退出;内核停机卡住时 10 秒后强制退出。 */
|
|
40
|
+
function shutdown() {
|
|
41
|
+
if (exiting)
|
|
42
|
+
return;
|
|
43
|
+
exiting = true;
|
|
44
|
+
setTimeout(() => process.exit(1), 10_000).unref();
|
|
45
|
+
(kernel?.stop() ?? Promise.resolve()).finally(() => process.exit(0));
|
|
46
|
+
}
|
|
47
|
+
// 父进程(含被 SIGKILL)消失后 IPC 通道断开,副本自行退出,不留孤儿
|
|
48
|
+
process.on("disconnect", shutdown);
|
|
49
|
+
process.on("message", (msg) => {
|
|
50
|
+
if (msg.type === "start") {
|
|
51
|
+
startKernel(msg.config).catch((e) => {
|
|
52
|
+
// 只回报错误信息,绝不回显配置
|
|
53
|
+
process.send?.({ type: "failed", error: e instanceof Error ? e.message : String(e) }, () => process.exit(1));
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
else if (msg.type === "stop") {
|
|
57
|
+
shutdown();
|
|
58
|
+
}
|
|
59
|
+
});
|
package/dist/sse.d.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/** One parsed SSE message; `comment` is a line starting with `:` (a heartbeat). */
|
|
2
|
+
export interface SseMessage {
|
|
3
|
+
id?: string;
|
|
4
|
+
event?: string;
|
|
5
|
+
data?: string;
|
|
6
|
+
comment?: string;
|
|
7
|
+
}
|
|
8
|
+
/** An SSE stream read with fetch: messages parsed as they arrive, `until` waits for a condition. */
|
|
9
|
+
export interface SseClient {
|
|
10
|
+
status: number;
|
|
11
|
+
headers: Headers;
|
|
12
|
+
messages: SseMessage[];
|
|
13
|
+
until(pred: (msgs: SseMessage[]) => boolean, timeoutMs?: number, label?: string): Promise<void>;
|
|
14
|
+
close(): void;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Opens an SSE connection (a real port). A non-200 answer is returned too, with no `messages`;
|
|
18
|
+
* `close()` aborts the request.
|
|
19
|
+
*/
|
|
20
|
+
export declare function openSse(url: string, headers: Record<string, string>): Promise<SseClient>;
|
package/dist/sse.js
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Opens an SSE connection (a real port). A non-200 answer is returned too, with no `messages`;
|
|
3
|
+
* `close()` aborts the request.
|
|
4
|
+
*/
|
|
5
|
+
export async function openSse(url, headers) {
|
|
6
|
+
const ac = new AbortController();
|
|
7
|
+
const res = await fetch(url, { headers, signal: ac.signal });
|
|
8
|
+
const messages = [];
|
|
9
|
+
const waiters = new Set();
|
|
10
|
+
const notify = () => {
|
|
11
|
+
for (const w of [...waiters])
|
|
12
|
+
w();
|
|
13
|
+
};
|
|
14
|
+
let ended = false;
|
|
15
|
+
void (async () => {
|
|
16
|
+
if (!res.body)
|
|
17
|
+
return;
|
|
18
|
+
const reader = res.body.getReader();
|
|
19
|
+
const dec = new TextDecoder();
|
|
20
|
+
let buf = "";
|
|
21
|
+
try {
|
|
22
|
+
for (;;) {
|
|
23
|
+
const { done, value } = await reader.read();
|
|
24
|
+
if (done)
|
|
25
|
+
break;
|
|
26
|
+
buf += dec.decode(value, { stream: true });
|
|
27
|
+
for (let i = buf.indexOf("\n\n"); i >= 0; i = buf.indexOf("\n\n")) {
|
|
28
|
+
const block = buf.slice(0, i);
|
|
29
|
+
buf = buf.slice(i + 2);
|
|
30
|
+
const m = {};
|
|
31
|
+
for (const line of block.split("\n")) {
|
|
32
|
+
if (line.startsWith(":"))
|
|
33
|
+
m.comment = line.slice(1).trim();
|
|
34
|
+
else {
|
|
35
|
+
const c = line.indexOf(":");
|
|
36
|
+
const key = line.slice(0, c);
|
|
37
|
+
const v = line.slice(c + 1).replace(/^ /, "");
|
|
38
|
+
if (key === "id" || key === "event")
|
|
39
|
+
m[key] = v;
|
|
40
|
+
else if (key === "data")
|
|
41
|
+
m.data = m.data === undefined ? v : `${m.data}\n${v}`;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
messages.push(m);
|
|
45
|
+
}
|
|
46
|
+
notify();
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
// aborted by close()
|
|
51
|
+
}
|
|
52
|
+
finally {
|
|
53
|
+
ended = true;
|
|
54
|
+
notify();
|
|
55
|
+
}
|
|
56
|
+
})();
|
|
57
|
+
return {
|
|
58
|
+
status: res.status,
|
|
59
|
+
headers: res.headers,
|
|
60
|
+
messages,
|
|
61
|
+
until(pred, timeoutMs = 10_000, label = "sse condition") {
|
|
62
|
+
return new Promise((resolve, reject) => {
|
|
63
|
+
const check = () => {
|
|
64
|
+
if (pred(messages)) {
|
|
65
|
+
cleanup();
|
|
66
|
+
resolve();
|
|
67
|
+
}
|
|
68
|
+
else if (ended) {
|
|
69
|
+
cleanup();
|
|
70
|
+
reject(new Error(`stream ended before ${label}`));
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
const timer = setTimeout(() => {
|
|
74
|
+
cleanup();
|
|
75
|
+
reject(new Error(`timed out waiting for ${label}`));
|
|
76
|
+
}, timeoutMs);
|
|
77
|
+
const cleanup = () => {
|
|
78
|
+
clearTimeout(timer);
|
|
79
|
+
waiters.delete(check);
|
|
80
|
+
};
|
|
81
|
+
waiters.add(check);
|
|
82
|
+
check();
|
|
83
|
+
});
|
|
84
|
+
},
|
|
85
|
+
close: () => ac.abort(),
|
|
86
|
+
};
|
|
87
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@capybara-hq/test-plugin-fake-bootstrap-a",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"description": "Test fake for bootstrap: a model-provider@2 extension that needs deployment config",
|
|
6
|
+
"license": "Apache-2.0",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"dependencies": {
|
|
9
|
+
"@capybara-hq/plugin-sdk": "^0.1.0-rc.0",
|
|
10
|
+
"zod": "^4.6.7"
|
|
11
|
+
}
|
|
12
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import {
|
|
2
|
+
defineExtension,
|
|
3
|
+
MODEL_PROVIDER_EXTENSION_POINT_V2,
|
|
4
|
+
type ModelProvider,
|
|
5
|
+
secret,
|
|
6
|
+
} from "@capybara-hq/plugin-sdk";
|
|
7
|
+
import { z } from "zod";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Test fake for the bootstrap step: a model provider (`model-provider@2`) whose deployment config
|
|
11
|
+
* has a required secret `apiKey`, so an unconfigured deployment leaves it pending configuration
|
|
12
|
+
* (as the image's model provider is). Once configured it offers one model `fake-a-1` that answers
|
|
13
|
+
* every request with `fake-bootstrap-a`; it never calls a network service.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const DeploymentConfig = z.object({
|
|
17
|
+
apiKey: secret(z.string().min(1)),
|
|
18
|
+
baseUrl: z.string().url().optional(),
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
const REPLY = "fake-bootstrap-a";
|
|
22
|
+
|
|
23
|
+
const provider: ModelProvider = {
|
|
24
|
+
async listModels() {
|
|
25
|
+
return [
|
|
26
|
+
{
|
|
27
|
+
id: "fake-a-1",
|
|
28
|
+
displayName: "Fake A 1",
|
|
29
|
+
contextWindow: 8192,
|
|
30
|
+
maxOutputTokens: 1024,
|
|
31
|
+
capabilities: {
|
|
32
|
+
tools: false,
|
|
33
|
+
vision: false,
|
|
34
|
+
reasoning: false,
|
|
35
|
+
caching: false,
|
|
36
|
+
midConversationSystem: false,
|
|
37
|
+
},
|
|
38
|
+
parameters: [],
|
|
39
|
+
},
|
|
40
|
+
];
|
|
41
|
+
},
|
|
42
|
+
async *stream() {
|
|
43
|
+
yield { type: "text_delta", text: REPLY };
|
|
44
|
+
yield {
|
|
45
|
+
type: "message_complete",
|
|
46
|
+
content: [{ type: "text", text: REPLY }],
|
|
47
|
+
stopReason: "end_turn",
|
|
48
|
+
usage: { inputTokens: 1, outputTokens: 1, cacheReadTokens: 0, cacheWriteTokens: 0 },
|
|
49
|
+
};
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export default defineExtension({
|
|
54
|
+
name: "fake-bootstrap-a",
|
|
55
|
+
version: "0.1.0",
|
|
56
|
+
sdk: "^0.1.0-rc.0",
|
|
57
|
+
provides: [MODEL_PROVIDER_EXTENSION_POINT_V2],
|
|
58
|
+
config: { deployment: DeploymentConfig },
|
|
59
|
+
setup(ctx) {
|
|
60
|
+
ctx.provide(MODEL_PROVIDER_EXTENSION_POINT_V2, provider);
|
|
61
|
+
},
|
|
62
|
+
});
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
-- Runs with search_path "plugin_fake_bootstrap_b, public": table names carry no schema prefix.
|
|
2
|
+
-- The objects of the fake store, per configured bucket.
|
|
3
|
+
CREATE TABLE IF NOT EXISTS objects (
|
|
4
|
+
bucket text NOT NULL,
|
|
5
|
+
key text NOT NULL,
|
|
6
|
+
content_type text NOT NULL,
|
|
7
|
+
data bytea NOT NULL,
|
|
8
|
+
updated_at timestamptz NOT NULL DEFAULT now(),
|
|
9
|
+
PRIMARY KEY (bucket, key)
|
|
10
|
+
);
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@capybara-hq/test-plugin-fake-bootstrap-b",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"description": "Test fake for bootstrap: an object-store@1 extension that keeps objects in its PostgreSQL schema",
|
|
6
|
+
"license": "Apache-2.0",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"dependencies": {
|
|
9
|
+
"@capybara-hq/plugin-sdk": "^0.1.0-rc.0",
|
|
10
|
+
"zod": "^4.6.7"
|
|
11
|
+
}
|
|
12
|
+
}
|