@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.
Files changed (61) hide show
  1. package/CHANGELOG.md +12 -1
  2. package/README.md +7 -8
  3. package/package.json +3 -3
  4. package/scripts/apply-pi-natives-patch.js +82 -56
  5. package/src/adapters/telegram/bot.ts +41 -1
  6. package/src/adapters/telegram/bridge.ts +186 -0
  7. package/src/adapters/telegram/handler.ts +138 -13
  8. package/src/adapters/telegram/index.ts +12 -2
  9. package/src/agents/__tests__/config.test.ts +79 -0
  10. package/src/agents/__tests__/resilience.test.ts +119 -0
  11. package/src/agents/__tests__/strategies.test.ts +83 -0
  12. package/src/agents/config.ts +367 -0
  13. package/src/agents/ensemble.ts +224 -0
  14. package/src/agents/resilience.ts +332 -0
  15. package/src/agents/strategies/best-of-n.ts +108 -0
  16. package/src/agents/strategies/consensus.ts +105 -0
  17. package/src/agents/strategies/critic.ts +131 -0
  18. package/src/agents/strategies/types.ts +68 -0
  19. package/src/async/__tests__/task-runner.test.ts +162 -0
  20. package/src/async/__tests__/task-store.test.ts +146 -0
  21. package/src/async/index.ts +28 -1
  22. package/src/async/notifier.ts +133 -0
  23. package/src/async/task-runner.ts +175 -0
  24. package/src/async/task-store.ts +170 -0
  25. package/src/async/types.ts +70 -0
  26. package/src/cli/entry.ts +3 -1
  27. package/src/cli/index.ts +69 -54
  28. package/src/config/index.ts +1 -1
  29. package/src/debug/index.ts +1 -1
  30. package/src/modes/components/welcome.ts +13 -6
  31. package/src/modes/controllers/event-controller.ts +1 -1
  32. package/src/modes/setup-wizard/scenes/splash.ts +1 -1
  33. package/src/session/agent-session.ts +1 -1
  34. package/src/slash-commands/builtin-registry.ts +63 -0
  35. package/src/slash-commands/helpers/task.ts +181 -0
  36. package/src/tbm/__tests__/tbm.test.ts +660 -0
  37. package/src/tbm/comm-modes.ts +165 -0
  38. package/src/tbm/config.ts +136 -0
  39. package/src/tbm/context-delta.ts +146 -0
  40. package/src/tbm/context-pyramid.ts +202 -0
  41. package/src/tbm/dashboard.ts +131 -0
  42. package/src/tbm/index.ts +247 -0
  43. package/src/tbm/lazy-skills.ts +182 -0
  44. package/src/tbm/response-cache.ts +220 -0
  45. package/src/tbm/tombstone.ts +189 -0
  46. package/src/tbm/tool-compress.ts +230 -0
  47. package/src/tools/ask.ts +1 -1
  48. package/src/tui/chat-blocks.ts +205 -0
  49. package/src/tui/chat-ui.ts +270 -0
  50. package/src/tui/code-cell.ts +90 -1
  51. package/src/tui/command-palette.ts +189 -0
  52. package/src/tui/index.ts +4 -0
  53. package/src/tui/splash.ts +130 -0
  54. package/src/ui/banner.ts +69 -29
  55. package/src/ui/colors.ts +24 -0
  56. package/src/ui/error-display.ts +130 -0
  57. package/src/ui/gradient.ts +104 -0
  58. package/src/ui/index.ts +15 -0
  59. package/src/ui/memory-toast.ts +102 -0
  60. package/src/ui/status-bar.ts +36 -30
  61. 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 = "start" | "help" | "chat" | "reset" | "status";
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(["start", "help", "chat", "reset", "status"]);
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\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: "Context cleared for this chat. Next message starts a fresh thread.",
80
- status:
81
- "*Adapter status*\n\n" +
82
- "• transport: polling\n" +
83
- " backend: grammY v1\n" +
84
- "• agent: stub \\(Phase 2 will wire real session\\)",
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
- if (ctx.forwardToAgent) return ctx.forwardToAgent(parsed.text, ctx.sessionKey);
120
- return `[stub\\-agent echo] you said: ${escapeForPlain(parsed.text)}`;
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: { autostart?: boolean; forwardToAgent?: (text: string, sessionKey: string) => Promise<string> } = {},
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({ token, forwardToAgent: opts.forwardToAgent });
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
+ });