fix: LLM 工具循环 400、中断消息注入、ConPTY 终端支持

- agent: 工具轮请求尾部补 user 占位(zen 网关强制),tool 消息正确配对
- agent: 工具提醒/中断以 system 角色注入并带 [中断消息] 前缀,不进用户履历;系统提示词说明中断消息格式
- agentcli: 基于 ConPTY 的交互式终端(ptywin fork),terminal_create/read/write/resize/close/watch
- webui: server 输出通道适配器(保留 reasoning_content/disable_thinking)
- GUI: 沉浸式标题栏、icon 圆角重制、mascot 等打磨
This commit is contained in:
JianFeeeee
2026-08-14 00:48:40 +08:00
parent 816597caac
commit 147d0baaf9
43 changed files with 4670 additions and 1478 deletions

View File

@ -46,8 +46,17 @@ func terminalRunning(t *TerminalSession) bool {
return t.cmd != nil && (t.cmd.ProcessState == nil || !t.cmd.ProcessState.Exited())
}
// terminalWatch 终端提醒规则(由 terminal_watch 工具设置)。
type terminalWatch struct {
interval time.Duration // 固定时间反馈间隔0 禁用
onExit bool // 命令执行结束提醒(默认 true
bufferBytes int // 该终端专用缓冲阈值字节0 使用全局 notify_bytes
quiet bool // 静默模式:不随输出流通知,仅定时反馈/结束提醒/空闲汇总
}
type TerminalSession struct {
id string
command string
cmd *exec.Cmd
session ptyTerm
mu sync.Mutex
@ -59,8 +68,15 @@ type TerminalSession struct {
done chan struct{}
// 通知节流字段
unreadBytes int // 最近一次通知后积累的未读字节数
lastNotify time.Time // 最近一次通知时间
unreadBytes int // 最近一次通知后积累的未读字节数
lastNotify time.Time // 最近一次通知时间
lastData time.Time // 最近一次读到的数据时间(用于判定输出停止)
lastFeedback time.Time // 最近一次定时反馈时间
backoff time.Duration // 输出风暴退避:持续高速输出时通知间隔翻倍
watch terminalWatch // 该终端的提醒规则
// 实时画面推流terminal_output 事件)
stream bytes.Buffer // 待推送的增量输出,由 readLoop 每 200ms flush 一次
}
func (t *TerminalSession) Write(input string) (int, error) {
@ -85,12 +101,13 @@ func (t *TerminalSession) Close() {
t.mu.Unlock()
close(t.stopCh)
// 先终止进程各平台实现Linux 信号 / Windows TerminateProcess幂等再释放资源。
// 不能依赖 cmd.Process.Kill()Windows 后端 cmd.Process 为占位(仅 Pid
if t.session != nil {
_ = t.session.Kill()
}
t.session.Close()
<-t.done
if t.cmd != nil && t.cmd.Process != nil {
t.cmd.Process.Kill()
}
}
func (t *TerminalSession) ReadOutput() string {
@ -119,6 +136,17 @@ func (t *TerminalSession) appendOutput(data []byte) {
}
}
t.buf.Write(data)
// 同步追加到实时画面推流缓冲(最大 64KB超出丢弃最旧部分
const maxStream = 64 * 1024
if t.stream.Len()+len(data) > maxStream {
excess := t.stream.Len() + len(data) - maxStream
if t.stream.Len() > excess {
t.stream.Next(excess)
} else {
t.stream.Reset()
}
}
t.stream.Write(data)
}
func (t *TerminalSession) IsExpired() bool {
@ -194,8 +222,10 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
}
s.RegisterTool("terminal_create", sdk.ToolDef{
Name: "terminal_create",
Description: "创建一个新的交互式终端会话。返回终端 ID后续通过此 ID 进行读写操作。适用于运行交互式程序如 vim、ssh、top、nano 等。终端默认 5 分钟后自动关闭,可通过 timeout 参数调整。",
Name: "terminal_create",
Description: "创建一个新的交互式终端会话。返回终端 ID后续通过此 ID 进行读写操作。适用于运行交互式程序如 vim、ssh、top、nano 等。" +
"通知模式通过 notify 参数选择(默认 exitexit=仅命令执行结束后提醒一次interval=定时反馈(如 interval=30s 每 30 秒反馈一次状态摘要);" +
"buffer=未读输出积累到指定字节数后提醒(如 buffer=8192多个模式用逗号组合如 interval=30s,buffer=8192。终端默认 5 分钟后自动关闭,可通过 timeout 参数调整。",
NoMemory: true,
Parameters: map[string]interface{}{
"type": "object",
@ -204,6 +234,10 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
"type": "string",
"description": "要执行的命令(默认 bash。如需运行特定程序直接传入即可例如vim /tmp/test.txt",
},
"notify": map[string]interface{}{
"type": "string",
"description": "通知模式可选exit默认命令结束后提醒interval=时长(定时反馈,如 30s/1mbuffer=字节数(缓冲阈值提醒);可逗号组合",
},
"timeout": map[string]interface{}{
"type": "string",
"description": "终端自动关闭时间,例如 5m, 10m, 30m, 1h默认 5m",
@ -249,8 +283,8 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
})
s.RegisterTool("terminal_read", sdk.ToolDef{
Name: "terminal_read",
Description: "读取指定终端的当前屏幕内容。返回自上次读取以来的新输出。如需持续监控请多次调用。",
Name: "terminal_read",
Description: "读取指定终端的输出。mode=new默认返回自上次读取以来的新输出并清空缓冲mode=now 返回终端当前显示的全部屏幕内容(不清空缓冲)。如需持续监控请多次调用。",
NoMemory: true,
Parameters: map[string]interface{}{
"type": "object",
@ -259,9 +293,13 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
"type": "string",
"description": "终端 ID",
},
"mode": map[string]interface{}{
"type": "string",
"description": "读取模式new默认新输出并清空缓冲或 now当前屏幕全部内容不清理",
},
"clear": map[string]interface{}{
"type": "boolean",
"description": "读取后是否清除缓冲区(默认 true",
"description": "读取后是否清除缓冲区(默认与 mode 一致new 清除now 不清除",
},
},
"required": []string{"id"},
@ -326,6 +364,48 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
return p.handleList()
})
s.RegisterTool("terminal_watch", sdk.ToolDef{
Name: "terminal_watch",
Description: "为指定终端设置提醒规则,避免长时间运行任务(编译/下载/构建等)的输出造成通知风暴。" +
"可选规则interval=固定时间反馈(每隔该时长向 agent 反馈一次终端状态摘要);" +
"on_exit=命令执行结束提醒buffer_bytes=未读输出积累到该字节数时提醒一次;" +
"quiet=静默模式(抑制随输出流的通知,仅保留定时反馈与结束提醒,推荐长任务使用)。" +
"未提供的字段保持原值clear=true 清除全部规则。默认 on_exit=true。",
NoMemory: true,
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"id": map[string]interface{}{
"type": "string",
"description": "终端 ID来自 terminal_create 的返回值",
},
"interval": map[string]interface{}{
"type": "string",
"description": "固定时间反馈间隔,如 30s, 1m, 5m可选0 禁用)",
},
"on_exit": map[string]interface{}{
"type": "boolean",
"description": "命令执行结束时是否提醒(默认 true",
},
"buffer_bytes": map[string]interface{}{
"type": "integer",
"description": "未读输出积累阈值(字节),达到后提醒一次(可选,默认全局 2048",
},
"quiet": map[string]interface{}{
"type": "boolean",
"description": "静默模式:不随输出流通知,仅保留定时反馈与结束提醒(推荐编译/下载等长任务)",
},
"clear": map[string]interface{}{
"type": "boolean",
"description": "清除该终端全部提醒规则(恢复默认行为)",
},
},
"required": []string{"id"},
},
}, func(args map[string]interface{}) (interface{}, error) {
return p.handleWatch(args)
})
p.wg.Add(1)
go p.cleanupLoop(s)
@ -378,18 +458,28 @@ func (p *Plugin) handleCreate(s *sdk.PluginSDK, args map[string]interface{}) (in
cols = uint16(c)
}
// 通知模式:默认 exit命令执行结束后提醒一次
// 支持 interval=30s / buffer=8192 / quiet可逗号组合。
watch := terminalWatch{onExit: true, quiet: true}
if notifyStr, ok := args["notify"].(string); ok && notifyStr != "" {
watch = parseNotifyMode(notifyStr, watch)
}
term, cmd, err := newCommandPty(command, rows, cols)
if err != nil {
return map[string]interface{}{"error": fmt.Sprintf("创建终端失败: %v", err)}, nil
}
session := &TerminalSession{
id: "",
command: command,
cmd: cmd,
session: term,
createdAt: time.Now(),
timeout: timeout,
stopCh: make(chan struct{}),
done: make(chan struct{}),
watch: watch,
}
p.mu.Lock()
@ -404,15 +494,34 @@ func (p *Plugin) handleCreate(s *sdk.PluginSDK, args map[string]interface{}) (in
log.Printf("[agentcli] created terminal %s: command=%q timeout=%v rows=%d cols=%d", id, command, timeout, rows, cols)
return map[string]interface{}{
"id": id,
"status": "created",
"command": command,
"timeout": timeout.String(),
"rows": rows,
"cols": cols,
"id": id,
"status": "created",
"command": command,
"timeout": timeout.String(),
"rows": rows,
"cols": cols,
"notify_mode": notifyModeString(watch),
}, nil
}
// notifyModeString 输出可读的通知模式描述。
func notifyModeString(w terminalWatch) string {
var parts []string
if w.onExit {
parts = append(parts, "exit")
}
if w.interval > 0 {
parts = append(parts, "interval="+w.interval.String())
}
if w.bufferBytes > 0 {
parts = append(parts, fmt.Sprintf("buffer=%d", w.bufferBytes))
}
if len(parts) == 0 {
return "quiet"
}
return strings.Join(parts, ",")
}
func (p *Plugin) handleWrite(s *sdk.PluginSDK, args map[string]interface{}) (interface{}, error) {
id, _ := args["id"].(string)
if id == "" {
@ -455,13 +564,49 @@ func (p *Plugin) handleWrite(s *sdk.PluginSDK, args map[string]interface{}) (int
}, nil
}
// parseNotifyMode 解析 notify 参数并合并进 watch。
// 支持exit / quiet / interval=时长 / buffer=字节数,逗号分隔组合。
func parseNotifyMode(s string, base terminalWatch) terminalWatch {
w := base
for _, part := range strings.Split(s, ",") {
part = strings.TrimSpace(part)
if part == "" {
continue
}
kv := strings.SplitN(part, "=", 2)
key := strings.TrimSpace(kv[0])
val := ""
if len(kv) == 2 {
val = strings.TrimSpace(kv[1])
}
switch key {
case "exit":
w.onExit = true
w.quiet = false
case "quiet", "silent":
w.quiet = true
case "interval":
if d, err := time.ParseDuration(val); err == nil && d > 0 {
w.interval = d
}
case "buffer":
var n int
if _, err := fmt.Sscanf(val, "%d", &n); err == nil && n > 0 {
w.bufferBytes = n
}
}
}
return w
}
func (p *Plugin) handleRead(args map[string]interface{}) (interface{}, error) {
id, _ := args["id"].(string)
if id == "" {
return map[string]interface{}{"error": "id is required"}, nil
}
clear := true
mode, _ := args["mode"].(string)
clear := mode != "now"
if v, ok := args["clear"].(bool); ok {
clear = v
}
@ -474,19 +619,29 @@ func (p *Plugin) handleRead(args map[string]interface{}) (interface{}, error) {
}
var output string
session.mu.Lock()
if clear {
output = session.ReadAndClearOutput()
output = session.buf.String()
session.buf.Reset()
// 实时画面推流缓冲同步清空,避免 terminal_output 事件与读取结果重复
session.stream.Reset()
} else {
output = session.ReadOutput()
output = session.buf.String()
}
session.mu.Unlock()
if output == "" {
output = "[终端无新输出]"
if mode == "now" {
output = "[终端当前无屏幕内容]"
} else {
output = "[终端无新输出]"
}
}
return map[string]interface{}{
"status": "ok",
"terminal": id,
"mode": mode,
"output": output,
"running": terminalRunning(session),
"uptime": time.Since(session.createdAt).String(),
@ -550,6 +705,55 @@ func (p *Plugin) handleClose(args map[string]interface{}) (interface{}, error) {
}, nil
}
func (p *Plugin) handleWatch(args map[string]interface{}) (interface{}, error) {
id, _ := args["id"].(string)
if id == "" {
return map[string]interface{}{"error": "id is required"}, nil
}
p.mu.Lock()
session, ok := p.sessions[id]
p.mu.Unlock()
if !ok {
return map[string]interface{}{"error": fmt.Sprintf("终端 %s 不存在或已关闭", id)}, nil
}
session.mu.Lock()
if v, ok := args["clear"].(bool); ok && v {
session.watch = terminalWatch{onExit: true}
} else {
if v, ok := args["interval"].(string); ok && v != "" {
if d, err := time.ParseDuration(v); err == nil && d >= 0 {
session.watch.interval = d
}
}
if v, ok := args["on_exit"].(bool); ok {
session.watch.onExit = v
}
if v, ok := args["buffer_bytes"].(float64); ok && v >= 0 {
session.watch.bufferBytes = int(v)
}
if v, ok := args["quiet"].(bool); ok {
session.watch.quiet = v
}
if session.watch.interval == 0 && session.watch.bufferBytes == 0 && !session.watch.quiet {
session.watch.onExit = true
}
}
w := session.watch
session.mu.Unlock()
log.Printf("[agentcli] watch updated for %s: %+v", id, w)
return map[string]interface{}{
"status": "ok",
"terminal": id,
"interval": w.interval.String(),
"on_exit": w.onExit,
"buffer_bytes": w.bufferBytes,
"quiet": w.quiet,
}, nil
}
func (p *Plugin) handleList() (interface{}, error) {
p.mu.Lock()
defer p.mu.Unlock()
@ -571,6 +775,7 @@ func (p *Plugin) handleList() (interface{}, error) {
}
terms = append(terms, termInfo{
ID: t.id,
Command: t.command,
Uptime: time.Since(t.createdAt).Round(time.Second).String(),
ExpiresIn: remaining.Round(time.Second).String(),
Running: running,
@ -598,9 +803,24 @@ func (p *Plugin) readLoop(t *TerminalSession, s *sdk.PluginSDK) {
readCh := make(chan readResult, 4)
go p.reader(t, buf, readCh)
// 实时画面推流 ticker每 200ms 批量发布一次 terminal_output 事件
flushTicker := time.NewTicker(200 * time.Millisecond)
defer flushTicker.Stop()
// 立即发送首次"终端已启动"通知,让 agent 感知存在
s.InjectText("agentcli", "agentcli", fmt.Sprintf("[终端 %s 已启动]", t.id))
t.lastNotify = time.Now()
now := time.Now()
t.mu.Lock()
t.lastNotify = now
t.lastData = now
t.lastFeedback = now
t.mu.Unlock()
// 硬上限:未读输出积累达到该值也通知一次(防大输出静默丢失),频率极低
hardNotifyBytes := 64 * 1024
hardNotifyInterval := 10 * time.Second
// 输出停止判定:超过该时长无新数据则视为输出停止
quietLatency := 2 * time.Second
for {
if t.IsExpired() {
@ -613,16 +833,52 @@ func (p *Plugin) readLoop(t *TerminalSession, s *sdk.PluginSDK) {
}
if !terminalRunning(t) {
s.InjectText("agentcli", "agentcli", fmt.Sprintf("[终端 %s 中的进程已退出]", t.id))
if t.watch.onExit {
s.InjectText("agentcli", "agentcli", fmt.Sprintf("[终端 %s 中的命令已执行结束]", t.id))
} else {
s.InjectText("agentcli", "agentcli", fmt.Sprintf("[终端 %s 中的进程已退出]", t.id))
}
p.mu.Lock()
delete(p.sessions, t.id)
p.mu.Unlock()
return
}
// 固定时间反馈watch.interval > 0 时每隔该时长主动反馈一次状态摘要
t.mu.Lock()
if t.watch.interval > 0 && time.Since(t.lastFeedback) >= t.watch.interval {
t.lastFeedback = time.Now()
t.lastNotify = t.lastFeedback
unread := t.unreadBytes
t.unreadBytes = 0
preview := previewTail(t.buf.String(), 120)
t.mu.Unlock()
s.InjectText("agentcli", "agentcli",
fmt.Sprintf("[终端 %s 定时反馈: 运行中, 期间新输出约 %d 字节]\n%s", t.id, unread, preview))
continue
}
t.mu.Unlock()
select {
case <-t.stopCh:
return
case <-flushTicker.C:
// 批量推送终端实时画面增量(独立 ticker避免被高密度数据饿死
var streamData string
t.mu.Lock()
if t.stream.Len() > 0 {
streamData = t.stream.String()
t.stream.Reset()
}
t.mu.Unlock()
if streamData != "" {
s.Publish(&sdk.Event{
Type: sdk.EventTerminalOutput,
Source: "agentcli",
Payload: map[string]interface{}{"terminal_id": t.id, "output": streamData, "running": terminalRunning(t)},
Timestamp: time.Now().UnixMilli(),
})
}
case r := <-readCh:
if r.err != nil {
// 读取错误/EOF → 立即通知(进程可能已结束)
@ -634,32 +890,66 @@ func (p *Plugin) readLoop(t *TerminalSession, s *sdk.PluginSDK) {
copy(data, buf[:r.n])
t.appendOutput(data)
// 语义通知:累积未读字节数
// 缓冲阈值通知(仅当 agent 显式选择 buffer 模式,或未读积累达到硬上限)。
// 默认模式(仅 exit 提醒)下不随输出流通知,杜绝通知风暴。
t.mu.Lock()
t.lastData = time.Now()
t.unreadBytes += r.n
needNotify := t.unreadBytes >= p.notifyBytes ||
time.Since(t.lastNotify) >= p.notifyInterval
t.mu.Unlock()
if needNotify {
t.mu.Lock()
preview := t.buf.String()
if len(preview) > 200 {
preview = preview[len(preview)-200:] // 取最新 200 字符
bufThr := t.watch.bufferBytes
if bufThr <= 0 {
bufThr = p.notifyBytes
}
minInterval := p.notifyInterval
if t.watch.interval > 0 {
minInterval = t.watch.interval
}
// 风暴退避:距上次通知不足 1s 说明输出极速,通知间隔翻倍(上限 30s
if time.Since(t.lastNotify) < time.Second && t.unreadBytes >= bufThr {
if t.backoff == 0 {
t.backoff = minInterval
} else if t.backoff < 30*time.Second {
t.backoff *= 2
if t.backoff > 30*time.Second {
t.backoff = 30 * time.Second
}
}
preview = sanitizePreview(preview)
t.unreadBytes = 0
}
interval := t.backoff + minInterval
isHard := t.watch.bufferBytes <= 0 && t.unreadBytes >= hardNotifyBytes
if isHard && hardNotifyInterval > interval {
interval = hardNotifyInterval
}
need := t.unreadBytes >= bufThr && time.Since(t.lastNotify) >= interval
if need {
t.lastNotify = time.Now()
t.unreadBytes = 0
preview := previewTail(t.buf.String(), 200)
t.mu.Unlock()
s.InjectText("agentcli", "agentcli",
fmt.Sprintf("[终端 %s 有新输出]\n%s", t.id, preview))
} else {
t.mu.Unlock()
s.InjectText("agentcli", "agentcli", fmt.Sprintf("[终端 %s 有新输出]\n%s", t.id, preview))
}
}
case <-time.After(pollInterval):
// 空闲轮询:输出已停止时复位退避
t.mu.Lock()
if t.backoff > 0 && time.Since(t.lastData) >= quietLatency {
t.backoff = 0
}
t.mu.Unlock()
}
}
}
// previewTail 返回 s 末尾最多 n 字符,并转义控制字符保证可读。
func previewTail(s string, n int) string {
if len(s) > n {
s = s[len(s)-n:]
}
return sanitizePreview(s)
}
type readResult struct {
n int
err error

View File

@ -4,6 +4,9 @@ package agentcli
import (
"encoding/json"
"fmt"
"strings"
"sync"
"testing"
"time"
@ -389,3 +392,253 @@ func TestToolsRegistered(t *testing.T) {
}
}
}
// ——— 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) InjectInputSync(source, channel, text string) string { return "" }
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
}

View File

@ -8,208 +8,31 @@ import (
"os/exec"
"strings"
"sync"
"syscall"
"unsafe"
"gitcode.com/JianFeeeee/HomeAgent/internal/ptywin"
)
var (
kernel32 = syscall.NewLazyDLL("kernel32.dll")
procCreatePseudoConsole = kernel32.NewProc("CreatePseudoConsole")
procResizePseudoConsole = kernel32.NewProc("ResizePseudoConsole")
procClosePseudoConsole = kernel32.NewProc("ClosePseudoConsole")
procInitializeProcThreadAttributeList = kernel32.NewProc("InitializeProcThreadAttributeList")
procUpdateProcThreadAttribute = kernel32.NewProc("UpdateProcThreadAttribute")
procDeleteProcThreadAttributeList = kernel32.NewProc("DeleteProcThreadAttributeList")
procCreateProcessW = kernel32.NewProc("CreateProcessW")
procGetExitCodeProcess = kernel32.NewProc("GetExitCodeProcess")
procTerminateProcess = kernel32.NewProc("TerminateProcess")
procCloseHandle = kernel32.NewProc("CloseHandle")
)
const (
procThreadAttributePseudoConsole = 0x16 // PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE (22)
extendedStartupinfoPresent = 0x00080000
createUnicodeEnvironment = 0x00000400
stillActive = 259 // STILL_ACTIVE
)
type coord struct {
x int16
y int16
}
type processInformation struct {
process syscall.Handle
thread syscall.Handle
pid uint32
tid uint32
}
// startupInfoEx 对应 STARTUPINFOEXWSTARTUPINFOW 之后追加 attribute list 指针。
type startupInfoEx struct {
cb uint32
lpReserved *uint16
lpDesktop *uint16
lpTitle *uint16
dwX uint32
dwY uint32
dwXSize uint32
dwYSize uint32
dwXCountChars uint32
dwYCountChars uint32
dwFillAttribute uint32
dwFlags uint32
wShowWindow uint16
cbReserved2 uint16
lpReserved2 *byte
hStdInput syscall.Handle
hStdOutput syscall.Handle
hStdErr syscall.Handle
lpAttributeList uintptr
}
func defaultShell() string { return "cmd.exe" }
// windowsPty 基于 Windows ConPTYPseudo Console)的终端后端。
//
// ConPTY 通过 CreatePseudoConsole 创建伪控制台,子进程以
// PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE 挂到伪控制台。宿主侧使用两根
// 管道与伪控制台通信:我们写 inW输入、读 outR输出
// windowsPty 基于 internal/ptywinConPTY)的终端后端。
type windowsPty struct {
hpc syscall.Handle // 伪控制台句柄
inW *os.File // 我们向伪控制台写输入
outR *os.File // 我们读伪控制台输出
proc syscall.Handle // 子进程句柄
procID int
c *ptywin.ConPty
cmd *exec.Cmd
attrList []byte
closeOnce sync.Once
}
// newCommandPty 创建 ConPTY 并在其上运行命令cmd.exe /c <command>)。
func newCommandPty(command string, rows, cols uint16) (ptyTerm, *exec.Cmd, error) {
inR, inW, err := os.Pipe()
if err != nil {
return nil, nil, fmt.Errorf("create input pipe: %w", err)
}
outR, outW, err := os.Pipe()
if err != nil {
inR.Close()
inW.Close()
return nil, nil, fmt.Errorf("create output pipe: %w", err)
}
sz := coord{x: int16(cols), y: int16(rows)}
var hpc syscall.Handle
r, _, e := procCreatePseudoConsole.Call(
uintptr(unsafe.Pointer(&sz)),
inW.Fd(),
outR.Fd(),
0,
uintptr(unsafe.Pointer(&hpc)),
)
if r == 0 {
inR.Close()
inW.Close()
outR.Close()
outW.Close()
return nil, nil, fmt.Errorf("CreatePseudoConsole: %v", e)
}
// 初始化 process thread attribute list 并注入伪控制台句柄
attrList, err := buildAttrList(hpc)
if err != nil {
procClosePseudoConsole.Call(uintptr(hpc))
inR.Close()
inW.Close()
outR.Close()
outW.Close()
return nil, nil, err
}
cmdLine := windowsCommandLine(command)
cli, err := syscall.UTF16PtrFromString(cmdLine)
c, err := ptywin.Start(cmdLine, ptywin.ConPtyDimensions(int(cols), int(rows)))
if err != nil {
return nil, nil, err
return nil, nil, fmt.Errorf("conpty start: %v", err)
}
var si startupInfoEx
si.cb = uint32(unsafe.Sizeof(si))
si.lpAttributeList = uintptr(unsafe.Pointer(&attrList[0]))
var pi processInformation
flags := uint32(extendedStartupinfoPresent | createUnicodeEnvironment)
r, _, e = procCreateProcessW.Call(
0, // 应用名
uintptr(unsafe.Pointer(cli)), // 命令行CreateProcessW 会就地改写,可写 buffer
0, 0, // 无安全属性
0, // bInheritHandles FALSE
uintptr(flags), // 创建标志
0, // 环境(继承)
0, // 工作目录
uintptr(unsafe.Pointer(&si)),
uintptr(unsafe.Pointer(&pi)),
)
if r == 0 {
procDeleteProcThreadAttributeList.Call(uintptr(unsafe.Pointer(&attrList[0])))
procClosePseudoConsole.Call(uintptr(hpc))
inR.Close()
inW.Close()
outR.Close()
outW.Close()
return nil, nil, fmt.Errorf("CreateProcessW: %v", e)
}
// 子进程无需 pipe 的父侧副本;我们只保留 inW/outR
inR.Close()
outW.Close()
cmdObj := exec.Command("cmd.exe")
cmdObj.Process = &os.Process{Pid: int(pi.pid)}
cmdObj.Process = &os.Process{Pid: c.Pid()}
pt := &windowsPty{
hpc: hpc,
inW: inW,
outR: outR,
proc: pi.process,
procID: int(pi.pid),
cmd: cmdObj,
attrList: attrList,
}
return pt, cmdObj, nil
}
func buildAttrList(hpc syscall.Handle) ([]byte, error) {
var size uintptr
r, _, e := procInitializeProcThreadAttributeList.Call(0, 1, 0, uintptr(unsafe.Pointer(&size)))
if r == 0 || size == 0 {
return nil, fmt.Errorf("InitializeProcThreadAttributeList(size): %v", e)
}
buf := make([]byte, size)
r, _, e = procInitializeProcThreadAttributeList.Call(
uintptr(unsafe.Pointer(&buf[0])),
1,
0,
uintptr(unsafe.Pointer(&size)),
)
if r == 0 {
return nil, fmt.Errorf("InitializeProcThreadAttributeList: %v", e)
}
r, _, e = procUpdateProcThreadAttribute.Call(
uintptr(unsafe.Pointer(&buf[0])),
0,
procThreadAttributePseudoConsole,
uintptr(hpc),
unsafe.Sizeof(hpc),
0,
0,
)
if r == 0 {
procDeleteProcThreadAttributeList.Call(uintptr(unsafe.Pointer(&buf[0])))
return nil, fmt.Errorf("UpdateProcThreadAttribute: %v", e)
}
return buf, nil
return &windowsPty{c: c, cmd: cmdObj}, cmdObj, nil
}
func windowsCommandLine(command string) string {
@ -217,43 +40,24 @@ func windowsCommandLine(command string) string {
}
func (p *windowsPty) Read(buf []byte) (int, error) {
return p.outR.Read(buf)
return p.c.Read(buf)
}
func (p *windowsPty) WriteString(s string) (int, error) {
return p.inW.WriteString(s)
return p.c.Write([]byte(s))
}
func (p *windowsPty) Resize(rows, cols uint16) error {
if p.hpc == 0 {
return fmt.Errorf("pseudo console closed")
}
sz := coord{x: int16(cols), y: int16(rows)}
r, _, e := procResizePseudoConsole.Call(uintptr(p.hpc), uintptr(unsafe.Pointer(&sz)))
if r == 0 {
return fmt.Errorf("ResizePseudoConsole: %v", e)
}
return nil
return p.c.Resize(int(cols), int(rows))
}
func (p *windowsPty) Running() bool {
if p.proc == 0 {
return false
}
var code uint32
r, _, _ := procGetExitCodeProcess.Call(uintptr(p.proc), uintptr(unsafe.Pointer(&code)))
if r == 0 {
// 句柄失效(进程已退出并释放句柄)视为停止
return false
}
return code == stillActive
return p.c != nil && p.c.Running()
}
func (p *windowsPty) Kill() error {
if p.proc != 0 {
procTerminateProcess.Call(uintptr(p.proc), 1)
procCloseHandle.Call(uintptr(p.proc))
p.proc = 0
if p.c != nil {
return p.c.Kill()
}
return nil
}
@ -261,28 +65,15 @@ func (p *windowsPty) Kill() error {
func (p *windowsPty) Close() error {
var errs []string
p.closeOnce.Do(func() {
if p.inW != nil {
if err := p.inW.Close(); err != nil {
if p.c != nil {
if err := p.c.Close(); err != nil {
errs = append(errs, err.Error())
}
p.c = nil
}
if p.outR != nil {
if err := p.outR.Close(); err != nil {
errs = append(errs, err.Error())
}
}
if p.hpc != 0 {
procClosePseudoConsole.Call(uintptr(p.hpc))
p.hpc = 0
}
if len(p.attrList) > 0 {
procDeleteProcThreadAttributeList.Call(uintptr(unsafe.Pointer(&p.attrList[0])))
p.attrList = nil
}
_ = p.Kill()
})
if len(errs) > 0 {
return fmt.Errorf("close: %s", strings.Join(errs, "; "))
}
return nil
}
}

View File

@ -0,0 +1,65 @@
//go:build windows
package agentcli
import (
"strings"
"testing"
"time"
)
// TestNewCommandPtyConPTY 验证 Windows ConPTY 后端:一次性命令输出可读,
// 交互式会话可写读往返。
func TestNewCommandPtyConPTY(t *testing.T) {
ta, _, err := newCommandPty("cmd.exe /c echo conpty-ok", 24, 80)
if err != nil {
t.Fatalf("once: %v", err)
}
outA := drainFor(ta, 3*time.Second)
if !strings.Contains(string(outA), "conpty-ok") {
t.Fatalf("once output missing echo: %q", string(outA))
}
ta.Close()
tb, _, err := newCommandPty("cmd.exe", 24, 80)
if err != nil {
t.Fatalf("interactive: %v", err)
}
defer tb.Close()
time.Sleep(300 * time.Millisecond)
if _, err := tb.WriteString("echo hi-123\r\n"); err != nil {
t.Fatalf("write: %v", err)
}
outB := drainFor(tb, 3*time.Second)
if !strings.Contains(string(outB), "hi-123") {
t.Fatalf("interactive output missing echo: %q", string(outB))
}
if !tb.Running() {
t.Fatalf("interactive shell should still be running")
}
}
func drainFor(term ptyTerm, dur time.Duration) []byte {
deadline := time.Now().Add(dur)
buf := make([]byte, 4096)
var out []byte
for time.Now().Before(deadline) {
ch := make(chan struct{ N int; E error }, 1)
go func() {
n, e := term.Read(buf)
ch <- struct{ N int; E error }{n, e}
}()
select {
case r := <-ch:
if r.N > 0 {
out = append(out, buf[:r.N]...)
}
if r.E != nil {
return out
}
case <-time.After(500 * time.Millisecond):
return out
}
}
return out
}