mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-21 09:28:14 +00:00
配套 SDK 提交:homeagent-sdk ba49dfd(公开 API 纯追加,无签名变更)。
本仓第三方的库镜像同步至该版本,以保证全新 clone 能编译。
## 1. 注入标志位(内核侧)
- 7 条注入路径(排队/中断/同步 × 纯文本/带媒体 + 旧 NoMem 变体)解析并转发
no_memory / context_policy / cleaner_name;策略在入口**校验**,
非法值报错而不是静默降级成 none(降级会让调用方以为自己声明的裁剪在生效)。
- 新增 validateContextPolicy(与 tool.register 同一套规则)与 pubSdkInjectOpts。
- input.register 不再手写字段白名单重建 ChannelDef,改为整体传递 + 补 ContextPolicy。
- io 层:applyInjectOpts 把标志位写进事件 payload,仅非零时写
(零值与旧 payload 逐字节一致,事件订阅方与旧内核都不受影响)。
- ioAdapter / procCore / internal-sdk 别名补齐六个 *Opts 实现。
## 2. 修掉「输入无条件裁剪」这个真缺陷
eventloop 此前对**每条非中断输入**都调 `context.Prune(...)`:破坏性(低相关事件被
归档移出上下文)且无法从调用点看出是谁触发的。改为 pruneOnInput/pruneDeclared:
优先级:注入点声明(payload.context_policy)> 通道声明(ChannelDef.ContextPolicy)
> 默认**不裁剪**
查询向量仍取清洗后的内容;新增 cleanInputFor 解析清洗文本,优先级为
注入点声明的 cleaner(cleaner_name)> 按 source 查到的通道 cleaner > 原文,
名字查不到时**记日志再回退**(注入是 fire-and-forget,插件看不到错误,
至少要在内核日志留下「你声明的清洗没生效」的痕迹)。
## 3. jieba 词库内嵌(修「猜 GOMODCACHE → 静默失效」)
原 jiebaDictDir() 去猜 GOMODCACHE/GOPATH/~/go/pkg/mod,部署机上通常没有 Go 模块
缓存 → GetJieba() 返回 nil → 分词/关键词提取/NLP 依存解析(进而 doc→graph 三元组
抽取)/静态词向量 tokenizer **一律静默返回空列表**,只有一行日志。本机看起来正常
只因开发机与生产机重合、恰好有那份缓存。
现在词库随二进制分发:internal/memory/jiebadict/ 5 文件约 11.6MB + go:embed,
按**内容哈希**命名缓存目录落盘(词库升级不复用旧文件),已齐全则跳过写入。
模块缓存降为兜底。homed 体积 32MB。
顺带确认(并有测试佐证):gojieba 的 Tag() 不需要 pos_dict/ 目录——
cppjieba 的 PosTagger 从主词典每行的词性列取 tag。
## 4. homed 放弃 Windows 原生,改走 WSL2
插件体系依赖「继承的 fd」+「统一共享内存区的段内偏移解引用」,Windows 既无 fd
继承语义,其句柄模型也无法表达后者;强行适配等于再维护一套平台专属 ABI
(C ABI 时代三套 ABI 并存曾导致改写型插件在某平台静默失效)。
- cmd/homed/platform_{windows,other}.go:原生 Windows 启动即拒绝并打印 WSL2 指引。
- internal/plugin/proc/shmalloc_windows.go:allocShm 直接返回「请用 WSL2」,
**不返回半可用的段**(与 shmalloc_other.go 同风格:未支持平台显式报错);
procEnvForShm 返回 nil。顺手修掉两处长期编译错误
(cryptorand→rand、h.evData→h.unified.evtData),使 GOOS=windows 至少能编译。
注:homed 本就编不出 Windows——internal/memory 依赖 cgo-only 的 gojieba。
- deploy/packaging/installer.nsi:不再安装 homed.exe/initconfig.exe,改为携带
**linux payload** 并调用新的 install-via-wsl.ps1;退出码 20/21 表示
「需先装 WSL/发行版」,走指引而非报错。
- deploy/packaging/windows/install-via-wsl.ps1(新):检测 WSL → 引导安装 →
确保 WSL2 → 送包进发行版 → 在 WSL 内按 Linux 方式安装。**复用 Linux 包与
linux/setup.sh**,不另写一套安装逻辑;落点与 deb 布局统一
(/usr/bin/homed + /usr/lib/homeagent/setup.sh)。
- deploy/packaging/linux/setup.sh:API Key 允许 HOMEAGENT_API_KEY 覆盖
(否则安装器界面显示一份、config.db 里另一份 → 登录不上)。
- deploy/packaging/build.sh:windows 目标只构建 waiter + gui,并新增
stage_linux_payload 把 Linux 包暂存给安装器;homed/initconfig 在 windows
目标下明确拒绝。
## 5. 插件调用点统一写明意图
- webui 的 OpenAI 兼容端点(固定提示词模板)→ InjectTextSyncNoMemory。
- agentcli 的 5 处纯状态通知(已启动/超时/执行结束/进程退出/读取结束)→ NoMemory;
**带输出**的 2 处(定时反馈、有新输出)刻意保留记忆并注明理由。
- timer 的定时提醒 → NoMemory(中断本来也隐含 NoMemory,这里是写明意图)。
## 6. 版本
meta.Version 仍为 1.2.0(main 是下一个未发布中版本);
SDKCompatibleVersion 1.1.0 → **1.2.0**(本内核已实现 SDK 1.2.0 全部新增方法)。
## 测试
- core:默认不裁剪(无声明/none/空)、通道 opt-in、注入点双向覆盖通道、
nil context/io 安全、cleaner 优先级与未知名回退。
- io:零值 opts 与历史 payload 逐键相同;text/中断/媒体三类注入标志位都落到
payload;旧方法仍生效。
- proc:validateContextPolicy 只接受 ""/none/prune,报错含位置与实际值;
**跨进程** e2e——testdata 插件经 io.injectText 送出三个标志位,断言它们穿过 RPC
到达内核。
- memory:模块缓存不可见时内嵌词库仍可用(分词与 POS 内容词均非空)、
落盘幂等、内容哈希稳定。
验证:go build ./... / go vet ./... / go vet -tags onnxruntime ./...
go test -short ./internal/memory/... ./internal/nlp/... ./internal/plugin/...
./internal/agent/{core,io}/... ./pkg/...
689 lines
16 KiB
Go
689 lines
16 KiB
Go
//go:build linux || windows
|
||
|
||
package agentcli
|
||
|
||
import (
|
||
"encoding/json"
|
||
"fmt"
|
||
"strings"
|
||
"sync"
|
||
"testing"
|
||
"time"
|
||
|
||
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
|
||
sdkpub "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
|
||
)
|
||
|
||
type toolCapture struct {
|
||
handlers map[string]sdk.ToolHandler
|
||
defs map[string]sdk.ToolDef
|
||
}
|
||
|
||
func newToolCapture() *toolCapture {
|
||
return &toolCapture{
|
||
handlers: make(map[string]sdk.ToolHandler),
|
||
defs: make(map[string]sdk.ToolDef),
|
||
}
|
||
}
|
||
|
||
func (tc *toolCapture) RegisterTool(name string, def sdk.ToolDef, handler sdk.ToolHandler) error {
|
||
tc.handlers[name] = handler
|
||
tc.defs[name] = def
|
||
return nil
|
||
}
|
||
func (tc *toolCapture) RegisterStage(stage sdk.Stage, handler sdk.StageHandler) {}
|
||
func (tc *toolCapture) RegisterAPI(name string) error { return nil }
|
||
|
||
func setupPlugin() (*Plugin, *toolCapture, error) {
|
||
p := New("agentcli")
|
||
tc := newToolCapture()
|
||
sdk := sdk.New("agentcli", sdk.SDKConfig{
|
||
RegTool: tc.RegisterTool,
|
||
RegStage: tc.RegisterStage,
|
||
RegAPI: tc.RegisterAPI,
|
||
Settings: sdk.NewSettings("agentcli", nil),
|
||
})
|
||
if err := p.Start(sdk); err != nil {
|
||
return nil, nil, err
|
||
}
|
||
return p, tc, nil
|
||
}
|
||
|
||
func TestKeyMapping(t *testing.T) {
|
||
tests := []struct {
|
||
key string
|
||
expected []byte
|
||
}{
|
||
{"enter", []byte{0x0D}},
|
||
{"tab", []byte{0x09}},
|
||
{"escape", []byte{0x1B}},
|
||
{"esc", []byte{0x1B}},
|
||
{"backspace", []byte{0x7F}},
|
||
{"delete", []byte{0x1B, 0x5B, 0x33, 0x7E}},
|
||
{"home", []byte{0x1B, 0x5B, 0x48}},
|
||
{"end", []byte{0x1B, 0x5B, 0x46}},
|
||
{"up", []byte{0x1B, 0x5B, 0x41}},
|
||
{"down", []byte{0x1B, 0x5B, 0x42}},
|
||
{"left", []byte{0x1B, 0x5B, 0x44}},
|
||
{"right", []byte{0x1B, 0x5B, 0x43}},
|
||
{"page_up", []byte{0x1B, 0x5B, 0x35, 0x7E}},
|
||
{"page_down", []byte{0x1B, 0x5B, 0x36, 0x7E}},
|
||
{"ctrl_a", []byte{0x01}},
|
||
{"ctrl_z", []byte{0x1A}},
|
||
{"alt_a", []byte{0x1B, 'a'}},
|
||
{"alt_z", []byte{0x1B, 'z'}},
|
||
{"f1", []byte{0x1B, 0x5B, 0x50}},
|
||
{"f4", []byte{0x1B, 0x5B, 0x53}},
|
||
{"f12", []byte{0x1B, 0x5B, 0x32, 0x34, 0x7E}},
|
||
}
|
||
|
||
for _, tt := range tests {
|
||
t.Run(tt.key, func(t *testing.T) {
|
||
result, err := mapKey(tt.key)
|
||
if err != nil {
|
||
t.Fatalf("unexpected error: %v", err)
|
||
}
|
||
if !bytesEqual(result, tt.expected) {
|
||
t.Fatalf("expected %v, got %v", tt.expected, result)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
func TestKeyMappingInvalid(t *testing.T) {
|
||
_, err := mapKey("unknown_key")
|
||
if err == nil {
|
||
t.Fatal("expected error for unknown key")
|
||
}
|
||
}
|
||
|
||
func bytesEqual(a, b []byte) bool {
|
||
if len(a) != len(b) {
|
||
return false
|
||
}
|
||
for i := range a {
|
||
if a[i] != b[i] {
|
||
return false
|
||
}
|
||
}
|
||
return true
|
||
}
|
||
|
||
func TestListEmpty(t *testing.T) {
|
||
p, tc, err := setupPlugin()
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
defer p.Stop()
|
||
|
||
handler := tc.handlers["terminal_list"]
|
||
result, err := handler(map[string]interface{}{})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
data, _ := json.Marshal(result)
|
||
var resp map[string]interface{}
|
||
json.Unmarshal(data, &resp)
|
||
|
||
if resp["status"] != "ok" {
|
||
t.Fatalf("expected status ok, got %v", resp["status"])
|
||
}
|
||
if resp["count"].(float64) != 0 {
|
||
t.Fatalf("expected count 0, got %v", resp["count"])
|
||
}
|
||
}
|
||
|
||
func TestCreateAndCloseTerminal(t *testing.T) {
|
||
p, tc, err := setupPlugin()
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
defer p.Stop()
|
||
|
||
createHandler := tc.handlers["terminal_create"]
|
||
result, err := createHandler(map[string]interface{}{
|
||
"command": "echo hello",
|
||
"timeout": "10s",
|
||
})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
data, _ := json.Marshal(result)
|
||
var createResp map[string]interface{}
|
||
json.Unmarshal(data, &createResp)
|
||
|
||
if v, ok := createResp["error"]; ok {
|
||
t.Skipf("PTY not available in this environment: %v", v)
|
||
}
|
||
if createResp["status"] != "created" {
|
||
t.Fatalf("expected status created, got %v", createResp["status"])
|
||
}
|
||
|
||
id := createResp["id"].(string)
|
||
if id == "" {
|
||
t.Fatal("expected non-empty terminal id")
|
||
}
|
||
|
||
// Give the terminal a moment to output
|
||
time.Sleep(200 * time.Millisecond)
|
||
|
||
// Read output
|
||
readHandler := tc.handlers["terminal_read"]
|
||
result, err = readHandler(map[string]interface{}{
|
||
"id": id,
|
||
"clear": true,
|
||
})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
data, _ = json.Marshal(result)
|
||
var readResp map[string]interface{}
|
||
json.Unmarshal(data, &readResp)
|
||
if readResp["status"] != "ok" {
|
||
t.Fatalf("expected status ok, got %v", readResp["status"])
|
||
}
|
||
|
||
// Close
|
||
closeHandler := tc.handlers["terminal_close"]
|
||
result, err = closeHandler(map[string]interface{}{
|
||
"id": id,
|
||
})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
data, _ = json.Marshal(result)
|
||
var closeResp map[string]interface{}
|
||
json.Unmarshal(data, &closeResp)
|
||
if closeResp["status"] != "closed" {
|
||
t.Fatalf("expected status closed, got %v", closeResp["status"])
|
||
}
|
||
}
|
||
|
||
func TestCreateTerminalMissingArgs(t *testing.T) {
|
||
p, tc, err := setupPlugin()
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
defer p.Stop()
|
||
|
||
handler := tc.handlers["terminal_create"]
|
||
result, err := handler(map[string]interface{}{})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
data, _ := json.Marshal(result)
|
||
var resp map[string]interface{}
|
||
json.Unmarshal(data, &resp)
|
||
|
||
if v, ok := resp["error"]; ok {
|
||
t.Skipf("PTY not available in this environment: %v", v)
|
||
}
|
||
if resp["status"] != "created" {
|
||
t.Fatalf("expected status created, got %v", resp["status"])
|
||
}
|
||
id := resp["id"].(string)
|
||
p.handleClose(map[string]interface{}{"id": id})
|
||
}
|
||
|
||
func TestWriteToNonexistentTerminal(t *testing.T) {
|
||
_, tc, err := setupPlugin()
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
handler := tc.handlers["terminal_write"]
|
||
result, err := handler(map[string]interface{}{
|
||
"id": "nonexistent",
|
||
"input": "test",
|
||
})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
data, _ := json.Marshal(result)
|
||
var resp map[string]interface{}
|
||
json.Unmarshal(data, &resp)
|
||
|
||
if _, ok := resp["error"]; !ok {
|
||
t.Fatal("expected error for nonexistent terminal")
|
||
}
|
||
}
|
||
|
||
func TestReadNonexistentTerminal(t *testing.T) {
|
||
_, tc, err := setupPlugin()
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
handler := tc.handlers["terminal_read"]
|
||
result, err := handler(map[string]interface{}{
|
||
"id": "nonexistent",
|
||
})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
data, _ := json.Marshal(result)
|
||
var resp map[string]interface{}
|
||
json.Unmarshal(data, &resp)
|
||
|
||
if _, ok := resp["error"]; !ok {
|
||
t.Fatal("expected error for nonexistent terminal")
|
||
}
|
||
}
|
||
|
||
func TestResizeNonexistentTerminal(t *testing.T) {
|
||
_, tc, err := setupPlugin()
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
handler := tc.handlers["terminal_resize"]
|
||
result, err := handler(map[string]interface{}{
|
||
"id": "nonexistent",
|
||
"rows": float64(40),
|
||
"cols": float64(120),
|
||
})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
data, _ := json.Marshal(result)
|
||
var resp map[string]interface{}
|
||
json.Unmarshal(data, &resp)
|
||
|
||
if _, ok := resp["error"]; !ok {
|
||
t.Fatal("expected error for nonexistent terminal")
|
||
}
|
||
}
|
||
|
||
func TestCloseNonexistentTerminal(t *testing.T) {
|
||
_, tc, err := setupPlugin()
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
handler := tc.handlers["terminal_close"]
|
||
result, err := handler(map[string]interface{}{
|
||
"id": "nonexistent",
|
||
})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
data, _ := json.Marshal(result)
|
||
var resp map[string]interface{}
|
||
json.Unmarshal(data, &resp)
|
||
|
||
if _, ok := resp["error"]; !ok {
|
||
t.Fatal("expected error for nonexistent terminal")
|
||
}
|
||
}
|
||
|
||
func TestTerminalWriteRequiresId(t *testing.T) {
|
||
_, tc, err := setupPlugin()
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
handler := tc.handlers["terminal_write"]
|
||
result, err := handler(map[string]interface{}{
|
||
"input": "test",
|
||
})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
data, _ := json.Marshal(result)
|
||
var resp map[string]interface{}
|
||
json.Unmarshal(data, &resp)
|
||
|
||
if _, ok := resp["error"]; !ok {
|
||
t.Fatal("expected error when id is missing")
|
||
}
|
||
}
|
||
|
||
func TestTerminalWriteRequiresContent(t *testing.T) {
|
||
_, tc, err := setupPlugin()
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
handler := tc.handlers["terminal_write"]
|
||
result, err := handler(map[string]interface{}{
|
||
"id": "test",
|
||
})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
data, _ := json.Marshal(result)
|
||
var resp map[string]interface{}
|
||
json.Unmarshal(data, &resp)
|
||
|
||
if _, ok := resp["error"]; !ok {
|
||
t.Fatal("expected error when both input and key are missing")
|
||
}
|
||
}
|
||
|
||
func TestToolsRegistered(t *testing.T) {
|
||
p, tc, err := setupPlugin()
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
defer p.Stop()
|
||
|
||
expectedTools := []string{
|
||
"terminal_create",
|
||
"terminal_write",
|
||
"terminal_read",
|
||
"terminal_resize",
|
||
"terminal_close",
|
||
"terminal_list",
|
||
}
|
||
|
||
for _, name := range expectedTools {
|
||
if _, ok := tc.handlers[name]; !ok {
|
||
t.Errorf("tool %s not registered", name)
|
||
}
|
||
}
|
||
}
|
||
|
||
// ——— Phase 6: 通知节流测试(mock 终端 + 捕获注入) ———
|
||
|
||
type injectCapture struct {
|
||
mu sync.Mutex
|
||
texts []string
|
||
}
|
||
|
||
func (c *injectCapture) InjectInterruptText(source, channel, text string) {
|
||
c.mu.Lock()
|
||
c.texts = append(c.texts, text)
|
||
c.mu.Unlock()
|
||
}
|
||
func (c *injectCapture) InjectText(source, channel, text string) {
|
||
c.mu.Lock()
|
||
c.texts = append(c.texts, text)
|
||
c.mu.Unlock()
|
||
}
|
||
func (c *injectCapture) InjectTextNoMemory(source, channel, text string) {
|
||
c.mu.Lock()
|
||
c.texts = append(c.texts, text)
|
||
c.mu.Unlock()
|
||
}
|
||
|
||
func (c *injectCapture) SetToolBlocks(blocks []sdkpub.ContentBlock) {
|
||
// 测试桩:忽略多模态块
|
||
}
|
||
func (c *injectCapture) InjectInputSync(source, channel, text string) string { return "" }
|
||
|
||
// 三个带媒体的注入方法同样记录文本:本测试只关心「注入了什么话」,
|
||
// 媒体块的转发在 core 的 injectedBlocks 测试里覆盖。
|
||
func (c *injectCapture) InjectInputMedia(source, channel, text string, blocks []sdkpub.ContentBlock) {
|
||
c.mu.Lock()
|
||
c.texts = append(c.texts, text)
|
||
c.mu.Unlock()
|
||
}
|
||
|
||
func (c *injectCapture) InjectInputMediaSync(source, channel, text string, blocks []sdkpub.ContentBlock) string {
|
||
return ""
|
||
}
|
||
|
||
func (c *injectCapture) InjectInterruptMedia(source, channel, text string, blocks []sdkpub.ContentBlock) {
|
||
c.mu.Lock()
|
||
c.texts = append(c.texts, text)
|
||
c.mu.Unlock()
|
||
}
|
||
|
||
// ---- 带 InjectOptions 的注入(1.2.0):同样只记文本 ----
|
||
|
||
func (c *injectCapture) InjectTextOpts(source, channel, text string, opts sdkpub.InjectOptions) {
|
||
c.InjectText(source, channel, text)
|
||
}
|
||
func (c *injectCapture) InjectInterruptTextOpts(source, channel, text string, opts sdkpub.InjectOptions) {
|
||
c.InjectInterruptText(source, channel, text)
|
||
}
|
||
func (c *injectCapture) InjectInputSyncOpts(source, channel, text string, opts sdkpub.InjectOptions) string {
|
||
return ""
|
||
}
|
||
func (c *injectCapture) InjectInputMediaOpts(source, channel, text string, blocks []sdkpub.ContentBlock, opts sdkpub.InjectOptions) {
|
||
c.InjectInputMedia(source, channel, text, blocks)
|
||
}
|
||
func (c *injectCapture) InjectInputMediaSyncOpts(source, channel, text string, blocks []sdkpub.ContentBlock, opts sdkpub.InjectOptions) string {
|
||
return ""
|
||
}
|
||
func (c *injectCapture) InjectInterruptMediaOpts(source, channel, text string, blocks []sdkpub.ContentBlock, opts sdkpub.InjectOptions) {
|
||
c.InjectInterruptMedia(source, channel, text, blocks)
|
||
}
|
||
|
||
func (c *injectCapture) snapshot() []string {
|
||
c.mu.Lock()
|
||
defer c.mu.Unlock()
|
||
out := make([]string, len(c.texts))
|
||
copy(out, c.texts)
|
||
return out
|
||
}
|
||
|
||
// mockTerm 可控输出流的假终端:Read 从 data chan 取数据,可模拟进程退出/读取错误
|
||
type mockTerm struct {
|
||
mu sync.Mutex
|
||
data chan []byte
|
||
running bool
|
||
err error
|
||
}
|
||
|
||
func newMockTerm() *mockTerm {
|
||
return &mockTerm{data: make(chan []byte, 16), running: true}
|
||
}
|
||
|
||
func (m *mockTerm) Read(buf []byte) (int, error) {
|
||
for {
|
||
m.mu.Lock()
|
||
err := m.err
|
||
running := m.running
|
||
m.mu.Unlock()
|
||
if err != nil {
|
||
return 0, err
|
||
}
|
||
if !running {
|
||
return 0, fmt.Errorf("process exited")
|
||
}
|
||
select {
|
||
case data, ok := <-m.data:
|
||
if !ok {
|
||
return 0, fmt.Errorf("closed")
|
||
}
|
||
n := copy(buf, data)
|
||
return n, nil
|
||
case <-time.After(20 * time.Millisecond):
|
||
}
|
||
}
|
||
}
|
||
|
||
func (m *mockTerm) WriteString(s string) (int, error) { return len(s), nil }
|
||
func (m *mockTerm) Resize(rows, cols uint16) error { return nil }
|
||
func (m *mockTerm) Running() bool {
|
||
m.mu.Lock()
|
||
defer m.mu.Unlock()
|
||
return m.running
|
||
}
|
||
func (m *mockTerm) Kill() error { return nil }
|
||
func (m *mockTerm) Close() error { return nil }
|
||
|
||
func (m *mockTerm) push(data []byte) {
|
||
m.data <- data
|
||
}
|
||
|
||
func (m *mockTerm) setRunning(v bool) {
|
||
m.mu.Lock()
|
||
m.running = v
|
||
m.mu.Unlock()
|
||
}
|
||
|
||
func (m *mockTerm) setErr(err error) {
|
||
m.mu.Lock()
|
||
m.err = err
|
||
m.mu.Unlock()
|
||
}
|
||
|
||
func newTestSession(term ptyTerm) *TerminalSession {
|
||
return &TerminalSession{
|
||
id: "t1",
|
||
session: term,
|
||
createdAt: time.Now(),
|
||
timeout: 10 * time.Minute,
|
||
stopCh: make(chan struct{}),
|
||
done: make(chan struct{}),
|
||
}
|
||
}
|
||
|
||
func startReadLoop(p *Plugin, s *sdk.PluginSDK, t *TerminalSession) {
|
||
p.wg.Add(1)
|
||
go p.readLoop(t, s)
|
||
}
|
||
|
||
func waitInjected(c *injectCapture, substr string, timeout time.Duration) bool {
|
||
deadline := time.Now().Add(timeout)
|
||
for time.Now().Before(deadline) {
|
||
for _, text := range c.snapshot() {
|
||
if strings.Contains(text, substr) {
|
||
return true
|
||
}
|
||
}
|
||
time.Sleep(20 * time.Millisecond)
|
||
}
|
||
return false
|
||
}
|
||
|
||
// Phase 6: 持续吐进度时,通知频率显著低于 500ms/条(节流生效)
|
||
func TestReadLoopNotifyThrottle(t *testing.T) {
|
||
p := New("agentcli")
|
||
p.notifyBytes = 2048
|
||
p.notifyInterval = 2 * time.Second
|
||
|
||
capture := &injectCapture{}
|
||
sdkInst := sdk.New("agentcli", sdk.SDKConfig{
|
||
RegTool: newToolCapture().RegisterTool,
|
||
RegStage: func(sdk.Stage, sdk.StageHandler) {},
|
||
RegAPI: func(string) error { return nil },
|
||
Settings: sdk.NewSettings("agentcli", nil),
|
||
})
|
||
sdkInst.SetIOInjector(capture)
|
||
|
||
term := newMockTerm()
|
||
ts := newTestSession(term)
|
||
startReadLoop(p, sdkInst, ts)
|
||
|
||
if !waitInjected(capture, "已启动", 2*time.Second) {
|
||
t.Fatal("expected startup notification")
|
||
}
|
||
|
||
// 持续以 100B/50ms(=2KB/s) 吐进度 3 秒
|
||
stop := make(chan struct{})
|
||
go func() {
|
||
ticker := time.NewTicker(50 * time.Millisecond)
|
||
defer ticker.Stop()
|
||
chunk := make([]byte, 100)
|
||
for i := range chunk {
|
||
chunk[i] = 'x'
|
||
}
|
||
for {
|
||
select {
|
||
case <-stop:
|
||
return
|
||
case <-ticker.C:
|
||
term.push(chunk)
|
||
}
|
||
}
|
||
}()
|
||
|
||
time.Sleep(3 * time.Second)
|
||
close(stop)
|
||
|
||
notifies := 0
|
||
for _, text := range capture.snapshot() {
|
||
if strings.Contains(text, "有新输出") {
|
||
notifies++
|
||
}
|
||
}
|
||
// 3 秒持续输出,500ms/条 的旧行为应有 6 条;节流后 ≤3 条
|
||
if notifies > 3 {
|
||
t.Errorf("notify throttle ineffective: %d notifies in 3s (expected <=3)", notifies)
|
||
}
|
||
if notifies == 0 {
|
||
t.Error("expected at least one output notification")
|
||
}
|
||
|
||
close(ts.stopCh)
|
||
<-ts.done
|
||
}
|
||
|
||
// Phase 6: 进程退出 → 立即通知(两条路径:PTY Read 返回 EOF 走"读取结束",
|
||
// 或 reader 阻塞时顶部 terminalRunning 检测走"进程已退出")
|
||
func TestReadLoopNotifyOnExit(t *testing.T) {
|
||
p := New("agentcli")
|
||
p.notifyBytes = 2048
|
||
p.notifyInterval = 2 * time.Second
|
||
|
||
capture := &injectCapture{}
|
||
sdkInst := sdk.New("agentcli", sdk.SDKConfig{
|
||
RegTool: newToolCapture().RegisterTool,
|
||
RegStage: func(sdk.Stage, sdk.StageHandler) {},
|
||
RegAPI: func(string) error { return nil },
|
||
Settings: sdk.NewSettings("agentcli", nil),
|
||
})
|
||
sdkInst.SetIOInjector(capture)
|
||
|
||
term := newMockTerm()
|
||
ts := newTestSession(term)
|
||
startReadLoop(p, sdkInst, ts)
|
||
|
||
if !waitInjected(capture, "已启动", 2*time.Second) {
|
||
t.Fatal("expected startup notification")
|
||
}
|
||
|
||
term.setRunning(false)
|
||
gotExit := waitInjected(capture, "进程已退出", 2*time.Second)
|
||
gotReadEnd := waitInjected(capture, "读取结束", time.Second)
|
||
if !gotExit && !gotReadEnd {
|
||
t.Error("expected immediate notification on process exit (either 进程已退出 or 读取结束)")
|
||
}
|
||
close(ts.stopCh)
|
||
}
|
||
|
||
// Phase 6: 读取错误/EOF → 立即通知
|
||
func TestReadLoopNotifyOnReadError(t *testing.T) {
|
||
p := New("agentcli")
|
||
p.notifyBytes = 2048
|
||
p.notifyInterval = 2 * time.Second
|
||
|
||
capture := &injectCapture{}
|
||
sdkInst := sdk.New("agentcli", sdk.SDKConfig{
|
||
RegTool: newToolCapture().RegisterTool,
|
||
RegStage: func(sdk.Stage, sdk.StageHandler) {},
|
||
RegAPI: func(string) error { return nil },
|
||
Settings: sdk.NewSettings("agentcli", nil),
|
||
})
|
||
sdkInst.SetIOInjector(capture)
|
||
|
||
term := newMockTerm()
|
||
ts := newTestSession(term)
|
||
startReadLoop(p, sdkInst, ts)
|
||
|
||
if !waitInjected(capture, "已启动", 2*time.Second) {
|
||
t.Fatal("expected startup notification")
|
||
}
|
||
|
||
term.setErr(fmt.Errorf("read timeout"))
|
||
if !waitInjected(capture, "读取结束", 3*time.Second) {
|
||
t.Error("expected immediate notification on read error")
|
||
}
|
||
close(ts.stopCh)
|
||
<-ts.done
|
||
}
|