@ycodium-ai/agent-fake 0.2.3034 → 0.2.3054

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 (3) hide show
  1. package/README.md +77 -1
  2. package/dist/index.js +326 -27
  3. package/package.json +2 -2
package/README.md CHANGED
@@ -4,7 +4,7 @@ A minimal executor plugin. It starts no CLI and replays a fixed event script:
4
4
 
5
5
  - `startSession` emits `session.started`
6
6
  - `sendTurn` emits `turn.started`, one `content.delta`, then `turn.completed` (the host binds the turn by `turn.started`)
7
- - `config.script` picks the script: `plain` (default), `approval-tool` (two approvals before the reply), `async-question` (on a thread's first turn, the reply plus one non-blocking `user-input.requested` with `responseMode: "message"`), `stream` (a Markdown answer in `config.streamChunks` deltas, default 200, one every `config.streamIntervalMs`, default 30; the UI probe `interaction-table.ts` measures streaming with it), `mcp-send` (real calls through the built-in MCP server the host handed to `startSession`, the same path a real agent takes: a turn whose input is a JSON object is passed as the arguments of one `send_message` call, and a JSON array of `{ "tool": "…", "arguments": { … } }` is called step by step, for example `ycodium_plugin_define`, then `ycodium_plugin_run`, then a tool the mounted plugin contributes; the reply has one line per call with its result or error, and any other input gets the plain reply), `visualize` (emits one in-progress builtin tool row, holds for `config.previewHoldMs` milliseconds so the input preview can paint, then one real `visualize_visualize` call on that same server; the tool row’s result is the MCP confirmation text, and the reply is “The figure is in the card above.”), or `lane` (the UI probe `lane1-journey.ts` script: the user message's `lane:stream|plain|approve|question|attach|hold` picks the turn; `hold` accepts a second `sendTurn` with the in-flight turn id as a steer and folds that text into the reply; `config.holdMs` is how long `hold` waits, default 15000)
7
+ - `config.script` picks the script: `plain` (default), `approval-tool` (two approvals before the reply), `async-question` (on a thread's first turn, the reply plus one non-blocking `user-input.requested` with `responseMode: "message"`), `stream` (a Markdown answer in `config.streamChunks` deltas, default 200, one every `config.streamIntervalMs`, default 30; the UI probe `interaction-table.ts` measures streaming with it), `mcp-send` (real calls through the built-in MCP server the host handed to `startSession`, the same path a real agent takes: a turn whose input is a JSON object is passed as the arguments of one `send_message` call, and a JSON array of `{ "tool": "…", "arguments": { … } }` is called step by step, for example `ycodium_plugin_define`, then `ycodium_plugin_run`, then a tool the mounted plugin contributes; the reply has one line per call with its result or error, and any other input gets the plain reply; see [Playbook mode](#playbook-mode) for per-thread scripts that ignore turn input), `visualize` (emits one in-progress builtin tool row, holds for `config.previewHoldMs` milliseconds so the input preview can paint, then one real `visualize_visualize` call on that same server; the tool row’s result is the MCP confirmation text, and the reply is “The figure is in the card above.”), or `lane` (the UI probe `lane1-journey.ts` script: the user message's `lane:stream|plain|approve|question|attach|hold` picks the turn; `hold` accepts a second `sendTurn` with the in-flight turn id as a steer and folds that text into the reply; `config.holdMs` is how long `hold` waits, default 15000)
8
8
  - `interruptTurn` (or an aborted `AbortSignal`) ends the in-flight turn as `interrupted`
9
9
  - `readThread` returns the bookkeeping the instance recorded
10
10
  - `probe` reports two models, `fake` (default) and `fake-large`
@@ -23,6 +23,82 @@ This package is **not** in the default bundle and **not** a first-party add-grid
23
23
 
24
24
  Develop with `vp run --filter @ycodium-ai/agent-fake typecheck`, `build`, and `test`.
25
25
 
26
+ ## Playbook mode
27
+
28
+ Replay recorded conversations or drive multi-thread acceptance tests with
29
+ `config.script: "mcp-send"` and `config.playbookPath` pointing to a JSON file on the executor's
30
+ machine. Use an absolute path. This option is outside the settings form and applies only to
31
+ `mcp-send`. Each turn runs the next batch, ignoring its input, including host wake-ups and follow-ups.
32
+
33
+ This example assigns one script by thread ID and another by title:
34
+
35
+ ```json
36
+ {
37
+ "version": 1,
38
+ "threads": [
39
+ {
40
+ "match": { "threadId": "^team-member:[^:]+:[^:]+:B$" },
41
+ "turns": [
42
+ [{ "id": "list", "tool": "work_list_items" }],
43
+ [
44
+ {
45
+ "tool": "work_claim_item",
46
+ "arguments": { "workItemId": "{{step:list.items.0.workItemId}}" }
47
+ }
48
+ ]
49
+ ]
50
+ },
51
+ {
52
+ "match": { "title": "Coordinator" },
53
+ "turns": [
54
+ [
55
+ {
56
+ "tool": "send_message",
57
+ "arguments": { "threadId": "team-member:demo:run:B", "text": "Continue" }
58
+ }
59
+ ],
60
+ [
61
+ { "wait": { "tool": "my-plugin_hello", "timeoutMs": 30000 } },
62
+ { "tool": "my-plugin_hello" }
63
+ ]
64
+ ]
65
+ }
66
+ ]
67
+ }
68
+ ```
69
+
70
+ `version` must be `1`. Matches need a `threadId` regex (supply `^…$` for whole-ID matching)
71
+ or a case-sensitive `title` substring. Both conditions must hold if both exist; missing titles
72
+ cannot match. On its first turn, a thread claims the first unclaimed matching line in file order.
73
+ Claims, cursors and results survive `stopSession` within the instance. The file is read once
74
+ on first use, including failures: an unreadable or invalid file yields `playbook: <reason>`
75
+ every turn without tools. A new instance is required to reload it.
76
+
77
+ Each batch runs steps in order. Calls take `tool`, optional `id`, and object `arguments`
78
+ (default `{}`). String values at any depth support `{{thread}}` and `{{step:<id>.<path>}}`.
79
+ References can cross turns; dot paths use numeric array indexes, and omitting the path takes
80
+ the whole value. Results prefer `structuredContent`, then JSON parsed from `content[0].text`,
81
+ then that text. A whole-string placeholder preserves types; interpolation JSON-stringifies
82
+ non-strings. Unresolved references skip that call and reply
83
+ `mcp-send <tool> failed: unresolved placeholder <placeholder>`, then continue.
84
+
85
+ A `wait` polls `tools/list` on that MCP server every 250 ms for the tool name or its
86
+ `mcp__ycodium__` prefix, defaulting to a 30000 ms timeout. It replies
87
+ `mcp-send wait <tool>: ready` or `mcp-send wait <tool> failed: timeout`, then continues, with no
88
+ tool items. A `waitFor`, such as
89
+ `{ "waitFor": { "tool": "message_inbox_peek", "path": "items", "atLeast": 2 } }`, calls that tool
90
+ with no arguments every 250 ms on one session until the value at the dot `path` of its result is
91
+ an array at least `atLeast` long, or a number at least `atLeast`; a tool error counts as not yet.
92
+ Point it only at read-only tools, since every poll is a real call. It also defaults to 30000 ms,
93
+ replies `mcp-send waitFor <tool> <path> >= <atLeast>: ready` or `… failed: timeout`, emits no tool
94
+ items and continues. Calls reply `mcp-send <tool>: <JSON result>` or `mcp-send <tool> failed: <error>` and
95
+ emit paired `item.started` / `item.completed` events carrying the prefixed tool name, resolved
96
+ input, raw result and status. Abort stops before the next step and ends the turn as `interrupted`.
97
+
98
+ Threads with no matching line, or with all batches consumed, use the original `mcp-send`
99
+ input handling: JSON requests call tools and ordinary text gets `replyText`. Omitting
100
+ `playbookPath` keeps the existing scripts and output unchanged.
101
+
26
102
  ## License
27
103
 
28
104
  UNLICENSED.
package/dist/index.js CHANGED
@@ -60,6 +60,8 @@ var FAKE_CARD = {
60
60
  holdMs: { type: "integer", minimum: 0 },
61
61
  // stream 脚本循环切片的源文本;性能探针用它换上可控的长回复,不进设置表单。
62
62
  streamText: { type: "string" },
63
+ // mcp-send 的逐轮剧本文件路径;不进设置表单。
64
+ playbookPath: { type: "string" },
63
65
  // 额度与用量怎么答(见 fakeUsage.ts);不进设置表单,开发与验收时手改实例配置。
64
66
  usageScript: {
65
67
  type: "string",
@@ -463,7 +465,12 @@ function parseMessage(text) {
463
465
  return json.length === 0 ? null : JSON.parse(json);
464
466
  }
465
467
  async function callMcpTool(input) {
468
+ const session = await openMcpSession(input);
469
+ return session.request("tools/call", { name: input.name, arguments: input.arguments });
470
+ }
471
+ async function openMcpSession(input) {
466
472
  const post = async (headers, body) => {
473
+ input.signal?.throwIfAborted();
467
474
  const response = await input.fetch(input.server.transport.url, {
468
475
  method: "POST",
469
476
  headers: {
@@ -471,7 +478,8 @@ async function callMcpTool(input) {
471
478
  accept: "application/json, text/event-stream",
472
479
  ...headers
473
480
  },
474
- body: JSON.stringify({ jsonrpc: "2.0", ...body })
481
+ body: JSON.stringify({ jsonrpc: "2.0", ...body }),
482
+ ...input.signal === void 0 ? {} : { signal: input.signal }
475
483
  });
476
484
  const text = await response.text();
477
485
  if (!response.ok)
@@ -497,16 +505,16 @@ async function callMcpTool(input) {
497
505
  ...sessionId === null ? {} : { "mcp-session-id": sessionId }
498
506
  };
499
507
  await post(session, { method: "notifications/initialized" });
500
- const called = await post(session, {
501
- id: 2,
502
- method: "tools/call",
503
- params: { name: input.name, arguments: input.arguments }
504
- });
505
- if (called.message?.error !== void 0)
506
- throw new Error(
507
- `MCP tools/call ${input.name} failed: ${called.message.error.message ?? "error"}`
508
- );
509
- return called.message?.result;
508
+ let nextId = 2;
509
+ return {
510
+ request: async (method, params) => {
511
+ const called = await post(session, { id: nextId++, method, params });
512
+ const label = method === "tools/call" ? `tools/call ${String(params.name)}` : method;
513
+ if (called.message?.error !== void 0)
514
+ throw new Error(`MCP ${label} failed: ${called.message.error.message ?? "error"}`);
515
+ return called.message?.result;
516
+ }
517
+ };
510
518
  }
511
519
  var isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
512
520
  function parseMcpSendInput(input) {
@@ -545,6 +553,299 @@ async function runMcpCalls(input) {
545
553
  return lines.join("\n");
546
554
  }
547
555
 
556
+ // src/playbook.ts
557
+ import * as NodeFSP from "node:fs/promises";
558
+
559
+ // src/hold.ts
560
+ function hold(ms, signal) {
561
+ if (ms === 0 || signal.aborted) return Promise.resolve();
562
+ return new Promise((resolve) => {
563
+ let timer;
564
+ const finish = () => {
565
+ if (timer !== void 0) clearTimeout(timer);
566
+ signal.removeEventListener("abort", finish);
567
+ resolve();
568
+ };
569
+ timer = setTimeout(finish, ms);
570
+ signal.addEventListener("abort", finish, { once: true });
571
+ });
572
+ }
573
+
574
+ // src/playbook.ts
575
+ var isRecord2 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
576
+ var isName = (value) => typeof value === "string" && value.length > 0;
577
+ var onlyKeys = (value, keys) => Object.keys(value).every((key) => keys.includes(key));
578
+ var parseTimeout = (value) => {
579
+ const timeoutMs = value === void 0 ? 3e4 : value;
580
+ return typeof timeoutMs === "number" && Number.isFinite(timeoutMs) && timeoutMs >= 0 ? timeoutMs : null;
581
+ };
582
+ function parseStep(value) {
583
+ if (!isRecord2(value)) return null;
584
+ if (Object.hasOwn(value, "wait")) {
585
+ const wait = value.wait;
586
+ if (!onlyKeys(value, ["wait"]) || !isRecord2(wait) || !isName(wait.tool)) return null;
587
+ if (!onlyKeys(wait, ["tool", "timeoutMs"])) return null;
588
+ const timeoutMs = parseTimeout(wait.timeoutMs);
589
+ return timeoutMs === null ? null : { wait: { tool: wait.tool, timeoutMs } };
590
+ }
591
+ if (Object.hasOwn(value, "waitFor")) {
592
+ const wait = value.waitFor;
593
+ if (!onlyKeys(value, ["waitFor"]) || !isRecord2(wait) || !isName(wait.tool)) return null;
594
+ if (!onlyKeys(wait, ["tool", "path", "atLeast", "timeoutMs"]) || !isName(wait.path))
595
+ return null;
596
+ if (typeof wait.atLeast !== "number" || !Number.isInteger(wait.atLeast) || wait.atLeast < 0)
597
+ return null;
598
+ const timeoutMs = parseTimeout(wait.timeoutMs);
599
+ return timeoutMs === null ? null : { waitFor: { tool: wait.tool, path: wait.path, atLeast: wait.atLeast, timeoutMs } };
600
+ }
601
+ if (!onlyKeys(value, ["id", "tool", "arguments"]) || !isName(value.tool)) return null;
602
+ if (value.id !== void 0 && typeof value.id !== "string") return null;
603
+ if (value.arguments !== void 0 && !isRecord2(value.arguments)) return null;
604
+ return {
605
+ tool: value.tool,
606
+ ...value.id === void 0 ? {} : { id: value.id },
607
+ ...value.arguments === void 0 ? {} : { arguments: value.arguments }
608
+ };
609
+ }
610
+ function parsePlaybook(text) {
611
+ try {
612
+ const value = JSON.parse(text);
613
+ if (!isRecord2(value) || value.version !== 1) return { ok: false, reason: "version must be 1" };
614
+ if (!onlyKeys(value, ["version", "threads"]) || !Array.isArray(value.threads))
615
+ return { ok: false, reason: "threads must be an array" };
616
+ const threads = [];
617
+ for (const [index, line] of value.threads.entries()) {
618
+ if (!isRecord2(line) || !onlyKeys(line, ["match", "turns"]) || !isRecord2(line.match))
619
+ return { ok: false, reason: `threads[${index}] must have match and turns` };
620
+ const match = line.match;
621
+ if (!onlyKeys(match, ["threadId", "title"]) || match.threadId === void 0 && match.title === void 0 || match.threadId !== void 0 && typeof match.threadId !== "string" || match.title !== void 0 && typeof match.title !== "string")
622
+ return { ok: false, reason: `threads[${index}].match needs threadId or title` };
623
+ if (!Array.isArray(line.turns))
624
+ return { ok: false, reason: `threads[${index}].turns must be an array` };
625
+ const turns = [];
626
+ for (const [turn, batch] of line.turns.entries()) {
627
+ if (!Array.isArray(batch))
628
+ return { ok: false, reason: `threads[${index}].turns[${turn}] must be an array` };
629
+ const steps = [];
630
+ for (const entry of batch) {
631
+ const step = parseStep(entry);
632
+ if (step === null)
633
+ return { ok: false, reason: `threads[${index}].turns[${turn}] has an invalid step` };
634
+ steps.push(step);
635
+ }
636
+ turns.push(steps);
637
+ }
638
+ threads.push({
639
+ match: {
640
+ ...typeof match.threadId === "string" ? { threadId: new RegExp(match.threadId) } : {},
641
+ ...typeof match.title === "string" ? { title: match.title } : {}
642
+ },
643
+ turns
644
+ });
645
+ }
646
+ return { ok: true, threads };
647
+ } catch (error) {
648
+ return { ok: false, reason: String(error) };
649
+ }
650
+ }
651
+ var UnresolvedPlaceholder = class extends Error {
652
+ };
653
+ function placeholderValue(raw, threadId, results) {
654
+ if (raw === "{{thread}}") return threadId;
655
+ const [id, ...path] = raw.slice(7, -2).split(".");
656
+ const value = id === void 0 || !results.has(id) ? void 0 : valueAt(results.get(id), path);
657
+ if (value === void 0) throw new UnresolvedPlaceholder(`unresolved placeholder ${raw}`);
658
+ return value;
659
+ }
660
+ function valueAt(start, path) {
661
+ let value = start;
662
+ for (const part of path) {
663
+ if (Array.isArray(value) && /^\d+$/.test(part) && Object.hasOwn(value, Number(part))) {
664
+ value = value[Number(part)];
665
+ } else if (isRecord2(value) && Object.hasOwn(value, part)) {
666
+ value = value[part];
667
+ } else {
668
+ return void 0;
669
+ }
670
+ }
671
+ return value;
672
+ }
673
+ function resolveValue(value, threadId, results) {
674
+ if (typeof value === "string") {
675
+ const placeholders = [...value.matchAll(/\{\{(?:thread|step:[^{}]*)\}\}/g)];
676
+ if (placeholders.length === 1 && placeholders[0]?.[0] === value)
677
+ return placeholderValue(value, threadId, results);
678
+ return value.replace(/\{\{(?:thread|step:[^{}]*)\}\}/g, (raw) => {
679
+ const resolved = placeholderValue(raw, threadId, results);
680
+ return typeof resolved === "string" ? resolved : JSON.stringify(resolved);
681
+ });
682
+ }
683
+ if (Array.isArray(value)) return value.map((entry) => resolveValue(entry, threadId, results));
684
+ if (isRecord2(value)) return resolveArguments(value, threadId, results);
685
+ return value;
686
+ }
687
+ function resolveArguments(args, threadId, results) {
688
+ return Object.fromEntries(
689
+ Object.entries(args).map(([key, value]) => [key, resolveValue(value, threadId, results)])
690
+ );
691
+ }
692
+ function stepResult(result) {
693
+ if (!isRecord2(result)) return void 0;
694
+ if (Object.hasOwn(result, "structuredContent")) return result.structuredContent;
695
+ const first = Array.isArray(result.content) ? result.content[0] : void 0;
696
+ if (!isRecord2(first) || typeof first.text !== "string") return void 0;
697
+ try {
698
+ return JSON.parse(first.text);
699
+ } catch {
700
+ return first.text;
701
+ }
702
+ }
703
+ async function poll(timeoutMs, input, ready) {
704
+ const server = builtinMcpServer(input.mcpServers);
705
+ const deadline = Date.now() + timeoutMs;
706
+ const controller = new AbortController();
707
+ const abort = () => controller.abort();
708
+ input.signal.addEventListener("abort", abort, { once: true });
709
+ if (input.signal.aborted) abort();
710
+ const timer = setTimeout(abort, timeoutMs);
711
+ try {
712
+ let session;
713
+ while (!controller.signal.aborted && Date.now() < deadline) {
714
+ try {
715
+ if (server !== null)
716
+ session ??= await openMcpSession({
717
+ fetch: input.fetch,
718
+ server,
719
+ signal: controller.signal
720
+ });
721
+ const done = session !== void 0 && await ready(session);
722
+ if (controller.signal.aborted || Date.now() >= deadline) return false;
723
+ if (done) return true;
724
+ } catch {
725
+ session = void 0;
726
+ if (controller.signal.aborted) return false;
727
+ }
728
+ await hold(Math.min(250, Math.max(0, deadline - Date.now())), controller.signal);
729
+ }
730
+ return false;
731
+ } finally {
732
+ clearTimeout(timer);
733
+ input.signal.removeEventListener("abort", abort);
734
+ }
735
+ }
736
+ var waitForTool = (step, input) => poll(step.wait.timeoutMs, input, async (session) => {
737
+ const listed = await session.request("tools/list", {});
738
+ return isRecord2(listed) && Array.isArray(listed.tools) && listed.tools.some(
739
+ (tool) => isRecord2(tool) && (tool.name === step.wait.tool || tool.name === `mcp__ycodium__${step.wait.tool}`)
740
+ );
741
+ });
742
+ var waitForCount = (step, input) => poll(step.waitFor.timeoutMs, input, async (session) => {
743
+ const called = await session.request("tools/call", { name: step.waitFor.tool, arguments: {} });
744
+ if (isRecord2(called) && called.isError === true) return false;
745
+ const value = valueAt(stepResult(called), step.waitFor.path.split("."));
746
+ const count = Array.isArray(value) ? value.length : value;
747
+ return typeof count === "number" && count >= step.waitFor.atLeast;
748
+ });
749
+ async function runCall(step, playback, input) {
750
+ let args = step.arguments ?? {};
751
+ let failure;
752
+ try {
753
+ args = resolveArguments(args, input.threadId, playback.results);
754
+ } catch (error) {
755
+ failure = error instanceof UnresolvedPlaceholder ? error.message : String(error);
756
+ }
757
+ const toolName = `mcp__ycodium__${step.tool}`;
758
+ const extra = { turnId: input.turnId, itemId: input.nextItemId() };
759
+ const payload = { itemType: "mcp_tool_call", title: toolName, data: { toolName, input: args } };
760
+ input.emit("item.started", input.threadId, { ...payload, status: "inProgress" }, extra);
761
+ let result;
762
+ if (failure === void 0) {
763
+ try {
764
+ const server = builtinMcpServer(input.mcpServers);
765
+ if (server === null) throw new Error("no MCP server was handed to this session");
766
+ result = await callMcpTool({
767
+ fetch: input.fetch,
768
+ server,
769
+ name: step.tool,
770
+ arguments: args,
771
+ signal: input.signal
772
+ });
773
+ } catch (error) {
774
+ failure = String(error);
775
+ }
776
+ }
777
+ if (failure !== void 0) result = { isError: true, content: [{ type: "text", text: failure }] };
778
+ if (step.id !== void 0) playback.results.set(step.id, stepResult(result));
779
+ const failed = failure !== void 0 || isRecord2(result) && result.isError === true;
780
+ input.emit(
781
+ "item.completed",
782
+ input.threadId,
783
+ {
784
+ ...payload,
785
+ status: failed ? "failed" : "completed",
786
+ data: { ...payload.data, result }
787
+ },
788
+ extra
789
+ );
790
+ return failure === void 0 ? `mcp-send ${step.tool}: ${JSON.stringify(result)}` : `mcp-send ${step.tool} failed: ${failure}`;
791
+ }
792
+ function createPlaybookRunner(input) {
793
+ const config = isRecord2(input) && isRecord2(input.config) ? input.config : void 0;
794
+ const path = config?.playbookPath;
795
+ if (typeof path !== "string") return void 0;
796
+ const titles = /* @__PURE__ */ new Map();
797
+ const claimed = /* @__PURE__ */ new Set();
798
+ const sessions = /* @__PURE__ */ new Map();
799
+ let loaded;
800
+ return {
801
+ startSession: (session) => {
802
+ titles.set(session.threadId, session.title);
803
+ },
804
+ runTurn: async (turn) => {
805
+ if (turn.signal.aborted) return "";
806
+ loaded ??= NodeFSP.readFile(path, "utf8").then(
807
+ parsePlaybook,
808
+ (error) => ({ ok: false, reason: String(error) })
809
+ );
810
+ const parsed = await loaded;
811
+ if (turn.signal.aborted) return "";
812
+ if (!parsed.ok) return `playbook: ${parsed.reason}`;
813
+ let playback = sessions.get(turn.threadId);
814
+ if (playback === void 0) {
815
+ const title = titles.get(turn.threadId);
816
+ const index = parsed.threads.findIndex(
817
+ (line, index2) => !claimed.has(index2) && (line.match.threadId === void 0 || line.match.threadId.test(turn.threadId)) && (line.match.title === void 0 || title !== void 0 && title.includes(line.match.title))
818
+ );
819
+ if (index >= 0) claimed.add(index);
820
+ playback = { line: parsed.threads[index] ?? null, cursor: 0, results: /* @__PURE__ */ new Map() };
821
+ sessions.set(turn.threadId, playback);
822
+ }
823
+ const batch = playback.line?.turns[playback.cursor];
824
+ if (batch === void 0) return null;
825
+ playback.cursor += 1;
826
+ const lines = [];
827
+ for (const step of batch) {
828
+ if (turn.signal.aborted) break;
829
+ if ("wait" in step) {
830
+ const ready = await waitForTool(step, turn);
831
+ if (turn.signal.aborted) break;
832
+ lines.push(`mcp-send wait ${step.wait.tool}${ready ? ": ready" : " failed: timeout"}`);
833
+ } else if ("waitFor" in step) {
834
+ const ready = await waitForCount(step, turn);
835
+ if (turn.signal.aborted) break;
836
+ const { tool, path: path2, atLeast } = step.waitFor;
837
+ lines.push(
838
+ `mcp-send waitFor ${tool} ${path2} >= ${String(atLeast)}${ready ? ": ready" : " failed: timeout"}`
839
+ );
840
+ } else {
841
+ lines.push(await runCall(step, playback, turn));
842
+ }
843
+ }
844
+ return lines.join("\n");
845
+ }
846
+ };
847
+ }
848
+
548
849
  // src/streamScript.ts
549
850
  var FAKE_STREAM_TEXT = [
550
851
  "Here is the plan for the change, step by step.\n\n",
@@ -589,19 +890,6 @@ function confirmationText(result) {
589
890
  }
590
891
  return chunks.join("\n");
591
892
  }
592
- function hold(ms, signal) {
593
- if (ms === 0 || signal.aborted) return Promise.resolve();
594
- return new Promise((resolve) => {
595
- let timer;
596
- const finish = () => {
597
- if (timer !== void 0) clearTimeout(timer);
598
- signal.removeEventListener("abort", finish);
599
- resolve();
600
- };
601
- timer = setTimeout(finish, ms);
602
- signal.addEventListener("abort", finish, { once: true });
603
- });
604
- }
605
893
  async function runVisualizeTurn(input) {
606
894
  const args = JSON.stringify({ action: "create", title: TITLE, fragment: FRAGMENT });
607
895
  const tool = (status, result) => ({
@@ -728,6 +1016,7 @@ var createFakeInstance = (config, host) => {
728
1016
  const events = new EventBus();
729
1017
  const threads = /* @__PURE__ */ new Map();
730
1018
  const script = parseScript(config);
1019
+ const playbook = script === "mcp-send" ? createPlaybookRunner(config) : void 0;
731
1020
  const replyText = parseReplyText(config);
732
1021
  const failStartSession = parseFailStartSession(config);
733
1022
  const streamOptions = parseStreamOptions(config);
@@ -910,7 +1199,16 @@ var createFakeInstance = (config, host) => {
910
1199
  finishTurn(booked, turnId, "interrupted", []);
911
1200
  return;
912
1201
  }
913
- const failure = parseFailInput(input);
1202
+ const playbookText = playbook === void 0 ? null : await playbook.runTurn({
1203
+ threadId,
1204
+ turnId,
1205
+ signal,
1206
+ fetch: host.fetch,
1207
+ mcpServers: booked.mcpServers,
1208
+ emit,
1209
+ nextItemId: () => nextId("item")
1210
+ });
1211
+ const failure = playbookText === null ? parseFailInput(input) : void 0;
914
1212
  if (failure !== void 0) {
915
1213
  const { category, message } = failure;
916
1214
  emit("runtime.error", threadId, { message, failureCategory: category }, { turnId });
@@ -958,7 +1256,7 @@ var createFakeInstance = (config, host) => {
958
1256
  return;
959
1257
  }
960
1258
  if (script === "mcp-send") {
961
- const text = await runMcpSendScript(booked, input);
1259
+ const text = playbookText ?? await runMcpSendScript(booked, input);
962
1260
  emit("content.delta", threadId, { streamKind: "assistant_text", delta: text }, { turnId });
963
1261
  await yieldTick();
964
1262
  finishTurn(booked, turnId, aborted() ? "interrupted" : "completed", [
@@ -1057,6 +1355,7 @@ var createFakeInstance = (config, host) => {
1057
1355
  ...input.bindingGeneration === void 0 ? {} : { bindingGeneration: input.bindingGeneration }
1058
1356
  };
1059
1357
  threads.set(input.threadId, { session, turns: [], mcpServers: input.mcpServers });
1358
+ playbook?.startSession(input);
1060
1359
  emit(
1061
1360
  "session.started",
1062
1361
  input.threadId,
@@ -1163,7 +1462,7 @@ var executors = [
1163
1462
  ];
1164
1463
  var index_default = definePlugin({
1165
1464
  id: "agent-fake",
1166
- apiVersion: 9,
1465
+ apiVersion: 11,
1167
1466
  executors,
1168
1467
  activate(ctx) {
1169
1468
  ctx.log.info("fake executor ready");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ycodium-ai/agent-fake",
3
- "version": "0.2.3034",
3
+ "version": "0.2.3054",
4
4
  "description": "Minimal fake executor for plugin authors and host orchestration tests.",
5
5
  "keywords": [
6
6
  "ycodium",
@@ -21,7 +21,7 @@
21
21
  "@types/node": "24.12.4",
22
22
  "esbuild": "0.28.1",
23
23
  "vite-plus": "0.2.2",
24
- "@ycodium-ai/plugin-api": "0.2.3034",
24
+ "@ycodium-ai/plugin-api": "0.2.3054",
25
25
  "ycodium-executor-testing": "0.0.0"
26
26
  },
27
27
  "ycodium": {