minimal-agent 0.2.0 → 0.3.1
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 +54 -72
- package/package.json +18 -13
- package/plugins/ralph-wiggum/plugin.js +205 -0
- package/plugins/ralph-wiggum/src/goalState.js +260 -0
- package/plugins/ralph-wiggum/src/{sentinels.ts → sentinels.js} +4 -7
- package/plugins/ralph-wiggum/src/stopHookRunner.js +104 -0
- package/plugins/ralph-wiggum/src/verificationGate.js +202 -0
- package/plugins/workflow-runner/commands/workflow.md +13 -3
- package/plugins/workflow-runner/{plugin.ts → plugin.js} +20 -26
- package/plugins/workflow-runner/src/expressions.js +369 -0
- package/plugins/workflow-runner/src/index.js +216 -0
- package/plugins/workflow-runner/src/loader.js +183 -0
- package/plugins/workflow-runner/src/runner.js +290 -0
- package/plugins/workflow-runner/src/stepExecutors/assert.js +28 -0
- package/plugins/workflow-runner/src/stepExecutors/llm.js +44 -0
- package/plugins/workflow-runner/src/stepExecutors/skill.js +103 -0
- package/plugins/workflow-runner/src/stepExecutors/{tool.ts → tool.js} +19 -25
- package/plugins/workflow-runner/src/types.js +59 -0
- package/plugins/workflow-runner/src/{workflowState.ts → workflowState.js} +21 -40
- package/src/bootstrap/cwdArg.js +22 -0
- package/src/bootstrap/workingDir.js +31 -0
- package/src/cli/configWizard.js +272 -0
- package/src/cli/print.js +197 -0
- package/src/config/configFile.js +78 -0
- package/src/config.js +118 -0
- package/src/context/compact.js +357 -0
- package/src/context/microCompactLite.js +151 -0
- package/src/context/persistContext.js +109 -0
- package/src/context/reactiveCompact.js +121 -0
- package/src/context/sessionPath.js +58 -0
- package/src/context/snipCompact.js +112 -0
- package/src/context/tokenCounter.js +66 -0
- package/src/llm/client.js +182 -0
- package/src/loop.js +230 -0
- package/src/main.js +116 -0
- package/src/plugin-sdk.js +24 -0
- package/src/plugins/commandRouter.js +169 -0
- package/src/plugins/hookEngine.js +258 -0
- package/src/plugins/pluginApi.js +23 -0
- package/src/plugins/pluginLoader.js +71 -0
- package/src/plugins/pluginRunner.js +65 -0
- package/src/plugins/transcript.js +171 -0
- package/src/prompts/projectInstructions.js +48 -0
- package/src/prompts/skillList.js +126 -0
- package/src/prompts/system.js +155 -0
- package/src/session/runTurn.js +41 -0
- package/src/session/sessionState.js +19 -0
- package/src/tools/bash/bash.js +352 -0
- package/src/tools/bash/semantics.js +85 -0
- package/src/tools/bash/warnings.js +98 -0
- package/src/tools/edit/edit.js +253 -0
- package/src/tools/edit/multi-edit.js +155 -0
- package/src/tools/glob/glob.js +97 -0
- package/src/tools/grep/grep.js +185 -0
- package/src/tools/grep/rgPath.js +173 -0
- package/src/tools/index.js +94 -0
- package/src/tools/read/read.js +209 -0
- package/src/tools/shared/fileState.js +61 -0
- package/src/tools/shared/fileUtils.js +281 -0
- package/src/tools/shared/schemas.js +16 -0
- package/src/tools/types.js +21 -0
- package/src/tools/webbrowser/browser.js +55 -0
- package/src/tools/webbrowser/webbrowser.js +194 -0
- package/src/tools/webfetch/preapproved.js +267 -0
- package/src/tools/webfetch/webfetch.js +317 -0
- package/src/tools/websearch/websearch.js +161 -0
- package/src/tools/write/write.js +125 -0
- package/src/types/turndown.d.ts +23 -0
- package/src/types.js +16 -0
- package/src/ui/App.js +37 -0
- package/src/ui/InputBox.js +240 -0
- package/src/ui/MessageList.js +28 -0
- package/src/ui/Root.js +70 -0
- package/src/ui/StatusLine.js +41 -0
- package/src/ui/ToolStatus.js +11 -0
- package/src/ui/hooks/useChat.js +234 -0
- package/src/ui/hooks/usePasteHandler.js +137 -0
- package/src/ui/hooks/useTextBuffer.js +55 -0
- package/src/ui/hooks/useTokenUsage.js +30 -0
- package/src/ui/textBuffer.js +217 -0
- package/src/utils/packageRoot.js +37 -0
- package/src/utils/resourcePaths.js +49 -0
- package/src/utils/zodToJson.js +29 -0
- package/dist/main.js +0 -5315
- package/plugins/ralph-wiggum/plugin.ts +0 -275
- package/plugins/ralph-wiggum/scripts/setup-ralph-loop.sh +0 -203
- package/plugins/ralph-wiggum/src/goalState.ts +0 -310
- package/plugins/ralph-wiggum/src/stopHookRunner.ts +0 -136
- package/plugins/ralph-wiggum/src/verificationGate.ts +0 -252
- package/plugins/ralph-wiggum/test/goalState.test.ts +0 -410
- package/plugins/ralph-wiggum/test/verificationGate.test.ts +0 -122
- package/plugins/workflow-runner/src/expressions.ts +0 -371
- package/plugins/workflow-runner/src/index.ts +0 -194
- package/plugins/workflow-runner/src/loader.ts +0 -193
- package/plugins/workflow-runner/src/runner.ts +0 -313
- package/plugins/workflow-runner/src/stepExecutors/assert.ts +0 -30
- package/plugins/workflow-runner/src/stepExecutors/llm.ts +0 -54
- package/plugins/workflow-runner/src/stepExecutors/skill.ts +0 -115
- package/plugins/workflow-runner/src/types.ts +0 -183
- package/plugins/workflow-runner/test/cli.e2e.test.ts +0 -114
- package/plugins/workflow-runner/test/e2e.test.ts +0 -268
- package/plugins/workflow-runner/test/expressions.test.ts +0 -140
- package/plugins/workflow-runner/test/fixtures/cli-e2e.yaml +0 -27
- package/plugins/workflow-runner/test/fixtures/hello-workflow.yaml +0 -49
- package/plugins/workflow-runner/test/graceful.test.ts +0 -139
- package/plugins/workflow-runner/test/loader.test.ts +0 -216
- package/plugins/workflow-runner/test/pluginRunner.isolation.test.ts +0 -230
- package/plugins/workflow-runner/test/runner.test.ts +0 -511
|
@@ -1,115 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* ============================================================
|
|
3
|
-
* plugins/workflow-runner/src/stepExecutors/skill.ts —— skill step 执行
|
|
4
|
-
* ------------------------------------------------------------
|
|
5
|
-
* 把一个具名 skill(skills/<name>/SKILL.md)当作"迷你 ReAct 子循环"
|
|
6
|
-
* 跑一次:复用 src/loop.ts::runQuery,让模型在子 history 内 T-A-O-R。
|
|
7
|
-
*
|
|
8
|
-
* 实现要点:
|
|
9
|
-
* - 双源查找:cwd/skills/<name>/SKILL.md 优先,packageRoot/skills/ fallback;
|
|
10
|
-
* 支持按目录名 or 按 frontmatter::name 匹配
|
|
11
|
-
* - 剥掉 frontmatter,body 作 system 指令;step.input 插值后作为 user
|
|
12
|
-
* - 子 history 与外层 ctx.history 隔离,避免污染主对话
|
|
13
|
-
* - 流式转发:runQuery yield 的事件直接 yield 出去(让 UI 看到子循环进度)
|
|
14
|
-
* - 收集 text 事件累计为 raw.text,供 capture 绑变量使用
|
|
15
|
-
* - maxTurns 可在 step 里覆盖,默认走 runQuery 自带(50)
|
|
16
|
-
*
|
|
17
|
-
* capture:{ text: var_name } / { result: var_name }
|
|
18
|
-
* ============================================================
|
|
19
|
-
*/
|
|
20
|
-
|
|
21
|
-
import { readdir, readFile } from 'node:fs/promises';
|
|
22
|
-
import { join } from 'node:path';
|
|
23
|
-
|
|
24
|
-
import {
|
|
25
|
-
getResourceSearchPaths,
|
|
26
|
-
runQuery,
|
|
27
|
-
type LoopEvent,
|
|
28
|
-
type Message,
|
|
29
|
-
} from '../../../../src/plugin-sdk.ts';
|
|
30
|
-
import { interpolate } from '../expressions.ts';
|
|
31
|
-
import type { RunContext, StepDef, StepResult, VarStack } from '../types.ts';
|
|
32
|
-
|
|
33
|
-
function stripFrontmatter(content: string): string {
|
|
34
|
-
return content.replace(/^---\n[\s\S]*?\n---\n?/, '');
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
function parseFrontmatterName(content: string): string | null {
|
|
38
|
-
const m = content.match(/^---\n([\s\S]*?)\n---/);
|
|
39
|
-
if (!m) return null;
|
|
40
|
-
const nm = m[1].match(/^name:\s*(.+)$/m);
|
|
41
|
-
if (!nm) return null;
|
|
42
|
-
return nm[1].trim().replace(/^["']|["']$/g, '');
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
async function findSkillFile(skillName: string): Promise<string | null> {
|
|
46
|
-
const roots = getResourceSearchPaths('skills', import.meta.url);
|
|
47
|
-
for (const root of roots) {
|
|
48
|
-
let entries: { name: string; isDirectory: () => boolean }[];
|
|
49
|
-
try {
|
|
50
|
-
entries = await readdir(root, { withFileTypes: true });
|
|
51
|
-
} catch {
|
|
52
|
-
continue;
|
|
53
|
-
}
|
|
54
|
-
for (const e of entries) {
|
|
55
|
-
if (!e.isDirectory()) continue;
|
|
56
|
-
const skillFile = join(root, e.name, 'SKILL.md');
|
|
57
|
-
if (e.name === skillName) return skillFile;
|
|
58
|
-
try {
|
|
59
|
-
const md = await readFile(skillFile, 'utf8');
|
|
60
|
-
if (parseFrontmatterName(md) === skillName) return skillFile;
|
|
61
|
-
} catch {
|
|
62
|
-
// 该子目录无 SKILL.md,跳过
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
return null;
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
export async function* execSkillStep(
|
|
70
|
-
step: StepDef,
|
|
71
|
-
vars: VarStack,
|
|
72
|
-
ctx: RunContext,
|
|
73
|
-
): AsyncGenerator<LoopEvent, StepResult, void> {
|
|
74
|
-
if (!step.skill) throw new Error(`step ${step.id}: 缺少 skill 字段`);
|
|
75
|
-
const skillName = interpolate(step.skill, vars);
|
|
76
|
-
|
|
77
|
-
const skillFile = await findSkillFile(skillName);
|
|
78
|
-
if (!skillFile) {
|
|
79
|
-
throw new Error(
|
|
80
|
-
`step ${step.id}: 未找到 skill "${skillName}"(cwd/skills/ 与 packageRoot/skills/ 双源均未命中)`,
|
|
81
|
-
);
|
|
82
|
-
}
|
|
83
|
-
const md = await readFile(skillFile, 'utf8');
|
|
84
|
-
const body = stripFrontmatter(md).trim();
|
|
85
|
-
|
|
86
|
-
const userInput = step.input
|
|
87
|
-
? interpolate(step.input, vars)
|
|
88
|
-
: `请按 SKILL "${skillName}" 的指示完成任务。`;
|
|
89
|
-
|
|
90
|
-
const subHistory: Message[] = [
|
|
91
|
-
{
|
|
92
|
-
role: 'system',
|
|
93
|
-
content: `下面是要遵循的 SKILL 指示(来源:${skillFile}):\n\n${body}`,
|
|
94
|
-
},
|
|
95
|
-
];
|
|
96
|
-
|
|
97
|
-
let text = '';
|
|
98
|
-
for await (const ev of runQuery(userInput, {
|
|
99
|
-
provider: ctx.provider,
|
|
100
|
-
history: subHistory,
|
|
101
|
-
signal: ctx.signal,
|
|
102
|
-
maxTurns: step.maxTurns,
|
|
103
|
-
})) {
|
|
104
|
-
if (ev.type === 'text') text += ev.delta;
|
|
105
|
-
if (ev.type === 'interrupted') throw new Error('用户中断');
|
|
106
|
-
if (ev.type === 'error') throw new Error(ev.error);
|
|
107
|
-
yield ev;
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
const trimmed = text.trim();
|
|
111
|
-
return {
|
|
112
|
-
raw: { text: trimmed, result: trimmed },
|
|
113
|
-
preview: trimmed.length > 200 ? `${trimmed.slice(0, 200)}...` : trimmed,
|
|
114
|
-
};
|
|
115
|
-
}
|
|
@@ -1,183 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* ============================================================
|
|
3
|
-
* src/workflows/types.ts —— workflow runner 核心类型
|
|
4
|
-
* ------------------------------------------------------------
|
|
5
|
-
* ralph-loop 让 LLM 自规划;workflow 反过来 —— 外层确定性 step 列表
|
|
6
|
-
* 驱动执行,LLM 只在 llm: / skill: 节点出场。本文件定义 YAML 解析
|
|
7
|
-
* 后的内存形态(WorkflowDef / StepDef),以及运行时上下文(VarStack /
|
|
8
|
-
* RunContext)和 UI 事件(WorkflowEvent)。
|
|
9
|
-
*
|
|
10
|
-
* 与 src/types.ts 关系:WorkflowEvent 是插件**私有**事件,结构上满足
|
|
11
|
-
* framework 的 PluginEvent 开放契约(type: string + 任意 payload),
|
|
12
|
-
* 经 plugin.ts -> pluginRunner 原样透传给 UI;UI 不识别即静默忽略。
|
|
13
|
-
* src/types.ts 的 LoopEvent union **不**包含 workflow_*,框架零插件耦合。
|
|
14
|
-
* ============================================================
|
|
15
|
-
*/
|
|
16
|
-
|
|
17
|
-
import type { Message, Provider } from '../../../src/plugin-sdk.ts';
|
|
18
|
-
|
|
19
|
-
// ---------------- 1. Workflow / Step 定义 ----------------
|
|
20
|
-
|
|
21
|
-
export type InputType = 'string' | 'number' | 'enum';
|
|
22
|
-
|
|
23
|
-
export interface InputDef {
|
|
24
|
-
name: string;
|
|
25
|
-
type: InputType;
|
|
26
|
-
required?: boolean;
|
|
27
|
-
default?: unknown;
|
|
28
|
-
/** type=enum 时必填 */
|
|
29
|
-
values?: string[];
|
|
30
|
-
description?: string;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
export type StepKind = 'tool' | 'llm' | 'skill' | 'assert' | 'branch' | 'loop' | 'pause';
|
|
34
|
-
|
|
35
|
-
/**
|
|
36
|
-
* StepDef 是 YAML 里 steps[] 元素直接反序列化的形态。
|
|
37
|
-
*
|
|
38
|
-
* 动作字段四选一(互斥优先级 type > tool > skill > llm):
|
|
39
|
-
* - tool: 调原子工具
|
|
40
|
-
* - skill: 迷你 ReAct 子循环(P1)
|
|
41
|
-
* - llm: 单轮 LLM 生成(不开工具)
|
|
42
|
-
* - type: 控制流(assert / branch / loop / pause)
|
|
43
|
-
*
|
|
44
|
-
* 其它字段按 step 类型选择性使用,未用到的字段静默忽略。
|
|
45
|
-
*/
|
|
46
|
-
export interface StepDef {
|
|
47
|
-
id: string;
|
|
48
|
-
tool?: string;
|
|
49
|
-
skill?: string;
|
|
50
|
-
llm?: string;
|
|
51
|
-
type?: 'assert' | 'branch' | 'loop' | 'pause';
|
|
52
|
-
|
|
53
|
-
args?: Record<string, unknown>;
|
|
54
|
-
input?: string;
|
|
55
|
-
capture?: Record<string, string>;
|
|
56
|
-
when?: string;
|
|
57
|
-
onError?: 'halt' | 'continue';
|
|
58
|
-
maxTurns?: number;
|
|
59
|
-
|
|
60
|
-
condition?: string;
|
|
61
|
-
onFail?: string;
|
|
62
|
-
then?: StepDef[];
|
|
63
|
-
else?: StepDef[];
|
|
64
|
-
over?: string;
|
|
65
|
-
as?: string;
|
|
66
|
-
do?: StepDef[];
|
|
67
|
-
|
|
68
|
-
prompt?: string;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
export interface WorkflowDef {
|
|
72
|
-
name: string;
|
|
73
|
-
description: string;
|
|
74
|
-
version?: string;
|
|
75
|
-
inputs?: InputDef[];
|
|
76
|
-
steps: StepDef[];
|
|
77
|
-
/** 编辑器布局信息;runner 严格忽略 */
|
|
78
|
-
__meta?: unknown;
|
|
79
|
-
/** loader 写入:yaml 来源(cwd / packageRoot),便于排错 */
|
|
80
|
-
__source?: string;
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
// ---------------- 2. 变量栈(loop scope 用) ----------------
|
|
84
|
-
|
|
85
|
-
/**
|
|
86
|
-
* 简单的多帧变量栈:
|
|
87
|
-
* - 最外层(frame 0)放 inputs + 全局变量
|
|
88
|
-
* - 进入 loop 时 push() 新帧,存当前迭代的 as / as_idx
|
|
89
|
-
* - 退出 loop 时 pop()
|
|
90
|
-
*
|
|
91
|
-
* get(key) 从栈顶往下找第一个匹配的 frame,类似 JS 的 lexical scope。
|
|
92
|
-
*/
|
|
93
|
-
export class VarStack {
|
|
94
|
-
private frames: Record<string, unknown>[] = [{}];
|
|
95
|
-
|
|
96
|
-
set(key: string, val: unknown): void {
|
|
97
|
-
this.frames[this.frames.length - 1][key] = val;
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
/** 写到栈底(root frame),用于 inputs / 全局常量 */
|
|
101
|
-
setGlobal(key: string, val: unknown): void {
|
|
102
|
-
this.frames[0][key] = val;
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
get(key: string): unknown {
|
|
106
|
-
for (let i = this.frames.length - 1; i >= 0; i--) {
|
|
107
|
-
if (key in this.frames[i]) return this.frames[i][key];
|
|
108
|
-
}
|
|
109
|
-
return undefined;
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
has(key: string): boolean {
|
|
113
|
-
for (let i = this.frames.length - 1; i >= 0; i--) {
|
|
114
|
-
if (key in this.frames[i]) return true;
|
|
115
|
-
}
|
|
116
|
-
return false;
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
push(): void {
|
|
120
|
-
this.frames.push({});
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
pop(): void {
|
|
124
|
-
if (this.frames.length > 1) this.frames.pop();
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
/** 合并所有帧为一个普通对象(外层覆盖内层),用于 vars 持久化与事件 */
|
|
128
|
-
snapshot(): Record<string, unknown> {
|
|
129
|
-
return Object.assign({}, ...this.frames);
|
|
130
|
-
}
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
// ---------------- 3. 运行上下文 ----------------
|
|
134
|
-
|
|
135
|
-
/**
|
|
136
|
-
* runner 内部传递的运行时上下文。
|
|
137
|
-
*
|
|
138
|
-
* 设计:不让 step executors 直接拿 PluginRunnerOptions,避免把"插件运行机制"
|
|
139
|
-
* 暴露到 workflow 层。RunContext 只暴露 executors 真正需要的东西。
|
|
140
|
-
*/
|
|
141
|
-
export interface RunContext {
|
|
142
|
-
provider: Provider;
|
|
143
|
-
signal?: AbortSignal;
|
|
144
|
-
/**
|
|
145
|
-
* 外部 history(来自 pluginRunner.runWithPlugins 的 baseHistory)。
|
|
146
|
-
* workflow runner 不直接 push 到这里;step executors 只读。
|
|
147
|
-
*/
|
|
148
|
-
history: Message[];
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
// ---------------- 4. step 执行结果 ----------------
|
|
152
|
-
|
|
153
|
-
/**
|
|
154
|
-
* 每个 step executor 的返回。capture 字段会从 raw 上按 capture map 取值。
|
|
155
|
-
*
|
|
156
|
-
* - raw: 完整结构化结果,给 capture 用
|
|
157
|
-
* - preview: 给 UI 展示的短摘要(≤200 char)
|
|
158
|
-
*/
|
|
159
|
-
export interface StepResult {
|
|
160
|
-
raw: Record<string, unknown>;
|
|
161
|
-
preview: string;
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
// ---------------- 5. workflow 事件(独立类型,便于 step executors 直接 yield) ----------------
|
|
165
|
-
|
|
166
|
-
export type WorkflowEvent =
|
|
167
|
-
| { type: 'workflow_start'; name: string; totalSteps: number }
|
|
168
|
-
| {
|
|
169
|
-
type: 'workflow_step_start';
|
|
170
|
-
id: string;
|
|
171
|
-
kind: StepKind;
|
|
172
|
-
index: number;
|
|
173
|
-
total: number;
|
|
174
|
-
}
|
|
175
|
-
| {
|
|
176
|
-
type: 'workflow_step_end';
|
|
177
|
-
id: string;
|
|
178
|
-
ok: boolean;
|
|
179
|
-
output?: string;
|
|
180
|
-
error?: string;
|
|
181
|
-
}
|
|
182
|
-
| { type: 'workflow_step_skipped'; id: string; reason: string }
|
|
183
|
-
| { type: 'workflow_done'; name: string; vars: Record<string, unknown> };
|
|
@@ -1,114 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* ============================================================
|
|
3
|
-
* plugins/workflow-runner/test/cli.e2e.test.ts
|
|
4
|
-
* ------------------------------------------------------------
|
|
5
|
-
* 最外层 CLI 端到端:拉子进程跑 `bun src/main.tsx -p "/workflow ..."`,
|
|
6
|
-
* 验证 stdout / stderr / 退出码 / 文件副作用 全链路。
|
|
7
|
-
*
|
|
8
|
-
* 覆盖 3 个用例:
|
|
9
|
-
* 1. golden path:/workflow cli-e2e --input topic=world → exit 0 + 文件落盘
|
|
10
|
-
* 2. /workflows 列出 cli-e2e
|
|
11
|
-
* 3. 缺必填 input → exit 1 + 错误信息含 "topic"
|
|
12
|
-
*
|
|
13
|
-
* fake provider env:workflow 是 tool-only,永不调 API;fake env 仅满足
|
|
14
|
-
* main.tsx 的 provider 配置校验。
|
|
15
|
-
* ============================================================
|
|
16
|
-
*/
|
|
17
|
-
|
|
18
|
-
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
|
19
|
-
import {
|
|
20
|
-
copyFileSync,
|
|
21
|
-
existsSync,
|
|
22
|
-
mkdirSync,
|
|
23
|
-
mkdtempSync,
|
|
24
|
-
readFileSync,
|
|
25
|
-
rmSync,
|
|
26
|
-
} from 'node:fs';
|
|
27
|
-
import { tmpdir } from 'node:os';
|
|
28
|
-
import { join, resolve } from 'node:path';
|
|
29
|
-
|
|
30
|
-
const REPO_ROOT = resolve(import.meta.dir, '..', '..', '..');
|
|
31
|
-
const FIXTURE = join(import.meta.dir, 'fixtures', 'cli-e2e.yaml');
|
|
32
|
-
|
|
33
|
-
const SPAWN_TIMEOUT = 60_000;
|
|
34
|
-
|
|
35
|
-
interface SpawnResult {
|
|
36
|
-
exitCode: number;
|
|
37
|
-
stdout: string;
|
|
38
|
-
stderr: string;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
async function spawnMain(promptArg: string, cwd: string): Promise<SpawnResult> {
|
|
42
|
-
const proc = Bun.spawn(
|
|
43
|
-
['bun', 'src/main.tsx', '-d', cwd, '-p', promptArg],
|
|
44
|
-
{
|
|
45
|
-
cwd: REPO_ROOT,
|
|
46
|
-
env: {
|
|
47
|
-
...process.env,
|
|
48
|
-
MINIMAL_AGENT_BASE_URL: 'http://fake.invalid',
|
|
49
|
-
MINIMAL_AGENT_API_KEY: 'fake-key',
|
|
50
|
-
MINIMAL_AGENT_MODEL: 'fake-model',
|
|
51
|
-
MINIMAL_AGENT_CONTEXT_WINDOW: '128000',
|
|
52
|
-
},
|
|
53
|
-
stdout: 'pipe',
|
|
54
|
-
stderr: 'pipe',
|
|
55
|
-
},
|
|
56
|
-
);
|
|
57
|
-
|
|
58
|
-
const [stdout, stderr, exitCode] = await Promise.all([
|
|
59
|
-
new Response(proc.stdout).text(),
|
|
60
|
-
new Response(proc.stderr).text(),
|
|
61
|
-
proc.exited,
|
|
62
|
-
]);
|
|
63
|
-
|
|
64
|
-
return { exitCode, stdout, stderr };
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
describe('CLI 端到端 /workflow(拉子进程跑 main.tsx)', () => {
|
|
68
|
-
let tmp: string;
|
|
69
|
-
|
|
70
|
-
beforeEach(() => {
|
|
71
|
-
tmp = mkdtempSync(join(tmpdir(), 'cli-e2e-'));
|
|
72
|
-
mkdirSync(join(tmp, 'workflows'), { recursive: true });
|
|
73
|
-
copyFileSync(FIXTURE, join(tmp, 'workflows', 'cli-e2e.yaml'));
|
|
74
|
-
});
|
|
75
|
-
|
|
76
|
-
afterEach(() => {
|
|
77
|
-
rmSync(tmp, { recursive: true, force: true });
|
|
78
|
-
});
|
|
79
|
-
|
|
80
|
-
it(
|
|
81
|
-
'golden path:跑完 + 文件落盘 + exit 0',
|
|
82
|
-
async () => {
|
|
83
|
-
const r = await spawnMain(
|
|
84
|
-
'/workflow cli-e2e --input topic=world',
|
|
85
|
-
tmp,
|
|
86
|
-
);
|
|
87
|
-
expect(r.exitCode).toBe(0);
|
|
88
|
-
const filePath = join(tmp, 'out', 'world', 'note.txt');
|
|
89
|
-
expect(existsSync(filePath)).toBe(true);
|
|
90
|
-
expect(readFileSync(filePath, 'utf8')).toBe('topic=world ok');
|
|
91
|
-
},
|
|
92
|
-
SPAWN_TIMEOUT,
|
|
93
|
-
);
|
|
94
|
-
|
|
95
|
-
it(
|
|
96
|
-
'/workflows 列出 cli-e2e',
|
|
97
|
-
async () => {
|
|
98
|
-
const r = await spawnMain('/workflows', tmp);
|
|
99
|
-
expect(r.exitCode).toBe(0);
|
|
100
|
-
expect(r.stdout).toContain('cli-e2e');
|
|
101
|
-
},
|
|
102
|
-
SPAWN_TIMEOUT,
|
|
103
|
-
);
|
|
104
|
-
|
|
105
|
-
it(
|
|
106
|
-
'缺必填 input → exit 1 + 错误信息含 "topic"',
|
|
107
|
-
async () => {
|
|
108
|
-
const r = await spawnMain('/workflow cli-e2e', tmp);
|
|
109
|
-
expect(r.exitCode).toBe(1);
|
|
110
|
-
expect(r.stderr).toContain('topic');
|
|
111
|
-
},
|
|
112
|
-
SPAWN_TIMEOUT,
|
|
113
|
-
);
|
|
114
|
-
});
|
|
@@ -1,268 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* ============================================================
|
|
3
|
-
* test/workflows/e2e.test.ts —— /workflow 端到端测试
|
|
4
|
-
* ------------------------------------------------------------
|
|
5
|
-
* 从用户敲 `/workflow hello-workflow --input ...` 这一最外层入口开始,
|
|
6
|
-
* 穿过:
|
|
7
|
-
*
|
|
8
|
-
* runWithPlugins
|
|
9
|
-
* → discoverPlugins (扫到真实 workflow-runner 插件)
|
|
10
|
-
* → resolveCommand (识别 /workflow)
|
|
11
|
-
* → cmd.executionMode === 'workflow' 路径
|
|
12
|
-
* → lazy import workflows/index.ts
|
|
13
|
-
* → parseWorkflowArgs + coerceInputs
|
|
14
|
-
* → findWorkflowByName (从 tmp/workflows/ 加载 fixture)
|
|
15
|
-
* → runWorkflow → step executors (真实 executeTool('Write'))
|
|
16
|
-
*
|
|
17
|
-
* 断言:
|
|
18
|
-
* - 整个事件序列形态合理(workflow_start → step_start/end ×N → workflow_done)
|
|
19
|
-
* - 文件真的落盘到正确路径,内容正确
|
|
20
|
-
* - 缺必填 input 时不写任何文件
|
|
21
|
-
* - assert 失败时整个 workflow halt
|
|
22
|
-
*
|
|
23
|
-
* 不 mock chat / runQuery —— fixture 不依赖 LLM。
|
|
24
|
-
* 只 mock workingDir.ts,因为其它测试文件留下的全局 mock 会污染。
|
|
25
|
-
* ============================================================
|
|
26
|
-
*/
|
|
27
|
-
|
|
28
|
-
import {
|
|
29
|
-
afterEach,
|
|
30
|
-
beforeEach,
|
|
31
|
-
describe,
|
|
32
|
-
expect,
|
|
33
|
-
it,
|
|
34
|
-
mock,
|
|
35
|
-
} from 'bun:test';
|
|
36
|
-
import {
|
|
37
|
-
copyFileSync,
|
|
38
|
-
existsSync,
|
|
39
|
-
mkdirSync,
|
|
40
|
-
mkdtempSync,
|
|
41
|
-
readFileSync,
|
|
42
|
-
rmSync,
|
|
43
|
-
} from 'node:fs';
|
|
44
|
-
import { tmpdir } from 'node:os';
|
|
45
|
-
import { join } from 'node:path';
|
|
46
|
-
|
|
47
|
-
import { _resetWorkingDir } from '../../../src/bootstrap/workingDir.ts';
|
|
48
|
-
import { _resetPluginCache } from '../../../src/plugins/commandRouter.ts';
|
|
49
|
-
import type { LoopEvent, Provider } from '../../../src/types.ts';
|
|
50
|
-
import type { PluginEvent } from '../../../src/plugins/pluginApi.ts';
|
|
51
|
-
import type { WorkflowEvent } from '../src/types.ts';
|
|
52
|
-
|
|
53
|
-
type WfOrLoop = LoopEvent | PluginEvent | WorkflowEvent;
|
|
54
|
-
|
|
55
|
-
const FIXTURE_SRC = join(import.meta.dir, 'fixtures', 'hello-workflow.yaml');
|
|
56
|
-
|
|
57
|
-
const provider: Provider = {
|
|
58
|
-
name: 'test',
|
|
59
|
-
baseURL: '',
|
|
60
|
-
apiKey: '',
|
|
61
|
-
model: 'm',
|
|
62
|
-
contextWindow: 128000,
|
|
63
|
-
};
|
|
64
|
-
|
|
65
|
-
async function collect(
|
|
66
|
-
gen: AsyncGenerator<WfOrLoop, void, void>,
|
|
67
|
-
): Promise<WfOrLoop[]> {
|
|
68
|
-
const out: WfOrLoop[] = [];
|
|
69
|
-
for await (const ev of gen) out.push(ev as WfOrLoop);
|
|
70
|
-
return out;
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
describe('workflow E2E(/workflow → 路由 → runner → 文件落盘)', () => {
|
|
74
|
-
let tmp: string;
|
|
75
|
-
|
|
76
|
-
beforeEach(() => {
|
|
77
|
-
tmp = mkdtempSync(join(tmpdir(), 'wf-e2e-'));
|
|
78
|
-
mkdirSync(join(tmp, 'workflows'), { recursive: true });
|
|
79
|
-
copyFileSync(FIXTURE_SRC, join(tmp, 'workflows', 'hello-workflow.yaml'));
|
|
80
|
-
|
|
81
|
-
process.env.MINIMAL_AGENT_CWD = tmp;
|
|
82
|
-
_resetWorkingDir();
|
|
83
|
-
_resetPluginCache();
|
|
84
|
-
|
|
85
|
-
// 抵抗其它测试文件留下的 workingDir.ts 全局 mock 污染。
|
|
86
|
-
// 注:这一行让 getWorkingDir 跟着 tmp 走,相对路径锚点 + workflows/ 双源扫描都受益。
|
|
87
|
-
mock.module('../../../src/bootstrap/workingDir.ts', () => ({
|
|
88
|
-
getWorkingDir: () => tmp,
|
|
89
|
-
initWorkingDir: () => tmp,
|
|
90
|
-
_resetWorkingDir: () => {},
|
|
91
|
-
}));
|
|
92
|
-
});
|
|
93
|
-
|
|
94
|
-
afterEach(() => {
|
|
95
|
-
delete process.env.MINIMAL_AGENT_CWD;
|
|
96
|
-
_resetWorkingDir();
|
|
97
|
-
_resetPluginCache();
|
|
98
|
-
rmSync(tmp, { recursive: true, force: true });
|
|
99
|
-
});
|
|
100
|
-
|
|
101
|
-
async function runCmd(input: string): Promise<WfOrLoop[]> {
|
|
102
|
-
const { runWithPlugins } = await import('../../../src/plugins/pluginRunner.ts');
|
|
103
|
-
return collect(
|
|
104
|
-
runWithPlugins(input, { provider, history: [] }) as AsyncGenerator<WfOrLoop, void, void>,
|
|
105
|
-
);
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
it('golden path:/workflow hello-workflow --input name=alice --input lang=zh 跑完且文件落盘', async () => {
|
|
109
|
-
const events = await runCmd('/workflow hello-workflow --input name=alice --input lang=zh');
|
|
110
|
-
|
|
111
|
-
// 整体形态
|
|
112
|
-
expect(events[0]?.type).toBe('workflow_start');
|
|
113
|
-
expect(events[events.length - 1]?.type).toBe('workflow_done');
|
|
114
|
-
expect(events.some((e) => e.type === 'error')).toBe(false);
|
|
115
|
-
|
|
116
|
-
// 每个 step 都跑完且 ok=true
|
|
117
|
-
const ends = events.filter(
|
|
118
|
-
(e): e is Extract<WorkflowEvent, { type: 'workflow_step_end' }> => e.type === 'workflow_step_end',
|
|
119
|
-
);
|
|
120
|
-
expect(ends.length).toBe(4);
|
|
121
|
-
expect(ends.every((e) => e.ok)).toBe(true);
|
|
122
|
-
expect(ends.map((e) => e.id)).toEqual([
|
|
123
|
-
'write_greeting',
|
|
124
|
-
'write_meta',
|
|
125
|
-
'verify_files',
|
|
126
|
-
'verify_capture',
|
|
127
|
-
]);
|
|
128
|
-
|
|
129
|
-
// 文件实际写盘
|
|
130
|
-
const helloPath = join(tmp, 'greetings', 'alice', 'hello.txt');
|
|
131
|
-
const metaPath = join(tmp, 'greetings', 'alice', 'meta.json');
|
|
132
|
-
expect(existsSync(helloPath)).toBe(true);
|
|
133
|
-
expect(existsSync(metaPath)).toBe(true);
|
|
134
|
-
|
|
135
|
-
// hello.txt 内容验证(${var} 插值确实工作了)
|
|
136
|
-
expect(readFileSync(helloPath, 'utf8')).toBe('Hello, alice! (lang=zh)');
|
|
137
|
-
|
|
138
|
-
// meta.json 是合法 JSON,字段对得上
|
|
139
|
-
const meta = JSON.parse(readFileSync(metaPath, 'utf8'));
|
|
140
|
-
expect(meta.name).toBe('alice');
|
|
141
|
-
expect(meta.lang).toBe('zh');
|
|
142
|
-
// length() 内置函数 + capture 链路:greeting_text="Hello, alice! (lang=zh)" → 长度 23
|
|
143
|
-
expect(meta.greeting_length).toBe('Hello, alice! (lang=zh)'.length);
|
|
144
|
-
});
|
|
145
|
-
|
|
146
|
-
it('lang 使用默认值 en(input enum default 路径)', async () => {
|
|
147
|
-
const events = await runCmd('/workflow hello-workflow --input name=bob');
|
|
148
|
-
expect(events[events.length - 1]?.type).toBe('workflow_done');
|
|
149
|
-
expect(readFileSync(join(tmp, 'greetings', 'bob', 'hello.txt'), 'utf8'))
|
|
150
|
-
.toBe('Hello, bob! (lang=en)');
|
|
151
|
-
});
|
|
152
|
-
|
|
153
|
-
it('workflow_step_start 事件 index/total 单调且与 yaml 一致', async () => {
|
|
154
|
-
const events = await runCmd('/workflow hello-workflow --input name=carol');
|
|
155
|
-
const starts = events.filter(
|
|
156
|
-
(e): e is Extract<WorkflowEvent, { type: 'workflow_step_start' }> => e.type === 'workflow_step_start',
|
|
157
|
-
);
|
|
158
|
-
expect(starts.length).toBe(4);
|
|
159
|
-
expect(starts.map((s) => s.index)).toEqual([0, 1, 2, 3]);
|
|
160
|
-
expect(starts.every((s) => s.total === 4)).toBe(true);
|
|
161
|
-
expect(starts.map((s) => s.kind)).toEqual(['tool', 'tool', 'assert', 'assert']);
|
|
162
|
-
});
|
|
163
|
-
|
|
164
|
-
it('缺必填 input name → yield error,且没写任何文件', async () => {
|
|
165
|
-
const events = await runCmd('/workflow hello-workflow');
|
|
166
|
-
expect(events.some((e) => e.type === 'error')).toBe(true);
|
|
167
|
-
expect(events.some((e) => e.type === 'workflow_start')).toBe(false);
|
|
168
|
-
// tmp/greetings/ 不应被创建
|
|
169
|
-
expect(existsSync(join(tmp, 'greetings'))).toBe(false);
|
|
170
|
-
});
|
|
171
|
-
|
|
172
|
-
it('lang 传非法值 → yield enum 校验 error,且不进入 runWorkflow', async () => {
|
|
173
|
-
const events = await runCmd('/workflow hello-workflow --input name=dave --input lang=fr');
|
|
174
|
-
const err = events.find(
|
|
175
|
-
(e): e is Extract<LoopEvent, { type: 'error' }> => e.type === 'error',
|
|
176
|
-
);
|
|
177
|
-
expect(err).toBeDefined();
|
|
178
|
-
expect(err!.error).toContain('lang');
|
|
179
|
-
expect(events.some((e) => e.type === 'workflow_start')).toBe(false);
|
|
180
|
-
});
|
|
181
|
-
|
|
182
|
-
it('未知 workflow 名 → yield error,不污染文件系统', async () => {
|
|
183
|
-
const events = await runCmd('/workflow definitely-not-exists --input name=eve');
|
|
184
|
-
expect(events.some((e) => e.type === 'error')).toBe(true);
|
|
185
|
-
expect(events.some((e) => e.type === 'workflow_start')).toBe(false);
|
|
186
|
-
expect(existsSync(join(tmp, 'greetings'))).toBe(false);
|
|
187
|
-
});
|
|
188
|
-
|
|
189
|
-
it('/workflows 列表能看到 hello-workflow(cwd 优先双源加载)', async () => {
|
|
190
|
-
const events = await runCmd('/workflows');
|
|
191
|
-
const text = events
|
|
192
|
-
.filter((e): e is Extract<LoopEvent, { type: 'text' }> => e.type === 'text')
|
|
193
|
-
.map((e) => e.delta)
|
|
194
|
-
.join('');
|
|
195
|
-
expect(text).toContain('hello-workflow');
|
|
196
|
-
});
|
|
197
|
-
});
|
|
198
|
-
|
|
199
|
-
describe('workflow E2E:assert 失败 → halt(用变体 fixture)', () => {
|
|
200
|
-
let tmp: string;
|
|
201
|
-
|
|
202
|
-
beforeEach(() => {
|
|
203
|
-
tmp = mkdtempSync(join(tmpdir(), 'wf-e2e-fail-'));
|
|
204
|
-
mkdirSync(join(tmp, 'workflows'), { recursive: true });
|
|
205
|
-
// 写一个故意 assert 不通过的 yaml
|
|
206
|
-
const failYaml = `name: assert-fail-fixture
|
|
207
|
-
description: assert 必失败的端到端 fixture
|
|
208
|
-
steps:
|
|
209
|
-
- id: write_a
|
|
210
|
-
tool: Write
|
|
211
|
-
args:
|
|
212
|
-
file_path: "a.txt"
|
|
213
|
-
content: "hi"
|
|
214
|
-
- id: bad_assert
|
|
215
|
-
type: assert
|
|
216
|
-
condition: '1 == 2'
|
|
217
|
-
onFail: "故意失败"
|
|
218
|
-
- id: should_not_run
|
|
219
|
-
tool: Write
|
|
220
|
-
args:
|
|
221
|
-
file_path: "b.txt"
|
|
222
|
-
content: "should not exist"
|
|
223
|
-
`;
|
|
224
|
-
require('node:fs').writeFileSync(
|
|
225
|
-
join(tmp, 'workflows', 'assert-fail-fixture.yaml'),
|
|
226
|
-
failYaml,
|
|
227
|
-
'utf8',
|
|
228
|
-
);
|
|
229
|
-
|
|
230
|
-
process.env.MINIMAL_AGENT_CWD = tmp;
|
|
231
|
-
_resetWorkingDir();
|
|
232
|
-
_resetPluginCache();
|
|
233
|
-
mock.module('../../../src/bootstrap/workingDir.ts', () => ({
|
|
234
|
-
getWorkingDir: () => tmp,
|
|
235
|
-
initWorkingDir: () => tmp,
|
|
236
|
-
_resetWorkingDir: () => {},
|
|
237
|
-
}));
|
|
238
|
-
});
|
|
239
|
-
|
|
240
|
-
afterEach(() => {
|
|
241
|
-
delete process.env.MINIMAL_AGENT_CWD;
|
|
242
|
-
_resetWorkingDir();
|
|
243
|
-
_resetPluginCache();
|
|
244
|
-
rmSync(tmp, { recursive: true, force: true });
|
|
245
|
-
});
|
|
246
|
-
|
|
247
|
-
it('assert 失败 → 后续 step 不执行 + yield error + 没 workflow_done', async () => {
|
|
248
|
-
const { runWithPlugins } = await import('../../../src/plugins/pluginRunner.ts');
|
|
249
|
-
const events = await collect(
|
|
250
|
-
runWithPlugins('/workflow assert-fail-fixture', { provider, history: [] }),
|
|
251
|
-
);
|
|
252
|
-
|
|
253
|
-
expect(events.some((e) => e.type === 'workflow_done')).toBe(false);
|
|
254
|
-
expect(events.some((e) => e.type === 'error')).toBe(true);
|
|
255
|
-
|
|
256
|
-
// a.txt 在 assert 前就写盘,应存在
|
|
257
|
-
expect(existsSync(join(tmp, 'a.txt'))).toBe(true);
|
|
258
|
-
// b.txt 在 assert 后,不应被写
|
|
259
|
-
expect(existsSync(join(tmp, 'b.txt'))).toBe(false);
|
|
260
|
-
|
|
261
|
-
// 错误信息含 onFail 自定义文案
|
|
262
|
-
const errs = events
|
|
263
|
-
.filter((e): e is Extract<LoopEvent, { type: 'error' }> => e.type === 'error')
|
|
264
|
-
.map((e) => e.error)
|
|
265
|
-
.join('\n');
|
|
266
|
-
expect(errs).toContain('故意失败');
|
|
267
|
-
});
|
|
268
|
-
});
|