@ycodium-ai/agent-fake 0.2.3024 → 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.
- package/README.md +77 -1
- package/dist/index.js +355 -28
- 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",
|
|
@@ -315,6 +317,23 @@ async function holdTurn(ctx) {
|
|
|
315
317
|
return text;
|
|
316
318
|
}
|
|
317
319
|
|
|
320
|
+
// src/failScript.ts
|
|
321
|
+
var FAIL_SENTENCES = {
|
|
322
|
+
quota: "Fake provider: this account has used up its usage limit.",
|
|
323
|
+
"rate-limit": "Fake provider: too many requests, slow down.",
|
|
324
|
+
"context-limit": "Fake provider: the prompt is longer than the model's context window.",
|
|
325
|
+
auth: "Fake provider: the saved API key was rejected.",
|
|
326
|
+
"model-unavailable": "Fake provider: the selected model is overloaded right now.",
|
|
327
|
+
transport: "Fake provider: the connection to the provider was lost.",
|
|
328
|
+
unknown: "Fake provider: something went wrong."
|
|
329
|
+
};
|
|
330
|
+
var isFailCategory = (value) => Object.hasOwn(FAIL_SENTENCES, value);
|
|
331
|
+
var parseFailInput = (input) => {
|
|
332
|
+
const match = /^fail:([a-z-]+)$/.exec(input?.trim() ?? "");
|
|
333
|
+
const category = match?.[1];
|
|
334
|
+
return category !== void 0 && isFailCategory(category) ? { category, message: FAIL_SENTENCES[category] } : void 0;
|
|
335
|
+
};
|
|
336
|
+
|
|
318
337
|
// src/fakeUsage.ts
|
|
319
338
|
var FAKE_USAGE_SCRIPTS = [
|
|
320
339
|
"available",
|
|
@@ -446,7 +465,12 @@ function parseMessage(text) {
|
|
|
446
465
|
return json.length === 0 ? null : JSON.parse(json);
|
|
447
466
|
}
|
|
448
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) {
|
|
449
472
|
const post = async (headers, body) => {
|
|
473
|
+
input.signal?.throwIfAborted();
|
|
450
474
|
const response = await input.fetch(input.server.transport.url, {
|
|
451
475
|
method: "POST",
|
|
452
476
|
headers: {
|
|
@@ -454,7 +478,8 @@ async function callMcpTool(input) {
|
|
|
454
478
|
accept: "application/json, text/event-stream",
|
|
455
479
|
...headers
|
|
456
480
|
},
|
|
457
|
-
body: JSON.stringify({ jsonrpc: "2.0", ...body })
|
|
481
|
+
body: JSON.stringify({ jsonrpc: "2.0", ...body }),
|
|
482
|
+
...input.signal === void 0 ? {} : { signal: input.signal }
|
|
458
483
|
});
|
|
459
484
|
const text = await response.text();
|
|
460
485
|
if (!response.ok)
|
|
@@ -480,16 +505,16 @@ async function callMcpTool(input) {
|
|
|
480
505
|
...sessionId === null ? {} : { "mcp-session-id": sessionId }
|
|
481
506
|
};
|
|
482
507
|
await post(session, { method: "notifications/initialized" });
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
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
|
+
};
|
|
493
518
|
}
|
|
494
519
|
var isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
495
520
|
function parseMcpSendInput(input) {
|
|
@@ -528,6 +553,299 @@ async function runMcpCalls(input) {
|
|
|
528
553
|
return lines.join("\n");
|
|
529
554
|
}
|
|
530
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
|
+
|
|
531
849
|
// src/streamScript.ts
|
|
532
850
|
var FAKE_STREAM_TEXT = [
|
|
533
851
|
"Here is the plan for the change, step by step.\n\n",
|
|
@@ -572,19 +890,6 @@ function confirmationText(result) {
|
|
|
572
890
|
}
|
|
573
891
|
return chunks.join("\n");
|
|
574
892
|
}
|
|
575
|
-
function hold(ms, signal) {
|
|
576
|
-
if (ms === 0 || signal.aborted) return Promise.resolve();
|
|
577
|
-
return new Promise((resolve) => {
|
|
578
|
-
let timer;
|
|
579
|
-
const finish = () => {
|
|
580
|
-
if (timer !== void 0) clearTimeout(timer);
|
|
581
|
-
signal.removeEventListener("abort", finish);
|
|
582
|
-
resolve();
|
|
583
|
-
};
|
|
584
|
-
timer = setTimeout(finish, ms);
|
|
585
|
-
signal.addEventListener("abort", finish, { once: true });
|
|
586
|
-
});
|
|
587
|
-
}
|
|
588
893
|
async function runVisualizeTurn(input) {
|
|
589
894
|
const args = JSON.stringify({ action: "create", title: TITLE, fragment: FRAGMENT });
|
|
590
895
|
const tool = (status, result) => ({
|
|
@@ -711,6 +1016,7 @@ var createFakeInstance = (config, host) => {
|
|
|
711
1016
|
const events = new EventBus();
|
|
712
1017
|
const threads = /* @__PURE__ */ new Map();
|
|
713
1018
|
const script = parseScript(config);
|
|
1019
|
+
const playbook = script === "mcp-send" ? createPlaybookRunner(config) : void 0;
|
|
714
1020
|
const replyText = parseReplyText(config);
|
|
715
1021
|
const failStartSession = parseFailStartSession(config);
|
|
716
1022
|
const streamOptions = parseStreamOptions(config);
|
|
@@ -742,7 +1048,7 @@ var createFakeInstance = (config, host) => {
|
|
|
742
1048
|
if (!booked) throw new Error(`unknown thread ${threadId}`);
|
|
743
1049
|
return booked;
|
|
744
1050
|
};
|
|
745
|
-
const finishTurn = (booked, turnId, state, items, errorMessage) => {
|
|
1051
|
+
const finishTurn = (booked, turnId, state, items, errorMessage, failureCategory) => {
|
|
746
1052
|
booked.turns.push({ id: turnId, items });
|
|
747
1053
|
const { activeTurnId: _cleared, ...idle } = booked.session;
|
|
748
1054
|
booked.session = {
|
|
@@ -753,7 +1059,11 @@ var createFakeInstance = (config, host) => {
|
|
|
753
1059
|
emit(
|
|
754
1060
|
"turn.completed",
|
|
755
1061
|
booked.session.threadId,
|
|
756
|
-
|
|
1062
|
+
{
|
|
1063
|
+
state,
|
|
1064
|
+
...errorMessage === void 0 ? {} : { errorMessage },
|
|
1065
|
+
...failureCategory === void 0 ? {} : { failureCategory }
|
|
1066
|
+
},
|
|
757
1067
|
{ turnId }
|
|
758
1068
|
);
|
|
759
1069
|
if (inflight.get(booked.session.threadId)?.turnId === turnId) {
|
|
@@ -889,6 +1199,22 @@ var createFakeInstance = (config, host) => {
|
|
|
889
1199
|
finishTurn(booked, turnId, "interrupted", []);
|
|
890
1200
|
return;
|
|
891
1201
|
}
|
|
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;
|
|
1212
|
+
if (failure !== void 0) {
|
|
1213
|
+
const { category, message } = failure;
|
|
1214
|
+
emit("runtime.error", threadId, { message, failureCategory: category }, { turnId });
|
|
1215
|
+
finishTurn(booked, turnId, "failed", [], message, category);
|
|
1216
|
+
return;
|
|
1217
|
+
}
|
|
892
1218
|
if (script === "async-question" && booked.asked !== true) {
|
|
893
1219
|
booked.asked = true;
|
|
894
1220
|
emit(
|
|
@@ -930,7 +1256,7 @@ var createFakeInstance = (config, host) => {
|
|
|
930
1256
|
return;
|
|
931
1257
|
}
|
|
932
1258
|
if (script === "mcp-send") {
|
|
933
|
-
const text = await runMcpSendScript(booked, input);
|
|
1259
|
+
const text = playbookText ?? await runMcpSendScript(booked, input);
|
|
934
1260
|
emit("content.delta", threadId, { streamKind: "assistant_text", delta: text }, { turnId });
|
|
935
1261
|
await yieldTick();
|
|
936
1262
|
finishTurn(booked, turnId, aborted() ? "interrupted" : "completed", [
|
|
@@ -1029,6 +1355,7 @@ var createFakeInstance = (config, host) => {
|
|
|
1029
1355
|
...input.bindingGeneration === void 0 ? {} : { bindingGeneration: input.bindingGeneration }
|
|
1030
1356
|
};
|
|
1031
1357
|
threads.set(input.threadId, { session, turns: [], mcpServers: input.mcpServers });
|
|
1358
|
+
playbook?.startSession(input);
|
|
1032
1359
|
emit(
|
|
1033
1360
|
"session.started",
|
|
1034
1361
|
input.threadId,
|
|
@@ -1135,7 +1462,7 @@ var executors = [
|
|
|
1135
1462
|
];
|
|
1136
1463
|
var index_default = definePlugin({
|
|
1137
1464
|
id: "agent-fake",
|
|
1138
|
-
apiVersion:
|
|
1465
|
+
apiVersion: 11,
|
|
1139
1466
|
executors,
|
|
1140
1467
|
activate(ctx) {
|
|
1141
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.
|
|
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.
|
|
24
|
+
"@ycodium-ai/plugin-api": "0.2.3054",
|
|
25
25
|
"ycodium-executor-testing": "0.0.0"
|
|
26
26
|
},
|
|
27
27
|
"ycodium": {
|