mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-11-01 14:57:38 +00:00
按用户指正调整架构:终端是 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 文本解析、生命周期、上限裁剪)
108 lines
3.5 KiB
Go
108 lines
3.5 KiB
Go
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)
|
||
}
|
||
}
|