Files
HomeAgent/internal/plugins/agentcli/plugin_test.go
JianFeeeee ba8a33dad8 fix(agentcli): 终端默认不推送 + input/key 可同传 + 失效终端结构化答复
## 一、默认不主动推送(用户裁定)

	「agentcli 没必要进行直接推送,因为本质它就类似人类开启的终端窗口」

终端窗口的语义是:人敲命令 → 自己去看输出。输出不该主动跳到眼前。
主动注入虽然走 inputCh(排队、不抢占),但**每次都会唤醒 agent 跑一轮
推理**;`tail -f` / `npm install` 这类长输出命令会让它被反复唤醒。

改动:
- terminal_create 默认 watch 从 `{onExit:true}` 改为 `{onExit:false, quiet:true}`
- 结束/退出/超时清理一律静默,输出只由 terminal_read 主动读
- 需要推送必须**显式**声明 notify=exit / interval=Ns / buffer=N
- terminal_watch 的 clear 语义改为「回到默认(不推送)」,
  不再自动恢复 exit 提醒
- 删掉「没配 interval/buffer/quiet 就自动 onExit」的兜底(它会让默认
  静默被悄悄推翻)

## 二、input 与 key 可以同时提交(真 bug)

旧实现是互斥结构:

	if key != "" { ... } else if input != "" { ... }

于是 `{"input":"ls","key":"enter"}` 只发出 `\r`,**input 被静默丢弃**。
模型收到 status=ok / bytes=1 以为成功,去读输出却是空的,于是反复重试
——这就是「终端没反应」的直接成因。

现在按声明顺序发送:先 input,再 key。整对写入在同一把会话锁
(新增 TerminalSession.writeMu)内完成,避免同终端并发写插进中间把命令拆坏。

## 三、失效终端给结构化答复

「终端不存在或已关闭」从一句文本改为:

	{"error":"terminal_gone","terminal":"...","next_action":"调用 terminal_create 重建..."}

原因与 browser 插件同形态:只给普通失败时,模型会拿同一个 id 反复重试。
覆盖 terminal_write/read/resize/close/watch 五处。

## 四、通道注册不再吞错

	- _ = s.RegisterInputChannel("agentcli", sdk.ChannelDef{})
	+ if err := s.RegisterInputChannel(...); err != nil { return fmt.Errorf(...) }

注册失败时该通道根本不存在,之后所有 InjectText 都投进虚空 ——
通知静默消失且日志无痕。宁可启动失败也不静默降级。

## 判据(均已变异验证可红)

- TestWriteSendsInputThenKey        断言 input 与 \r 两段都真的写进终端
- TestWriteInputOnlyUnchanged       只传 input 时不额外发键
- TestTerminalCreateDefaultsToNoPush 默认 watch 必须不推送
- TestExplicitExitNotifyStillWorks  显式 notify=exit 仍然生效
- TestWatchClearRestoresNoPush      clear 回到默认而非 exit
- TestTerminalGoneIsStructured      五个工具都返回 terminal_gone + next_action

变异验证:
- 默认改回 onExit:true        → TestTerminalCreateDefaultsToNoPush 红
- input 分支不发送            → TestWriteSendsInputThenKey 红(实际 1 次: ["\r"])
- 错误码改成其它文本          → TestTerminalGoneIsStructured 红

## 验证

	go build ./...                 → 0
	go vet ./...                   → 0
	gofmt -l internal/plugins/agentcli/ → 干净
	go test ./... -count=1 -race   → 62 ok / 0 FAIL / 0 DATA RACE
2026-11-04 09:51:22 +08:00

1277 lines
36 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//go:build linux || windows
package agentcli
import (
"encoding/json"
"fmt"
"os"
"strings"
"sync"
"testing"
"time"
sdk "github.com/JianFeeeee/HologramAgent/internal/sdk"
sdkpub "github.com/JianFeeeee/hologramagentsdk/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)
// 经注册的 handler 关闭(handler 内部会带上 sdk);
// 直接调 p.handleClose 需自备 sdk 参数。
closeHandler := tc.handlers["terminal_close"]
if _, err := closeHandler(map[string]interface{}{"id": id}); err != nil {
t.Fatal(err)
}
}
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
// writes 记录每次 WriteString 的原文,供判据断言"到底发出了什么"。
writes []string
// reads 记录 Read 被调用次数,供 waitReadLoopReady 判断 readLoop 已起来。
// 不用固定 sleep:机器慢时 sleep 不够、快时白等。
reads int
}
// readCalls 返回 Read 被调用次数(并发安全)。
func (m *mockTerm) readCalls() int {
m.mu.Lock()
defer m.mu.Unlock()
return m.reads
}
func newMockTerm() *mockTerm {
return &mockTerm{data: make(chan []byte, 16), running: true}
}
func (m *mockTerm) Read(buf []byte) (int, error) {
m.mu.Lock()
m.reads++
m.mu.Unlock()
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) {
m.mu.Lock()
m.writes = append(m.writes, s)
m.mu.Unlock()
return len(s), nil
}
// writeLog 返回已写入内容(并发安全)。
func (m *mockTerm) writeLog() []string {
m.mu.Lock()
defer m.mu.Unlock()
out := make([]string, len(m.writes))
copy(out, m.writes)
return out
}
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
}
// Kill 模拟真实 PTY 的行为:把一份 **buffer 里的输出 + EIO** 交给 Read。
//
// 两点必须还原,否则测不出线上的回声:
//
// 1. Read 返回错误(真实 PTY 被 Kill 后读端立刻 `read /dev/ptmx:
// input/output error`)。旧版这里是空操作,于是"主动关闭"在测试里
// 从不产生回声,判据永远绿(假绿)。
// 2. **已产出的数据先于错误到达**。真实内核里 PTY 缓冲中残留的输出
// 会先被读走,EIO 随后才到——这一点决定了 readLoop 的 select
// 在"stopCh 已关"与"readCh 有数据/错误"之间如何竞争。
func (m *mockTerm) Kill() error {
m.mu.Lock()
m.running = false
if m.err == nil {
m.err = fmt.Errorf("read /dev/ptmx: input/output error")
}
m.mu.Unlock()
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)
}
// waitReadLoopReady 等 readLoop 真正进入工作状态。
//
// ★ 为什么需要它:原测试用「已启动」注入当同步点(等那条通知出现再继续)。
// 但 2026-10-25 起该注入已移除(它是"同一条信息告知两次",每条都会唤醒
// 一整轮推理)。同步点因此需要换成不依赖注入的方式。
//
// 这里用「mockTerm 的 Read 至少被调用过一次」作为信号:readLoop 的第一件事
// 就是 Read,比固定 sleep 可靠,也不引入对业务通知的依赖。
func waitReadLoopReady(t *testing.T, term *mockTerm) {
t.Helper()
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if term.readCalls() > 0 {
return
}
time.Sleep(2 * time.Millisecond)
}
t.Fatal("readLoop 未在 2s 内进入读取状态")
}
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)
// 等 readLoop 真正起来(不再依赖"已启动"注入:那已被移除,
// 理由见 plugin.go 中"不注入终端已启动通知"的说明)
waitReadLoopReady(t, term)
// 持续以 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)
// 等 readLoop 真正起来(不再依赖"已启动"注入:那已被移除,
// 理由见 plugin.go 中"不注入终端已启动通知"的说明)
waitReadLoopReady(t, term)
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)
// 等 readLoop 真正起来(不再依赖"已启动"注入:那已被移除,
// 理由见 plugin.go 中"不注入终端已启动通知"的说明)
waitReadLoopReady(t, term)
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
}
// 主动关闭终端后**不得**再注入任何收尾状态。
//
// 这是用户报的线上问题(2026-10-25):
//
// 关闭终端 → agent 收到「[终端 term_6 读取结束: read /dev/ptmx: input/output error]」
// → 又收到「[终端 term_6 中的进程已退出]」
// → 而 terminal_list 此时已是 0 个会话
//
// 后果不只是刷屏:每条注入都会唤醒一轮推理,agent 只能一遍遍解释
// "这是旧终端的回声,不用管它"。而且它**永远无法自证清白**——
// 会话列表已经是空的,它拿不到任何证据说明这条消息是历史遗留。
//
// 根因:主动 Close() 会 ①关 stopCh ②Kill 进程,而 ③PTY 读端随即返回
// 「input/output error」、④terminalRunning 变 false。旧实现在这两处
// **无条件注入**,把"我们自己关掉的"当成"进程自己结束了"上报。
//
// 判据:Close 之后只允许**关闭前**已发生的注入存在,不得新增。
// 变异:删掉 isClosing 判断 ⇒ 本判据变红。
func TestClose_NoEchoNotificationsAfterClose(t *testing.T) {
p := New("agentcli")
p.notifyBytes = 1 << 20 // 抬高阈值,避免缓冲通知混进来干扰判据
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)
mterm := newMockTerm()
ts := newTestSession(mterm)
startReadLoop(p, sdkInst, ts)
// 等 readLoop 真正起来(不再依赖"已启动"注入:那已被移除,
// 理由见 plugin.go 中"不注入终端已启动通知"的说明)
waitReadLoopReady(t, mterm)
before := len(capture.snapshot())
// 主动关闭:模拟 close 工具路径(关 stopCh + 杀进程 + 释放 PTY)。
//
// 注意:Close() 内部会 <-t.done 等 readLoop 退出,所以它返回时
// readLoop 已经走完——那正是回声注入发生的时机,判据必须等到它之后。
ts.Close()
// 给 readLoop 充分时间走完所有收尾分支(含 r.err 与 terminalRunning)。
// 用固定等待而不是"等到某条消息"——我们要断言的是**什么都没有**。
time.Sleep(600 * time.Millisecond)
after := capture.snapshot()
if len(after) != before {
t.Fatalf("主动关闭后不应新增注入,实际多了 %d 条:\n%s",
len(after)-before, strings.Join(after[before:], "\n"))
}
t.Logf("关闭前后注入数:%d(无回声)", len(after))
// 明确点出两种最典型的回声,便于失败时一眼看出原因。
for _, s := range after[before:] {
if strings.Contains(s, "读取结束") || strings.Contains(s, "进程已退出") ||
strings.Contains(s, "命令已执行结束") {
t.Fatalf("关闭后出现了收尾回声:%s", s)
}
}
}
// 反向判据:进程**自己**结束时必须有通知。
//
// 这条守住修复不过度——不能因为要消除回声,把正常的上报也一起关掉。
// 变异:把 isClosing 判断写成无条件 return ⇒ 本判据变红。
func TestProcessExit_StillNotifies(t *testing.T) {
p := New("agentcli")
p.notifyBytes = 1 << 20
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)
// 等 readLoop 真正起来(不再依赖"已启动"注入:那已被移除,
// 理由见 plugin.go 中"不注入终端已启动通知"的说明)
waitReadLoopReady(t, term)
// 进程自己退出(**不是** Close):mockTerm 返回 process exited。
term.mu.Lock()
term.running = false
term.mu.Unlock()
got := waitInjected(capture, "进程已退出", 3*time.Second) ||
waitInjected(capture, "命令已执行结束", 3*time.Second) ||
waitInjected(capture, "读取结束", 3*time.Second)
if !got {
t.Fatal("进程自己结束必须有通知(修复不能把正常上报一起关掉)")
}
}
// 竞态版回声判据:Close() 会让 stopCh 与 Read 错误**同时**就绪,
// 而 readLoop 的 select 在两者都就绪时**随机**选一个分支。
//
// 所以「关闭后不注入回声」不能只在"select 恰好选中 stopCh"时成立——
// 必须在**任何**顺序下都成立。上面那条判据只跑一次,抓不到这个随机性
// (实测:把 isClosing 改成恒 false 仍然绿)。
//
// 这里连开关闭 20 次,只要有一次出现回声就算失败。
func TestClose_NoEcho_Repeated(t *testing.T) {
for i := 0; i < 20; i++ {
p := New("agentcli")
p.notifyBytes = 1 << 20
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)
waitReadLoopReady(t, term)
before := len(capture.snapshot())
// ★ 关键:先在 PTY 缓冲里**留下未读数据**,再关闭。
//
// 线上关闭终端时 PTY 缓冲里通常还有未读输出(提示符、命令尾部)。
// 这时 readLoop 的 select 在 `<-t.stopCh` 与 `<-readCh` 之间是
// **随机**选择——选到 readCh 就会先处理数据,随后紧跟着拿到
// EIO(因为 Kill 后读端立刻报错),于是走进 r.err 分支。
// 若这里不预置数据,select 几乎总是选中 stopCh,回声永远复现不出来
// (这就是我第一版判据显示"全绿"的原因)。
term.push([]byte("leftover output" + "\r\n"))
time.Sleep(30 * time.Millisecond)
ts.Close()
time.Sleep(120 * time.Millisecond)
after := capture.snapshot()
for _, msg := range after[before:] {
if strings.Contains(msg, "读取结束") || strings.Contains(msg, "进程已退出") ||
strings.Contains(msg, "命令已执行结束") {
t.Fatalf("第 %d 次关闭后出现回声(select 随机选中了 r.err 分支): %s", i, msg)
}
}
}
}
// 关闭终端后,session 必须**立刻**从表里消失。
//
// 这是"关闭终端后反复注入过期状态"的**直接**成因(2026-10-25 线上):
//
// close 工具 → session.Close() → readLoop 从 <-t.stopCh 静默 return
// → 但**没删 sessions 条目**
// → cleanupLoop 每 30s 扫到它,发现 !terminalRunning
// → 又注入一条「[终端 term_6 中的进程已退出]」
// → 而 terminal_list 已经是 0 个会话
//
// 所以用户看到的不是"一次回声",而是**每 30 秒一次、永不停止**的
// 过期状态注入——每条都会唤醒一轮推理,agent 只能反复解释"这是旧回声"。
//
// 判据:Close() 返回后 sessions 里不得再有它。
// 变异:把 removeSession 调用删掉 ⇒ 本判据变红。
func TestClose_RemovesSessionFromTable(t *testing.T) {
p := New("agentcli")
p.notifyBytes = 1 << 20
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)
mterm2 := newMockTerm()
ts := newTestSession(mterm2)
ts.id = "term_leak"
p.mu.Lock()
p.sessions = map[string]*TerminalSession{"term_leak": ts}
p.mu.Unlock()
startReadLoop(p, sdkInst, ts)
// 等 readLoop 真正起来(不再依赖"已启动"注入:那已被移除,
// 理由见 plugin.go 中"不注入终端已启动通知"的说明)
waitReadLoopReady(t, mterm2)
ts.Close() // 内部会 <-t.done,等 readLoop 走完
p.mu.Lock()
_, still := p.sessions["term_leak"]
remaining := len(p.sessions)
p.mu.Unlock()
if still {
t.Fatalf("关闭后 session 仍留在表里(%d 个)——cleanupLoop 会每 30s "+
"把它当成「进程已退出」再注入一次,形成持续回声", remaining)
}
}
// cleanupLoop 对**已关闭**的终端不得再注入通知。
//
// 这是上一条的补强:即使将来某条路径又漏删了表,清理路径也必须靠
// isClosing 兜住,不能把"我们自己关掉的"当成"进程自己结束了"上报。
func TestCleanup_DoesNotNotifyForClosedTerminal(t *testing.T) {
p := New("agentcli")
p.notifyBytes = 1 << 20
capture := &injectCapture{}
term := newMockTerm()
ts := newTestSession(term)
ts.id = "term_closed"
ts.closed = true // 已被 close 工具关闭(但故意留在表里)
if !p.isClosing(ts) {
t.Fatal("isClosing 应识别 closed 标记")
}
// 模拟「漏删表」的坏状态:关闭了但还在 sessions 里。
p.sessions = map[string]*TerminalSession{"term_closed": ts}
term.setRunning(false)
// 走一遍 cleanupLoop 的判定逻辑(与生产同一段代码路径的判据部分)。
p.mu.Lock()
var wouldNotify []string
for id, t := range p.sessions {
if !p.isClosing(t) && !terminalRunning(t) {
wouldNotify = append(wouldNotify, id)
}
}
p.mu.Unlock()
if len(wouldNotify) > 0 {
t.Fatalf("已关闭的终端不应被 cleanupLoop 当作「进程已退出」上报:%v", wouldNotify)
}
_ = capture
}
// ── 2026-10-25 新增:禁止「自造状态回声」的判据 ──────────────────
//
// 背景(真机事故):agent 通过 terminal_create 建终端后,插件注入
// 「[终端 X 已启动]」;超时后又注入「[终端 X 已超时关闭]」。
// 这两条都是**插件自己造成、且 agent 已能从工具返回值推导**的状态,
// 而每条注入都会唤醒一整轮 LLM 推理。实测后果:agent 反复向用户
// 解释"终端怎么又没了",真正要做的活一件没做。
//
// 下面两条是**反向判据**:断言这些注入**不再出现**。没有它们,
// 后人(或我自己)很容易为了"让 agent 感知"把注入加回来,而那时
// 上面那些 waitReadLoopReady 的测试依然全绿 —— 缺陷会静默复发。
// 判据一:建终端不得产生「已启动」注入(terminal_create 返回值已足够)。
func TestNoStartupNotificationInjected(t *testing.T) {
p := New("agentcli")
p.notifyBytes = 1 << 20 // 不触发缓冲通知,隔离变量
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)
waitReadLoopReady(t, term)
// 给足时间让任何潜在注入发生
time.Sleep(150 * time.Millisecond)
for _, text := range capture.snapshot() {
if strings.Contains(text, "已启动") {
t.Fatalf("不得注入「已启动」(terminal_create 返回值已告知): %q", text)
}
}
close(ts.stopCh)
<-ts.done
}
// 判据二:超时关闭不得注入(创建时 timeout 已告知)。
func TestNoExpiredNotificationInjected(t *testing.T) {
p := New("agentcli")
p.notifyBytes = 1 << 20
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)
// 设为已超时:readLoop 下一轮循环就会走 IsExpired 分支
ts.mu.Lock()
ts.timeout = time.Nanosecond
ts.createdAt = time.Now().Add(-time.Hour)
ts.mu.Unlock()
startReadLoop(p, sdkInst, ts)
waitReadLoopReady(t, term)
// 等 removeSession 真的发生(说明走了超时分支)
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
p.mu.Lock()
_, still := p.sessions[ts.id]
p.mu.Unlock()
if !still {
break
}
time.Sleep(2 * time.Millisecond)
}
for _, text := range capture.snapshot() {
if strings.Contains(text, "已超时关闭") {
t.Fatalf("不得注入「已超时关闭」(创建时 timeout 已告知): %q", text)
}
}
}
// ── 2026-11-03 修复的判据 ──
// TestWriteSendsInputThenKey 钉住 input 与 key 可以同时提交。
//
// 旧实现的 if key / else if input 是**互斥**结构:
//
// {"input":"ls","key":"enter"} ⇒ 只发 \r,input 被静默丢弃
//
// 模型收到 status=ok / bytes=1 以为成功,读输出却是空的,于是反复重试。
// 判据必须断言**两段都真的写进了终端**,只看返回值是测不出来的。
func TestWriteSendsInputThenKey(t *testing.T) {
p, tc, err := setupPlugin()
if err != nil {
t.Fatal(err)
}
term := newMockTerm()
ts := newTestSession(term)
ts.id = "t-write"
p.mu.Lock()
p.sessions["t-write"] = ts
p.mu.Unlock()
handler, ok := tc.handlers["terminal_write"]
if !ok {
t.Fatal("terminal_write 未注册")
}
if _, err := handler(map[string]interface{}{
"id": "t-write",
"input": "ls",
"key": "enter",
}); err != nil {
t.Fatal(err)
}
writes := term.writeLog()
if len(writes) != 2 {
t.Fatalf("应分两次写(input 一次、key 一次),实际 %d 次: %q", len(writes), writes)
}
if writes[0] != "ls" {
t.Fatalf("第一段必须是 input,实际 %q", writes[0])
}
if writes[1] != "\r" {
t.Fatalf("第二段必须是 enter 键(\\r),实际 %q", writes[1])
}
}
// TestWriteInputOnlyUnchanged 保证只传 input 时不额外发键。
func TestWriteInputOnlyUnchanged(t *testing.T) {
p, tc, err := setupPlugin()
if err != nil {
t.Fatal(err)
}
term := newMockTerm()
ts := newTestSession(term)
ts.id = "t-in"
p.mu.Lock()
p.sessions["t-in"] = ts
p.mu.Unlock()
if _, err := tc.handlers["terminal_write"](map[string]interface{}{
"id": "t-in",
"input": "echo hi",
}); err != nil {
t.Fatal(err)
}
writes := term.writeLog()
if len(writes) != 1 || writes[0] != "echo hi" {
t.Fatalf("只传 input 时应只写一次,实际 %q", writes)
}
}
// TestTerminalCreateDefaultsToNoPush 钉住默认不推送。
//
// 用户裁定(2026-11-03):
//
// 「agentcli 没必要进行直接推送,因为本质它就类似人类开启的终端窗口」
//
// 终端窗口不会把输出推到眼前;唤醒 agent 跑一轮推理的代价必须由调用方
// 显式声明(notify=exit / interval / buffer)才付出。
//
// ★ 为什么不 spawn 真终端来断言:这条默认值在 handleCreate 的局部变量里,
//
// 没有可观测的返回字段。用源码约束 + parseNotifyMode 的默认入参一起钉:
// 删掉那行默认值、或把它改回 onExit:true,这条会红。
func TestTerminalCreateDefaultsToNoPush(t *testing.T) {
// ① 生产代码里 handleCreate 的初始 watch 必须是「不推送」
src, err := os.ReadFile("plugin.go")
if err != nil {
t.Fatal(err)
}
text := string(src)
if !strings.Contains(text, "watch := terminalWatch{onExit: false, quiet: true}") {
t.Fatal("handleCreate 的默认 watch 必须是 {onExit:false, quiet:true}(默认不推送)")
}
// ② 不传 notify 时,该初始值不会被任何解析改写
w := terminalWatch{onExit: false, quiet: true}
if w.onExit || w.interval != 0 || w.bufferBytes != 0 {
t.Fatalf("默认 watch 不应开启任何推送: %+v", w)
}
}
// TestExplicitExitNotifyStillWorks 显式声明时推送仍然生效(A 方案:默认关,可显式开)。
func TestExplicitExitNotifyStillWorks(t *testing.T) {
w := parseNotifyMode("exit", terminalWatch{onExit: false, quiet: true})
if !w.onExit {
t.Fatal("显式 notify=exit 必须开启结束提醒")
}
if w.quiet {
t.Fatal("显式 notify=exit 应关闭 quiet(否则退出通知被静默)")
}
}
// TestWatchClearRestoresNoPush 钉住 clear 的语义:回到默认(不推送),
// 而不是回到 exit 提醒。
func TestWatchClearRestoresNoPush(t *testing.T) {
p, tc, err := setupPlugin()
if err != nil {
t.Fatal(err)
}
term := newMockTerm()
ts := newTestSession(term)
ts.id = "t-clear"
ts.watch = terminalWatch{onExit: true, interval: time.Minute}
p.mu.Lock()
p.sessions["t-clear"] = ts
p.mu.Unlock()
if _, err := tc.handlers["terminal_watch"](map[string]interface{}{
"id": "t-clear",
"clear": true,
}); err != nil {
t.Fatal(err)
}
ts.mu.Lock()
got := ts.watch
ts.mu.Unlock()
if got.onExit {
t.Fatal("clear 后不应恢复 exit 提醒(应回到默认不推送)")
}
if got.interval != 0 {
t.Fatalf("clear 后 interval 应清零,实际 %v", got.interval)
}
}
// TestTerminalGoneIsStructured 钉住已关闭终端的答复是**可执行**的,
// 不是一句没有出路的文本 —— 否则模型会拿同一个 id 反复重试。
func TestTerminalGoneIsStructured(t *testing.T) {
_, tc, err := setupPlugin()
if err != nil {
t.Fatal(err)
}
for _, tool := range []string{"terminal_write", "terminal_read", "terminal_resize", "terminal_close", "terminal_watch"} {
h, ok := tc.handlers[tool]
if !ok {
t.Fatalf("%s 未注册", tool)
}
args := map[string]interface{}{"id": "does-not-exist"}
if tool == "terminal_resize" {
args["rows"] = float64(24)
args["cols"] = float64(80)
}
if tool == "terminal_write" {
args["input"] = "x"
}
res, err := h(args)
if err != nil {
t.Fatalf("%s: %v", tool, err)
}
m, ok := res.(map[string]interface{})
if !ok {
t.Fatalf("%s: 返回类型不是 map: %T", tool, res)
}
if m["error"] != "terminal_gone" {
t.Fatalf("%s: error 应为 terminal_gone,实际 %v", tool, m["error"])
}
if _, ok := m["next_action"]; !ok {
t.Fatalf("%s: 缺少 next_action(模型没有可执行的下一步)", tool)
}
}
}