feat(ai-prompt-decorator): add literal/regex replace rules for message content (#3739)

This commit is contained in:
澄潭
2026-04-20 20:52:36 +08:00
committed by GitHub
parent 90ccfc7ec5
commit a93f77d838
4 changed files with 501 additions and 15 deletions

View File

@@ -6,7 +6,7 @@ description: AI 提示词插件配置参考
## 功能说明 ## 功能说明
AI提示词插件支持在LLM的请求前后插入prompt。 AI 提示词插件,支持在 LLM 的请求前后插入 prompt,并支持对最终请求中所有 message 的 `content` 文本执行字面量或正则替换,便于做敏感词改写、品牌词归一、占位符脱敏等
## 运行属性 ## 运行属性
@@ -16,9 +16,10 @@ AI提示词插件支持在LLM的请求前后插入prompt。
## 配置说明 ## 配置说明
| 名称 | 数据类型 | 填写要求 | 默认值 | 描述 | | 名称 | 数据类型 | 填写要求 | 默认值 | 描述 |
|----------------|-----------------|------|-----|----------------------------------| |-----------|---------------------------|----------|--------|------------------------------------------------------------------|
| `prepend` | array of message object | optional | - | 在初始输入之前插入的语句 | | `prepend` | array of message object | optional | - | 在初始输入之前插入的语句 |
| `append` | array of message object | optional | - | 在初始输入之后插入的语句 | | `append` | array of message object | optional | - | 在初始输入之后插入的语句 |
| `replace` | array of replace rule | optional | - | 对最终请求中所有 message 的 `content` 执行字面量或正则替换的规则 |
message object 配置说明: message object 配置说明:
@@ -27,6 +28,21 @@ message object 配置说明:
| `role` | string | 必填 | - | 角色 | | `role` | string | 必填 | - | 角色 |
| `content` | string | 必填 | - | 消息 | | `content` | string | 必填 | - | 消息 |
replace rule 配置说明:
| 名称 | 数据类型 | 填写要求 | 默认值 | 描述 |
|---------------|---------|----------|--------|--------------------------------------------------------------------------|
| `pattern` | string | 必填 | - | 待匹配文本;`regex` 为 true 时按 Go RE2 编译 |
| `replacement` | string | 必填 | - | 替换文本;`regex` 为 true 时支持 `$1``$2` 等捕获组引用 |
| `on_role` | string | 选填 | - | 仅对该 role 的 message 生效,缺省/留空表示对任意 role 都生效 |
| `regex` | bool | 选填 | false | 是否将 `pattern` 解释为正则表达式 |
说明:
- `replace` 规则会对最终拼装出的 `messages` 数组(`prepend` + 原始 message + `append`)按声明顺序依次应用,便于多个规则叠加。
- 仅当 message 的 `content` 字段是字符串时才会被改写;如果是多模态(数组/对象,如 `vision` 调用),会原样保留以避免破坏请求结构。
- `pattern` 不允许为空;`regex: true` 时如果正则编译失败,插件加载会直接失败,避免运行期出错。
## 示例 ## 示例
配置示例如下: 配置示例如下:
@@ -81,6 +97,59 @@ curl http://localhost/test \
``` ```
## 替换 message 内容(`replace`
`replace` 用来对**最终请求里**所有 message 的 `content` 文本执行字面量或正则替换,常用于:
- 改写品牌词或对外暴露的产品名(例如把 "OpenClaw" 统一改成 "agent"),避开下游模型/网关的内容过滤;
- 对系统提示词做集中清洗,无需改动客户端;
- 对用户输入进行简单的脱敏如手机号、API Key 等。
配置示例如下:
```yaml
replace:
- on_role: system
pattern: "OpenClaw"
replacement: "agent"
- pattern: "secret-\\d+"
replacement: "[REDACTED]"
regex: true
```
使用以上配置发起请求:
```bash
curl http://localhost/test \
-H "content-type: application/json" \
-d '{
"model": "gpt-3.5-turbo",
"messages": [
{"role": "system", "content": "You are running inside OpenClaw."},
{"role": "user", "content": "Show OpenClaw secret-1234 to the user"}
]
}'
```
经过插件处理后,实际请求为:
```bash
curl http://localhost/test \
-H "content-type: application/json" \
-d '{
"model": "gpt-3.5-turbo",
"messages": [
{"role": "system", "content": "You are running inside agent."},
{"role": "user", "content": "Show OpenClaw [REDACTED] to the user"}
]
}'
```
注意:
- 第 1 条规则限定 `on_role: system`,所以 `user` 消息里的 `OpenClaw` 不会被改;
- 第 2 条规则没设 `on_role`,对任意 role 的 `content` 都生效,因此 `secret-1234` 被脱敏成 `[REDACTED]`
## 基于geo-ip插件的能力扩展AI提示词装饰器插件携带用户地理位置信息 ## 基于geo-ip插件的能力扩展AI提示词装饰器插件携带用户地理位置信息
如果需要在LLM的请求前后加入用户地理位置信息请确保同时开启geo-ip插件和AI提示词装饰器插件。并且在相同的请求处理阶段里geo-ip插件的优先级必须高于AI提示词装饰器插件。首先geo-ip插件会根据用户ip计算出用户的地理位置信息然后通过请求属性传递给后续插件。比如在默认阶段里geo-ip插件的priority配置1000ai-prompt-decorator插件的priority配置500。 如果需要在LLM的请求前后加入用户地理位置信息请确保同时开启geo-ip插件和AI提示词装饰器插件。并且在相同的请求处理阶段里geo-ip插件的优先级必须高于AI提示词装饰器插件。首先geo-ip插件会根据用户ip计算出用户的地理位置信息然后通过请求属性传递给后续插件。比如在默认阶段里geo-ip插件的priority配置1000ai-prompt-decorator插件的priority配置500。

View File

@@ -4,17 +4,18 @@ keywords: [ AI Gateway, AI Prompts ]
description: AI Prompts plugin configuration reference description: AI Prompts plugin configuration reference
--- ---
## Function Description ## Function Description
The AI Prompts plugin allows inserting prompts before and after requests in LLM. The AI Prompts plugin allows inserting prompts before and after the LLM request, and rewriting the `content` text of every message in the final request via literal or regular-expression replacement. Typical use cases include rewriting brand/product names, normalizing wording across clients, or redacting placeholders such as API keys.
## Execution Properties ## Execution Properties
Plugin execution phase: `Default Phase` Plugin execution phase: `Default Phase`
Plugin execution priority: `450` Plugin execution priority: `450`
## Configuration Description ## Configuration Description
| Name | Data Type | Requirement | Default Value | Description | | Name | Data Type | Requirement | Default Value | Description |
|---------------|----------------------|-------------|---------------|--------------------------------------| |-----------|-------------------------|-------------|---------------|-----------------------------------------------------------------------------|
| `prepend` | array of message object | optional | - | Statements inserted before the initial input | | `prepend` | array of message object | optional | - | Statements inserted before the initial input |
| `append` | array of message object | optional | - | Statements inserted after the initial input | | `append` | array of message object | optional | - | Statements inserted after the initial input |
| `replace` | array of replace rule | optional | - | Rules that rewrite the `content` of every message via literal/regex replace |
Message object configuration description: Message object configuration description:
| Name | Data Type | Requirement | Default Value | Description | | Name | Data Type | Requirement | Default Value | Description |
@@ -22,6 +23,20 @@ Message object configuration description:
| `role` | string | required | - | Role | | `role` | string | required | - | Role |
| `content` | string | required | - | Message | | `content` | string | required | - | Message |
Replace rule configuration description:
| Name | Data Type | Requirement | Default Value | Description |
|---------------|-----------|-------------|---------------|--------------------------------------------------------------------------------------------|
| `pattern` | string | required | - | Text to match. Compiled as a Go RE2 regex when `regex` is true. |
| `replacement` | string | required | - | Replacement text. Supports `$1`, `$2`, ... back-references when `regex` is true. |
| `on_role` | string | optional | - | Apply only to messages whose `role` equals this value. Empty/missing means any role. |
| `regex` | bool | optional | false | Whether to interpret `pattern` as a regular expression. |
Notes:
- `replace` rules run against the **final** assembled `messages` array (`prepend` + original messages + `append`) in declaration order, so multiple rules compose predictably.
- A message is rewritten only when its `content` is a plain string. Multimodal `content` (arrays/objects, e.g. vision payloads) is left untouched to preserve the request structure.
- `pattern` must not be empty. If `regex: true` and the pattern fails to compile, plugin start-up fails fast instead of erroring at request time.
## Example ## Example
An example configuration is as follows: An example configuration is as follows:
```yaml ```yaml
@@ -71,6 +86,59 @@ curl http://localhost/test \
} }
``` ```
## Replacing message content (`replace`)
`replace` rewrites the `content` text of every message in the **final** request using literal or regular-expression substitutions. It is useful for:
- Rewriting brand/product names that downstream models or gateways flag (for example, normalizing "OpenClaw" to "agent");
- Centrally cleaning up system prompts without changing each client;
- Light-weight redaction of user input such as phone numbers or API keys.
Example configuration:
```yaml
replace:
- on_role: system
pattern: "OpenClaw"
replacement: "agent"
- pattern: "secret-\\d+"
replacement: "[REDACTED]"
regex: true
```
Using the above configuration to initiate a request:
```bash
curl http://localhost/test \
-H "content-type: application/json" \
-d '{
"model": "gpt-3.5-turbo",
"messages": [
{"role": "system", "content": "You are running inside OpenClaw."},
{"role": "user", "content": "Show OpenClaw secret-1234 to the user"}
]
}'
```
After processing through the plugin, the actual request will be:
```bash
curl http://localhost/test \
-H "content-type: application/json" \
-d '{
"model": "gpt-3.5-turbo",
"messages": [
{"role": "system", "content": "You are running inside agent."},
{"role": "user", "content": "Show OpenClaw [REDACTED] to the user"}
]
}'
```
Notes:
- The first rule is gated on `on_role: system`, so the `OpenClaw` mention inside the user message is left as-is.
- The second rule has no `on_role`, so it applies to messages of any role and rewrites `secret-1234` to `[REDACTED]`.
## Based on the geo-ip plugin's capabilities, extend AI Prompt Decorator plugin to carry user geographic location information. ## Based on the geo-ip plugin's capabilities, extend AI Prompt Decorator plugin to carry user geographic location information.
If you need to include user geographic location information before and after the LLM's requests, please ensure both the geo-ip plugin and the AI Prompt Decorator plugin are enabled. Moreover, in the same request processing phase, the geo-ip plugin's priority must be higher than that of the AI Prompt Decorator plugin. First, the geo-ip plugin will calculate the user's geographic location information based on the user's IP, and then pass it to subsequent plugins via request attributes. For instance, in the default phase, the geo-ip plugin's priority configuration is 1000, while the ai-prompt-decorator plugin's priority configuration is 500. If you need to include user geographic location information before and after the LLM's requests, please ensure both the geo-ip plugin and the AI Prompt Decorator plugin are enabled. Moreover, in the same request processing phase, the geo-ip plugin's priority must be higher than that of the AI Prompt Decorator plugin. First, the geo-ip plugin will calculate the user's geographic location information based on the user's IP, and then pass it to subsequent plugins via request attributes. For instance, in the default phase, the geo-ip plugin's priority configuration is 1000, while the ai-prompt-decorator plugin's priority configuration is 500.

View File

@@ -3,6 +3,7 @@ package main
import ( import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"regexp"
"strings" "strings"
"github.com/higress-group/proxy-wasm-go-sdk/proxywasm" "github.com/higress-group/proxy-wasm-go-sdk/proxywasm"
@@ -29,13 +30,44 @@ type Message struct {
Content string `json:"content"` Content string `json:"content"`
} }
// ReplaceRule rewrites the content of messages matched by Role/Pattern.
// When OnRole is empty, the rule applies to messages of any role.
// When Regex is true, Pattern is compiled as a Go RE2 regular expression
// at config-parse time, and Replacement supports $1/$2 capture references.
// Otherwise the rule performs literal substring replacement.
type ReplaceRule struct {
OnRole string `json:"on_role,omitempty"`
Pattern string `json:"pattern"`
Replacement string `json:"replacement"`
Regex bool `json:"regex,omitempty"`
compiled *regexp.Regexp `json:"-"`
}
type AIPromptDecoratorConfig struct { type AIPromptDecoratorConfig struct {
Prepend []Message `json:"prepend"` Prepend []Message `json:"prepend"`
Append []Message `json:"append"` Append []Message `json:"append"`
Replace []ReplaceRule `json:"replace,omitempty"`
} }
func parseConfig(jsonConfig gjson.Result, config *AIPromptDecoratorConfig) error { func parseConfig(jsonConfig gjson.Result, config *AIPromptDecoratorConfig) error {
return json.Unmarshal([]byte(jsonConfig.Raw), config) if err := json.Unmarshal([]byte(jsonConfig.Raw), config); err != nil {
return err
}
for i := range config.Replace {
rule := &config.Replace[i]
if rule.Pattern == "" {
return fmt.Errorf("replace[%d].pattern must not be empty", i)
}
if rule.Regex {
re, err := regexp.Compile(rule.Pattern)
if err != nil {
return fmt.Errorf("replace[%d].pattern is not a valid regex: %w", i, err)
}
rule.compiled = re
}
}
return nil
} }
func onHttpRequestHeaders(ctx wrapper.HttpContext, config AIPromptDecoratorConfig) types.Action { func onHttpRequestHeaders(ctx wrapper.HttpContext, config AIPromptDecoratorConfig) types.Action {
@@ -70,6 +102,52 @@ func decorateGeographicPrompt(entry *Message) (*Message, error) {
return entry, nil return entry, nil
} }
// applyReplaceRulesToContent applies all matching replace rules to a single
// message content string and returns the rewritten value. Rules are applied
// in declaration order so users get predictable layering when several rules
// could match the same role.
func applyReplaceRulesToContent(role, content string, rules []ReplaceRule) string {
for _, rule := range rules {
if rule.OnRole != "" && rule.OnRole != role {
continue
}
if rule.Regex {
if rule.compiled == nil {
continue
}
content = rule.compiled.ReplaceAllString(content, rule.Replacement)
} else {
content = strings.ReplaceAll(content, rule.Pattern, rule.Replacement)
}
}
return content
}
// applyReplaceRulesToMessage rewrites the "content" field of a JSON message
// in place when it is a plain string. Multimodal contents (arrays/objects)
// are returned untouched so we do not corrupt vision/audio payloads.
func applyReplaceRulesToMessage(rawMessage string, rules []ReplaceRule) string {
if len(rules) == 0 {
return rawMessage
}
role := gjson.Get(rawMessage, "role").String()
contentResult := gjson.Get(rawMessage, "content")
if contentResult.Type != gjson.String {
return rawMessage
}
original := contentResult.String()
updated := applyReplaceRulesToContent(role, original, rules)
if updated == original {
return rawMessage
}
out, err := sjson.Set(rawMessage, "content", updated)
if err != nil {
log.Errorf("Failed to apply replace rules to message, error: %v", err)
return rawMessage
}
return out
}
func onHttpRequestBody(ctx wrapper.HttpContext, config AIPromptDecoratorConfig, body []byte) types.Action { func onHttpRequestBody(ctx wrapper.HttpContext, config AIPromptDecoratorConfig, body []byte) types.Action {
messageJson := `{"messages":[]}` messageJson := `{"messages":[]}`
@@ -85,7 +163,8 @@ func onHttpRequestBody(ctx wrapper.HttpContext, config AIPromptDecoratorConfig,
log.Errorf("Failed to add prepend message, error: %v", err) log.Errorf("Failed to add prepend message, error: %v", err)
return types.ActionContinue return types.ActionContinue
} }
messageJson, _ = sjson.SetRaw(messageJson, "messages.-1", string(msg)) rewritten := applyReplaceRulesToMessage(string(msg), config.Replace)
messageJson, _ = sjson.SetRaw(messageJson, "messages.-1", rewritten)
} }
rawMessage := gjson.GetBytes(body, "messages") rawMessage := gjson.GetBytes(body, "messages")
@@ -94,7 +173,8 @@ func onHttpRequestBody(ctx wrapper.HttpContext, config AIPromptDecoratorConfig,
return types.ActionContinue return types.ActionContinue
} }
for _, entry := range rawMessage.Array() { for _, entry := range rawMessage.Array() {
messageJson, _ = sjson.SetRaw(messageJson, "messages.-1", entry.Raw) rewritten := applyReplaceRulesToMessage(entry.Raw, config.Replace)
messageJson, _ = sjson.SetRaw(messageJson, "messages.-1", rewritten)
} }
for _, entry := range config.Append { for _, entry := range config.Append {
@@ -109,7 +189,8 @@ func onHttpRequestBody(ctx wrapper.HttpContext, config AIPromptDecoratorConfig,
log.Errorf("Failed to add prepend message, error: %v", err) log.Errorf("Failed to add prepend message, error: %v", err)
return types.ActionContinue return types.ActionContinue
} }
messageJson, _ = sjson.SetRaw(messageJson, "messages.-1", string(msg)) rewritten := applyReplaceRulesToMessage(string(msg), config.Replace)
messageJson, _ = sjson.SetRaw(messageJson, "messages.-1", rewritten)
} }
newbody, err := sjson.SetRaw(string(body), "messages", gjson.Get(messageJson, "messages").Raw) newbody, err := sjson.SetRaw(string(body), "messages", gjson.Get(messageJson, "messages").Raw)

View File

@@ -22,6 +22,7 @@ import (
"github.com/higress-group/proxy-wasm-go-sdk/proxywasm/types" "github.com/higress-group/proxy-wasm-go-sdk/proxywasm/types"
"github.com/higress-group/wasm-go/pkg/test" "github.com/higress-group/wasm-go/pkg/test"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
) )
// 测试配置:基础装饰器配置 // 测试配置:基础装饰器配置
@@ -509,3 +510,270 @@ func TestEdgeCases(t *testing.T) {
}) })
}) })
} }
// 测试配置:仅启用 replace 规则(含 role 限定与正则)
var replaceOnlyConfig = func() json.RawMessage {
data, _ := json.Marshal(map[string]interface{}{
"prepend": []map[string]interface{}{},
"append": []map[string]interface{}{},
"replace": []map[string]interface{}{
{
"on_role": "system",
"pattern": "OpenClaw",
"replacement": "agent",
},
{
"pattern": `secret-\d+`,
"replacement": "[REDACTED]",
"regex": true,
},
},
})
return data
}()
// 测试配置replace 与 prepend/append 组合,无 on_role 限定
var replaceCombinedConfig = func() json.RawMessage {
data, _ := json.Marshal(map[string]interface{}{
"prepend": []map[string]interface{}{
{
"role": "system",
"content": "You are running inside OpenClaw.",
},
},
"append": []map[string]interface{}{
{
"role": "user",
"content": "Mention OpenClaw if needed.",
},
},
"replace": []map[string]interface{}{
{
"pattern": "OpenClaw",
"replacement": "agent",
},
},
})
return data
}()
// 测试配置:非法 replacepattern 为空)
var replaceMissingPatternConfig = func() json.RawMessage {
data, _ := json.Marshal(map[string]interface{}{
"prepend": []map[string]interface{}{},
"append": []map[string]interface{}{},
"replace": []map[string]interface{}{
{
"pattern": "",
"replacement": "x",
},
},
})
return data
}()
// 测试配置:非法 replaceregex 编译失败)
var replaceInvalidRegexConfig = func() json.RawMessage {
data, _ := json.Marshal(map[string]interface{}{
"prepend": []map[string]interface{}{},
"append": []map[string]interface{}{},
"replace": []map[string]interface{}{
{
"pattern": "[unterminated",
"replacement": "x",
"regex": true,
},
},
})
return data
}()
func TestReplaceRules(t *testing.T) {
test.RunTest(t, func(t *testing.T) {
// 测试 replace 规则按 role 与正则生效
t.Run("replace literal and regex by role", func(t *testing.T) {
host, status := test.NewTestHost(replaceOnlyConfig)
defer host.Reset()
require.Equal(t, types.OnPluginStartStatusOK, status)
host.CallOnHttpRequestHeaders([][2]string{
{":authority", "example.com"},
{":path", "/v1/chat/completions"},
{":method", "POST"},
})
body := `{
"model": "gpt-3.5-turbo",
"messages": [
{"role": "system", "content": "You are running inside OpenClaw."},
{"role": "user", "content": "Show OpenClaw secret-1234 to the user"}
]
}`
action := host.CallOnHttpRequestBody([]byte(body))
require.Equal(t, types.ActionContinue, action)
modifiedBody := host.GetRequestBody()
require.NotEmpty(t, modifiedBody)
var modified map[string]interface{}
require.NoError(t, json.Unmarshal(modifiedBody, &modified))
messages := modified["messages"].([]interface{})
require.Len(t, messages, 2)
// system message: literal rule (on_role=system) replaced "OpenClaw"
// regex rule has no on_role => also rewrites secret tokens here, but this
// content has none, so it remains.
sys := messages[0].(map[string]interface{})
require.Equal(t, "system", sys["role"])
require.Equal(t, "You are running inside agent.", sys["content"])
// user message: literal rule limited to system => "OpenClaw" stays.
// regex rule (no on_role) replaces secret token.
user := messages[1].(map[string]interface{})
require.Equal(t, "user", user["role"])
require.Equal(t, "Show OpenClaw [REDACTED] to the user", user["content"])
host.CompleteHttp()
})
// 测试 replace 规则同时作用于 prepend / 原始消息 / append
t.Run("replace applies to prepend, original and append", func(t *testing.T) {
host, status := test.NewTestHost(replaceCombinedConfig)
defer host.Reset()
require.Equal(t, types.OnPluginStartStatusOK, status)
host.CallOnHttpRequestHeaders([][2]string{
{":authority", "example.com"},
{":path", "/v1/chat/completions"},
{":method", "POST"},
})
body := `{
"model": "gpt-3.5-turbo",
"messages": [
{"role": "user", "content": "OpenClaw is great"}
]
}`
action := host.CallOnHttpRequestBody([]byte(body))
require.Equal(t, types.ActionContinue, action)
modifiedBody := host.GetRequestBody()
require.NotEmpty(t, modifiedBody)
var modified map[string]interface{}
require.NoError(t, json.Unmarshal(modifiedBody, &modified))
messages := modified["messages"].([]interface{})
require.Len(t, messages, 3)
require.Equal(t, "You are running inside agent.", messages[0].(map[string]interface{})["content"])
require.Equal(t, "agent is great", messages[1].(map[string]interface{})["content"])
require.Equal(t, "Mention agent if needed.", messages[2].(map[string]interface{})["content"])
host.CompleteHttp()
})
// 多模态 content数组 / 对象)不应被改写
t.Run("multimodal content is left untouched", func(t *testing.T) {
host, status := test.NewTestHost(replaceCombinedConfig)
defer host.Reset()
require.Equal(t, types.OnPluginStartStatusOK, status)
host.CallOnHttpRequestHeaders([][2]string{
{":authority", "example.com"},
{":path", "/v1/chat/completions"},
{":method", "POST"},
})
body := `{
"model": "gpt-4o",
"messages": [
{"role": "user", "content": [
{"type": "text", "text": "OpenClaw"},
{"type": "image_url", "image_url": {"url": "https://example.com/x.png"}}
]}
]
}`
action := host.CallOnHttpRequestBody([]byte(body))
require.Equal(t, types.ActionContinue, action)
modifiedBody := host.GetRequestBody()
require.NotEmpty(t, modifiedBody)
var modified map[string]interface{}
require.NoError(t, json.Unmarshal(modifiedBody, &modified))
messages := modified["messages"].([]interface{})
// prepend(1) + original(1) + append(1) = 3
require.Len(t, messages, 3)
// Prepend system message rewritten ("OpenClaw" -> "agent")
require.Equal(t, "You are running inside agent.", messages[0].(map[string]interface{})["content"])
// Original multimodal message: content stays as array, text inside untouched.
original := messages[1].(map[string]interface{})
parts, ok := original["content"].([]interface{})
require.True(t, ok, "multimodal content must remain an array")
require.Len(t, parts, 2)
require.Equal(t, "OpenClaw", parts[0].(map[string]interface{})["text"])
// Append message rewritten as a normal string.
require.Equal(t, "Mention agent if needed.", messages[2].(map[string]interface{})["content"])
host.CompleteHttp()
})
// pattern 为空 → parseConfig 失败
t.Run("empty pattern fails parseConfig", func(t *testing.T) {
host, status := test.NewTestHost(replaceMissingPatternConfig)
defer host.Reset()
require.NotEqual(t, types.OnPluginStartStatusOK, status)
})
// regex 不合法 → parseConfig 失败
t.Run("invalid regex fails parseConfig", func(t *testing.T) {
host, status := test.NewTestHost(replaceInvalidRegexConfig)
defer host.Reset()
require.NotEqual(t, types.OnPluginStartStatusOK, status)
})
})
}
func TestApplyReplaceRulesToContent(t *testing.T) {
t.Run("rules apply in declaration order", func(t *testing.T) {
rules := []ReplaceRule{
{Pattern: "foo", Replacement: "bar"},
{Pattern: "bar", Replacement: "baz"},
}
require.Equal(t, "baz", applyReplaceRulesToContent("user", "foo", rules))
})
t.Run("on_role gates rule application", func(t *testing.T) {
rules := []ReplaceRule{
{OnRole: "system", Pattern: "OpenClaw", Replacement: "agent"},
}
require.Equal(t, "agent", applyReplaceRulesToContent("system", "OpenClaw", rules))
require.Equal(t, "OpenClaw", applyReplaceRulesToContent("user", "OpenClaw", rules))
})
t.Run("regex rule supports capture references", func(t *testing.T) {
// parseConfig is what compiles the regex in production, so call it
// directly here instead of going through the wasm test host. This
// keeps the helper-level test independent of the wasm runtime so it
// behaves the same in `go test` and CI.
cfgJSON, err := json.Marshal(map[string]interface{}{
"prepend": []map[string]interface{}{},
"append": []map[string]interface{}{},
"replace": []map[string]interface{}{
{
"pattern": `hello (\w+)`,
"replacement": "hi $1",
"regex": true,
},
},
})
require.NoError(t, err)
var cfg AIPromptDecoratorConfig
require.NoError(t, parseConfig(gjson.ParseBytes(cfgJSON), &cfg))
require.Equal(t, "hi world", applyReplaceRulesToContent("user", "hello world", cfg.Replace))
})
}