Files
HomeAgent/internal/pluginview/terminal_test.go
root 3119d48625 refactor(terminal): 终端逻辑回归 agentcli,内核不再编码终端语义
按用户指正调整架构:终端是 agentcli 的职责,内核不该有终端逻辑;插件与
agentcli 的联动应走事件总线,而不是内核内部组件。

## 删掉内核里的终端语义

`internal/agent/core/terminal_registry.go`(280 行)整体删除。它做了两件不该
内核做的事:定义「什么是终端会话」、从 EventToolCall 的 Go map 文本里解析
terminal id / command 并归并会话与命令历史。连带移除:

- Agent 的 terminalReg 字段、构造、subscribeTerminalRegistry 订阅
- StatusAPI 上的 ListTerminals / CmdHistory
- sdk.TerminalAPI + PluginSDK.Terminal() + Registry.SetTerminalAPI 及接线
- 对应测试(迁到插件侧视图包)

保留 `sdk.TerminalStatus` / `CmdExecStatus`:它们只是线上 JSON 格式,WebUI/CLI
返回给前端时口径一致。

## 插件侧订阅事件总线自攒视图

新增 `internal/pluginview`(TerminalView):把原先内核那份归并逻辑原样搬过来,
由插件自己持有。数据源仍是两条总线事件:

    EventTerminalOutput(agentcli 发)→ 会话输出 + 生命周期
    EventToolCall      (内核发)    → cmd_run 历史、terminal_create/close 归并

WebUI 在 NewHandler 订阅、Close 退订;CLI 在 Start 订阅、Stop 退订。
两边共用同一实现,避免历史上「两份推导各踩一次 Go map 文本」的坑。
视图不是权威:真值在 agentcli,插件晚于会话启动会缺早期会话(注释写明)。

## agentcli 保持内置

它现在仍需要 `Publish`(进程内事件总线写入)——外部插件没有上行事件通道
(`EventPublish` 在 withheldCapabilities 里,proc 只有 events.subscribe)。
按判定标准留内置;将来要外置,须先开一条受控的插件→内核上行事件通道,
本次刻意不开(用户决定:先保持内置)。

## 文档

中英 ARCHITECTURE 新增「终端归属」小节:说明职责边界、两条事件、视图非权威、
以及 agentcli 为何仍内置;内置判定表里 cli/webui 去掉 TerminalAPI 一项。

## 验收(本机实例实测)

- 内核编译通过,删除后 `go build ./... && go vet ./...` 干净,`go test ./internal/...` 全绿
- 实例重启后:让 agent 开终端跑 `echo term-view-test` →
  WebUI `GET /api/v1/terminals` 返回 term_1(output 正确、running=false)
  CLI `/terminals`(unix socket)返回同一份;
  两处都是从事件总线自攒,内核侧无终端代码参与
- cmd 历史分支由迁过来的单测覆盖(Go map 文本解析、生命周期、上限裁剪)
2026-10-20 09:33:08 +08:00

108 lines
3.5 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.

package pluginview
import "testing"
// 这条用例锁死的是曾经的线上缺陷根因:EventToolCall 的 result 是 Go 的
// map 文本(map[cols:80 command:sleep 120 id:term_2 ...]),不是
// map[string]interface{}。旧代码断言成后者永远失败 → /terminals 恒空。
func TestMapFieldFromMapText(t *testing.T) {
res := "map[cols:80 command:sleep 120 id:term_2 notify_mode:exit rows:24 status:created timeout:5m0s]"
if got := mapFieldFromMapText(res, "id"); got != "term_2" {
t.Fatalf("id = %q, want term_2", got)
}
// command 含空格:按空格切字段会把它切断,这里只要求拿到首段(与事件
// 负载同源,command 的真实值另有 args 路径可拿,不靠 map 文本)。
if got := mapFieldFromMapText(res, "cols"); got != "80" {
t.Fatalf("cols = %q, want 80", got)
}
if got := mapFieldFromMapText(res, "notify_mode"); got != "exit" {
t.Fatalf("notify_mode = %q, want exit", got)
}
if got := mapFieldFromMapText(res, "nope"); got != "" {
t.Fatalf("missing key = %q, want empty", got)
}
if got := terminalIDFromMapText("not a map at all"); got != "" {
t.Fatalf("garbage = %q, want empty", got)
}
}
func TestTerminalRegistryLifecycle(t *testing.T) {
r := NewTerminalView()
// agent 路径:terminal_create 的 result 是 Go map 文本,要能回填 id。
r.OnToolCall(map[string]interface{}{
"tool": "terminal_create",
"args": map[string]interface{}{"command": "sleep 120"},
"result": "map[cols:80 command:sleep 120 id:term_7 status:created]",
"status": "ok",
})
terms, _ := r.Snapshot()
if len(terms) != 1 || terms[0].ID != "term_7" || !terms[0].Running {
t.Fatalf("after create: %+v", terms)
}
// agentcli 输出事件:追加 output。
r.OnTerminalOutput(map[string]interface{}{
"terminal_id": "term_7", "output": "hello", "running": true,
})
terms, _ = r.Snapshot()
if terms[0].Output != "hello" {
t.Fatalf("output = %q", terms[0].Output)
}
// 生命周期事件:退出置 running=false。
r.OnTerminalOutput(map[string]interface{}{
"terminal_id": "term_7", "running": false,
})
terms, _ = r.Snapshot()
if terms[0].Running {
t.Fatalf("should be stopped: %+v", terms)
}
// CLI 直调路径:没有 EventToolCall,只有 terminal_output 生命周期事件,
// 依然要能凭 command 字段建出条目(设备名不丢)。
r.OnTerminalOutput(map[string]interface{}{
"terminal_id": "term_9", "command": "top", "running": true,
})
terms, _ = r.Snapshot()
var found bool
for _, tm := range terms {
if tm.ID == "term_9" && tm.Command == "top" && tm.Running {
found = true
}
}
if !found {
t.Fatalf("direct-path terminal missing: %+v", terms)
}
// cmd_run 历史:只保留最近 maxCmdHistory 条。
for i := 0; i < maxCmdHistory+10; i++ {
r.OnToolCall(map[string]interface{}{
"tool": "cmd_run",
"args": map[string]interface{}{"command": "echo hi"},
"status": "ok",
})
}
_, cmds := r.Snapshot()
if len(cmds) != maxCmdHistory {
t.Fatalf("cmd history len = %d, want %d", len(cmds), maxCmdHistory)
}
if cmds[0].Command != "echo hi" || cmds[0].Status != "ok" {
t.Fatalf("cmd exec = %+v", cmds[0])
}
}
// 终端数超上限时要淘汰,不能无界增长。
func TestTerminalRegistryCap(t *testing.T) {
r := NewTerminalView()
for i := 0; i < maxTerminals+20; i++ {
r.OnTerminalOutput(map[string]interface{}{
"terminal_id": string(rune('a'+i%26)) + "-x", "running": true,
})
}
terms, _ := r.Snapshot()
if len(terms) > maxTerminals {
t.Fatalf("terminals = %d, want <= %d", len(terms), maxTerminals)
}
}