@ycodium-ai/agent-fake 0.2.3074 → 0.2.3078

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 +8 -2
  2. package/dist/index.js +174 -44
  3. package/package.json +3 -3
package/README.md CHANGED
@@ -82,12 +82,18 @@ then that text. A whole-string placeholder preserves types; interpolation JSON-s
82
82
  non-strings. Unresolved references skip that call and reply
83
83
  `mcp-send <tool> failed: unresolved placeholder <placeholder>`, then continue.
84
84
 
85
+ Calls, `wait` and `waitFor` accept an optional `server` alongside `tool` (for example,
86
+ `{ "server": "ycodium-home", "tool": "work_list_items" }`); it selects the named HTTP server
87
+ from `startSession.mcpServers`, defaults to the built-in server, and gives tool events the
88
+ `mcp__<server>__<tool>` prefix (`mcp__ycodium__<tool>` by default). Clients are reused within
89
+ the session and closed when it ends; events and replies use server names and redact server URLs.
90
+
85
91
  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
92
+ `mcp__<server>__` prefix, defaulting to a 30000 ms timeout. It replies
87
93
  `mcp-send wait <tool>: ready` or `mcp-send wait <tool> failed: timeout`, then continues, with no
88
94
  tool items. A `waitFor`, such as
89
95
  `{ "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
96
+ with its optional `arguments` (none by default; placeholders are not resolved there) every 250 ms on one session until the value at the dot `path` of its result is
91
97
  an array at least `atLeast` long, or a number at least `atLeast`; a tool error counts as not yet.
92
98
  Point it only at read-only tools, since every poll is a real call. It also defaults to 30000 ms,
93
99
  replies `mcp-send waitFor <tool> <path> >= <atLeast>: ready` or `… failed: timeout`, emits no tool
package/dist/index.js CHANGED
@@ -469,8 +469,8 @@ async function callMcpTool(input) {
469
469
  return session.request("tools/call", { name: input.name, arguments: input.arguments });
470
470
  }
471
471
  async function openMcpSession(input) {
472
- const post = async (headers, body) => {
473
- input.signal?.throwIfAborted();
472
+ const post = async (headers, body, signal = input.signal) => {
473
+ signal?.throwIfAborted();
474
474
  const response = await input.fetch(input.server.transport.url, {
475
475
  method: "POST",
476
476
  headers: {
@@ -479,14 +479,18 @@ async function openMcpSession(input) {
479
479
  ...headers
480
480
  },
481
481
  body: JSON.stringify({ jsonrpc: "2.0", ...body }),
482
- ...input.signal === void 0 ? {} : { signal: input.signal }
482
+ ...signal === void 0 ? {} : { signal }
483
483
  });
484
484
  const text = await response.text();
485
485
  if (!response.ok)
486
486
  throw new Error(
487
- `MCP ${String(body.method)} failed: HTTP ${response.status} ${text.slice(0, 200)}`
487
+ `MCP ${String(body.method)} failed: HTTP ${response.status} ${text.replaceAll(input.server.transport.url, input.server.name).slice(0, 200)}`
488
488
  );
489
- return { response, message: parseMessage(text) };
489
+ try {
490
+ return { response, message: parseMessage(text) };
491
+ } catch {
492
+ throw new Error(`MCP ${String(body.method)} failed: invalid response`);
493
+ }
490
494
  };
491
495
  const base = { ...input.server.transport.headers };
492
496
  const initialized = await post(base, {
@@ -504,16 +508,36 @@ async function openMcpSession(input) {
504
508
  "mcp-protocol-version": PROTOCOL_VERSION,
505
509
  ...sessionId === null ? {} : { "mcp-session-id": sessionId }
506
510
  };
507
- await post(session, { method: "notifications/initialized" });
511
+ let closed = false;
512
+ const close = async () => {
513
+ if (closed) return;
514
+ closed = true;
515
+ if (sessionId === null) return;
516
+ const response = await input.fetch(input.server.transport.url, {
517
+ method: "DELETE",
518
+ headers: session,
519
+ signal: AbortSignal.timeout(1e3)
520
+ });
521
+ await response.body?.cancel();
522
+ };
523
+ try {
524
+ await post(session, { method: "notifications/initialized" });
525
+ } catch (error) {
526
+ await close().catch(() => {
527
+ });
528
+ throw error;
529
+ }
508
530
  let nextId = 2;
509
531
  return {
510
- request: async (method, params) => {
511
- const called = await post(session, { id: nextId++, method, params });
532
+ request: async (method, params, signal) => {
533
+ if (closed) throw new Error("MCP session closed");
534
+ const called = await post(session, { id: nextId++, method, params }, signal);
512
535
  const label = method === "tools/call" ? `tools/call ${String(params.name)}` : method;
513
536
  if (called.message?.error !== void 0)
514
537
  throw new Error(`MCP ${label} failed: ${called.message.error.message ?? "error"}`);
515
538
  return called.message?.result;
516
- }
539
+ },
540
+ close
517
541
  };
518
542
  }
519
543
  var isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
@@ -571,6 +595,66 @@ function hold(ms, signal) {
571
595
  });
572
596
  }
573
597
 
598
+ // src/playbookMcp.ts
599
+ function redactMcpUrls(value, servers) {
600
+ if (typeof value === "string") {
601
+ let text = value;
602
+ for (const server of servers ?? []) {
603
+ if (server.transport.type !== "stdio")
604
+ text = text.replaceAll(server.transport.url, server.name);
605
+ }
606
+ return text;
607
+ }
608
+ if (Array.isArray(value)) return value.map((entry) => redactMcpUrls(entry, servers));
609
+ if (typeof value === "object" && value !== null)
610
+ return Object.fromEntries(
611
+ Object.entries(value).map(([key, entry]) => [
612
+ redactMcpUrls(key, servers),
613
+ redactMcpUrls(entry, servers)
614
+ ])
615
+ );
616
+ return value;
617
+ }
618
+ function createPlaybookMcp(input) {
619
+ const sessions = /* @__PURE__ */ new Map();
620
+ const lifetime = new AbortController();
621
+ const serverFor = (name) => name === void 0 ? builtinMcpServer(input.servers) : input.servers?.find((server) => server.name === name) ?? null;
622
+ return {
623
+ missing: (name) => serverFor(name) === null ? name === void 0 ? "Error: no MCP server was handed to this session" : `no MCP server ${name}` : void 0,
624
+ request: async (name, method, params, signal) => {
625
+ const server = serverFor(name);
626
+ if (server === null) throw new Error("MCP server missing");
627
+ if (server.transport.type !== "http")
628
+ throw new Error(`unsupported MCP transport ${server.transport.type} for ${server.name}`);
629
+ const requestSignal = AbortSignal.any([lifetime.signal, signal]);
630
+ requestSignal.throwIfAborted();
631
+ let pending = sessions.get(server.name);
632
+ if (pending === void 0) {
633
+ pending = openMcpSession({
634
+ fetch: input.fetch,
635
+ server: { ...server, transport: { ...server.transport, type: "http" } },
636
+ signal: requestSignal
637
+ });
638
+ sessions.set(server.name, pending);
639
+ const opening = pending;
640
+ void pending.catch(() => {
641
+ if (sessions.get(server.name) === opening) sessions.delete(server.name);
642
+ });
643
+ }
644
+ const session = await pending;
645
+ requestSignal.throwIfAborted();
646
+ return session.request(method, params, requestSignal);
647
+ },
648
+ close: async () => {
649
+ lifetime.abort();
650
+ await Promise.allSettled(
651
+ [...sessions.values()].map(async (pending) => (await pending).close())
652
+ );
653
+ sessions.clear();
654
+ }
655
+ };
656
+ }
657
+
574
658
  // src/playbook.ts
575
659
  var isRecord2 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
576
660
  var isName = (value) => typeof value === "string" && value.length > 0;
@@ -584,25 +668,43 @@ function parseStep(value) {
584
668
  if (Object.hasOwn(value, "wait")) {
585
669
  const wait = value.wait;
586
670
  if (!onlyKeys(value, ["wait"]) || !isRecord2(wait) || !isName(wait.tool)) return null;
587
- if (!onlyKeys(wait, ["tool", "timeoutMs"])) return null;
671
+ if (!onlyKeys(wait, ["server", "tool", "timeoutMs"]) || wait.server !== void 0 && !isName(wait.server))
672
+ return null;
588
673
  const timeoutMs = parseTimeout(wait.timeoutMs);
589
- return timeoutMs === null ? null : { wait: { tool: wait.tool, timeoutMs } };
674
+ return timeoutMs === null ? null : {
675
+ wait: {
676
+ tool: wait.tool,
677
+ timeoutMs,
678
+ ...wait.server === void 0 ? {} : { server: wait.server }
679
+ }
680
+ };
590
681
  }
591
682
  if (Object.hasOwn(value, "waitFor")) {
592
683
  const wait = value.waitFor;
593
684
  if (!onlyKeys(value, ["waitFor"]) || !isRecord2(wait) || !isName(wait.tool)) return null;
594
- if (!onlyKeys(wait, ["tool", "path", "atLeast", "timeoutMs"]) || !isName(wait.path))
685
+ if (!onlyKeys(wait, ["server", "tool", "arguments", "path", "atLeast", "timeoutMs"]) || !isName(wait.path) || wait.arguments !== void 0 && !isRecord2(wait.arguments) || wait.server !== void 0 && !isName(wait.server))
595
686
  return null;
596
687
  if (typeof wait.atLeast !== "number" || !Number.isInteger(wait.atLeast) || wait.atLeast < 0)
597
688
  return null;
598
689
  const timeoutMs = parseTimeout(wait.timeoutMs);
599
- return timeoutMs === null ? null : { waitFor: { tool: wait.tool, path: wait.path, atLeast: wait.atLeast, timeoutMs } };
690
+ return timeoutMs === null ? null : {
691
+ waitFor: {
692
+ tool: wait.tool,
693
+ path: wait.path,
694
+ atLeast: wait.atLeast,
695
+ timeoutMs,
696
+ ...wait.server === void 0 ? {} : { server: wait.server },
697
+ ...wait.arguments === void 0 ? {} : { arguments: wait.arguments }
698
+ }
699
+ };
600
700
  }
601
- if (!onlyKeys(value, ["id", "tool", "arguments"]) || !isName(value.tool)) return null;
701
+ if (!onlyKeys(value, ["id", "server", "tool", "arguments"]) || !isName(value.tool) || value.server !== void 0 && !isName(value.server))
702
+ return null;
602
703
  if (value.id !== void 0 && typeof value.id !== "string") return null;
603
704
  if (value.arguments !== void 0 && !isRecord2(value.arguments)) return null;
604
705
  return {
605
706
  tool: value.tool,
707
+ ...value.server === void 0 ? {} : { server: value.server },
606
708
  ...value.id === void 0 ? {} : { id: value.id },
607
709
  ...value.arguments === void 0 ? {} : { arguments: value.arguments }
608
710
  };
@@ -753,7 +855,6 @@ function stepResult(result) {
753
855
  }
754
856
  }
755
857
  async function poll(timeoutMs, input, ready) {
756
- const server = builtinMcpServer(input.mcpServers);
757
858
  const deadline = Date.now() + timeoutMs;
758
859
  const controller = new AbortController();
759
860
  const abort = () => controller.abort();
@@ -761,20 +862,12 @@ async function poll(timeoutMs, input, ready) {
761
862
  if (input.signal.aborted) abort();
762
863
  const timer = setTimeout(abort, timeoutMs);
763
864
  try {
764
- let session;
765
865
  while (!controller.signal.aborted && Date.now() < deadline) {
766
866
  try {
767
- if (server !== null)
768
- session ??= await openMcpSession({
769
- fetch: input.fetch,
770
- server,
771
- signal: controller.signal
772
- });
773
- const done = session !== void 0 && await ready(session);
867
+ const done = await ready(controller.signal);
774
868
  if (controller.signal.aborted || Date.now() >= deadline) return false;
775
869
  if (done) return true;
776
870
  } catch {
777
- session = void 0;
778
871
  if (controller.signal.aborted) return false;
779
872
  }
780
873
  await hold(Math.min(250, Math.max(0, deadline - Date.now())), controller.signal);
@@ -785,20 +878,25 @@ async function poll(timeoutMs, input, ready) {
785
878
  input.signal.removeEventListener("abort", abort);
786
879
  }
787
880
  }
788
- var waitForTool = (step, input) => poll(step.wait.timeoutMs, input, async (session) => {
789
- const listed = await session.request("tools/list", {});
881
+ var waitForTool = (step, input, clients) => poll(step.wait.timeoutMs, input, async (signal) => {
882
+ const listed = await clients.request(step.wait.server, "tools/list", {}, signal);
790
883
  return isRecord2(listed) && Array.isArray(listed.tools) && listed.tools.some(
791
- (tool) => isRecord2(tool) && (tool.name === step.wait.tool || tool.name === `mcp__ycodium__${step.wait.tool}`)
884
+ (tool) => isRecord2(tool) && (tool.name === step.wait.tool || tool.name === `mcp__${step.wait.server ?? "ycodium"}__${step.wait.tool}`)
792
885
  );
793
886
  });
794
- var waitForCount = (step, input) => poll(step.waitFor.timeoutMs, input, async (session) => {
795
- const called = await session.request("tools/call", { name: step.waitFor.tool, arguments: {} });
887
+ var waitForCount = (step, input, clients) => poll(step.waitFor.timeoutMs, input, async (signal) => {
888
+ const called = await clients.request(
889
+ step.waitFor.server,
890
+ "tools/call",
891
+ { name: step.waitFor.tool, arguments: step.waitFor.arguments ?? {} },
892
+ signal
893
+ );
796
894
  if (isRecord2(called) && called.isError === true) return false;
797
895
  const value = valueAt(stepResult(called), step.waitFor.path.split("."));
798
896
  const count = Array.isArray(value) ? value.length : value;
799
897
  return typeof count === "number" && count >= step.waitFor.atLeast;
800
898
  });
801
- async function runCall(step, playback, input) {
899
+ async function runCall(step, playback, input, clients) {
802
900
  let args = step.arguments ?? {};
803
901
  let failure;
804
902
  try {
@@ -806,22 +904,20 @@ async function runCall(step, playback, input) {
806
904
  } catch (error) {
807
905
  failure = error instanceof UnresolvedPlaceholder ? error.message : String(error);
808
906
  }
809
- const toolName = `mcp__ycodium__${step.tool}`;
907
+ const toolName = `mcp__${step.server ?? "ycodium"}__${step.tool}`;
810
908
  const extra = { turnId: input.turnId, itemId: input.nextItemId() };
811
909
  const payload = { itemType: "mcp_tool_call", title: toolName, data: { toolName, input: args } };
812
910
  input.emit("item.started", input.threadId, { ...payload, status: "inProgress" }, extra);
813
911
  let result;
912
+ failure ??= clients.missing(step.server);
814
913
  if (failure === void 0) {
815
914
  try {
816
- const server = builtinMcpServer(input.mcpServers);
817
- if (server === null) throw new Error("no MCP server was handed to this session");
818
- result = await callMcpTool({
819
- fetch: input.fetch,
820
- server,
821
- name: step.tool,
822
- arguments: args,
823
- signal: input.signal
824
- });
915
+ result = await clients.request(
916
+ step.server,
917
+ "tools/call",
918
+ { name: step.tool, arguments: args },
919
+ input.signal
920
+ );
825
921
  } catch (error) {
826
922
  failure = String(error);
827
923
  }
@@ -850,11 +946,22 @@ function createPlaybookRunner(input) {
850
946
  const titles = /* @__PURE__ */ new Map();
851
947
  const claimed = /* @__PURE__ */ new Set();
852
948
  const sessions = /* @__PURE__ */ new Map();
949
+ const clients = /* @__PURE__ */ new Map();
853
950
  let loaded;
854
951
  return {
855
952
  startSession: (session) => {
856
953
  titles.set(session.threadId, session.title);
857
954
  },
955
+ stopSession: async (threadId) => {
956
+ const opened = clients.get(threadId);
957
+ clients.delete(threadId);
958
+ await opened?.close();
959
+ },
960
+ dispose: async () => {
961
+ const opened = [...clients.values()];
962
+ clients.clear();
963
+ await Promise.all(opened.map((client) => client.close()));
964
+ },
858
965
  runTurn: async (turn) => {
859
966
  if (turn.signal.aborted) return "";
860
967
  loaded ??= NodeFSP.readFile(path, "utf8").then(
@@ -882,25 +989,46 @@ function createPlaybookRunner(input) {
882
989
  const batch = playback.line?.turns[playback.cursor];
883
990
  if (batch === void 0) return null;
884
991
  playback.cursor += 1;
992
+ let opened = clients.get(turn.threadId);
993
+ if (opened === void 0) {
994
+ opened = createPlaybookMcp({ fetch: turn.fetch, servers: turn.mcpServers });
995
+ clients.set(turn.threadId, opened);
996
+ }
997
+ const safeTurn = {
998
+ ...turn,
999
+ emit: (...args) => {
1000
+ turn.emit(args[0], args[1], redactMcpUrls(args[2], turn.mcpServers), args[3]);
1001
+ }
1002
+ };
885
1003
  const lines = [];
886
1004
  for (const step of batch) {
887
1005
  if (turn.signal.aborted) break;
888
1006
  if ("wait" in step) {
889
- const ready = await waitForTool(step, turn);
1007
+ const missing = opened.missing(step.wait.server);
1008
+ if (step.wait.server !== void 0 && missing !== void 0) {
1009
+ lines.push(`mcp-send ${step.wait.tool} failed: ${missing}`);
1010
+ continue;
1011
+ }
1012
+ const ready = await waitForTool(step, safeTurn, opened);
890
1013
  if (turn.signal.aborted) break;
891
1014
  lines.push(`mcp-send wait ${step.wait.tool}${ready ? ": ready" : " failed: timeout"}`);
892
1015
  } else if ("waitFor" in step) {
893
- const ready = await waitForCount(step, turn);
1016
+ const missing = opened.missing(step.waitFor.server);
1017
+ if (step.waitFor.server !== void 0 && missing !== void 0) {
1018
+ lines.push(`mcp-send ${step.waitFor.tool} failed: ${missing}`);
1019
+ continue;
1020
+ }
1021
+ const ready = await waitForCount(step, safeTurn, opened);
894
1022
  if (turn.signal.aborted) break;
895
1023
  const { tool, path: path2, atLeast } = step.waitFor;
896
1024
  lines.push(
897
1025
  `mcp-send waitFor ${tool} ${path2} >= ${String(atLeast)}${ready ? ": ready" : " failed: timeout"}`
898
1026
  );
899
1027
  } else {
900
- lines.push(await runCall(step, playback, turn));
1028
+ lines.push(await runCall(step, playback, safeTurn, opened));
901
1029
  }
902
1030
  }
903
- return lines.join("\n");
1031
+ return redactMcpUrls(lines.join("\n"), turn.mcpServers);
904
1032
  }
905
1033
  };
906
1034
  }
@@ -1489,6 +1617,7 @@ var createFakeInstance = (config, host) => {
1489
1617
  stopSession: async (threadId) => {
1490
1618
  inflight.get(threadId)?.abort();
1491
1619
  threads.delete(threadId);
1620
+ await playbook?.stopSession(threadId);
1492
1621
  },
1493
1622
  readThread: async (threadId) => {
1494
1623
  const booked = requireThread(threadId);
@@ -1503,6 +1632,7 @@ var createFakeInstance = (config, host) => {
1503
1632
  for (const settle of pendingDecisions.values()) settle("cancel");
1504
1633
  pendingDecisions.clear();
1505
1634
  threads.clear();
1635
+ await playbook?.dispose();
1506
1636
  events.close();
1507
1637
  }
1508
1638
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ycodium-ai/agent-fake",
3
- "version": "0.2.3074",
3
+ "version": "0.2.3078",
4
4
  "description": "Minimal fake executor for plugin authors and host orchestration tests.",
5
5
  "keywords": [
6
6
  "ycodium",
@@ -21,8 +21,8 @@
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.3074",
25
- "ycodium-executor-testing": "0.0.0"
24
+ "ycodium-executor-testing": "0.0.0",
25
+ "@ycodium-ai/plugin-api": "0.2.3078"
26
26
  },
27
27
  "ycodium": {
28
28
  "plugin": "./dist/index.js",