refactor(terminal): 内核开终端/命令历史权威视图,WebUI 与 CLI 都改接内核

按「内核开,两个插件接」重构终端与命令历史的数据归属。

背景:此前 WebUI 与 CLI 各订 EventToolCall/EventTerminalOutput 攅一份状态,
同一件事两份推导,还各自踩过同一个坑——工具 result 是 Go 的 map 文本
(map[cols:80 ... id:term_2 ...]),断言成 map[string]interface{} 永远失败,
terminal_create 的 id 回填不生效,/terminals 因此恒空(WebUI 也一样)。
实测确认:WebUI 自己的 /api/v1/terminals 与 /api/v1/cmd/history 同样是空的。

内核开(权威唯一真相):
- internal/agent/core/terminal_registry.go:TerminalRegistry 归并两类事件——
  EventToolCall(terminal_create/close、cmd_run,id/command 从 args 或 Go map
  文本回填)与 EventTerminalOutput(agentcli 生命周期 + 输出,含 64KB 缓冲上限、
  100 条命令历史、50 个终端上限)。
- internal/sdk/terminal.go:新增 TerminalAPI(ListTerminals/CmdHistory)与
  TerminalStatus/CmdExecStatus DTO。**不塞进 KernelStatus**:那是全量快照,
  前端每 3 秒轮询 /kernel,背上每终端最多 64KB 输出会让轮询成本爆炸;
  终端输出是按需拉取的明细,另开接口。
- Agent 订阅自己的事件总线(subscribeTerminalRegistry),且**只根 agent 建**
  (驻留子共用同一总线,每个子都建会 N+1 份重复记账)。
- SDKConfig/Registry/bootstrap 接线:pluginReg.SetTerminalAPI(agent)。

生产者补全(agentcli):终端无输出时 ticker 不发事件,内核就无从知道终端
存在。新增 emitTermState,在 handleCreate/handleClose/readLoop 退出(超时/
进程结束/读取错误/stopCh)显式上报 running 状态,并给输出事件补 command 字段。
handleClose 改为接收 *sdk.PluginSDK 以便上报。

两个插件接(消费方):
- WebUI:删掉本地 termStates/cmdHistory/subscribeTerminalStream/handleToolEvent
  及不再使用的 getStr;/terminals 与 /cmd/history 直接读 s.Terminal()。
- CLI:删掉上一轮刚加的 subscribeToolEvents 与 cliTermState/cliCmdExec;
  /terminals 与 /cmd/history 直接读 s.Terminal()。两条路(local/remote)都通。

测试:新增 terminal_registry_test.go,锁死 Go map 文本解析(旧缺陷根因)、
生命周期、CLI 直调路径(无 EventToolCall 仅凭 output 事件建条目)、历史与
终端数量上限。全量 go test ./internal/... ./cmd/... 通过。
This commit is contained in:
JianFeeeee
2026-09-17 18:56:15 +08:00
parent 3cce605722
commit e70d2171ee
14 changed files with 595 additions and 330 deletions

View File

@ -351,7 +351,7 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
"required": []string{"id"},
},
}, func(args map[string]interface{}) (interface{}, error) {
return p.handleClose(args)
return p.handleClose(s, args)
})
s.RegisterTool("terminal_list", sdk.ToolDef{
@ -493,6 +493,10 @@ func (p *Plugin) handleCreate(s *sdk.PluginSDK, args map[string]interface{}) (in
p.wg.Add(1)
go p.readLoop(session, s)
// 生命周期事件:终端创建即时上报(带 command),让内核权威视图与所有
// 订阅者(WebUI/CLI)即使在该终端无输出的情况下也能知道它的存在。
emitTermState(s, session, true)
log.Printf("[agentcli] created terminal %s: command=%q timeout=%v rows=%d cols=%d", id, command, timeout, rows, cols)
return map[string]interface{}{
@ -681,7 +685,7 @@ func (p *Plugin) handleResize(args map[string]interface{}) (interface{}, error)
}, nil
}
func (p *Plugin) handleClose(args map[string]interface{}) (interface{}, error) {
func (p *Plugin) handleClose(s *sdk.PluginSDK, args map[string]interface{}) (interface{}, error) {
id, _ := args["id"].(string)
if id == "" {
return map[string]interface{}{"error": "id is required"}, nil
@ -701,6 +705,9 @@ func (p *Plugin) handleClose(args map[string]interface{}) (interface{}, error) {
session.Close()
log.Printf("[agentcli] closed terminal %s", id)
// 生命周期事件:显式上报关闭(readLoop 退出时也会发,幂等)。
emitTermState(s, session, false)
return map[string]interface{}{
"status": "closed",
"terminal": id,
@ -795,6 +802,25 @@ func (p *Plugin) handleList() (interface{}, error) {
}, nil
}
// emitTermState 把终端存活状态作为 EventTerminalOutput 事件上报。
//
// 生命周期事件(创建/关闭/退出/超时)必须显式发:终端无输出时 ticker
// 不会发事件,内核的终端权威视图与所有插件(WebUI/CLI)都依赖这些事件
// 才能知道终端的存在与终止。payload 带 command 供无 ToolCall 事件的
// 直调路径(CLI /terminal create 经 ToolAPI.ExecuteTool)回填命令名。
// 幂等:重复发同一 running 值不会产生状态跳变。
func emitTermState(s *sdk.PluginSDK, t *TerminalSession, running bool) {
if s == nil || t == nil {
return
}
s.Publish(&sdk.Event{
Type: sdk.EventTerminalOutput,
Source: "agentcli",
Payload: map[string]interface{}{"terminal_id": t.id, "command": t.command, "running": running},
Timestamp: time.Now().UnixMilli(),
})
}
func (p *Plugin) readLoop(t *TerminalSession, s *sdk.PluginSDK) {
defer p.wg.Done()
defer close(t.done)
@ -812,6 +838,9 @@ func (p *Plugin) readLoop(t *TerminalSession, s *sdk.PluginSDK) {
flushTicker := time.NewTicker(200 * time.Millisecond)
defer flushTicker.Stop()
// 退出时确保内核权威视图标记该终端为已停止(readLoop 的所有 return 点)。
defer emitTermState(s, t, false)
// 立即发送首次"终端已启动"通知,让 agent 感知存在。
// 用 NoMemory:这是状态提示,不是对话内容。不关掉的话每开一个终端都会
// 在记忆里留下一条"[终端 X 已启动]",把真实内容挤掉。
@ -888,7 +917,7 @@ func (p *Plugin) readLoop(t *TerminalSession, s *sdk.PluginSDK) {
s.Publish(&sdk.Event{
Type: sdk.EventTerminalOutput,
Source: "agentcli",
Payload: map[string]interface{}{"terminal_id": t.id, "output": streamData, "running": terminalRunning(t)},
Payload: map[string]interface{}{"terminal_id": t.id, "command": t.command, "output": streamData, "running": terminalRunning(t)},
Timestamp: time.Now().UnixMilli(),
})
}