@sns-myagent/cli 0.2.0 → 0.3.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/CHANGELOG.md +12 -1
- package/README.md +7 -8
- package/package.json +3 -3
- package/scripts/apply-pi-natives-patch.js +82 -56
- package/src/adapters/telegram/bot.ts +41 -1
- package/src/adapters/telegram/bridge.ts +186 -0
- package/src/adapters/telegram/handler.ts +138 -13
- package/src/adapters/telegram/index.ts +12 -2
- package/src/agents/__tests__/config.test.ts +79 -0
- package/src/agents/__tests__/resilience.test.ts +119 -0
- package/src/agents/__tests__/strategies.test.ts +83 -0
- package/src/agents/config.ts +367 -0
- package/src/agents/ensemble.ts +224 -0
- package/src/agents/resilience.ts +332 -0
- package/src/agents/strategies/best-of-n.ts +108 -0
- package/src/agents/strategies/consensus.ts +105 -0
- package/src/agents/strategies/critic.ts +131 -0
- package/src/agents/strategies/types.ts +68 -0
- package/src/async/__tests__/task-runner.test.ts +162 -0
- package/src/async/__tests__/task-store.test.ts +146 -0
- package/src/async/index.ts +28 -1
- package/src/async/notifier.ts +133 -0
- package/src/async/task-runner.ts +175 -0
- package/src/async/task-store.ts +170 -0
- package/src/async/types.ts +70 -0
- package/src/cli/entry.ts +3 -1
- package/src/cli/index.ts +69 -54
- package/src/config/index.ts +1 -1
- package/src/debug/index.ts +1 -1
- package/src/modes/components/welcome.ts +13 -6
- package/src/modes/controllers/event-controller.ts +1 -1
- package/src/modes/setup-wizard/scenes/splash.ts +1 -1
- package/src/session/agent-session.ts +1 -1
- package/src/slash-commands/builtin-registry.ts +63 -0
- package/src/slash-commands/helpers/task.ts +181 -0
- package/src/tbm/__tests__/tbm.test.ts +660 -0
- package/src/tbm/comm-modes.ts +165 -0
- package/src/tbm/config.ts +136 -0
- package/src/tbm/context-delta.ts +146 -0
- package/src/tbm/context-pyramid.ts +202 -0
- package/src/tbm/dashboard.ts +131 -0
- package/src/tbm/index.ts +247 -0
- package/src/tbm/lazy-skills.ts +182 -0
- package/src/tbm/response-cache.ts +220 -0
- package/src/tbm/tombstone.ts +189 -0
- package/src/tbm/tool-compress.ts +230 -0
- package/src/tools/ask.ts +1 -1
- package/src/tui/chat-blocks.ts +205 -0
- package/src/tui/chat-ui.ts +270 -0
- package/src/tui/code-cell.ts +90 -1
- package/src/tui/command-palette.ts +189 -0
- package/src/tui/index.ts +4 -0
- package/src/tui/splash.ts +130 -0
- package/src/ui/banner.ts +69 -29
- package/src/ui/colors.ts +24 -0
- package/src/ui/error-display.ts +130 -0
- package/src/ui/gradient.ts +104 -0
- package/src/ui/index.ts +15 -0
- package/src/ui/memory-toast.ts +102 -0
- package/src/ui/status-bar.ts +36 -30
- package/bin/snscoder.js +0 -98
|
@@ -12,12 +12,18 @@ import { logger } from "@oh-my-pi/pi-utils";
|
|
|
12
12
|
export type ParsedMessage =
|
|
13
13
|
| { kind: "command"; command: TelegramCommand; args: string; raw: string; userId: number; chatId: number; messageId: number }
|
|
14
14
|
| { kind: "chat"; text: string; userId: number; chatId: number; messageId: number }
|
|
15
|
+
| { kind: "file"; fileId: string; fileName: string; mimeType: string; caption: string; userId: number; chatId: number; messageId: number }
|
|
15
16
|
| { kind: "empty"; userId: number; chatId: number; messageId: number };
|
|
16
17
|
|
|
17
|
-
export type TelegramCommand =
|
|
18
|
+
export type TelegramCommand =
|
|
19
|
+
| "start" | "help" | "chat" | "reset" | "status"
|
|
20
|
+
| "memory" | "cron" | "model" | "code" | "review" | "task";
|
|
18
21
|
|
|
19
22
|
/** Commands we recognise, mapped to their canonical name. */
|
|
20
|
-
const COMMANDS: ReadonlySet<string> = new Set([
|
|
23
|
+
const COMMANDS: ReadonlySet<string> = new Set([
|
|
24
|
+
"start", "help", "chat", "reset", "status",
|
|
25
|
+
"memory", "cron", "model", "code", "review", "task",
|
|
26
|
+
]);
|
|
21
27
|
|
|
22
28
|
/**
|
|
23
29
|
* Parse a grammY `Message` into a `ParsedMessage`. Returns `kind: "empty"`
|
|
@@ -29,6 +35,59 @@ export function parseMessage(msg: Message): ParsedMessage {
|
|
|
29
35
|
const messageId = msg.message_id;
|
|
30
36
|
const text = (msg.text ?? msg.caption ?? "").trim();
|
|
31
37
|
|
|
38
|
+
// Detect file attachments (document, photo, video, audio, voice)
|
|
39
|
+
if (msg.document) {
|
|
40
|
+
return {
|
|
41
|
+
kind: "file",
|
|
42
|
+
fileId: msg.document.file_id,
|
|
43
|
+
fileName: msg.document.file_name ?? "file",
|
|
44
|
+
mimeType: msg.document.mime_type ?? "application/octet-stream",
|
|
45
|
+
caption: text,
|
|
46
|
+
userId, chatId, messageId,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
if (msg.photo && msg.photo.length > 0) {
|
|
50
|
+
const largest = msg.photo[msg.photo.length - 1]!;
|
|
51
|
+
return {
|
|
52
|
+
kind: "file",
|
|
53
|
+
fileId: largest.file_id,
|
|
54
|
+
fileName: `photo_${messageId}.jpg`,
|
|
55
|
+
mimeType: "image/jpeg",
|
|
56
|
+
caption: text,
|
|
57
|
+
userId, chatId, messageId,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
if (msg.video) {
|
|
61
|
+
return {
|
|
62
|
+
kind: "file",
|
|
63
|
+
fileId: msg.video.file_id,
|
|
64
|
+
fileName: msg.video.file_name ?? `video_${messageId}.mp4`,
|
|
65
|
+
mimeType: msg.video.mime_type ?? "video/mp4",
|
|
66
|
+
caption: text,
|
|
67
|
+
userId, chatId, messageId,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
if (msg.voice) {
|
|
71
|
+
return {
|
|
72
|
+
kind: "file",
|
|
73
|
+
fileId: msg.voice.file_id,
|
|
74
|
+
fileName: `voice_${messageId}.ogg`,
|
|
75
|
+
mimeType: msg.voice.mime_type ?? "audio/ogg",
|
|
76
|
+
caption: text,
|
|
77
|
+
userId, chatId, messageId,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
if (msg.audio) {
|
|
81
|
+
return {
|
|
82
|
+
kind: "file",
|
|
83
|
+
fileId: msg.audio.file_id,
|
|
84
|
+
fileName: msg.audio.file_name ?? `audio_${messageId}.mp3`,
|
|
85
|
+
mimeType: msg.audio.mime_type ?? "audio/mpeg",
|
|
86
|
+
caption: text,
|
|
87
|
+
userId, chatId, messageId,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
32
91
|
if (text.length === 0) {
|
|
33
92
|
return { kind: "empty", userId, chatId, messageId };
|
|
34
93
|
}
|
|
@@ -73,15 +132,22 @@ export const COMMAND_REPLIES: Record<TelegramCommand, string> = {
|
|
|
73
132
|
"/help — list commands (this message)\n" +
|
|
74
133
|
"/chat \\<message\\> — send a prompt to the agent explicitly\n" +
|
|
75
134
|
"/reset — clear the per\\-chat conversation context\n" +
|
|
76
|
-
"/status — show adapter health\n
|
|
135
|
+
"/status — show adapter health + bridge stats\n" +
|
|
136
|
+
"/memory — show agent memory usage\n" +
|
|
137
|
+
"/cron — show cron jobs\n" +
|
|
138
|
+
"/model — show current model\n" +
|
|
139
|
+
"/code \\<task\\> — send a coding task to the agent\n" +
|
|
140
|
+
"/review \\<task\\> — ask the agent to review code\n\n" +
|
|
77
141
|
"Anything that isn't a command is also forwarded to the agent.",
|
|
78
142
|
chat: "", // computed dynamically when invoked
|
|
79
|
-
reset: "
|
|
80
|
-
status:
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
143
|
+
reset: "", // computed dynamically when invoked
|
|
144
|
+
status: "", // computed dynamically when invoked
|
|
145
|
+
memory: "", // forwarded to agent
|
|
146
|
+
cron: "", // forwarded to agent
|
|
147
|
+
model: "", // forwarded to agent
|
|
148
|
+
code: "", // forwarded to agent
|
|
149
|
+
review: "", // forwarded to agent
|
|
150
|
+
task: "", // forwarded to agent
|
|
85
151
|
};
|
|
86
152
|
|
|
87
153
|
export interface HandleContext {
|
|
@@ -89,6 +155,12 @@ export interface HandleContext {
|
|
|
89
155
|
readonly sessionKey: string;
|
|
90
156
|
/** Optional callback that the real agent integration replaces. */
|
|
91
157
|
forwardToAgent?: (text: string, sessionKey: string) => Promise<string>;
|
|
158
|
+
/** Optional callback to reset a chat session (bridge). */
|
|
159
|
+
resetChatSession?: (chatId: string) => boolean;
|
|
160
|
+
/** Optional callback to get bridge stats. */
|
|
161
|
+
getBridgeStats?: () => { activeSessions: number; chatIds: string[] };
|
|
162
|
+
/** Optional callback to download a Telegram file to local path. */
|
|
163
|
+
downloadFile?: (fileId: string, fileName: string) => Promise<string>;
|
|
92
164
|
}
|
|
93
165
|
|
|
94
166
|
/**
|
|
@@ -100,24 +172,77 @@ export async function resolveReply(parsed: ParsedMessage, ctx: HandleContext): P
|
|
|
100
172
|
switch (parsed.kind) {
|
|
101
173
|
case "empty":
|
|
102
174
|
return "I can only read text right now. Send a message and I'll forward it to the agent.";
|
|
175
|
+
|
|
103
176
|
case "command": {
|
|
104
177
|
switch (parsed.command) {
|
|
105
178
|
case "start":
|
|
106
179
|
case "help":
|
|
107
|
-
case "reset":
|
|
108
|
-
case "status":
|
|
109
180
|
return COMMAND_REPLIES[parsed.command];
|
|
181
|
+
|
|
182
|
+
case "reset": {
|
|
183
|
+
if (ctx.resetChatSession) {
|
|
184
|
+
const had = ctx.resetChatSession(ctx.sessionKey);
|
|
185
|
+
return had
|
|
186
|
+
? "Context cleared for this chat. Next message starts a fresh thread."
|
|
187
|
+
: "No active session for this chat. Next message will start one.";
|
|
188
|
+
}
|
|
189
|
+
return "Context cleared for this chat. Next message starts a fresh thread.";
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
case "status": {
|
|
193
|
+
const stats = ctx.getBridgeStats?.();
|
|
194
|
+
const sessions = stats ? stats.activeSessions : 0;
|
|
195
|
+
return (
|
|
196
|
+
"*Adapter status*\n\n" +
|
|
197
|
+
"• transport: polling\n" +
|
|
198
|
+
"• backend: grammY v1\n" +
|
|
199
|
+
`• agent sessions: ${sessions}\n` +
|
|
200
|
+
"• bridge: wired ✓"
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
|
|
110
204
|
case "chat": {
|
|
111
205
|
const text = parsed.args.length > 0 ? parsed.args : "(empty)";
|
|
112
206
|
if (ctx.forwardToAgent) return ctx.forwardToAgent(text, ctx.sessionKey);
|
|
113
207
|
logger.debug("telegram: forwardToAgent not wired; echoing", { sessionKey: ctx.sessionKey });
|
|
114
208
|
return `[stub\\-agent echo] you said: ${escapeForPlain(text)}`;
|
|
115
209
|
}
|
|
210
|
+
|
|
211
|
+
case "memory":
|
|
212
|
+
case "cron":
|
|
213
|
+
case "model":
|
|
214
|
+
case "code":
|
|
215
|
+
case "review":
|
|
216
|
+
case "task": {
|
|
217
|
+
// Route through agent — the slash command will be recognized by the session
|
|
218
|
+
const cmdText = `/${parsed.command} ${parsed.args}`.trim();
|
|
219
|
+
if (ctx.forwardToAgent) return ctx.forwardToAgent(cmdText, ctx.sessionKey);
|
|
220
|
+
return `⚠️ Agent not wired. Use /help for available commands.`;
|
|
221
|
+
}
|
|
116
222
|
}
|
|
117
223
|
}
|
|
224
|
+
|
|
118
225
|
case "chat": {
|
|
119
|
-
|
|
120
|
-
|
|
226
|
+
const chatText = (parsed as { kind: "chat"; text: string }).text;
|
|
227
|
+
if (ctx.forwardToAgent) return ctx.forwardToAgent(chatText, ctx.sessionKey);
|
|
228
|
+
return `[stub\\-agent echo] you said: ${escapeForPlain(chatText)}`;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
case "file": {
|
|
232
|
+
const f = parsed as Extract<ParsedMessage, { kind: "file" }>;
|
|
233
|
+
if (!ctx.downloadFile) {
|
|
234
|
+
return "⚠️ File handling not configured. Send text instead.";
|
|
235
|
+
}
|
|
236
|
+
try {
|
|
237
|
+
const localPath = await ctx.downloadFile(f.fileId, f.fileName);
|
|
238
|
+
const fileInfo = `📎 File uploaded: ${f.fileName} (${f.mimeType})\nSaved to: ${localPath}`;
|
|
239
|
+
const prompt = f.caption ? `${fileInfo}\n\nUser caption: ${f.caption}` : fileInfo;
|
|
240
|
+
if (ctx.forwardToAgent) return ctx.forwardToAgent(prompt, ctx.sessionKey);
|
|
241
|
+
return `File saved to ${localPath}. Agent not wired — cannot process.`;
|
|
242
|
+
} catch (err) {
|
|
243
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
244
|
+
return `⚠️ File download failed: ${escapeForPlain(msg)}`;
|
|
245
|
+
}
|
|
121
246
|
}
|
|
122
247
|
}
|
|
123
248
|
}
|
|
@@ -36,14 +36,24 @@ let shuttingDown = false;
|
|
|
36
36
|
*/
|
|
37
37
|
export function startTelegramAdapter(
|
|
38
38
|
token: string | undefined = process.env.SNS_TELEGRAM_BOT_TOKEN,
|
|
39
|
-
opts: {
|
|
39
|
+
opts: {
|
|
40
|
+
autostart?: boolean;
|
|
41
|
+
forwardToAgent?: (text: string, sessionKey: string) => Promise<string>;
|
|
42
|
+
resetChatSession?: (chatId: string) => boolean;
|
|
43
|
+
getBridgeStats?: () => { activeSessions: number; chatIds: string[] };
|
|
44
|
+
} = {},
|
|
40
45
|
): TelegramBot | undefined {
|
|
41
46
|
if (activeBot) return activeBot;
|
|
42
47
|
if (!token) return undefined;
|
|
43
48
|
const autostart = opts.autostart ?? process.env.SNS_TELEGRAM_AUTOSTART !== "0";
|
|
44
49
|
if (!autostart) return undefined;
|
|
45
50
|
|
|
46
|
-
const bot = new TelegramBot({
|
|
51
|
+
const bot = new TelegramBot({
|
|
52
|
+
token,
|
|
53
|
+
forwardToAgent: opts.forwardToAgent,
|
|
54
|
+
resetChatSession: opts.resetChatSession,
|
|
55
|
+
getBridgeStats: opts.getBridgeStats,
|
|
56
|
+
});
|
|
47
57
|
activeBot = bot;
|
|
48
58
|
|
|
49
59
|
bot.start().catch((error) => {
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* agents.yaml config tests — SNS-MyAgent Phase 5.2
|
|
3
|
+
*
|
|
4
|
+
* Tests YAML parsing + config validation without touching real fs paths
|
|
5
|
+
* (uses tmp dirs and cleans up after).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { describe, it, expect, beforeEach, afterEach } from "bun:test";
|
|
9
|
+
import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from "node:fs";
|
|
10
|
+
import { tmpdir } from "node:os";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
|
|
13
|
+
import { parseYamlSimple, DEFAULT_CONFIG, AgentsConfigManager } from "../config.js";
|
|
14
|
+
|
|
15
|
+
describe("parseYamlSimple", () => {
|
|
16
|
+
it("parses simple key:value pairs", () => {
|
|
17
|
+
const out = parseYamlSimple("model: gpt-4o\nprovider: openai");
|
|
18
|
+
expect(out.model).toBe("gpt-4o");
|
|
19
|
+
expect(out.provider).toBe("openai");
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("returns empty object on empty input", () => {
|
|
23
|
+
expect(parseYamlSimple("")).toEqual({});
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("does not throw on indented map", () => {
|
|
27
|
+
expect(() =>
|
|
28
|
+
parseYamlSimple(`agents:
|
|
29
|
+
coder:
|
|
30
|
+
model: gpt-4o
|
|
31
|
+
tools: bash
|
|
32
|
+
`),
|
|
33
|
+
).not.toThrow();
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe("AgentsConfigManager", () => {
|
|
38
|
+
let tmpDir: string;
|
|
39
|
+
beforeEach(() => {
|
|
40
|
+
tmpDir = mkdtempSync(join(tmpdir(), "agents-config-"));
|
|
41
|
+
mkdirSync(join(tmpDir, ".sns-myagent"), { recursive: true });
|
|
42
|
+
});
|
|
43
|
+
afterEach(() => {
|
|
44
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("loads bundled defaults when no custom config", () => {
|
|
48
|
+
const mgr = new AgentsConfigManager();
|
|
49
|
+
const cfg = mgr.load();
|
|
50
|
+
expect(cfg.agents).toBeDefined();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("loads project config from disk", () => {
|
|
54
|
+
const yaml = `agents:
|
|
55
|
+
reviewer:
|
|
56
|
+
model: gpt-4o-mini
|
|
57
|
+
tools:
|
|
58
|
+
- read
|
|
59
|
+
`;
|
|
60
|
+
writeFileSync(join(tmpDir, ".sns-myagent", "agents.yaml"), yaml);
|
|
61
|
+
const prevCwd = process.cwd();
|
|
62
|
+
process.chdir(tmpDir);
|
|
63
|
+
try {
|
|
64
|
+
const mgr = new AgentsConfigManager();
|
|
65
|
+
mgr.load();
|
|
66
|
+
expect(mgr.config.agents).toBeDefined();
|
|
67
|
+
} finally {
|
|
68
|
+
process.chdir(prevCwd);
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe("DEFAULT_CONFIG", () => {
|
|
74
|
+
it("has ensembles + defaults", () => {
|
|
75
|
+
expect(DEFAULT_CONFIG.ensembles).toBeDefined();
|
|
76
|
+
expect(DEFAULT_CONFIG.default_agent).toBe("task");
|
|
77
|
+
expect(typeof DEFAULT_CONFIG.max_concurrency).toBe("number");
|
|
78
|
+
});
|
|
79
|
+
});
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resilience module smoke tests — SNS-MyAgent Phase 5.4
|
|
3
|
+
*
|
|
4
|
+
* Pure logic tests for retry, timeout, circuit breaker, fallback.
|
|
5
|
+
* No DB, no LLM, no async intervals.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { describe, it, expect } from "bun:test";
|
|
9
|
+
import {
|
|
10
|
+
withRetry,
|
|
11
|
+
withTimeout,
|
|
12
|
+
withFallback,
|
|
13
|
+
CircuitBreaker,
|
|
14
|
+
TimeoutError,
|
|
15
|
+
CircuitBreakerOpenError,
|
|
16
|
+
} from "../resilience.js";
|
|
17
|
+
|
|
18
|
+
describe("withRetry", () => {
|
|
19
|
+
it("returns result on first success", async () => {
|
|
20
|
+
let calls = 0;
|
|
21
|
+
const out = await withRetry(
|
|
22
|
+
async () => {
|
|
23
|
+
calls++;
|
|
24
|
+
return 42;
|
|
25
|
+
},
|
|
26
|
+
{ maxAttempts: 3, baseDelayMs: 1, maxDelayMs: 1, backoffMultiplier: 1, jitterFactor: 0 },
|
|
27
|
+
);
|
|
28
|
+
expect(out.success).toBe(true);
|
|
29
|
+
expect(out.result).toBe(42);
|
|
30
|
+
expect(calls).toBe(1);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("retries until success", async () => {
|
|
34
|
+
let calls = 0;
|
|
35
|
+
const out = await withRetry(
|
|
36
|
+
async () => {
|
|
37
|
+
calls++;
|
|
38
|
+
if (calls < 3) throw new Error("transient");
|
|
39
|
+
return "ok";
|
|
40
|
+
},
|
|
41
|
+
{ maxAttempts: 5, baseDelayMs: 1, maxDelayMs: 1, backoffMultiplier: 1, jitterFactor: 0 },
|
|
42
|
+
);
|
|
43
|
+
expect(out.success).toBe(true);
|
|
44
|
+
expect(out.result).toBe("ok");
|
|
45
|
+
expect(calls).toBe(3);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("fails after maxAttempts", async () => {
|
|
49
|
+
let calls = 0;
|
|
50
|
+
const out = await withRetry(
|
|
51
|
+
async () => {
|
|
52
|
+
calls++;
|
|
53
|
+
throw new Error("permanent");
|
|
54
|
+
},
|
|
55
|
+
{ maxAttempts: 2, baseDelayMs: 1, maxDelayMs: 1, backoffMultiplier: 1, jitterFactor: 0 },
|
|
56
|
+
);
|
|
57
|
+
expect(out.success).toBe(false);
|
|
58
|
+
expect(out.error?.message).toBe("permanent");
|
|
59
|
+
expect(calls).toBe(2);
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe("withTimeout", () => {
|
|
64
|
+
it("resolves when fn finishes fast", async () => {
|
|
65
|
+
const out = await withTimeout(async () => "fast", 100);
|
|
66
|
+
expect(out).toBe("fast");
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("rejects with TimeoutError on slow fn", async () => {
|
|
70
|
+
await expect(
|
|
71
|
+
withTimeout(async () => {
|
|
72
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
73
|
+
return "slow";
|
|
74
|
+
}, 20),
|
|
75
|
+
).rejects.toBeInstanceOf(TimeoutError);
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe("withFallback", () => {
|
|
80
|
+
it("returns primary result when it succeeds", async () => {
|
|
81
|
+
const out = await withFallback([
|
|
82
|
+
async () => "primary",
|
|
83
|
+
async () => "fallback",
|
|
84
|
+
]);
|
|
85
|
+
expect(out.success).toBe(true);
|
|
86
|
+
expect(out.result).toBe("primary");
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("falls back when primary throws", async () => {
|
|
90
|
+
const out = await withFallback([
|
|
91
|
+
async () => {
|
|
92
|
+
throw new Error("primary down");
|
|
93
|
+
},
|
|
94
|
+
async () => "fallback",
|
|
95
|
+
]);
|
|
96
|
+
expect(out.success).toBe(true);
|
|
97
|
+
expect(out.result).toBe("fallback");
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
describe("CircuitBreaker", () => {
|
|
102
|
+
it("opens after threshold failures", async () => {
|
|
103
|
+
const cb = new CircuitBreaker("test1", { failureThreshold: 2, resetTimeoutMs: 60_000 });
|
|
104
|
+
const failing = async () => {
|
|
105
|
+
throw new Error("boom");
|
|
106
|
+
};
|
|
107
|
+
await expect(cb.execute(failing)).rejects.toThrow();
|
|
108
|
+
await expect(cb.execute(failing)).rejects.toThrow();
|
|
109
|
+
await expect(cb.execute(failing)).rejects.toBeInstanceOf(CircuitBreakerOpenError);
|
|
110
|
+
expect(cb.state).toBe("open");
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("closes after successful execution in closed state", async () => {
|
|
114
|
+
const cb = new CircuitBreaker("test2", { failureThreshold: 5 });
|
|
115
|
+
const out = await cb.execute(async () => "ok");
|
|
116
|
+
expect(out).toBe("ok");
|
|
117
|
+
expect(cb.state).toBe("closed");
|
|
118
|
+
});
|
|
119
|
+
});
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ensemble strategy smoke tests — SNS-MyAgent Phase 5
|
|
3
|
+
*
|
|
4
|
+
* Pure logic tests (no DB, no LLM). Verifies deterministic
|
|
5
|
+
* consensus/critic/best-of-N behavior with mock executors.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { describe, it, expect } from "bun:test";
|
|
9
|
+
import { ConsensusStrategy } from "../strategies/consensus.js";
|
|
10
|
+
import { BestOfNStrategy } from "../strategies/best-of-n.js";
|
|
11
|
+
import { CriticStrategy } from "../strategies/critic.js";
|
|
12
|
+
import { aggregateTokens } from "../strategies/types.js";
|
|
13
|
+
import type { AgentResponse } from "../strategies/types.js";
|
|
14
|
+
|
|
15
|
+
function mockResp(role: string, content: string, tokens = { input: 10, output: 20 }): AgentResponse {
|
|
16
|
+
return { role, model: "test", content, tokens, timeMs: 5 };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe("aggregateTokens", () => {
|
|
20
|
+
it("sums input/output across responses", () => {
|
|
21
|
+
const out = aggregateTokens([
|
|
22
|
+
mockResp("a", "x", { input: 5, output: 7 }),
|
|
23
|
+
mockResp("b", "y", { input: 3, output: 2 }),
|
|
24
|
+
]);
|
|
25
|
+
expect(out).toEqual({ input: 8, output: 9 });
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("treats missing tokens as zero", () => {
|
|
29
|
+
const out = aggregateTokens([{ role: "x", model: "m", content: "c", timeMs: 0 }]);
|
|
30
|
+
expect(out).toEqual({ input: 0, output: 0 });
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
describe("ConsensusStrategy", () => {
|
|
35
|
+
it("picks majority when threshold met", async () => {
|
|
36
|
+
const strat = new ConsensusStrategy({ n: 3, threshold: 0.5 });
|
|
37
|
+
const exec = async (role: string) => mockResp(role, role === "a" ? "YES" : "NO");
|
|
38
|
+
const result = await strat.execute("q?", ["a", "b"], exec);
|
|
39
|
+
expect(result.strategy).toBe("consensus");
|
|
40
|
+
expect(result.responses.length).toBe(3);
|
|
41
|
+
expect(["YES", "NO"]).toContain(result.final);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("throws when no agents provided", async () => {
|
|
45
|
+
const strat = new ConsensusStrategy();
|
|
46
|
+
await expect(strat.execute("q?", [], async () => mockResp("x", "x"))).rejects.toThrow(
|
|
47
|
+
/at least 1 agent/,
|
|
48
|
+
);
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe("BestOfNStrategy", () => {
|
|
53
|
+
it("executes and returns a ranked result", async () => {
|
|
54
|
+
const strat = new BestOfNStrategy({ n: 2 });
|
|
55
|
+
const exec = async (role: string, prompt: string) => {
|
|
56
|
+
// Scorer prompt format → respond with a number
|
|
57
|
+
if (prompt.includes("Rate")) return mockResp(role, "0.8");
|
|
58
|
+
return mockResp(role, `out-${role}`);
|
|
59
|
+
};
|
|
60
|
+
const result = await strat.execute("q?", ["a", "b"], exec);
|
|
61
|
+
expect(result.strategy).toBe("best_of_n");
|
|
62
|
+
expect(result.responses.length).toBeGreaterThan(0);
|
|
63
|
+
expect(result.final).toMatch(/out-[ab]/);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe("CriticStrategy", () => {
|
|
68
|
+
it("iterates generator→critic until accept or max rounds", async () => {
|
|
69
|
+
const strat = new CriticStrategy({ maxRounds: 2 });
|
|
70
|
+
let round = 0;
|
|
71
|
+
const exec = async (_role: string, prompt: string) => {
|
|
72
|
+
round++;
|
|
73
|
+
if (prompt.includes("[critic review")) {
|
|
74
|
+
return mockResp("critic", "accepted", { input: 5, output: 10 });
|
|
75
|
+
}
|
|
76
|
+
return mockResp("gen", `draft-v${round}`, { input: 1, output: 5 });
|
|
77
|
+
};
|
|
78
|
+
const result = await strat.execute("write essay", ["gen", "critic"], exec);
|
|
79
|
+
expect(result.strategy).toBe("critic");
|
|
80
|
+
expect(result.rounds).toBeGreaterThanOrEqual(1);
|
|
81
|
+
expect(result.final).toContain("draft");
|
|
82
|
+
});
|
|
83
|
+
});
|