merge origin main updates

This commit is contained in:
LPF
2026-05-10 17:43:06 +08:00
58 changed files with 7574 additions and 1192 deletions

View File

@@ -25,6 +25,7 @@ import (
"sync"
"time"
"github.com/YspCoder/clawgo/pkg/bus"
"github.com/YspCoder/clawgo/pkg/channels"
cfgpkg "github.com/YspCoder/clawgo/pkg/config"
"github.com/YspCoder/clawgo/pkg/providers"
@@ -46,10 +47,12 @@ type Server struct {
workspacePath string
logFilePath string
onChat func(ctx context.Context, sessionKey, content string) (string, error)
onChatHistory func(sessionKey string) []map[string]interface{}
onChatHistory func(query ChatHistoryQuery) []map[string]interface{}
onSessionSearch func(query SessionSearchQuery) []map[string]interface{}
onConfigAfter func(forceRuntimeReload bool) error
onCron func(action string, args map[string]interface{}) (interface{}, error)
onToolsCatalog func() interface{}
messageBus *bus.MessageBus
weixinChannel *channels.WeixinChannel
oauthFlowMu sync.Mutex
oauthFlows map[string]*providers.OAuthPendingFlow
@@ -57,6 +60,31 @@ type Server struct {
extraRoutes map[string]http.Handler
eventSubsMu sync.Mutex
eventSubs map[*websocket.Conn]struct{}
draftMu sync.RWMutex
channelDrafts channelDraftStore
}
type channelDraftStore struct {
Weixin *cfgpkg.WeixinConfig
Telegram *cfgpkg.TelegramConfig
Feishu *cfgpkg.FeishuConfig
weixinRuntime *channels.WeixinChannel
}
type ChatHistoryQuery struct {
Session string
Around int
Before int
After int
Limit int
}
type SessionSearchQuery struct {
Query string
Limit int
Kinds []string
ExcludeCurrent bool
Session string
}
func NewServer(host string, port int, token string) *Server {
@@ -83,9 +111,13 @@ func (s *Server) SetToken(token string) { s.token = strings.TrimSpace(tok
func (s *Server) SetChatHandler(fn func(ctx context.Context, sessionKey, content string) (string, error)) {
s.onChat = fn
}
func (s *Server) SetChatHistoryHandler(fn func(sessionKey string) []map[string]interface{}) {
func (s *Server) SetChatHistoryHandler(fn func(query ChatHistoryQuery) []map[string]interface{}) {
s.onChatHistory = fn
}
func (s *Server) SetSessionSearchHandler(fn func(query SessionSearchQuery) []map[string]interface{}) {
s.onSessionSearch = fn
}
func (s *Server) SetMessageBus(mb *bus.MessageBus) { s.messageBus = mb }
func (s *Server) SetConfigAfterHook(fn func(forceRuntimeReload bool) error) { s.onConfigAfter = fn }
func (s *Server) SetCronHandler(fn func(action string, args map[string]interface{}) (interface{}, error)) {
s.onCron = fn
@@ -117,6 +149,382 @@ func (s *Server) SetWeixinChannel(ch *channels.WeixinChannel) {
}
}
func cloneWeixinConfig(cfg cfgpkg.WeixinConfig) cfgpkg.WeixinConfig {
cp := cfg
cp.AllowFrom = append([]string(nil), cfg.AllowFrom...)
cp.Accounts = append([]cfgpkg.WeixinAccountConfig(nil), cfg.Accounts...)
return cp
}
func cloneTelegramConfig(cfg cfgpkg.TelegramConfig) cfgpkg.TelegramConfig {
cp := cfg
cp.AllowFrom = append([]string(nil), cfg.AllowFrom...)
cp.AllowChats = append([]string(nil), cfg.AllowChats...)
return cp
}
func cloneFeishuConfig(cfg cfgpkg.FeishuConfig) cfgpkg.FeishuConfig {
cp := cfg
cp.AllowFrom = append([]string(nil), cfg.AllowFrom...)
cp.AllowChats = append([]string(nil), cfg.AllowChats...)
return cp
}
func validChannelDraftName(name string) bool {
switch strings.ToLower(strings.TrimSpace(name)) {
case "weixin", "telegram", "feishu":
return true
default:
return false
}
}
func decodeMergedJSON[T any](current T, raw json.RawMessage) (T, error) {
out := current
if len(raw) == 0 || string(raw) == "null" {
return out, nil
}
baseBytes, err := json.Marshal(current)
if err != nil {
return out, err
}
merged := map[string]interface{}{}
if err := json.Unmarshal(baseBytes, &merged); err != nil {
return out, err
}
patch := map[string]interface{}{}
if err := json.Unmarshal(raw, &patch); err != nil {
return out, err
}
merged = mergeJSONMap(merged, patch)
mergedBytes, err := json.Marshal(merged)
if err != nil {
return out, err
}
if err := json.Unmarshal(mergedBytes, &out); err != nil {
return out, err
}
return out, nil
}
func (s *Server) syncWeixinDraftLocked() {
if s.channelDrafts.Weixin == nil || s.channelDrafts.weixinRuntime == nil {
return
}
snapshot := s.channelDrafts.weixinRuntime.SnapshotConfig()
s.channelDrafts.Weixin = &snapshot
}
func (s *Server) replaceWeixinDraftRuntimeLocked(cfg *cfgpkg.WeixinConfig) error {
if s.channelDrafts.weixinRuntime != nil {
_ = s.channelDrafts.weixinRuntime.Stop(context.Background())
s.channelDrafts.weixinRuntime = nil
}
if cfg == nil || !cfg.Enabled {
return nil
}
if s.messageBus == nil {
return fmt.Errorf("message bus not configured")
}
ch, err := channels.NewWeixinChannel(cloneWeixinConfig(*cfg), s.messageBus)
if err != nil {
return err
}
if err := ch.Start(context.Background()); err != nil {
return err
}
s.channelDrafts.weixinRuntime = ch
return nil
}
func (s *Server) clearChannelDraftsLocked() {
if s.channelDrafts.weixinRuntime != nil {
_ = s.channelDrafts.weixinRuntime.Stop(context.Background())
}
s.channelDrafts = channelDraftStore{}
}
func (s *Server) clearChannelDrafts() {
s.draftMu.Lock()
defer s.draftMu.Unlock()
s.clearChannelDraftsLocked()
}
func (s *Server) effectiveWeixinRuntime(persisted cfgpkg.WeixinConfig) (cfgpkg.WeixinConfig, *channels.WeixinChannel, bool) {
s.draftMu.Lock()
defer s.draftMu.Unlock()
if s.channelDrafts.Weixin != nil {
s.syncWeixinDraftLocked()
effective := cloneWeixinConfig(*s.channelDrafts.Weixin)
return effective, s.channelDrafts.weixinRuntime, true
}
return cloneWeixinConfig(persisted), s.weixinChannel, false
}
func (s *Server) ensureWeixinRuntimeForLogin(persisted cfgpkg.WeixinConfig) (cfgpkg.WeixinConfig, *channels.WeixinChannel, bool, error) {
s.draftMu.Lock()
defer s.draftMu.Unlock()
if s.channelDrafts.Weixin != nil {
s.syncWeixinDraftLocked()
effective := cloneWeixinConfig(*s.channelDrafts.Weixin)
return effective, s.channelDrafts.weixinRuntime, true, nil
}
if s.weixinChannel != nil {
return cloneWeixinConfig(persisted), s.weixinChannel, false, nil
}
bootstrap := cloneWeixinConfig(persisted)
bootstrap.Enabled = true
if strings.TrimSpace(bootstrap.BaseURL) == "" {
bootstrap.BaseURL = "https://ilinkai.weixin.qq.com"
}
s.channelDrafts.Weixin = &bootstrap
if err := s.replaceWeixinDraftRuntimeLocked(&bootstrap); err != nil {
return cloneWeixinConfig(persisted), nil, true, err
}
s.syncWeixinDraftLocked()
effective := cloneWeixinConfig(*s.channelDrafts.Weixin)
return effective, s.channelDrafts.weixinRuntime, true, nil
}
func (s *Server) currentChannelDraftPayload(cfg *cfgpkg.Config, channel string) map[string]interface{} {
channel = strings.ToLower(strings.TrimSpace(channel))
payload := map[string]interface{}{
"ok": true,
"channel": channel,
}
s.draftMu.Lock()
defer s.draftMu.Unlock()
switch channel {
case "weixin":
persisted := cloneWeixinConfig(cfg.Channels.Weixin)
var draft interface{}
effective := persisted
dirty := s.channelDrafts.Weixin != nil
if dirty {
s.syncWeixinDraftLocked()
effective = cloneWeixinConfig(*s.channelDrafts.Weixin)
draft = effective
}
payload["persisted"] = persisted
payload["draft"] = draft
payload["effective"] = effective
payload["dirty"] = dirty
payload["runtime_enabled"] = s.channelDrafts.weixinRuntime != nil && s.channelDrafts.weixinRuntime.IsRunning()
case "telegram":
persisted := cloneTelegramConfig(cfg.Channels.Telegram)
var draft interface{}
effective := persisted
dirty := s.channelDrafts.Telegram != nil
if dirty {
effective = cloneTelegramConfig(*s.channelDrafts.Telegram)
draft = effective
}
payload["persisted"] = persisted
payload["draft"] = draft
payload["effective"] = effective
payload["dirty"] = dirty
case "feishu":
persisted := cloneFeishuConfig(cfg.Channels.Feishu)
var draft interface{}
effective := persisted
dirty := s.channelDrafts.Feishu != nil
if dirty {
effective = cloneFeishuConfig(*s.channelDrafts.Feishu)
draft = effective
}
payload["persisted"] = persisted
payload["draft"] = draft
payload["effective"] = effective
payload["dirty"] = dirty
}
return payload
}
func (s *Server) applyChannelDrafts(cfg *cfgpkg.Config) {
if cfg == nil {
return
}
s.draftMu.Lock()
defer s.draftMu.Unlock()
s.syncWeixinDraftLocked()
if s.channelDrafts.Weixin != nil {
cfg.Channels.Weixin = cloneWeixinConfig(*s.channelDrafts.Weixin)
}
if s.channelDrafts.Telegram != nil {
cfg.Channels.Telegram = cloneTelegramConfig(*s.channelDrafts.Telegram)
}
if s.channelDrafts.Feishu != nil {
cfg.Channels.Feishu = cloneFeishuConfig(*s.channelDrafts.Feishu)
}
}
func (s *Server) handleWebUIChannelDraft(w http.ResponseWriter, r *http.Request) {
if !s.checkAuth(r) {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
cfg, err := s.loadConfig()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
switch r.Method {
case http.MethodGet:
channel := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("channel")))
if channel == "" {
writeJSON(w, map[string]interface{}{
"ok": true,
"channels": map[string]interface{}{
"weixin": s.currentChannelDraftPayload(cfg, "weixin"),
"telegram": s.currentChannelDraftPayload(cfg, "telegram"),
"feishu": s.currentChannelDraftPayload(cfg, "feishu"),
},
})
return
}
if !validChannelDraftName(channel) {
http.Error(w, "unsupported channel", http.StatusBadRequest)
return
}
writeJSON(w, s.currentChannelDraftPayload(cfg, channel))
case http.MethodPost:
var body struct {
Channel string `json:"channel"`
Config json.RawMessage `json:"config"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
http.Error(w, "invalid json", http.StatusBadRequest)
return
}
channel := strings.ToLower(strings.TrimSpace(body.Channel))
if !validChannelDraftName(channel) {
http.Error(w, "unsupported channel", http.StatusBadRequest)
return
}
s.draftMu.Lock()
switch channel {
case "weixin":
current := cfg.Channels.Weixin
if s.channelDrafts.Weixin != nil {
s.syncWeixinDraftLocked()
current = cloneWeixinConfig(*s.channelDrafts.Weixin)
}
next, err := decodeMergedJSON(current, body.Config)
if err != nil {
s.draftMu.Unlock()
http.Error(w, "invalid weixin config", http.StatusBadRequest)
return
}
next = cloneWeixinConfig(next)
if err := s.replaceWeixinDraftRuntimeLocked(&next); err != nil {
s.draftMu.Unlock()
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
s.channelDrafts.Weixin = &next
case "telegram":
current := cfg.Channels.Telegram
if s.channelDrafts.Telegram != nil {
current = cloneTelegramConfig(*s.channelDrafts.Telegram)
}
next, err := decodeMergedJSON(current, body.Config)
if err != nil {
s.draftMu.Unlock()
http.Error(w, "invalid telegram config", http.StatusBadRequest)
return
}
next = cloneTelegramConfig(next)
s.channelDrafts.Telegram = &next
case "feishu":
current := cfg.Channels.Feishu
if s.channelDrafts.Feishu != nil {
current = cloneFeishuConfig(*s.channelDrafts.Feishu)
}
next, err := decodeMergedJSON(current, body.Config)
if err != nil {
s.draftMu.Unlock()
http.Error(w, "invalid feishu config", http.StatusBadRequest)
return
}
next = cloneFeishuConfig(next)
s.channelDrafts.Feishu = &next
}
s.draftMu.Unlock()
s.broadcastEvent(map[string]interface{}{
"type": "channel_draft_changed",
"channel": channel,
})
writeJSON(w, s.currentChannelDraftPayload(cfg, channel))
case http.MethodDelete:
channel := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("channel")))
s.draftMu.Lock()
if channel == "" {
s.clearChannelDraftsLocked()
s.draftMu.Unlock()
writeJSON(w, map[string]interface{}{"ok": true, "cleared": "all"})
return
}
if !validChannelDraftName(channel) {
s.draftMu.Unlock()
http.Error(w, "unsupported channel", http.StatusBadRequest)
return
}
switch channel {
case "weixin":
if s.channelDrafts.weixinRuntime != nil {
_ = s.channelDrafts.weixinRuntime.Stop(context.Background())
s.channelDrafts.weixinRuntime = nil
}
s.channelDrafts.Weixin = nil
case "telegram":
s.channelDrafts.Telegram = nil
case "feishu":
s.channelDrafts.Feishu = nil
}
s.draftMu.Unlock()
s.broadcastEvent(map[string]interface{}{
"type": "channel_draft_changed",
"channel": channel,
})
writeJSON(w, map[string]interface{}{"ok": true, "channel": channel, "cleared": true})
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}
func (s *Server) handleWebUIChannelDraftCommit(w http.ResponseWriter, r *http.Request) {
if !s.checkAuth(r) {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
cfg, err := s.loadConfig()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
s.applyChannelDrafts(cfg)
if err := s.persistWebUIConfig(cfg); err != nil {
var validationErr *configValidationError
if errors.As(err, &validationErr) {
writeJSONStatus(w, http.StatusBadRequest, map[string]interface{}{
"ok": false,
"error": validationErr.Error(),
"errors": validationErr.Fields,
})
return
}
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
s.clearChannelDrafts()
writeJSON(w, map[string]interface{}{"ok": true, "committed": true})
}
func (s *Server) handleWebUIEventsLive(w http.ResponseWriter, r *http.Request) {
if !s.checkAuth(r) {
http.Error(w, "unauthorized", http.StatusUnauthorized)
@@ -230,13 +638,17 @@ func (s *Server) Start(ctx context.Context) error {
mux.HandleFunc("/api/cron", s.handleWebUICron)
mux.HandleFunc("/api/skills", s.handleWebUISkills)
mux.HandleFunc("/api/sessions", s.handleWebUISessions)
mux.HandleFunc("/api/sessions/search", s.handleWebUISessionSearch)
mux.HandleFunc("/api/memory", s.handleWebUIMemory)
mux.HandleFunc("/api/workspace_file", s.handleWebUIWorkspaceFile)
mux.HandleFunc("/api/workspace_docs", s.handleWebUIWorkspaceDocs)
mux.HandleFunc("/api/tool_allowlist_groups", s.handleWebUIToolAllowlistGroups)
mux.HandleFunc("/api/tools", s.handleWebUITools)
mux.HandleFunc("/api/mcp/install", s.handleWebUIMCPInstall)
mux.HandleFunc("/api/logs/live", s.handleWebUILogsLive)
mux.HandleFunc("/api/logs/recent", s.handleWebUILogsRecent)
mux.HandleFunc("/api/channels/draft", s.handleWebUIChannelDraft)
mux.HandleFunc("/api/channels/draft/commit", s.handleWebUIChannelDraftCommit)
s.extraRoutesMu.RLock()
for path, handler := range s.extraRoutes {
routePath := path
@@ -407,7 +819,12 @@ func (s *Server) saveWebUIConfig(r *http.Request) error {
if err := json.NewDecoder(r.Body).Decode(cfg); err != nil {
return fmt.Errorf("decode config: %w", err)
}
return s.persistWebUIConfig(cfg)
s.applyChannelDrafts(cfg)
if err := s.persistWebUIConfig(cfg); err != nil {
return err
}
s.clearChannelDrafts()
return nil
case "normalized":
cfg, err := cfgpkg.LoadConfig(s.configPath)
if err != nil {
@@ -418,7 +835,12 @@ func (s *Server) saveWebUIConfig(r *http.Request) error {
return fmt.Errorf("decode normalized config: %w", err)
}
cfg.ApplyNormalizedView(view)
return s.persistWebUIConfig(cfg)
s.applyChannelDrafts(cfg)
if err := s.persistWebUIConfig(cfg); err != nil {
return err
}
s.clearChannelDrafts()
return nil
default:
return fmt.Errorf("unsupported config mode: %s", mode)
}
@@ -1122,7 +1544,14 @@ func (s *Server) handleWebUIChatHistory(w http.ResponseWriter, r *http.Request)
writeJSON(w, map[string]interface{}{"ok": true, "session": session, "messages": []interface{}{}})
return
}
writeJSON(w, map[string]interface{}{"ok": true, "session": session, "messages": s.onChatHistory(session)})
query := ChatHistoryQuery{
Session: session,
Around: queryBoundedPositiveInt(r, "around", 0, 1_000_000),
Before: queryBoundedPositiveInt(r, "before", 0, 1_000_000),
After: queryBoundedPositiveInt(r, "after", 0, 1_000_000),
Limit: queryBoundedPositiveInt(r, "limit", 200, 2000),
}
writeJSON(w, map[string]interface{}{"ok": true, "session": session, "messages": s.onChatHistory(query)})
}
func (s *Server) handleWebUIChatLive(w http.ResponseWriter, r *http.Request) {
@@ -1234,11 +1663,21 @@ func (s *Server) handleWebUIWeixinLoginStart(w http.ResponseWriter, r *http.Requ
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
if s.weixinChannel == nil {
cfg, err := s.loadConfig()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
_, ch, _, err := s.ensureWeixinRuntimeForLogin(cfg.Channels.Weixin)
if err != nil {
http.Error(w, err.Error(), http.StatusBadGateway)
return
}
if ch == nil {
http.Error(w, "weixin channel unavailable", http.StatusServiceUnavailable)
return
}
if _, err := s.weixinChannel.StartLogin(r.Context()); err != nil {
if _, err := ch.StartLogin(r.Context()); err != nil {
http.Error(w, err.Error(), http.StatusBadGateway)
return
}
@@ -1255,7 +1694,13 @@ func (s *Server) handleWebUIWeixinLoginCancel(w http.ResponseWriter, r *http.Req
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
if s.weixinChannel == nil {
cfg, err := s.loadConfig()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
_, ch, _ := s.effectiveWeixinRuntime(cfg.Channels.Weixin)
if ch == nil {
http.Error(w, "weixin channel unavailable", http.StatusServiceUnavailable)
return
}
@@ -1266,7 +1711,7 @@ func (s *Server) handleWebUIWeixinLoginCancel(w http.ResponseWriter, r *http.Req
http.Error(w, "invalid json body", http.StatusBadRequest)
return
}
if !s.weixinChannel.CancelPendingLogin(body.LoginID) {
if !ch.CancelPendingLogin(body.LoginID) {
http.Error(w, "login_id not found", http.StatusNotFound)
return
}
@@ -1290,8 +1735,14 @@ func (s *Server) handleWebUIWeixinQR(w http.ResponseWriter, r *http.Request) {
}
qrCode := ""
loginID := strings.TrimSpace(r.URL.Query().Get("login_id"))
if loginID != "" && s.weixinChannel != nil {
if pending := s.weixinChannel.PendingLoginByID(loginID); pending != nil {
cfg, err := s.loadConfig()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
_, ch, _ := s.effectiveWeixinRuntime(cfg.Channels.Weixin)
if loginID != "" && ch != nil {
if pending := ch.PendingLoginByID(loginID); pending != nil {
qrCode = fallbackString(pending.QRCodeImgContent, pending.QRCode)
}
}
@@ -1325,7 +1776,13 @@ func (s *Server) handleWebUIWeixinAccountRemove(w http.ResponseWriter, r *http.R
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
if s.weixinChannel == nil {
cfg, err := s.loadConfig()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
_, ch, _ := s.effectiveWeixinRuntime(cfg.Channels.Weixin)
if ch == nil {
http.Error(w, "weixin channel unavailable", http.StatusServiceUnavailable)
return
}
@@ -1336,7 +1793,7 @@ func (s *Server) handleWebUIWeixinAccountRemove(w http.ResponseWriter, r *http.R
http.Error(w, "invalid json body", http.StatusBadRequest)
return
}
if err := s.weixinChannel.RemoveAccount(body.BotID); err != nil {
if err := ch.RemoveAccount(body.BotID); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
@@ -1353,7 +1810,13 @@ func (s *Server) handleWebUIWeixinAccountDefault(w http.ResponseWriter, r *http.
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
if s.weixinChannel == nil {
cfg, err := s.loadConfig()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
_, ch, _ := s.effectiveWeixinRuntime(cfg.Channels.Weixin)
if ch == nil {
http.Error(w, "weixin channel unavailable", http.StatusServiceUnavailable)
return
}
@@ -1364,7 +1827,7 @@ func (s *Server) handleWebUIWeixinAccountDefault(w http.ResponseWriter, r *http.
http.Error(w, "invalid json body", http.StatusBadRequest)
return
}
if err := s.weixinChannel.SetDefaultAccount(body.BotID); err != nil {
if err := ch.SetDefaultAccount(body.BotID); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
@@ -1380,25 +1843,35 @@ func (s *Server) webUIWeixinStatusPayload(ctx context.Context) (map[string]inter
"error": err.Error(),
}, http.StatusInternalServerError
}
weixinCfg := cfg.Channels.Weixin
if s.weixinChannel == nil {
persistedCfg := cloneWeixinConfig(cfg.Channels.Weixin)
weixinCfg, ch, usingDraft := s.effectiveWeixinRuntime(persistedCfg)
if ch == nil {
return map[string]interface{}{
"ok": false,
"enabled": weixinCfg.Enabled,
"base_url": weixinCfg.BaseURL,
"error": "weixin channel unavailable",
"ok": false,
"enabled": weixinCfg.Enabled,
"config_enabled": persistedCfg.Enabled,
"runtime_enabled": false,
"draft_dirty": usingDraft,
"base_url": weixinCfg.BaseURL,
"error": "weixin channel unavailable",
}, http.StatusOK
}
pendingLogins, err := s.weixinChannel.RefreshLoginStatuses(ctx)
pendingLogins, err := ch.RefreshLoginStatuses(ctx)
if err != nil {
return map[string]interface{}{
"ok": false,
"enabled": weixinCfg.Enabled,
"base_url": weixinCfg.BaseURL,
"error": err.Error(),
"ok": false,
"enabled": weixinCfg.Enabled,
"config_enabled": persistedCfg.Enabled,
"runtime_enabled": ch.IsRunning(),
"draft_dirty": usingDraft,
"base_url": weixinCfg.BaseURL,
"error": err.Error(),
}, http.StatusOK
}
accounts := s.weixinChannel.ListAccounts()
if usingDraft {
weixinCfg = ch.SnapshotConfig()
}
accounts := ch.ListAccounts()
pendingPayload := make([]map[string]interface{}, 0, len(pendingLogins))
for _, pending := range pendingLogins {
pendingPayload = append(pendingPayload, map[string]interface{}{
@@ -1416,10 +1889,13 @@ func (s *Server) webUIWeixinStatusPayload(ctx context.Context) (map[string]inter
firstPending = pendingLogins[0]
}
return map[string]interface{}{
"ok": true,
"enabled": weixinCfg.Enabled,
"base_url": fallbackString(weixinCfg.BaseURL, "https://ilinkai.weixin.qq.com"),
"pending_logins": pendingPayload,
"ok": true,
"enabled": weixinCfg.Enabled,
"config_enabled": persistedCfg.Enabled,
"runtime_enabled": ch.IsRunning(),
"draft_dirty": usingDraft,
"base_url": fallbackString(weixinCfg.BaseURL, "https://ilinkai.weixin.qq.com"),
"pending_logins": pendingPayload,
"pending_login": map[string]interface{}{
"login_id": pendingString(firstPending, "login_id"),
"qr_code": pendingString(firstPending, "qr_code"),
@@ -2908,10 +3384,25 @@ func (s *Server) handleWebUISessions(w http.ResponseWriter, r *http.Request) {
continue
}
name := e.Name()
if !strings.HasSuffix(name, ".jsonl") || strings.Contains(name, ".deleted.") {
key := ""
switch {
case strings.HasSuffix(name, ".meta.json"):
key = strings.TrimSuffix(name, ".meta.json")
case strings.HasSuffix(name, ".active.jsonl"):
key = strings.TrimSuffix(name, ".active.jsonl")
case strings.HasSuffix(name, ".jsonl") && !strings.Contains(name, ".deleted."):
key = strings.TrimSuffix(name, ".jsonl")
if strings.HasSuffix(key, ".active") {
key = strings.TrimSuffix(key, ".active")
}
if idx := strings.LastIndex(key, "."); idx > 0 {
if seqPart := key[idx+1:]; len(seqPart) == 4 && regexp.MustCompile(`^\d{4}$`).MatchString(seqPart) {
key = key[:idx]
}
}
default:
continue
}
key := strings.TrimSuffix(name, ".jsonl")
if strings.TrimSpace(key) == "" {
continue
}
@@ -2935,6 +3426,65 @@ func (s *Server) handleWebUISessions(w http.ResponseWriter, r *http.Request) {
writeJSON(w, map[string]interface{}{"ok": true, "sessions": out})
}
func (s *Server) handleWebUISessionSearch(w http.ResponseWriter, r *http.Request) {
if !s.checkAuth(r) {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
if s.onSessionSearch == nil {
writeJSON(w, map[string]interface{}{"ok": true, "results": []interface{}{}})
return
}
queryText := strings.TrimSpace(r.URL.Query().Get("query"))
if queryText == "" {
writeJSON(w, map[string]interface{}{"ok": true, "results": []interface{}{}})
return
}
kinds := splitCSVQueryParam(r.URL.Query()["kinds"])
if len(kinds) == 0 {
kinds = splitCSVQueryParam([]string{r.URL.Query().Get("kind")})
}
excludeCurrent := false
if raw := strings.TrimSpace(r.URL.Query().Get("exclude_current")); raw != "" {
excludeCurrent = raw == "1" || strings.EqualFold(raw, "true") || strings.EqualFold(raw, "yes")
}
query := SessionSearchQuery{
Query: queryText,
Limit: queryBoundedPositiveInt(r, "limit", 5, 100),
Kinds: kinds,
ExcludeCurrent: excludeCurrent,
Session: strings.TrimSpace(r.URL.Query().Get("session")),
}
writeJSON(w, map[string]interface{}{
"ok": true,
"query": query.Query,
"results": s.onSessionSearch(query),
})
}
func splitCSVQueryParam(values []string) []string {
out := make([]string, 0, len(values))
seen := map[string]struct{}{}
for _, value := range values {
for _, item := range strings.Split(value, ",") {
item = strings.TrimSpace(item)
if item == "" {
continue
}
if _, ok := seen[item]; ok {
continue
}
seen[item] = struct{}{}
out = append(out, item)
}
}
return out
}
func isUserFacingSessionKey(key string) bool {
k := strings.ToLower(strings.TrimSpace(key))
if k == "" {
@@ -2983,9 +3533,6 @@ func (s *Server) handleWebUIMemory(w http.ResponseWriter, r *http.Request) {
path := strings.TrimSpace(r.URL.Query().Get("path"))
if path == "" {
files := make([]string, 0, 16)
if _, err := os.Stat(filepath.Join(s.workspacePath, "MEMORY.md")); err == nil {
files = append(files, "MEMORY.md")
}
entries, err := os.ReadDir(memoryDir)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
@@ -3000,11 +3547,7 @@ func (s *Server) handleWebUIMemory(w http.ResponseWriter, r *http.Request) {
writeJSON(w, map[string]interface{}{"ok": true, "files": files})
return
}
baseDir := memoryDir
if strings.EqualFold(path, "MEMORY.md") {
baseDir = strings.TrimSpace(s.workspacePath)
}
clean, content, found, err := readRelativeTextFile(baseDir, path)
clean, content, found, err := readRelativeTextFile(memoryDir, path)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
@@ -3080,6 +3623,70 @@ func (s *Server) handleWebUIWorkspaceFile(w http.ResponseWriter, r *http.Request
}
}
var workspaceDocFiles = []string{
"AGENTS.md",
"BOOT.md",
"BOOTSTRAP.md",
"HEARTBEAT.md",
"IDENTITY.md",
"MEMORY.md",
"SOUL.md",
"TOOLS.md",
"USER.md",
}
func (s *Server) handleWebUIWorkspaceDocs(w http.ResponseWriter, r *http.Request) {
if !s.checkAuth(r) {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
workspace := strings.TrimSpace(s.workspacePath)
path := strings.TrimSpace(r.URL.Query().Get("path"))
if path != "" {
if !isWorkspaceDocAllowed(path) {
http.Error(w, "invalid path", http.StatusBadRequest)
return
}
clean, content, found, err := readRelativeTextFile(workspace, path)
if err != nil {
http.Error(w, err.Error(), relativeFilePathStatus(err))
return
}
if !found {
http.Error(w, os.ErrNotExist.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, map[string]interface{}{"ok": true, "path": clean, "content": content})
return
}
files := make([]string, 0, len(workspaceDocFiles))
for _, name := range workspaceDocFiles {
_, _, found, err := readRelativeTextFile(workspace, name)
if err != nil {
http.Error(w, err.Error(), relativeFilePathStatus(err))
return
}
if !found {
continue
}
files = append(files, name)
}
writeJSON(w, map[string]interface{}{"ok": true, "files": files})
}
func isWorkspaceDocAllowed(name string) bool {
for _, allowed := range workspaceDocFiles {
if name == allowed {
return true
}
}
return false
}
func (s *Server) handleWebUILogsRecent(w http.ResponseWriter, r *http.Request) {
if !s.checkAuth(r) {
http.Error(w, "unauthorized", http.StatusUnauthorized)

View File

@@ -12,6 +12,7 @@ import (
"testing"
"time"
"github.com/YspCoder/clawgo/pkg/bus"
cfgpkg "github.com/YspCoder/clawgo/pkg/config"
"github.com/gorilla/websocket"
)
@@ -139,6 +140,197 @@ func TestHandleWebUIConfigPostSavesNormalizedConfig(t *testing.T) {
}
}
func TestHandleWebUIChannelDraftCommitPersistsDrafts(t *testing.T) {
t.Parallel()
tmp := t.TempDir()
cfgPath := filepath.Join(tmp, "config.json")
cfg := cfgpkg.DefaultConfig()
if err := cfgpkg.SaveConfig(cfgPath, cfg); err != nil {
t.Fatalf("save config: %v", err)
}
srv := NewServer("127.0.0.1", 0, "")
srv.SetConfigPath(cfgPath)
srv.SetMessageBus(bus.NewMessageBus())
hookCalled := 0
srv.SetConfigAfterHook(func(forceRuntimeReload bool) error {
hookCalled++
return nil
})
draftReq := httptest.NewRequest(http.MethodPost, "/api/channels/draft", strings.NewReader(`{"channel":"telegram","config":{"enabled":true,"token":"bot-token","streaming":true}}`))
draftReq.Header.Set("Content-Type", "application/json")
draftRec := httptest.NewRecorder()
srv.handleWebUIChannelDraft(draftRec, draftReq)
if draftRec.Code != http.StatusOK {
t.Fatalf("expected 200 from draft save, got %d: %s", draftRec.Code, draftRec.Body.String())
}
commitReq := httptest.NewRequest(http.MethodPost, "/api/channels/draft/commit", nil)
commitRec := httptest.NewRecorder()
srv.handleWebUIChannelDraftCommit(commitRec, commitReq)
if commitRec.Code != http.StatusOK {
t.Fatalf("expected 200 from draft commit, got %d: %s", commitRec.Code, commitRec.Body.String())
}
if hookCalled != 1 {
t.Fatalf("expected reload hook once, got %d", hookCalled)
}
updated, err := cfgpkg.LoadConfig(cfgPath)
if err != nil {
t.Fatalf("reload config: %v", err)
}
if !updated.Channels.Telegram.Enabled {
t.Fatalf("expected telegram enabled after draft commit")
}
if updated.Channels.Telegram.Token != "bot-token" {
t.Fatalf("expected telegram token to persist, got %q", updated.Channels.Telegram.Token)
}
}
func TestHandleWebUIWeixinStatusReflectsDraftRuntime(t *testing.T) {
t.Parallel()
tmp := t.TempDir()
cfgPath := filepath.Join(tmp, "config.json")
cfg := cfgpkg.DefaultConfig()
cfg.Channels.Weixin.Enabled = false
if err := cfgpkg.SaveConfig(cfgPath, cfg); err != nil {
t.Fatalf("save config: %v", err)
}
srv := NewServer("127.0.0.1", 0, "")
srv.SetConfigPath(cfgPath)
srv.SetMessageBus(bus.NewMessageBus())
draftReq := httptest.NewRequest(http.MethodPost, "/api/channels/draft", strings.NewReader(`{"channel":"weixin","config":{"enabled":true,"base_url":"https://ilinkai.weixin.qq.com"}}`))
draftReq.Header.Set("Content-Type", "application/json")
draftRec := httptest.NewRecorder()
srv.handleWebUIChannelDraft(draftRec, draftReq)
if draftRec.Code != http.StatusOK {
t.Fatalf("expected 200 from weixin draft save, got %d: %s", draftRec.Code, draftRec.Body.String())
}
statusReq := httptest.NewRequest(http.MethodGet, "/api/weixin/status", nil)
statusRec := httptest.NewRecorder()
srv.handleWebUIWeixinStatus(statusRec, statusReq)
if statusRec.Code != http.StatusOK {
t.Fatalf("expected 200 from status, got %d: %s", statusRec.Code, statusRec.Body.String())
}
var payload map[string]interface{}
if err := json.Unmarshal(statusRec.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode status: %v", err)
}
if payload["draft_dirty"] != true {
t.Fatalf("expected draft_dirty=true, got %#v", payload["draft_dirty"])
}
if payload["config_enabled"] != false {
t.Fatalf("expected config_enabled=false, got %#v", payload["config_enabled"])
}
if payload["runtime_enabled"] != true {
t.Fatalf("expected runtime_enabled=true, got %#v", payload["runtime_enabled"])
}
}
func TestHandleWebUIConfigPostPersistsChannelDrafts(t *testing.T) {
t.Parallel()
tmp := t.TempDir()
cfgPath := filepath.Join(tmp, "config.json")
cfg := cfgpkg.DefaultConfig()
cfg.Channels.Weixin.Enabled = false
if err := cfgpkg.SaveConfig(cfgPath, cfg); err != nil {
t.Fatalf("save config: %v", err)
}
srv := NewServer("127.0.0.1", 0, "")
srv.SetConfigPath(cfgPath)
srv.SetMessageBus(bus.NewMessageBus())
srv.SetConfigAfterHook(func(forceRuntimeReload bool) error { return nil })
draftReq := httptest.NewRequest(http.MethodPost, "/api/channels/draft", strings.NewReader(`{"channel":"weixin","config":{"enabled":true,"base_url":"https://ilinkai.weixin.qq.com"}}`))
draftReq.Header.Set("Content-Type", "application/json")
draftRec := httptest.NewRecorder()
srv.handleWebUIChannelDraft(draftRec, draftReq)
if draftRec.Code != http.StatusOK {
t.Fatalf("expected 200 from weixin draft save, got %d: %s", draftRec.Code, draftRec.Body.String())
}
saveReq := httptest.NewRequest(http.MethodPost, "/api/config", strings.NewReader(`{"gateway":{"host":"127.0.0.1","port":7788,"token":"abc"},"logging":{"enabled":false,"persist":false,"level":"debug","file":"logs/app.log","format":"text"},"models":{"providers":{"openai":{"api_base":"https://api.openai.com/v1","auth":"bearer","api_key":"secret","models":["gpt-5"],"timeout_sec":120}}},"tools":{"shell":{"enabled":true},"mcp":{"enabled":false}},"agents":{"defaults":{"model":{"primary":"openai/gpt-5"},"max_tool_iterations":10,"execution":{"run_state_ttl_seconds":3600,"run_state_max":128,"tool_parallel_safe_names":[],"tool_max_parallel_calls":4}},"router":{"enabled":false,"policy":{"intent_max_input_chars":2000,"max_rounds_without_user":3}},"subagents":{}},"channels":{"telegram":{"enabled":true,"token":"bot-token"}},"cron":{"enabled":false},"sentinel":{"enabled":false}}`))
saveReq.Header.Set("Content-Type", "application/json")
saveRec := httptest.NewRecorder()
srv.handleWebUIConfig(saveRec, saveReq)
if saveRec.Code != http.StatusOK {
t.Fatalf("expected 200 from config save, got %d: %s", saveRec.Code, saveRec.Body.String())
}
updated, err := cfgpkg.LoadConfig(cfgPath)
if err != nil {
t.Fatalf("reload config: %v", err)
}
if !updated.Channels.Weixin.Enabled {
t.Fatalf("expected weixin enabled after config save with drafts")
}
srv.draftMu.Lock()
defer srv.draftMu.Unlock()
if srv.channelDrafts.Weixin != nil {
t.Fatalf("expected weixin draft cleared after config save")
}
}
func TestHandleWebUIWeixinLoginStartBootstrapsDraftRuntime(t *testing.T) {
t.Parallel()
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet || r.URL.Path != "/ilink/bot/get_bot_qrcode" {
http.NotFound(w, r)
return
}
_ = json.NewEncoder(w).Encode(map[string]string{
"qrcode": "wx-qr",
"qrcode_img_content": "wx-qr-img",
})
}))
defer upstream.Close()
tmp := t.TempDir()
cfgPath := filepath.Join(tmp, "config.json")
cfg := cfgpkg.DefaultConfig()
cfg.Channels.Weixin.Enabled = false
cfg.Channels.Weixin.BaseURL = upstream.URL
if err := cfgpkg.SaveConfig(cfgPath, cfg); err != nil {
t.Fatalf("save config: %v", err)
}
srv := NewServer("127.0.0.1", 0, "")
srv.SetConfigPath(cfgPath)
srv.SetMessageBus(bus.NewMessageBus())
req := httptest.NewRequest(http.MethodPost, "/api/weixin/login/start", nil)
rec := httptest.NewRecorder()
srv.handleWebUIWeixinLoginStart(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200 from login start, got %d: %s", rec.Code, rec.Body.String())
}
var payload map[string]interface{}
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode response: %v", err)
}
if payload["draft_dirty"] != true {
t.Fatalf("expected draft_dirty=true, got %#v", payload["draft_dirty"])
}
if payload["config_enabled"] != false {
t.Fatalf("expected config_enabled=false, got %#v", payload["config_enabled"])
}
if payload["runtime_enabled"] != true {
t.Fatalf("expected runtime_enabled=true, got %#v", payload["runtime_enabled"])
}
}
func TestWithCORSEchoesPreflightHeaders(t *testing.T) {
t.Parallel()
@@ -216,6 +408,73 @@ func TestHandleWebUISessionsHidesInternalSessionsByDefault(t *testing.T) {
}
}
func TestHandleWebUIChatHistorySupportsWindowQuery(t *testing.T) {
t.Parallel()
srv := NewServer("127.0.0.1", 0, "")
var got ChatHistoryQuery
srv.SetChatHistoryHandler(func(query ChatHistoryQuery) []map[string]interface{} {
got = query
return []map[string]interface{}{{"role": "assistant", "content": "ok"}}
})
req := httptest.NewRequest(http.MethodGet, "/api/chat/history?session=alpha&after=2&limit=3", nil)
rec := httptest.NewRecorder()
srv.handleWebUIChatHistory(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
}
if got.Session != "alpha" || got.After != 2 || got.Limit != 3 {
t.Fatalf("unexpected query: %+v", got)
}
}
func TestHandleWebUISessionSearchReturnsResults(t *testing.T) {
t.Parallel()
srv := NewServer("127.0.0.1", 0, "")
var got SessionSearchQuery
srv.SetSessionSearchHandler(func(query SessionSearchQuery) []map[string]interface{} {
got = query
return []map[string]interface{}{
{
"key": "main",
"kind": "main",
"updated_at": int64(123),
"summary": "deploy notes",
"score": 2,
"snippets": []map[string]interface{}{{"seq": 3, "content": "deploy timeout"}},
},
}
})
req := httptest.NewRequest(http.MethodGet, "/api/sessions/search?query=deploy&kinds=main,cron&exclude_current=1&session=current&limit=7", nil)
rec := httptest.NewRecorder()
srv.handleWebUISessionSearch(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
}
if got.Query != "deploy" || got.Session != "current" || !got.ExcludeCurrent || got.Limit != 7 {
t.Fatalf("unexpected search query: %+v", got)
}
if len(got.Kinds) != 2 || got.Kinds[0] != "main" || got.Kinds[1] != "cron" {
t.Fatalf("unexpected kinds: %+v", got.Kinds)
}
var payload struct {
OK bool `json:"ok"`
Results []map[string]interface{} `json:"results"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode payload: %v", err)
}
if !payload.OK || len(payload.Results) != 1 {
t.Fatalf("unexpected payload: %+v", payload)
}
}
func TestSaveProviderConfigForcesRuntimeReload(t *testing.T) {
t.Parallel()
@@ -255,13 +514,10 @@ func TestSaveProviderConfigForcesRuntimeReload(t *testing.T) {
}
}
func TestHandleWebUIMemoryListsAndReadsWorkspaceMemoryFile(t *testing.T) {
func TestHandleWebUIMemoryListsAndReadsMemoryDirFile(t *testing.T) {
t.Parallel()
tmp := t.TempDir()
if err := os.WriteFile(filepath.Join(tmp, "MEMORY.md"), []byte("# long-term\n"), 0o644); err != nil {
t.Fatalf("write workspace memory: %v", err)
}
if err := os.MkdirAll(filepath.Join(tmp, "memory"), 0o755); err != nil {
t.Fatalf("mkdir memory dir: %v", err)
}
@@ -285,11 +541,11 @@ func TestHandleWebUIMemoryListsAndReadsWorkspaceMemoryFile(t *testing.T) {
if err := json.Unmarshal(listRec.Body.Bytes(), &listPayload); err != nil {
t.Fatalf("decode list payload: %v", err)
}
if len(listPayload.Files) < 2 || listPayload.Files[0] != "MEMORY.md" {
t.Fatalf("expected MEMORY.md in memory file list, got %+v", listPayload.Files)
if len(listPayload.Files) != 1 || listPayload.Files[0] != "2026-03-19.md" {
t.Fatalf("expected only memory dir files, got %+v", listPayload.Files)
}
readReq := httptest.NewRequest(http.MethodGet, "/api/memory?path=MEMORY.md", nil)
readReq := httptest.NewRequest(http.MethodGet, "/api/memory?path=2026-03-19.md", nil)
readRec := httptest.NewRecorder()
srv.handleWebUIMemory(readRec, readReq)
if readRec.Code != http.StatusOK {
@@ -303,11 +559,71 @@ func TestHandleWebUIMemoryListsAndReadsWorkspaceMemoryFile(t *testing.T) {
if err := json.Unmarshal(readRec.Body.Bytes(), &readPayload); err != nil {
t.Fatalf("decode read payload: %v", err)
}
if readPayload.Path != "MEMORY.md" || readPayload.Content != "# long-term\n" {
if readPayload.Path != "2026-03-19.md" || readPayload.Content != "daily\n" {
t.Fatalf("unexpected memory payload: %+v", readPayload)
}
}
func TestHandleWebUIWorkspaceDocsListAndRead(t *testing.T) {
t.Parallel()
tmp := t.TempDir()
if err := os.WriteFile(filepath.Join(tmp, "AGENTS.md"), []byte("agents\n"), 0o644); err != nil {
t.Fatalf("write AGENTS.md: %v", err)
}
if err := os.WriteFile(filepath.Join(tmp, "MEMORY.md"), []byte("memory\n"), 0o644); err != nil {
t.Fatalf("write MEMORY.md: %v", err)
}
srv := NewServer("127.0.0.1", 0, "")
srv.SetWorkspacePath(tmp)
req := httptest.NewRequest(http.MethodGet, "/api/workspace_docs", nil)
rec := httptest.NewRecorder()
srv.handleWebUIWorkspaceDocs(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
}
var payload struct {
OK bool `json:"ok"`
Files []string `json:"files"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode payload: %v", err)
}
if !payload.OK {
t.Fatalf("expected ok=true, got %+v", payload)
}
if len(payload.Files) != 2 {
t.Fatalf("expected 2 existing docs, got %+v", payload.Files)
}
if payload.Files[0] != "AGENTS.md" {
t.Fatalf("unexpected first doc payload: %+v", payload.Files[0])
}
if payload.Files[1] != "MEMORY.md" {
t.Fatalf("unexpected second doc payload: %+v", payload.Files[1])
}
readReq := httptest.NewRequest(http.MethodGet, "/api/workspace_docs?path=AGENTS.md", nil)
readRec := httptest.NewRecorder()
srv.handleWebUIWorkspaceDocs(readRec, readReq)
if readRec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", readRec.Code, readRec.Body.String())
}
var readPayload struct {
OK bool `json:"ok"`
Path string `json:"path"`
Content string `json:"content"`
}
if err := json.Unmarshal(readRec.Body.Bytes(), &readPayload); err != nil {
t.Fatalf("decode read payload: %v", err)
}
if readPayload.Path != "AGENTS.md" || readPayload.Content != "agents\n" {
t.Fatalf("unexpected read payload: %+v", readPayload)
}
}
func TestHandleWebUIChatLive(t *testing.T) {
t.Parallel()