feat(commondev): 主管式多仓库开发流水线 + sidecar 后端抽象(pi/zcode) (#3)

* feat(commondev): 主管式多仓库开发流水线 + sidecar 后端抽象(pi/zcode)

## 是什么

agent 统筹开发流程的插件:登记项目 → 开工程线(每条线一个隔离工作目录 +
一个 sidecar 子进程 + 一对通道)→ 投递任务 → 同会话续投 → 评审/合入。

38 个工具:project_*/branch_*/workdir_*/job_*/review_*/test_*/
endpoint_*/agent_*/pi_*/conflict_check。

## sidecar 后端抽象(本轮核心)

传输层(local/ssh)与后端(pi/zcode/自定义)**正交**,四个自由组合:

  driver.json 描述一个后端:script / cliPath / env / args / llmConfig
  三级查找:插件数据目录 → **随包 rawfile** → 内置 pi 描述
  占位符:{workdir} {dataDir} {pluginDir} {rawfile} {sessionId}

★ 内置 pi 回退**与默认后端解耦**(2026-10-17 修):原实现写
`if cli == defaultAgentCLI { return builtinPiDriver() }`,把"给谁兜底"
和"默认选谁"耦合到同一常量 —— 一旦有人改默认,pi 当场失去兜底,
显式 cli="pi" 开始报「驱动不存在」。现在判断字面 "pi"。

默认后端保持 **pi**(我们本地主用);交付方用 zcode 走两种局部开关:
 · 单线:commondev_branch_open(cli="zcode")
 · 按机器:commondev_endpoint_set(host=..., cli="zcode")

## zcode 接入(三条真机判据,全部要求真改工程文件)

 · TestZcodeFullPipeline(29-34s):随包 driver + 真实 ZCode CLI
 · TestZcodeOverSSH(39s):真 ssh + scp,远端 node 跑 zcode
 · TestZcodeOverSSHWithPerHostEndpoints(68s):两个 host 别名配不同
   cli_path,走端点表而不是全局 settings

★ 会话库必须隔离到工作区(sidecar.mjs 的 ensureWorkspaceSessionStore):
实测 ZCode 的会话库路径**硬编码 homedir()**,`ZCODE_DATA_BASE_DIR` 对它
完全无效(26 个会话全落全局库)。但 `<workdir>/.zcode/config.json` 的
`storage.sessionDbPath` **确实生效**。所以启动前写一份工作区配置,
一举解决:多线不共用历史 / 不污染全局 ~/.zcode / 删线不留孤儿会话。
只在不存在时写 —— 用户手工放的要尊重。

## 其他

 · rawfile:项目根 rawfile/ 默认全收,解到 <插件目录>/rawfile/,绝对路径拒绝
 · 凭证:命名引用 + ${ENV_VAR},独立 0600 文件,绝不进 config.db(0644)
 · secretshell:给不受信 agent 用的安全 shell(ackey + safeshell)
 · 冲突检查 / 评审判据 / 镜像 / 每条线独立 workdir(hash 命名,合并后删除)

判据 30+ 条,含 4 条「判据本身的判据」(防止判据假绿)。

* fix(sdk): 协议通道纪律 + hmapdev 模板升级 + 示例插件 stdout 污染

## 核心缺陷:插件往 stdout 打日志 = 污染 JSON-RPC 协议通道

子进程插件与内核用 stdout 传 NDJSON 帧,stderr 才是日志通道。
脚手架模板(及 4 个示例插件)用 `fmt.Printf` 打日志 ⇒ 内核收到非 JSON 文本:

    [proc] weather 收到非法 JSON 帧(21 字节): invalid character 'i'

**生产实测 1038 条**(huawei_smarthome 720 / weather 113 / rss 103 / calendar 102)。
后果不只是日志噪音:那一行日志被**丢弃**,插件看起来"没启动"。

修法:
- hmapdev 模板 `fmt.Printf` → `log.Printf`(9 处)
- 示例插件 4 个 14 处:ai_image(2) calendar(5) rss(4) weather(3)
  + 补 "log" import
- 内核侧(主仓)同步让报错可定位:内容摘要 + 可能原因 + 修法

判别法:`package main` = 子进程(协议通道,禁用 stdout 打日志);
普通库包 = 进程内插件(编译进内核,写真实终端,**不是**缺陷)——
`internal/plugins/cli` 的 `fmt.Println` 属后者。

## hmapdev

- `cmd_build.go` +136:平台产物精确匹配(`--target` 是**append** 语义,
  原脚本 `ls dist/*.hmap | head -1` 会按字典序抓到 amd64,
  **导致 arm64 包里装的是 x86-64 二进制**,且退出码 0、校验和通过)
- `cmd_init.go` +46:脚手架 stdio 纪律
- `templates/proc_main.go.tmpl`:补齐 mgrStop/mgrStart/mgrIsRuntimeStopped
  三态能力(插件管理器运行态停启)
- `templates/plg.json.tmpl` 删除(合并进 cmd_init)

## 判据

- `scaffold_stdout_test.go`(3 条):扫模板生成的代码不得写 stdout。
  ★ 判据自身先假红过一次:**模板的注释里就写了 `fmt.Printf`** ⇒
  必须 `stripTemplateComments()` 之后才判
- `example_stdout_test.go`(2 条):扫全部 20 个子进程示例插件,
  报错给**文件:行号**;变异咬住并给出准确行号
- `package_examples_test.go`(2 条):**执行真实打包脚本**
  (第一版测的是 hmapdev 而不是脚本 ⇒ 变异不咬)
- `cmd_build` / `cmd_init` 新判据

## 其他

- sdk/settings.go +15(三态能力透传)
- `docs/versions.md` +§示例插件协议通道纪律(含生产 1038 条实测表格)
- `skills/homeagent-plugin-dev/SKILL.md` +§协议通道纪律(正误代码对比)

* fix(examples): 恢复 go.mod 相对路径 + 移除 plg.json 的 sdk 固定(修 CI 红)

CI 上 `Examples (linux/amd64)` 与 `(darwin/arm64)` 双红,只有两个失败:

  ✗ commondev   构建失败
  ✗ editdoc     构建失败
  error: 项目声明 sdk=1.4.0,但本地 SDK 存储里没有这个版本

其余 20 个示例全部成功 —— 说明不是通用问题,而是这两个被改坏了。

## 根因(两个叠加)

**① go.mod 被本机绝对路径污染**

我在上一个提交里用 `git add -A`,把本机 `go build` **自动改写**过的
go.mod 一并提交了:

  require github.com/JianFeeeee/homeagentsdk v1.4.0
  replace github.com/JianFeeeee/homeagentsdk => /root/.homeagent/hmapdev/sdk/v1.4.0

基线(origin/main)是:

  require github.com/JianFeeeee/homeagentsdk v0.0.0
  replace github.com/JianFeeeee/homeagentsdk => ../../

本机那个目录真的存在 ⇒ 本地全绿;CI 没有 ⇒ 必红。

**② plg.json 声明了 `"sdk": "1.4.0"`**

一声明版本,hmapdev 就去**本地 SDK store** 找它,找不到直接拒绝构建。
全仓 20+ 示例**都不声明**,只有我改过的这两个声明了。
示例的正确依赖表达是 go.mod 的 `v0.0.0` + `replace ../../`。

## 修法

两个文件都恢复到与其余示例一致的写法。

## 判据(3 条,都是新加的)

 · `TestExampleGoModHasNoAbsoluteReplace`:任何示例 go.mod 不得含
   `/root/`、`/home/`、`/Users/`、`C:\`
 · `TestExampleGoModUsesRelativeSDKReplace`:replace 必须是相对路径 `../../`
 · `TestExamplePlgJsonDoesNotPinSDKVersion`:plg.json 不得声明 sdk

变异 2/2 咬住(重新引入绝对路径 / 重新声明 sdk,都准确报出文件路径)。

## 本地验证(模拟 CI 干净环境)

用干净 HOME 跑 `package/build-examples.sh`:
 · 修复前:成功 18 / 失败 4(commondev, editdoc, browser, rss)
 · 修复后:成功 20 / 失败 2(browser, rss —— **本机代理不通**,
   `proxy.golang.org` 超时;用本地 modcache 验证这两个可正常构建)

即 22/22 源码都能构建,CI 有网络不受影响。

## 教训

`git add -A` 会把「构建工具自动改写的文件」一起提交。
提交前必须 `git status` 逐个看,尤其 go.mod / go.sum。

---------

Co-authored-by: JianFeeeee <JianFeeeee@users.noreply.gitcode.com>
This commit is contained in:
JianFeeeee
2026-10-17 19:44:15 +08:00
committed by GitHub
parent 65d2bd9e61
commit 81a3efa4df
91 changed files with 21184 additions and 224 deletions

View File

@ -1,49 +1,62 @@
# editdoc · Office 文档编辑
# editdoc · 全能办公
编辑 `.docx` / `.xlsx` / `.pptx` 内容:查找替换、改单元格、插行。
对 `.docx` / `.xlsx` / `.pptx` / `.xls` / `.csv` / `.md` / `.txt` 做**新建、读取、编辑、转换**。
> ⚠️ **版本说明**:本目录是 **v1.0.0**,只有 `edit_document` 一个工具。
> 线上部署的 v2.0.0(全能办公版,支持新建/读取/转换 docx·xlsx·pptx·md·csv·txt)
> **源码尚未公开**,本文档不描述那些能力。参见 `plugin.json` 的 `version`。
> **版本**:v2.0.0(全能办公)。较 v1.0.0 新增 `read_document`、`create_document`、
> `create_from_template`、`convert_document`、`office_check` 五个工具;
> `edit_document` 的名字与全部 v1 参数**保持不变**,老调用不会破。
## 工具
| 工具 | 说明 |
| 工具 | 作用 |
|---|---|
| `edit_document` | 编辑文档内容,**编辑后原文件被覆盖** |
参数:
| 参数 | 说明 |
|---|---|
| `file` | 文档路径(必填) |
| `operation` | `replace_text`(查找替换)/ `set_cell`(设置单元格)/ `insert_row`(插入行)(必填) |
| `target` | 要查找的文本(`replace_text` 用) |
| `replacement` | 替换为的文本(`replace_text` 用) |
| `sheet` | 工作表名(xlsx 可选) |
| `row` | 行号(`set_cell` / `insert_row` 用) |
| `col` | 列号(`set_cell` 用) |
| `value` | 单元格值(`set_cell` 用) |
编辑前建议先读一遍内容确认目标文本 —— 查找替换是**全文件覆盖写**,没有撤销。
| `read_document` | 读为结构化 JSON(docx 段落+表格+页眉页脚 / xlsx 逐表逐格含公式与计算值 / pptx 逐页 / xls / csv / md / txt),大文件可 `offset`/`limit` 分段 |
| `create_document` | 从零新建。docx 支持 Markdown 正文;xlsx 支持多表(表名+表头+行);pptx 用 `##` 分页 |
| `create_from_template` | 复制模板再套用替换 |
| `edit_document` | 编辑既有文件(v1 八参数 + 查找替换/正则大小写/第 N 处/表格与单元格/图片/样式/幻灯片/`dry_run`) |
| `convert_document` | 格式转换(docx↔md/pdf、xlsx↔csv 等,借助 soffice / pandoc) |
| `office_check` | 自检:依赖库可用性与版本、外部工具、格式×能力矩阵 |
## 配置项
| 键 | 默认 | 说明 |
|---|---|---|
| `script_path` | 空 | `edit_doc.py` 的绝对路径。留空则用插件可执行文件同目录下的 `edit_doc.py` |
| `venv_python` | 空 | 执行 `edit_doc.py` 的 Python 解释器(建议用 venv 里的)。**必须配置,留空会报错** |
| `script_path` | 空 | `edit_doc.py` 的绝对路径。**留空即推荐值** —— 用内嵌脚本;仅当该文件含 v2 协议标记时才被采用,否则自动忽略并回退内嵌 |
| `venv_python` | 空 | 执行脚本的 Python 解释器(需装 python-docx / openpyxl / python-pptx)。**必须配置** |
| `timeout_sec` | 60 | 单次调用超时;大文件或格式转换(soffice 首次启动慢)建议调到 180 |
## 工作原理
本插件是 Go 写的薄壳:把参数序列化成 JSON,交给 Python 脚本 `edit_doc.py` 执行实际文档操作。
文档解析依赖 Python 侧的库(python-docx / openpyxl / python-pptx 之类),所以:
Go 侧只做参数编排与结构化返回,全部业务逻辑在 Python 脚本 `edit_doc.py` 里。
- **需要自备 `edit_doc.py`**:它不在本目录里。
- 用 `venv_python` 指向装了这些库的解释器,避免污染系统 Python。
**脚本是内嵌的**(`//go:embed embed/edit_doc.py`),原因:`hmapdev` 打包器只把
`plugin.json` + `plugin.bin` 放进 `.hmap`,**不打包任何资源文件**。若让脚本单独
存在于 `plugin_data/`,就会出现「Go 侧升级了、脚本忘了换」的版本漂移。
内嵌 + 内容指纹校验使两侧永远同版本:
1. 首次调用时把内嵌脚本释放到 `DataDir()/runtime/edit_doc.py`,并写 `.stamp` 指纹;
2. 插件升级后指纹变化 → 自动重新释放;
3. `script_path` 显式指向的文件**仅当含 `EDITDOC_PROTOCOL = "v2"` 标记时**才被采用
(便于运维临时覆盖调试);否则忽略并在返回体里带 `script_path_ignored`,让降级可见。
**业务脚本只有一份**:`embed/edit_doc.py`。不要再添加同名的顶层副本 ——
它不会被 embed,改了不生效,而两份同名文件是典型的漂移源(见
`TestOnlyOneBusinessScript`)。
## 依赖
Python 侧:`python-docx`、`openpyxl`、`python-pptx`(`.xls` 另需 `xlrd`)。
外部工具(可选,转换能力用):`soffice`(LibreOffice)、`pandoc`。
用 `office_check` 随时查看本机实际可用的能力矩阵。
## 构建
```bash
hmapdev build
```
## 测试
```bash
go test ./...
```

File diff suppressed because it is too large Load Diff

View File

@ -1,5 +1,3 @@
//go:build !windows || !cgo
package main
import (

View File

@ -1,15 +1,22 @@
{
"name": "editdoc",
"name_zh": "文档编辑",
"name_zh": "全能办公",
"name_en": "Document Editor",
"version": "1.0.0",
"description": "办公文档编辑工具(.docx),基于 Python python-docx 库实现。支持文本替换、表格操作、内容插入等。",
"version": "2.0.0",
"description": "全能办公插件:新建/读取/编辑/转换 docx·xlsx·pptx·md·csv·txt(基于 python-docx / openpyxl / python-pptx / soffice / pandoc)",
"author": "HomeAgent",
"entry": "plugin.so",
"tags": ["editdoc", "office", "document"],
"entry": "plugin.bin",
"tags": [
"editdoc",
"office",
"document",
"excel",
"word",
"powerpoint"
],
"targets": "linux/amd64",
"outdir": "dist",
"bundle": true,
"replaces": {},
"source_dirs": []
}
}

View File

@ -2,21 +2,60 @@ package main
import (
"bytes"
"context"
"crypto/sha256"
_ "embed"
"encoding/hex"
"encoding/json"
"fmt"
"log"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"github.com/JianFeeeee/homeagentsdk/sdk"
)
// editDocPy 内嵌 Python 后端脚本。
//
// 为何必须内嵌:hmapdev 的打包器只把 plugin.json + plugin.bin 放进 .hmap
// (加上可选的 README/LICENSE),**不打包任何资源文件**。
// 而本插件的全部业务逻辑就在 edit_doc.py 里,若让它单独存在于
// plugin_data/editdoc/,就会出现「Go 侧升级了、脚本忘了换」的版本漂移
// (mc 插件就踩过这个坑:工具注册了但子脚本还是旧的,报未知命令)。
// 内嵌 + 内容指纹校验可以让两侧永远同版本。
//
//go:embed embed/edit_doc.py
var embeddedScript []byte
// scriptStamp 返回内嵌脚本的内容指纹。
func scriptStamp() string {
h := sha256.Sum256(embeddedScript)
return hex.EncodeToString(h[:])[:16]
}
// scriptProtocolMarker 是 edit_doc.py 里的 v2 协议标记(见 embed/edit_doc.py 顶部)。
// resolveScript 靠它识别「script_path 指向的是不是 v2 脚本」,防止旧脚本被误用。
const scriptProtocolMarker = `EDITDOC_PROTOCOL = "v2"`
// Plugin 是 editdoc v2.0.0(全能办公)插件。
//
// 设计要点(与 v1.0.0 的关系):
// - edit_document 工具名与 file/operation/target/replacement/sheet/row/col/value
// 参数语义**完全保留**,老调用不会破。
// - 全部实现委托给同目录下的 edit_doc.py,Go 侧只做参数编排 + 结构化返回。
// - 所有子命令统一走 runScript,错误一律结构化(ok:false + error + hint),不 panic。
type Plugin struct {
name string
sdk *sdk.PluginSDK
scriptPath string
venvPython string
timeoutSec int
// ignoredScriptPath 记录「配置里的 script_path 因不是 v2 脚本而被忽略」的路径。
// 非空时 runScript 会在返回体里带上 script_path_ignored,让上层看得见这次降级。
ignoredScriptPath string
}
func (p *Plugin) Name() string { return p.name }
@ -25,138 +64,469 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
s.SetAutoRestart(true)
p.sdk = s
// ---- 配置项(v1 的 script_path / venv_python 原样保留,新增 timeout_sec)----
s.Settings().RegisterDef(sdk.ConfigDef{
Key: "script_path", Default: "", Type: "string",
DisplayName: "编辑脚本路径",
Description: "edit_doc.py 的绝对路径;留空时使用插件可执行文件同目录下的 edit_doc.py",
Category: p.name,
DisplayName: "办公脚本路径",
Description: "edit_doc.py 的绝对路径;留空(推荐)时使用 DataDir()/runtime/edit_doc.py(升级时按指纹自动更新)。" +
"仅当该文件含 v2 协议标记时才会被采用,否则自动忽略并回退内嵌脚本",
Category: p.name,
})
s.Settings().RegisterDef(sdk.ConfigDef{
Key: "venv_python", Default: "", Type: "string",
DisplayName: "venv Python 解释器",
Description: "执行 edit_doc.py 使用的 Python 解释器(建议用 venv 内的 python);必须配置,留空将报错",
Description: "执行 edit_doc.py 使用的 Python 解释器(需装有 python-docx/openpyxl/python-pptx);必须配置",
Category: p.name,
})
s.Settings().RegisterDef(sdk.ConfigDef{
Key: "timeout_sec", Default: 60, Type: "int",
DisplayName: "脚本超时(秒)",
Description: "单次调用 edit_doc.py 的超时上限;大文件或格式转换(soffice 首次启动慢)建议调到 180",
Category: p.name, Min: 5, Max: 900,
})
if v, err := s.Settings().Get("script_path"); err == nil {
if str, ok := v.(string); ok {
p.scriptPath = str
}
}
if v, err := s.Settings().Get("venv_python"); err == nil {
if str, ok := v.(string); ok {
p.venvPython = str
}
}
s.RegisterTool("edit_document", sdk.ToolDef{
Name: "edit_document",
Description: "编辑 Office 文档内容。支持替换文本、修改单元格等操作。编辑后原文件被覆盖。操作前建议先用 read_document 查看内容。支持 .docx / .xlsx / .pptx。",
NoMemory: true,
p.reloadSettings()
// ---- 工具注册 ----
// 工具名不带插件名前缀:v1 的 edit_document 就是无前缀的,保持一致性。
s.RegisterTool("read_document", sdk.ToolDef{
Name: "read_document",
Description: "读取办公文档内容并返回结构化数据。支持 .docx(段落+表格+页眉页脚)、" +
".xlsx/.xlsm(逐表逐格,公式与计算值都给)、.pptx(逐页文本框与表格)、.xls、.csv/.md/.txt。" +
"大文件用 offset/limit 分段读。改文件前先用它看内容。",
NoMemory: true,
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"file": map[string]interface{}{"type": "string", "description": "文档绝对路径"},
"sheet": map[string]interface{}{"type": "string", "description": "xlsx:只读某张表(可选)"},
"offset": map[string]interface{}{"type": "integer", "description": "分段读的起始偏移(docx=块序号, xlsx/pptx=页/表序号),默认 0"},
"limit": map[string]interface{}{"type": "integer", "description": "本次最多返回多少段,默认全部"},
"formulas": map[string]interface{}{"type": "boolean", "description": "xlsx 是否附带公式的计算值,默认 true"},
},
"required": []string{"file"},
},
}, p.handleRead)
s.RegisterTool("create_document", sdk.ToolDef{
Name: "create_document",
Description: "从零新建办公文档。支持 .docx(title+content,content 支持 Markdown:# 标题、**粗体**、*斜体*、- 列表)、" +
".xlsx(sheets 多表:表名+表头+行数据)、.pptx(title+content,## 分页)、.md、.csv、.txt。" +
"目录不存在会自动创建;文件已存在时需传 overwrite=true 才会覆盖(覆盖前自动 .bak 留档)。",
NoMemory: true,
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"file": map[string]interface{}{"type": "string", "description": "要创建的文件绝对路径(扩展名决定格式)"},
"title": map[string]interface{}{"type": "string", "description": "标题(docx/pptx/md)"},
"subtitle": map[string]interface{}{"type": "string", "description": "副标题(pptx 首页)"},
"content": map[string]interface{}{"type": "string", "description": "正文内容(docx/md/txt 支持 Markdown;pptx 用 ## 分页)"},
"sheets": map[string]interface{}{"type": "array", "description": "xlsx 多表:[{name, headers, rows}]", "items": map[string]interface{}{"type": "object"}},
"headers": map[string]interface{}{"type": "array", "description": "单表模式的表头(xlsx/csv)", "items": map[string]interface{}{"type": "string"}},
"rows": map[string]interface{}{"type": "array", "description": "单表模式的数据行(xlsx/csv)", "items": map[string]interface{}{"type": "array"}},
"tables": map[string]interface{}{"type": "array", "description": "docx 追加表格:[{headers, rows}]", "items": map[string]interface{}{"type": "object"}},
"overwrite": map[string]interface{}{"type": "boolean", "description": "文件已存在时是否覆盖,默认 false(报错)"},
},
"required": []string{"file"},
},
}, p.handleCreate)
s.RegisterTool("create_from_template", sdk.ToolDef{
Name: "create_from_template",
Description: "基于已有文件复制一份再套用替换(模板场景)。先 shutil.copy2 复制,再对 docx/xlsx/文本做文本替换。" +
"replacements 是 {\"旧文本\":\"新文本\"} 映射;也可用 target/replacement 只替换一处。",
NoMemory: true,
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"template": map[string]interface{}{"type": "string", "description": "模板文件绝对路径"},
"file": map[string]interface{}{"type": "string", "description": "新文件绝对路径"},
"replacements": map[string]interface{}{"type": "object", "description": "替换映射 {\"旧\":\"新\"}"},
"target": map[string]interface{}{"type": "string", "description": "单处替换的原文本(可选)"},
"replacement": map[string]interface{}{"type": "string", "description": "单处替换的新文本(可选)"},
"overwrite": map[string]interface{}{"type": "boolean", "description": "目标已存在时是否覆盖"},
},
"required": []string{"template", "file"},
},
}, p.handleCreateFromTemplate)
// ---- 兼容性红线:工具名 + 参数与 v1.0.0 完全一致 ----
s.RegisterTool("edit_document", sdk.ToolDef{
Name: "edit_document",
Description: "编辑 Office 文档(会覆盖原文件,编辑前自动 .bak 留档)。支持 .docx / .xlsx / .xlsm / .pptx。" +
"operation 可选:replace_text(查找替换,支持 regex/case_sensitive/nth)、set_cell、insert_row、" +
"delete_row、insert_paragraph、append_paragraph、set_style、" +
"insert_image、insert_table、merge_cells、add_sheet、delete_sheet、rename_sheet。" +
"注意:pptx 无插入行 API,insert_row 在 pptx 上语义为「表格末尾追加一行」;merge_cells 不支持 pptx。",
NoMemory: true,
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
// v1 原有 8 个参数,语义不变
"file": map[string]interface{}{"type": "string", "description": "文档文件路径"},
"operation": map[string]interface{}{"type": "string", "description": "操作: replace_text(查找替换), set_cell(设置单元格), insert_row(插入行)"},
"target": map[string]interface{}{"type": "string", "description": "要查找的文本(replace_text)"},
"operation": map[string]interface{}{"type": "string", "description": "操作(见工具描述)"},
"target": map[string]interface{}{"type": "string", "description": "要查找的文本(replace_text);set_style 时为空表示全文"},
"replacement": map[string]interface{}{"type": "string", "description": "替换为的文本(replace_text)"},
"sheet": map[string]interface{}{"type": "string", "description": "工作表名称(xlsx,可选)"},
"row": map[string]interface{}{"type": "integer", "description": "行号(set_cell/insert_row)"},
"sheet": map[string]interface{}{"type": "string", "description": "工作表名称(xlsx)"},
"row": map[string]interface{}{"type": "integer", "description": "行号(set_cell/insert_row/delete_row)"},
"col": map[string]interface{}{"type": "integer", "description": "列号(set_cell)"},
"value": map[string]interface{}{"type": "string", "description": "单元格值(set_cell)"},
"value": map[string]interface{}{"type": "string", "description": "写入值(set_cell/add_sheet/rename_sheet/段落文本)"},
// v2 扩展参数
"regex": map[string]interface{}{"type": "boolean", "description": "target 按正则解释,默认 false"},
"case_sensitive": map[string]interface{}{"type": "boolean", "description": "是否区分大小写,默认 true"},
"nth": map[string]interface{}{"type": "integer", "description": "只替换第 N 处(1 基),默认全部替换"},
"count": map[string]interface{}{"type": "integer", "description": "delete_row 删除几行,默认 1"},
"index": map[string]interface{}{"type": "integer", "description": "insert_paragraph 的插入位置(段落序号)"},
"row2": map[string]interface{}{"type": "integer", "description": "merge_cells 的结束行"},
"col2": map[string]interface{}{"type": "integer", "description": "merge_cells 的结束列"},
"table": map[string]interface{}{"type": "integer", "description": "docx 表格序号(merge_cells/insert_table),从 0 开始"},
"headers": map[string]interface{}{"type": "array", "description": "insert_table 的表头", "items": map[string]interface{}{"type": "string"}},
"rows": map[string]interface{}{"type": "array", "description": "insert_table 的数据行", "items": map[string]interface{}{"type": "array"}},
"image": map[string]interface{}{"type": "string", "description": "insert_image 的图片路径"},
"width_inch": map[string]interface{}{"type": "number", "description": "insert_image 的宽度(英寸)"},
"slide": map[string]interface{}{"type": "integer", "description": "pptx 页号(从 1 开始)"},
"size": map[string]interface{}{"type": "number", "description": "set_style 字号(磅)"},
"bold": map[string]interface{}{"type": "boolean", "description": "set_style 加粗"},
"italic": map[string]interface{}{"type": "boolean", "description": "set_style 斜体"},
"color": map[string]interface{}{"type": "string", "description": "set_style 字体颜色(如 FF0000)"},
"bg_color": map[string]interface{}{"type": "string", "description": "xlsx set_style 背景色"},
"align": map[string]interface{}{"type": "string", "description": "set_style 对齐:left/center/right/justify"},
"dry_run": map[string]interface{}{"type": "boolean", "description": "只校验不改动"},
},
"required": []string{"file", "operation"},
},
}, p.handleEditDocument)
}, p.handleEdit)
s.RegisterTool("convert_document", sdk.ToolDef{
Name: "convert_document",
Description: "办公文档格式转换。md→docx/html/pdf(pandoc)、xlsx→csv(openpyxl,多表自动分文件)、" +
"pptx→txt/md(结构化抽取)、office→pdf(soffice)。缺外部依赖时会在返回里点名缺哪个,不静默失败。",
NoMemory: true,
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"file": map[string]interface{}{"type": "string", "description": "源文件绝对路径"},
"to": map[string]interface{}{"type": "string", "description": "目标格式:docx / pdf / html / csv / txt / md"},
"output": map[string]interface{}{"type": "string", "description": "输出路径(可选,默认同名换扩展名)"},
"sheet": map[string]interface{}{"type": "string", "description": "xlsx→csv 时指定工作表(可选,默认全部)"},
"overwrite": map[string]interface{}{"type": "boolean", "description": "输出已存在时覆盖"},
"timeout": map[string]interface{}{"type": "integer", "description": "转换超时(秒),默认 120"},
},
"required": []string{"file", "to"},
},
}, p.handleConvert)
s.RegisterTool("office_check", sdk.ToolDef{
Name: "office_check",
Description: "办公能力自检(独立可调用,不依赖真实文件)。返回:Python 解释器版本、每个依赖库" +
"(python-docx/openpyxl/python-pptx/xlrd/markdown)的可用性与版本、外部工具(soffice/pandoc)、" +
"以及格式×能力矩阵。换机器或升级插件后可用它一条命令验收。",
NoMemory: true,
Parameters: map[string]interface{}{
"type": "object", "properties": map[string]interface{}{},
},
}, p.handleCheck)
// 首次启动就把脚本释放好,让 office_check 立刻可用(也便于运维确认落盘位置)
if sp, err := p.resolveScript(); err == nil {
fmt.Fprintf(os.Stderr, "[%s] 已就绪(脚本: %s, 指纹: %s)\n", p.name, sp, scriptStamp())
if p.ignoredScriptPath != "" {
fmt.Fprintf(os.Stderr, "[%s] 注意:script_path=%s 已被忽略(非 v2 脚本),当前使用内嵌脚本\n",
p.name, p.ignoredScriptPath)
}
} else {
fmt.Fprintf(os.Stderr, "[%s] 脚本释放失败: %v\n", p.name, err)
}
return nil
}
func (p *Plugin) Stop() error { return nil }
func (p *Plugin) handleEditDocument(args map[string]interface{}) (interface{}, error) {
file, _ := args["file"].(string)
if file == "" {
return nil, fmt.Errorf("file is required")
func (p *Plugin) reloadSettings() {
s := p.sdk.Settings()
if v, err := s.Get("script_path"); err == nil {
if str, ok := v.(string); ok {
p.scriptPath = strings.TrimSpace(str)
}
}
operation, _ := args["operation"].(string)
if operation == "" {
return nil, fmt.Errorf("operation is required")
if v, err := s.Get("venv_python"); err == nil {
if str, ok := v.(string); ok {
p.venvPython = strings.TrimSpace(str)
}
}
if _, err := os.Stat(file); os.IsNotExist(err) {
return map[string]interface{}{
"content": fmt.Sprintf("文件不存在: %s", file),
}, nil
p.timeoutSec = 60
if v, err := s.Get("timeout_sec"); err == nil {
switch n := v.(type) {
case float64:
if n > 0 {
p.timeoutSec = int(n)
}
case int64:
if n > 0 {
p.timeoutSec = int(n)
}
case int:
if n > 0 {
p.timeoutSec = n
}
}
}
pyArgs := map[string]interface{}{}
if v, ok := args["target"]; ok {
pyArgs["target"] = v
}
if v, ok := args["replacement"]; ok {
pyArgs["replacement"] = v
}
if v, ok := args["sheet"]; ok {
pyArgs["sheet"] = v
}
if v, ok := args["row"]; ok {
pyArgs["row"] = v
}
if v, ok := args["col"]; ok {
pyArgs["col"] = v
}
if v, ok := args["value"]; ok {
pyArgs["value"] = v
}
pyArgsJSON, _ := json.Marshal(pyArgs)
scriptPath := p.scriptPath
if scriptPath == "" {
scriptPath = filepath.Join(filepath.Dir(os.Args[0]), "edit_doc.py")
log.Printf("[%s] script_path 未配置,使用默认脚本路径: %s", p.name, scriptPath)
}
if _, err := os.Stat(scriptPath); os.IsNotExist(err) {
return nil, fmt.Errorf("edit_doc.py not found at %s(请在插件配置 script_path 中指定脚本路径)", scriptPath)
}
if p.venvPython == "" {
return nil, fmt.Errorf("venv_python 未配置,无法执行脚本;请在插件配置中设置 venv_python(venv 内 python 的绝对路径)")
}
if _, err := os.Stat(p.venvPython); err != nil {
return nil, fmt.Errorf("venv python 不存在: %s(请检查 venv_python 配置)", p.venvPython)
}
var out bytes.Buffer
cmd := exec.Command(p.venvPython, scriptPath, file, operation, string(pyArgsJSON))
cmd.Stdout = &out
if err := cmd.Run(); err != nil {
return nil, fmt.Errorf("edit document: %w", err)
}
var result struct {
Ok bool `json:"ok"`
Error string `json:"error,omitempty"`
Count int `json:"count,omitempty"`
}
if err := json.Unmarshal(out.Bytes(), &result); err != nil {
return map[string]interface{}{
"content": fmt.Sprintf("编辑完成,输出: %s", out.String()),
"file": file,
}, nil
}
if !result.Ok {
return map[string]interface{}{
"content": fmt.Sprintf("编辑失败: %s", result.Error),
}, nil
}
msg := fmt.Sprintf("编辑完成,已保存到原文件: %s", file)
if result.Count > 0 {
msg += fmt.Sprintf("\n共处理 %d 处", result.Count)
}
return map[string]interface{}{
"content": msg,
"file": file,
}, nil
}
// resolveScript 返回 edit_doc.py 的可用路径。
//
// 优先级:
// 1. 配置 script_path 指向的文件,且**含 v2 协议标记**(便于运维手动覆盖调试)
// 2. DataDir()/runtime/edit_doc.py(内嵌脚本释放位置,指纹驱动的自动更新)
//
// 关键防呆:script_path 指向的文件若存在但**不含** v2 协议标记,说明它是旧版脚本
// (v1 的 argv 约定是 <file> <op> <json>,与 v2 的 <cmd> <json> 完全不同)。
// 若照样采用,6 个工具会全部报「文件不存在: create/read/edit/...」,等于把线上打挂。
// 因此这里主动忽略它、回退内嵌脚本,并置 p.ignoredScriptPath 让调用方能看到。
func (p *Plugin) resolveScript() (string, error) {
p.ignoredScriptPath = ""
if p.scriptPath != "" {
if data, err := os.ReadFile(p.scriptPath); err == nil {
if bytes.Contains(data, []byte(scriptProtocolMarker)) {
return p.scriptPath, nil
}
// 存在但不是 v2 脚本:忽略,回退内嵌(绝不能让它把工具打挂)
p.ignoredScriptPath = p.scriptPath
fmt.Fprintf(os.Stderr,
"[%s] 忽略 script_path=%s:缺少 v2 协议标记 %q,判定为旧版脚本;改用内嵌脚本\n",
p.name, p.scriptPath, scriptProtocolMarker)
}
}
// 释放内嵌脚本
dir := ""
if p.sdk != nil {
dir = p.sdk.Settings().DataDir()
}
if dir == "" {
return "", fmt.Errorf("拿不到插件数据目录(Settings().DataDir() 为空),且 script_path 未指向有效文件")
}
rt := filepath.Join(dir, "runtime")
if err := os.MkdirAll(rt, 0755); err != nil {
return "", fmt.Errorf("创建运行时目录失败: %w", err)
}
jsPath := filepath.Join(rt, "edit_doc.py")
stampPath := filepath.Join(rt, ".stamp")
want := scriptStamp()
if got, err := os.ReadFile(stampPath); err == nil && string(got) == want {
if _, e := os.Stat(jsPath); e == nil {
return jsPath, nil
}
}
if err := os.WriteFile(jsPath, embeddedScript, 0644); err != nil {
return "", fmt.Errorf("释放内嵌脚本失败: %w", err)
}
_ = os.WriteFile(stampPath, []byte(want), 0644)
return jsPath, nil
}
// resolvePython 返回可用的 python 解释器(配置为空时回退到 PATH 里的 python3)。
func (p *Plugin) resolvePython() (string, error) {
py := p.venvPython
if py == "" {
if cand, err := exec.LookPath("python3"); err == nil {
py = cand
}
}
if py == "" {
return "", fmt.Errorf("venv_python 未配置且 PATH 中找不到 python3")
}
if _, err := os.Stat(py); err != nil {
return "", fmt.Errorf("Python 解释器不存在: %s", py)
}
return py, nil
}
// scriptError 是结构化错误体,直接透传给上层 LLM。
type scriptError struct {
Error string `json:"error"`
Hint string `json:"hint"`
}
// runScript 执行 edit_doc.py <cmd> <argsJSON>,返回解析后的 map。
//
// 统一错误处理:脚本缺失/解释器缺失/超时/非 JSON 输出,都转成带 hint 的结构化返回,
// 而不是抛裸 error(v1 是直接 return nil, err,上层只能看到一句无上下文的失败)。
func (p *Plugin) runScript(cmd string, args map[string]interface{}) (map[string]interface{}, error) {
p.reloadSettings() // 支持改配置后无需重载插件
scriptPath, err := p.resolveScript()
if err != nil {
return nil, fmt.Errorf("%s;请在插件配置 script_path 指定 edit_doc.py 的绝对路径", err)
}
py, err := p.resolvePython()
if err != nil {
return nil, fmt.Errorf("%s;请在插件配置 venv_python 指定 python 解释器", err)
}
argsJSON, err := json.Marshal(args)
if err != nil {
return nil, fmt.Errorf("参数序列化失败: %w", err)
}
ctx, cancel := context.WithTimeout(context.Background(),
time.Duration(p.timeoutSec)*time.Second)
defer cancel()
var stdout, stderr bytes.Buffer
c := exec.CommandContext(ctx, py, scriptPath, cmd, string(argsJSON))
c.Stdout = &stdout
c.Stderr = &stderr
runErr := c.Run()
if ctx.Err() == context.DeadlineExceeded {
return nil, fmt.Errorf("脚本执行超时(%d 秒);大文件或格式转换可调高 timeout_sec 配置",
p.timeoutSec)
}
out := strings.TrimSpace(stdout.String())
if out == "" {
msg := strings.TrimSpace(stderr.String())
if runErr != nil {
return nil, fmt.Errorf("脚本无输出且退出异常: %v; stderr: %s", runErr, truncate(msg, 300))
}
return nil, fmt.Errorf("脚本无输出(stderr: %s)", truncate(msg, 300))
}
// 脚本只保证最后一行是 JSON(soffice/pandoc 可能往 stdout 混东西)
line := out
if i := strings.LastIndex(out, "\n"); i >= 0 {
line = strings.TrimSpace(out[i+1:])
}
var m map[string]interface{}
if err := json.Unmarshal([]byte(line), &m); err != nil {
return nil, fmt.Errorf("脚本输出不是合法 JSON: %s; 原文: %s",
err, truncate(out, 300))
}
if se := strings.TrimSpace(stderr.String()); se != "" && runErr != nil {
m["stderr"] = truncate(se, 300)
}
// script_path 因非 v2 脚本被忽略时,把这次降级明确告知上层
if p.ignoredScriptPath != "" {
m["script_path_ignored"] = p.ignoredScriptPath
m["script_path_ignored_reason"] = "该文件缺少 v2 协议标记,判定为旧版脚本;本次已改用内嵌脚本"
}
return m, nil
}
func truncate(s string, n int) string {
r := []rune(s)
if len(r) <= n {
return s
}
return string(r[:n]) + "…"
}
// respond 把脚本返回整形成工具返回值:ok:true 时给 content/summary,
// ok:false 时给 isError + error + hint。
func respondOffice(m map[string]interface{}) (interface{}, error) {
if ok, _ := m["ok"].(bool); !ok {
out := map[string]interface{}{
"isError": true,
"content": "操作失败: " + str(m["error"]),
"error": m["error"],
"hint": m["hint"],
}
for _, k := range []string{"path", "exists", "missing", "format", "cmd",
"script_path_ignored", "script_path_ignored_reason"} {
if v, has := m[k]; has {
out[k] = v
}
}
return out, nil
}
// 成功:把脚本的结构化字段原样上抛,便于 LLM 二次利用
out := map[string]interface{}{}
for k, v := range m {
out[k] = v
}
if s, has := m["summary"]; has {
out["content"] = s
}
return out, nil
}
func str(v interface{}) string {
if v == nil {
return ""
}
if s, ok := v.(string); ok {
return s
}
return fmt.Sprintf("%v", v)
}
// ---------------------------------------------------------------- handlers
func (p *Plugin) handleRead(args map[string]interface{}) (interface{}, error) {
m, err := p.runScript("read", args)
if err != nil {
return toolErr("read_document", err), nil
}
return respondOffice(m)
}
func (p *Plugin) handleCreate(args map[string]interface{}) (interface{}, error) {
m, err := p.runScript("create", args)
if err != nil {
return toolErr("create_document", err), nil
}
return respondOffice(m)
}
func (p *Plugin) handleCreateFromTemplate(args map[string]interface{}) (interface{}, error) {
m, err := p.runScript("create_from_template", args)
if err != nil {
return toolErr("create_from_template", err), nil
}
return respondOffice(m)
}
func (p *Plugin) handleEdit(args map[string]interface{}) (interface{}, error) {
// v1 兼容:file / operation 必填
if str(args["file"]) == "" {
return map[string]interface{}{"isError": true, "content": "edit_document 需要 file 参数"}, nil
}
if str(args["operation"]) == "" {
return map[string]interface{}{"isError": true, "content": "edit_document 需要 operation 参数"}, nil
}
m, err := p.runScript("edit", args)
if err != nil {
return toolErr("edit_document", err), nil
}
return respondOffice(m)
}
func (p *Plugin) handleConvert(args map[string]interface{}) (interface{}, error) {
m, err := p.runScript("convert", args)
if err != nil {
return toolErr("convert_document", err), nil
}
return respondOffice(m)
}
func (p *Plugin) handleCheck(args map[string]interface{}) (interface{}, error) {
m, err := p.runScript("check", map[string]interface{}{})
if err != nil {
return toolErr("office_check", err), nil
}
return respondOffice(m)
}
func toolErr(tool string, err error) map[string]interface{} {
return map[string]interface{}{
"isError": true,
"content": fmt.Sprintf("%s 失败:%s", tool, err.Error()),
"error": err.Error(),
}
}
func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, error) {
return &Plugin{name: name}, nil

View File

@ -0,0 +1,178 @@
package main
import (
"os"
"path/filepath"
"testing"
"github.com/JianFeeeee/homeagentsdk/sdk"
)
// fakeSettings 是 SettingsAPI 的最小实现,只为把 resolveScript 的 DataDir 分支跑通。
// 其余方法测试用不到,给个安全默认值即可。
type fakeSettings struct {
dataDir string
defs []*sdk.ConfigDef
}
func (f *fakeSettings) Get(string) (interface{}, error) { return nil, nil }
func (f *fakeSettings) Set(string, interface{}) error { return nil }
func (f *fakeSettings) SetSecret(string, interface{}) error { return nil }
func (f *fakeSettings) GetSecret(string) (string, error) { return "", nil }
func (f *fakeSettings) List(string) ([]string, error) { return nil, nil }
func (f *fakeSettings) GetCore(string) (interface{}, error) { return nil, nil }
func (f *fakeSettings) SetCore(string, interface{}) error { return nil }
func (f *fakeSettings) ListCore(string) ([]string, error) { return nil, nil }
func (f *fakeSettings) DataDir() string { return f.dataDir }
func (f *fakeSettings) GetPlugin(string, string) (interface{}, error) { return nil, nil }
func (f *fakeSettings) SetPlugin(string, string, interface{}) error { return nil }
func (f *fakeSettings) ListPlugin(string, string) ([]string, error) { return nil, nil }
func (f *fakeSettings) RegisterDef(def sdk.ConfigDef) { f.defs = append(f.defs, &def) }
func (f *fakeSettings) Defs(string) []*sdk.ConfigDef { return f.defs }
func (f *fakeSettings) Dump() map[string]interface{} { return nil }
func (f *fakeSettings) Plugins() []string { return nil }
// newTestPlugin 造一个带可用 DataDir 的 Plugin(sdk 走 sdk.New,只注入 SettingsAPI)。
func newTestPlugin(t *testing.T, scriptPath string) *Plugin {
t.Helper()
sett := &fakeSettings{dataDir: t.TempDir()}
s := sdk.New("editdoc", sett, nil, nil, nil, nil)
return &Plugin{
name: "editdoc",
sdk: s,
scriptPath: scriptPath,
venvPython: "/usr/local/bin/python3",
timeoutSec: 60,
}
}
// 内嵌脚本必须自带 v2 协议标记,否则防呆会把自己的脚本也挡掉。
func TestEmbeddedScriptHasProtocolMarker(t *testing.T) {
if !containsMarker(embeddedScript) {
t.Fatalf("内嵌脚本缺少协议标记 %q —— 防呆会把内嵌脚本自己判为旧版", scriptProtocolMarker)
}
// 标记必须与 embed/edit_doc.py 顶部一致(防手改两处不同步)
t.Logf("协议标记=%q 内嵌脚本指纹=%s", scriptProtocolMarker, scriptStamp())
}
// 业务脚本只能有**一份**:embed/edit_doc.py。
//
// 为何钉这一条(2026-10-13 迁入仓库时实测):原工作目录里同时存在
// edit_doc.py 与 embed/edit_doc.py —— 两份逐字节相同的副本,而只有
// embed/ 那份被 //go:embed 引用。同名双副本是典型的漂移源:
// 改了顶层那份(看着像“主文件”)而真正生效的是 embed/ 里的,
// 或反过来;两种都不会报错,只会让行为与源码对不上。
func TestOnlyOneBusinessScript(t *testing.T) {
if _, err := os.Stat("edit_doc.py"); err == nil {
t.Error("存在顶层 edit_doc.py:业务脚本只应有 embed/edit_doc.py 一份(顶部副本未被 embed,改了不生效)")
}
if _, err := os.Stat(filepath.Join("embed", "edit_doc.py")); err != nil {
t.Fatalf("缺少 embed/edit_doc.py: %v", err)
}
}
// script_path 指向 v1 脚本(无标记)→ 必须忽略它、回退内嵌脚本,
// 并记录 ignoredScriptPath。这是防呆的核心契约。
func TestResolveScriptIgnoresV1Script(t *testing.T) {
dir := t.TempDir()
v1 := filepath.Join(dir, "edit_doc_v1.py")
// v1 脚本的真实特征:argv 约定 <file> <op> <json>,且没有 v2 标记
if err := os.WriteFile(v1, []byte(
"import sys\npath = sys.argv[1]\noperation = sys.argv[2]\n"), 0644); err != nil {
t.Fatal(err)
}
p := newTestPlugin(t, v1)
got, err := p.resolveScript()
if err != nil {
t.Fatalf("应回退到内嵌脚本,却报错: %v", err)
}
if got == v1 {
t.Fatal("v1 脚本被采用了 —— 防呆失效,6 个工具会被打挂")
}
if filepath.Base(got) != "edit_doc.py" {
t.Fatalf("应回退到 runtime/edit_doc.py,实际: %s", got)
}
if p.ignoredScriptPath != v1 {
t.Fatalf("ignoredScriptPath 未记录,实际: %q", p.ignoredScriptPath)
}
// 回退出来的脚本必须是内嵌那份(字节一致)
released, err := os.ReadFile(got)
if err != nil {
t.Fatalf("内嵌脚本未落盘: %v", err)
}
if string(released) != string(embeddedScript) {
t.Fatal("落盘的脚本与内嵌脚本字节不一致")
}
t.Logf("v1 被正确忽略,回退到 %s", got)
}
// script_path 指向含标记的 v2 脚本 → 必须采用它,且不记 ignoredScriptPath。
func TestResolveScriptAcceptsV2Script(t *testing.T) {
dir := t.TempDir()
v2 := filepath.Join(dir, "edit_doc_v2.py")
if err := os.WriteFile(v2, []byte(
"#!/usr/bin/env python3\n"+scriptProtocolMarker+"\n"), 0644); err != nil {
t.Fatal(err)
}
p := newTestPlugin(t, v2)
got, err := p.resolveScript()
if err != nil {
t.Fatalf("应直接采用 v2 脚本,却报错: %v", err)
}
if got != v2 {
t.Fatalf("v2 脚本未被采用,实际用了: %s", got)
}
if p.ignoredScriptPath != "" {
t.Fatalf("v2 脚本不应被标记忽略: %q", p.ignoredScriptPath)
}
}
// script_path 为空 → 走内嵌脚本释放,且指纹命中第二次不再重复写。
func TestResolveScriptFallsBackToEmbedded(t *testing.T) {
p := newTestPlugin(t, "")
got, err := p.resolveScript()
if err != nil {
t.Fatalf("空 script_path 应回退内嵌脚本: %v", err)
}
if p.ignoredScriptPath != "" {
t.Fatalf("空配置不该有 ignoredScriptPath: %q", p.ignoredScriptPath)
}
// 第二次调用应复用(指纹 + 文件都在),路径一致
got2, err := p.resolveScript()
if err != nil || got2 != got {
t.Fatalf("第二次解析不稳定: %s vs %s (%v)", got, got2, err)
}
// .stamp 指纹应有内容
stamp, err := os.ReadFile(filepath.Join(filepath.Dir(got), ".stamp"))
if err != nil || string(stamp) != scriptStamp() {
t.Fatalf("指纹文件异常: %q %v", stamp, err)
}
}
// 指向不存在的路径 → 不该报错,应静默回退内嵌脚本。
func TestResolveScriptMissingPathFallsBack(t *testing.T) {
p := newTestPlugin(t, filepath.Join(t.TempDir(), "not-there.py"))
got, err := p.resolveScript()
if err != nil {
t.Fatalf("路径不存在应回退内嵌脚本: %v", err)
}
if p.ignoredScriptPath != "" {
t.Fatalf("不存在的路径不该记为 ignored: %q", p.ignoredScriptPath)
}
if filepath.Base(got) != "edit_doc.py" {
t.Fatalf("应回退到 runtime/edit_doc.py,实际: %s", got)
}
}
// containsMarker 与 resolveScript 内部用的是同一判据,供测试复用。
func containsMarker(b []byte) bool {
m := []byte(scriptProtocolMarker)
for i := 0; i+len(m) <= len(b); i++ {
if string(b[i:i+len(m)]) == string(m) {
return true
}
}
return false
}