From 4b76196e2ca7f1d6364bb7e8b27410b271cb4007 Mon Sep 17 00:00:00 2001 From: wenjie Date: Thu, 16 Apr 2026 16:47:23 +0800 Subject: [PATCH 01/31] refactor(web): secure Pico websocket access behind launcher auth - stop exposing the raw Pico token to the frontend - add /api/pico/info for non-secret Pico connection metadata - proxy /pico/ws through the launcher with same-origin and dashboard auth checks - inject the upstream Pico websocket protocol server-side - update frontend chat connection flow and Vite websocket proxy path - refresh related docs and tests --- docs/guides/docker.md | 2 +- pkg/channels/pico/protocol.go | 2 - pkg/gateway/gateway.go | 24 +-- web/backend/api/config.go | 4 - web/backend/api/gateway.go | 32 +-- web/backend/api/gateway_host_test.go | 12 +- web/backend/api/gateway_test.go | 12 ++ web/backend/api/pico.go | 191 ++++++++++++------ web/backend/api/pico_test.go | 91 ++++++--- .../middleware/launcher_dashboard_auth.go | 4 + .../launcher_dashboard_auth_test.go | 20 ++ web/frontend/src/api/pico.ts | 16 +- web/frontend/src/features/chat/controller.ts | 12 +- web/frontend/vite.config.ts | 2 +- 14 files changed, 253 insertions(+), 171 deletions(-) diff --git a/docs/guides/docker.md b/docs/guides/docker.md index 6c32879a6..3ccc7a2a7 100644 --- a/docs/guides/docker.md +++ b/docs/guides/docker.md @@ -27,7 +27,7 @@ docker compose -f docker/docker-compose.yml --profile gateway up -d > **Docker Users**: By default, the Gateway listens on `127.0.0.1` which is not accessible from the host. If you need to access the health endpoints or expose ports, set `PICOCLAW_GATEWAY_HOST=0.0.0.0` in your environment or update `config.json`. > [!NOTE] -> The `gateway` profile only serves the webhook handlers (including Pico when enabled) and health endpoints on the gateway port, so it does not expose generic REST chat endpoints such as `/chat` or `/a2a`. Launcher mode adds the browser UI plus `/api/pico/token` and a `/pico/ws` proxy on the launcher port, but `/pico/ws` is also available directly on the gateway whenever the Pico channel is enabled. +> The `gateway` profile only serves the webhook handlers (including Pico when enabled) and health endpoints on the gateway port, so it does not expose generic REST chat endpoints such as `/chat` or `/a2a`. Launcher mode adds the browser UI plus `/api/pico/info` and an authenticated `/pico/ws` proxy on the launcher port, but `/pico/ws` is also available directly on the gateway whenever the Pico channel is enabled. ```bash # 5. Check logs diff --git a/pkg/channels/pico/protocol.go b/pkg/channels/pico/protocol.go index ecdc2d140..051beed1b 100644 --- a/pkg/channels/pico/protocol.go +++ b/pkg/channels/pico/protocol.go @@ -18,8 +18,6 @@ const ( TypeError = "error" TypePong = "pong" - PicoTokenPrefix = "pico-" - PayloadKeyContent = "content" PayloadKeyThought = "thought" diff --git a/pkg/gateway/gateway.go b/pkg/gateway/gateway.go index 039f45075..f58590d5b 100644 --- a/pkg/gateway/gateway.go +++ b/pkg/gateway/gateway.go @@ -9,7 +9,6 @@ import ( "path/filepath" "sort" "strconv" - "strings" "sync" "sync/atomic" "syscall" @@ -27,7 +26,7 @@ import ( _ "github.com/sipeed/picoclaw/pkg/channels/line" _ "github.com/sipeed/picoclaw/pkg/channels/maixcam" _ "github.com/sipeed/picoclaw/pkg/channels/onebot" - "github.com/sipeed/picoclaw/pkg/channels/pico" + _ "github.com/sipeed/picoclaw/pkg/channels/pico" _ "github.com/sipeed/picoclaw/pkg/channels/qq" _ "github.com/sipeed/picoclaw/pkg/channels/slack" _ "github.com/sipeed/picoclaw/pkg/channels/teams_webhook" @@ -316,8 +315,6 @@ func executeReload( ) error { defer runningServices.reloading.Store(false) - overridePicoToken(newCfg, runningServices.authToken) - return handleConfigReload(ctx, agentLoop, newCfg, provider, runningServices, msgBus, allowEmptyStartup, debug) } @@ -386,8 +383,6 @@ func setupAndStartServices( fms.Start() } - overridePicoToken(cfg, authToken) - runningServices.ChannelManager, err = channels.NewManager(cfg, msgBus, runningServices.MediaStore) if err != nil { if fms, ok := runningServices.MediaStore.(*media.FileMediaStore); ok { @@ -788,23 +783,6 @@ func setupCronTool( return cronService, nil } -// overridePicoToken replaces the pico channel token with the one from the PID file. -// The PID file is the single source of truth for the pico auth token; -// it is generated once at gateway startup and remains unchanged across reloads. -func overridePicoToken(cfg *config.Config, token string) { - picoBC := cfg.Channels.GetByType(config.ChannelPico) - if picoBC == nil || !picoBC.Enabled { - return - } - var picoCfg config.PicoSettings - picoBC.Decode(&picoCfg) - picoToken := picoCfg.Token.String() - if picoToken == "" || strings.HasPrefix(picoToken, pico.PicoTokenPrefix) { - return - } - picoCfg.SetToken(pico.PicoTokenPrefix + token + picoToken) -} - func createHeartbeatHandler(agentLoop *agent.AgentLoop) func(prompt, channel, chatID string) *tools.ToolResult { return func(prompt, channel, chatID string) *tools.ToolResult { if channel == "" || chatID == "" { diff --git a/web/backend/api/config.go b/web/backend/api/config.go index 80ab80f35..c7bd21197 100644 --- a/web/backend/api/config.go +++ b/web/backend/api/config.go @@ -94,8 +94,6 @@ func (h *Handler) handleUpdateConfig(w http.ResponseWriter, r *http.Request) { return } - // Refresh cached pico token in case user changed it. - refreshPicoToken(&cfg) h.applyRuntimeLogLevel() logger.Infof("configuration updated successfully") @@ -193,8 +191,6 @@ func (h *Handler) handlePatchConfig(w http.ResponseWriter, r *http.Request) { return } - // Refresh cached pico token in case user changed it. - refreshPicoToken(&newCfg) h.applyRuntimeLogLevel() logger.Infof("configuration updated successfully") diff --git a/web/backend/api/gateway.go b/web/backend/api/gateway.go index fa5652323..ea43789d3 100644 --- a/web/backend/api/gateway.go +++ b/web/backend/api/gateway.go @@ -17,7 +17,6 @@ import ( "syscall" "time" - "github.com/sipeed/picoclaw/pkg/channels/pico" "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/health" "github.com/sipeed/picoclaw/pkg/logger" @@ -37,28 +36,12 @@ var gateway = struct { startupDeadline time.Time logs *LogBuffer pidData *ppid.PidFileData // pid file data read from picoclaw.pid.json - picoToken string // cached pico token from config (for proxy auth validation) + picoToken string // cached raw pico token for upstream gateway proxy injection }{ runtimeStatus: "stopped", logs: NewLogBuffer(200), } -// refreshPicoToken updates gateway.picoToken from cfg -func refreshPicoToken(cfg *config.Config) { - gateway.mu.Lock() - defer gateway.mu.Unlock() - var picoCfg config.PicoSettings - if bc := cfg.Channels.GetByType(config.ChannelPico); bc != nil { - decoded, err := bc.GetDecoded() - if err == nil && decoded != nil { - if p, ok := decoded.(*config.PicoSettings); ok { - picoCfg = *p - } - } - } - gateway.picoToken = picoCfg.Token.String() -} - // refreshPicoTokensLocked reads the pico token from config and caches it. // Caller must hold gateway.mu (or be sole writer). func refreshPicoTokensLocked(configPath string) { @@ -101,18 +84,15 @@ const ( tokenPrefix = "token." ) -// picoComposedToken returns "pico-"+pidToken+picoToken for gateway auth. -func picoComposedToken(token string) string { +// picoGatewayProtocol returns the gateway-facing pico subprotocol that the +// launcher should inject when proxying browser traffic upstream. +func picoGatewayProtocol() string { gateway.mu.Lock() defer gateway.mu.Unlock() - // if not initial pico token, don't allow gateway auth - if gateway.picoToken == "" || gateway.pidData == nil { + if gateway.picoToken == "" { return "" } - if tokenPrefix+gateway.picoToken != token { - return "" - } - return pico.PicoTokenPrefix + gateway.pidData.Token + gateway.picoToken + return tokenPrefix + gateway.picoToken } var ( diff --git a/web/backend/api/gateway_host_test.go b/web/backend/api/gateway_host_test.go index d0fc26d7b..c9802b30b 100644 --- a/web/backend/api/gateway_host_test.go +++ b/web/backend/api/gateway_host_test.go @@ -50,7 +50,7 @@ func TestBuildWsURLUsesRequestHostWhenLauncherPublicSaved(t *testing.T) { cfg.Gateway.Host = "127.0.0.1" cfg.Gateway.Port = 18790 - req := httptest.NewRequest("GET", "http://launcher.local/api/pico/token", nil) + req := httptest.NewRequest("GET", "http://launcher.local/api/pico/info", nil) req.Host = "192.168.1.9:18800" if got := h.buildWsURL(req); got != "ws://192.168.1.9:18800/pico/ws" { @@ -181,7 +181,7 @@ func TestBuildWsURLUsesWSSWhenForwardedProtoIsHTTPS(t *testing.T) { cfg.Gateway.Host = "0.0.0.0" cfg.Gateway.Port = 18790 - req := httptest.NewRequest("GET", "http://launcher.local/api/pico/token", nil) + req := httptest.NewRequest("GET", "http://launcher.local/api/pico/info", nil) req.Host = "chat.example.com" req.Header.Set("X-Forwarded-Proto", "https") @@ -198,7 +198,7 @@ func TestBuildWsURLUsesWSSWhenRequestIsTLS(t *testing.T) { cfg.Gateway.Host = "0.0.0.0" cfg.Gateway.Port = 18790 - req := httptest.NewRequest("GET", "https://launcher.local/api/pico/token", nil) + req := httptest.NewRequest("GET", "https://launcher.local/api/pico/info", nil) req.Host = "secure.example.com" req.TLS = &tls.ConnectionState{} @@ -224,7 +224,7 @@ func TestBuildPicoURLsPreferXForwardedHost(t *testing.T) { cfg.Gateway.Host = "0.0.0.0" cfg.Gateway.Port = 18790 - req := httptest.NewRequest("GET", "http://127.0.0.1:18800/api/pico/token", nil) + req := httptest.NewRequest("GET", "http://127.0.0.1:18800/api/pico/info", nil) req.Host = "127.0.0.1:18800" req.Header.Set("X-Forwarded-Host", "vscode-tunnel.example.com") req.Header.Set("X-Forwarded-Proto", "https") @@ -249,7 +249,7 @@ func TestBuildWsURLPrefersForwardedHTTPOverTLS(t *testing.T) { cfg.Gateway.Host = "0.0.0.0" cfg.Gateway.Port = 18790 - req := httptest.NewRequest("GET", "https://launcher.local/api/pico/token", nil) + req := httptest.NewRequest("GET", "https://launcher.local/api/pico/info", nil) req.Host = "chat.example.com" req.TLS = &tls.ConnectionState{} req.Header.Set("X-Forwarded-Proto", "http") @@ -264,7 +264,7 @@ func TestBuildWsURLUsesRequestHostNotGatewayBindLoopback(t *testing.T) { h := NewHandler(configPath) h.SetServerOptions(18800, false, false, nil) - req := httptest.NewRequest("GET", "http://localhost:18800/api/pico/token", nil) + req := httptest.NewRequest("GET", "http://localhost:18800/api/pico/info", nil) req.Host = "localhost:18800" if got := h.buildWsURL(req); got != "ws://localhost:18800/pico/ws" { diff --git a/web/backend/api/gateway_test.go b/web/backend/api/gateway_test.go index 78bf34a63..998ed3317 100644 --- a/web/backend/api/gateway_test.go +++ b/web/backend/api/gateway_test.go @@ -121,6 +121,18 @@ func resetGatewayTestState(t *testing.T) { }) } +func TestPicoGatewayProtocol(t *testing.T) { + resetGatewayTestState(t) + + gateway.mu.Lock() + gateway.picoToken = "ui-token" + gateway.mu.Unlock() + + if got := picoGatewayProtocol(); got != tokenPrefix+"ui-token" { + t.Fatalf("picoGatewayProtocol() = %q, want %q", got, tokenPrefix+"ui-token") + } +} + type gatewayStartEnvSnapshot struct { GatewayHost string `json:"gateway_host"` GatewayHostSet bool `json:"gateway_host_set"` diff --git a/web/backend/api/pico.go b/web/backend/api/pico.go index 00ffb8bb2..5e4848b01 100644 --- a/web/backend/api/pico.go +++ b/web/backend/api/pico.go @@ -5,8 +5,11 @@ import ( "encoding/hex" "encoding/json" "fmt" + "net" "net/http" "net/http/httputil" + "net/url" + "strings" "time" "github.com/sipeed/picoclaw/pkg/config" @@ -16,7 +19,7 @@ import ( // registerPicoRoutes binds Pico Channel management endpoints to the ServeMux. func (h *Handler) registerPicoRoutes(mux *http.ServeMux) { - mux.HandleFunc("GET /api/pico/token", h.handleGetPicoToken) + mux.HandleFunc("GET /api/pico/info", h.handleGetPicoInfo) mux.HandleFunc("POST /api/pico/token", h.handleRegenPicoToken) mux.HandleFunc("POST /api/pico/setup", h.handlePicoSetup) @@ -28,12 +31,15 @@ func (h *Handler) registerPicoRoutes(mux *http.ServeMux) { // createWsProxy creates a reverse proxy to the current gateway WebSocket endpoint. // The gateway bind host and port are resolved from the latest configuration. -func (h *Handler) createWsProxy(origProtocol string, token string) *httputil.ReverseProxy { +func (h *Handler) createWsProxy(origProtocol string, upstreamProtocol string) *httputil.ReverseProxy { wsProxy := &httputil.ReverseProxy{ Rewrite: func(r *httputil.ProxyRequest) { target := h.gatewayProxyURL() r.SetURL(target) - r.Out.Header.Set(protocolKey, tokenPrefix+token) + r.Out.Header.Del(protocolKey) + if upstreamProtocol != "" { + r.Out.Header.Set(protocolKey, upstreamProtocol) + } }, ModifyResponse: func(r *http.Response) error { if prot := r.Header.Values(protocolKey); len(prot) > 0 { @@ -52,10 +58,104 @@ func (h *Handler) createWsProxy(origProtocol string, token string) *httputil.Rev return wsProxy } +func canonicalOrigin(raw string) (string, bool) { + u, err := url.Parse(strings.TrimSpace(raw)) + if err != nil || u == nil { + return "", false + } + + scheme := strings.ToLower(strings.TrimSpace(u.Scheme)) + if scheme != "http" && scheme != "https" { + return "", false + } + + host := strings.TrimSpace(u.Hostname()) + if host == "" { + return "", false + } + + port := u.Port() + if port == "" { + if scheme == "https" { + port = "443" + } else { + port = "80" + } + } + + return scheme + "://" + net.JoinHostPort(host, port), true +} + +func (h *Handler) expectedPicoProxyOrigin(r *http.Request) string { + return requestHTTPScheme(r) + "://" + h.picoWebUIAddr(r) +} + +func (h *Handler) validPicoProxyOrigin(r *http.Request) bool { + want, ok := canonicalOrigin(h.expectedPicoProxyOrigin(r)) + if !ok { + return false + } + + got, ok := canonicalOrigin(r.Header.Get("Origin")) + if !ok { + return false + } + + return got == want +} + +func decodePicoSettings(cfg *config.Config) (config.PicoSettings, bool) { + if cfg == nil { + return config.PicoSettings{}, false + } + + bc := cfg.Channels.GetByType(config.ChannelPico) + if bc == nil { + return config.PicoSettings{}, false + } + + var picoCfg config.PicoSettings + if err := bc.Decode(&picoCfg); err != nil { + return config.PicoSettings{}, false + } + + return picoCfg, bc.Enabled +} + +func (h *Handler) writePicoInfoResponse( + w http.ResponseWriter, + r *http.Request, + cfg *config.Config, + changed *bool, +) { + picoCfg, enabled := decodePicoSettings(cfg) + + resp := map[string]any{ + "ws_url": h.buildWsURL(r), + "enabled": enabled, + } + if changed != nil { + resp["changed"] = *changed + } + if picoCfg.Token.String() != "" { + resp["configured"] = true + } + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(resp) +} + // handleWebSocketProxy wraps a reverse proxy to handle WebSocket connections. -// It validates the client token before forwarding; rejects immediately on failure. +// It relies on launcher dashboard auth and same-origin browser access, then +// injects the raw pico token only on the upstream gateway request. func (h *Handler) handleWebSocketProxy() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { + if !h.validPicoProxyOrigin(r) { + logger.Warnf("Invalid Pico WebSocket origin: %q", r.Header.Get("Origin")) + http.Error(w, "Forbidden", http.StatusForbidden) + return + } + gateway.mu.Lock() ensurePicoTokenCachedLocked(h.configPath) cachedPID := gateway.pidData @@ -91,51 +191,38 @@ func (h *Handler) handleWebSocketProxy() http.HandlerFunc { http.Error(w, "Gateway not available", http.StatusServiceUnavailable) return } - prot := r.Header.Values(protocolKey) - if len(prot) > 0 { - origProtocol := prot[0] - newToken := picoComposedToken(prot[0]) - if newToken != "" { - h.createWsProxy(origProtocol, newToken).ServeHTTP(w, r) - return - } + + upstreamProtocol := picoGatewayProtocol() + if upstreamProtocol == "" { + logger.Warn("Pico token unavailable for WebSocket proxy") + http.Error(w, "Pico channel not configured", http.StatusServiceUnavailable) + return } - logger.Warnf("Invalid Pico token: %v", prot) - http.Error(w, "Invalid Pico token", http.StatusForbidden) + var origProtocol string + if prot := r.Header.Values(protocolKey); len(prot) > 0 { + origProtocol = prot[0] + } + + h.createWsProxy(origProtocol, upstreamProtocol).ServeHTTP(w, r) } } -// handleGetPicoToken returns the current WS token and URL for the frontend. +// handleGetPicoInfo returns non-secret Pico connection info for the launcher UI. // -// GET /api/pico/token -func (h *Handler) handleGetPicoToken(w http.ResponseWriter, r *http.Request) { +// GET /api/pico/info +func (h *Handler) handleGetPicoInfo(w http.ResponseWriter, r *http.Request) { cfg, err := config.LoadConfig(h.configPath) if err != nil { http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError) return } - wsURL := h.buildWsURL(r) - - w.Header().Set("Content-Type", "application/json") - bc := cfg.Channels.GetByType(config.ChannelPico) - var picoCfg config.PicoSettings - if bc != nil { - bc.Decode(&picoCfg) - } - enabled := false - if bc != nil { - enabled = bc.Enabled - } - json.NewEncoder(w).Encode(map[string]any{ - "token": picoCfg.Token.String(), - "ws_url": wsURL, - "enabled": enabled, - }) + h.writePicoInfoResponse(w, r, cfg, nil) } -// handleRegenPicoToken generates a new Pico WebSocket token and saves it. +// handleRegenPicoToken rotates the raw Pico WebSocket token and returns +// non-secret connection info for the launcher UI. // // POST /api/pico/token func (h *Handler) handleRegenPicoToken(w http.ResponseWriter, r *http.Request) { @@ -160,18 +247,7 @@ func (h *Handler) handleRegenPicoToken(w http.ResponseWriter, r *http.Request) { return } - // Refresh cached pico token. - gateway.mu.Lock() - gateway.picoToken = token - gateway.mu.Unlock() - - wsURL := h.buildWsURL(r) - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]any{ - "token": token, - "ws_url": wsURL, - }) + h.writePicoInfoResponse(w, r, cfg, nil) } // EnsurePicoChannel enables the Pico channel with sane defaults if it isn't @@ -234,31 +310,14 @@ func (h *Handler) handlePicoSetup(w http.ResponseWriter, r *http.Request) { return } - // Reload config (EnsurePicoChannel may have modified it) and refresh cache. + // Reload config (EnsurePicoChannel may have modified it). cfg, err := config.LoadConfig(h.configPath) if err != nil { http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError) return } - if changed { - refreshPicoToken(cfg) - } - wsURL := h.buildWsURL(r) - - var picoCfg2 config.PicoSettings - if bc := cfg.Channels.GetByType(config.ChannelPico); bc != nil { - if decoded, err := bc.GetDecoded(); err == nil && decoded != nil { - picoCfg2 = *decoded.(*config.PicoSettings) - } - } - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]any{ - "token": picoCfg2.Token.String(), - "ws_url": wsURL, - "enabled": true, - "changed": changed, - }) + h.writePicoInfoResponse(w, r, cfg, &changed) } // generateSecureToken creates a random 32-character hex string. diff --git a/web/backend/api/pico_test.go b/web/backend/api/pico_test.go index 807c796dc..146f9e697 100644 --- a/web/backend/api/pico_test.go +++ b/web/backend/api/pico_test.go @@ -11,11 +11,16 @@ import ( "strconv" "testing" - "github.com/sipeed/picoclaw/pkg/channels/pico" "github.com/sipeed/picoclaw/pkg/config" ppid "github.com/sipeed/picoclaw/pkg/pid" ) +func newPicoProxyRequest(method, path string) *http.Request { + req := httptest.NewRequest(method, "http://launcher.local:18800"+path, nil) + req.Header.Set("Origin", "http://launcher.local:18800") + return req +} + func TestEnsurePicoChannel_FreshConfig(t *testing.T) { configPath := filepath.Join(t.TempDir(), "config.json") h := NewHandler(configPath) @@ -365,8 +370,8 @@ func TestHandlePicoSetup_Response(t *testing.T) { t.Fatalf("failed to decode response: %v", err) } - if resp["token"] == nil || resp["token"] == "" { - t.Error("response should contain a non-empty token") + if _, ok := resp["token"]; ok { + t.Error("response must not expose the raw pico token") } if resp["ws_url"] == nil || resp["ws_url"] == "" { t.Error("response should contain ws_url") @@ -377,6 +382,45 @@ func TestHandlePicoSetup_Response(t *testing.T) { if resp["changed"] != true { t.Error("response should have changed=true on first setup") } + if resp["configured"] != true { + t.Error("response should have configured=true") + } +} + +func TestHandleGetPicoInfo_OmitsToken(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "config.json") + h := NewHandler(configPath) + + if _, err := h.EnsurePicoChannel(""); err != nil { + t.Fatalf("EnsurePicoChannel() error = %v", err) + } + + req := httptest.NewRequest(http.MethodGet, "http://launcher.local/api/pico/info", nil) + rec := httptest.NewRecorder() + + h.handleGetPicoInfo(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) + } + + var resp map[string]any + if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + + if _, ok := resp["token"]; ok { + t.Fatal("info response must not expose the raw pico token") + } + if resp["enabled"] != true { + t.Fatalf("enabled = %#v, want true", resp["enabled"]) + } + if resp["configured"] != true { + t.Fatalf("configured = %#v, want true", resp["configured"]) + } + if resp["ws_url"] == nil || resp["ws_url"] == "" { + t.Fatal("response should contain ws_url") + } } func TestHandleWebSocketProxyReloadsGatewayTargetFromConfig(t *testing.T) { @@ -438,20 +482,10 @@ func TestHandleWebSocketProxyReloadsGatewayTargetFromConfig(t *testing.T) { gateway.pidData = &ppid.PidFileData{} gateway.picoToken = "pico" - req1 := httptest.NewRequest(http.MethodGet, "/pico/ws", nil) - req1.Header.Set(protocolKey, tokenPrefix+"wrong_token") + req1 := newPicoProxyRequest(http.MethodGet, "/pico/ws") rec1 := httptest.NewRecorder() handler(rec1, req1) - if rec1.Code != http.StatusForbidden { - t.Fatalf("first status = %d, want %d", rec1.Code, http.StatusForbidden) - } - - req1 = httptest.NewRequest(http.MethodGet, "/pico/ws", nil) - req1.Header.Set(protocolKey, tokenPrefix+"pico") - rec1 = httptest.NewRecorder() - handler(rec1, req1) - if rec1.Code != http.StatusOK { t.Fatalf("first status = %d, want %d", rec1.Code, http.StatusOK) } @@ -464,8 +498,7 @@ func TestHandleWebSocketProxyReloadsGatewayTargetFromConfig(t *testing.T) { t.Fatalf("SaveConfig() error = %v", err) } - req2 := httptest.NewRequest(http.MethodGet, "/pico/ws", nil) - req2.Header.Set(protocolKey, tokenPrefix+"pico") + req2 := newPicoProxyRequest(http.MethodGet, "/pico/ws") rec2 := httptest.NewRecorder() handler(rec2, req2) @@ -539,8 +572,7 @@ func TestHandleWebSocketProxyLoadsCachedPicoTokenWhenMissing(t *testing.T) { gateway.pidData = &ppid.PidFileData{} gateway.picoToken = "" - req := httptest.NewRequest(http.MethodGet, "/pico/ws?session_id=test-session", nil) - req.Header.Set(protocolKey, tokenPrefix+"cached-token") + req := newPicoProxyRequest(http.MethodGet, "/pico/ws?session_id=test-session") rec := httptest.NewRecorder() handler(rec, req) @@ -625,8 +657,7 @@ func TestHandleWebSocketProxyLoadsPidDataOnDemand(t *testing.T) { setGatewayRuntimeStatusLocked("stopped") gateway.mu.Unlock() - req := httptest.NewRequest(http.MethodGet, "/pico/ws?session_id=test-session", nil) - req.Header.Set(protocolKey, tokenPrefix+"ui-token") + req := newPicoProxyRequest(http.MethodGet, "/pico/ws?session_id=test-session") rec := httptest.NewRecorder() handler(rec, req) @@ -634,7 +665,7 @@ func TestHandleWebSocketProxyLoadsPidDataOnDemand(t *testing.T) { t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) } - expected := tokenPrefix + pico.PicoTokenPrefix + pidData.Token + "ui-token" + expected := tokenPrefix + "ui-token" if got := rec.Body.String(); got != expected { t.Fatalf("forwarded protocol = %q, want %q", got, expected) } @@ -696,8 +727,7 @@ func TestHandleWebSocketProxyRejectsStalePidDataAfterProcessExit(t *testing.T) { setGatewayRuntimeStatusLocked("running") gateway.mu.Unlock() - req := httptest.NewRequest(http.MethodGet, "/pico/ws?session_id=test-session", nil) - req.Header.Set(protocolKey, tokenPrefix+"ui-token") + req := newPicoProxyRequest(http.MethodGet, "/pico/ws?session_id=test-session") rec := httptest.NewRecorder() handler(rec, req) @@ -711,6 +741,21 @@ func TestHandleWebSocketProxyRejectsStalePidDataAfterProcessExit(t *testing.T) { } } +func TestHandleWebSocketProxyRejectsInvalidOrigin(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "config.json") + h := NewHandler(configPath) + handler := h.handleWebSocketProxy() + + req := httptest.NewRequest(http.MethodGet, "http://launcher.local/pico/ws", nil) + req.Header.Set("Origin", "http://evil.example") + rec := httptest.NewRecorder() + handler(rec, req) + + if rec.Code != http.StatusForbidden { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusForbidden) + } +} + func mustGatewayTestPort(t *testing.T, rawURL string) int { t.Helper() diff --git a/web/backend/middleware/launcher_dashboard_auth.go b/web/backend/middleware/launcher_dashboard_auth.go index c1c4c19c6..d72bd0f00 100644 --- a/web/backend/middleware/launcher_dashboard_auth.go +++ b/web/backend/middleware/launcher_dashboard_auth.go @@ -218,6 +218,10 @@ func validLauncherDashboardAuth(r *http.Request, cfg LauncherDashboardAuthConfig } func rejectLauncherDashboardAuth(w http.ResponseWriter, r *http.Request, canonicalPath string) { + if canonicalPath == "/pico/ws" { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } if strings.HasPrefix(canonicalPath, "/api/") { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusUnauthorized) diff --git a/web/backend/middleware/launcher_dashboard_auth_test.go b/web/backend/middleware/launcher_dashboard_auth_test.go index 1b919bf96..7b7418998 100644 --- a/web/backend/middleware/launcher_dashboard_auth_test.go +++ b/web/backend/middleware/launcher_dashboard_auth_test.go @@ -40,6 +40,7 @@ func TestLauncherDashboardAuth_AllowsPublicPaths(t *testing.T) { {http.MethodPost, "/api/auth/logout", http.StatusTeapot}, {http.MethodGet, "/api/auth/logout", http.StatusUnauthorized}, {http.MethodGet, "/api/config", http.StatusUnauthorized}, + {http.MethodGet, "/pico/ws", http.StatusUnauthorized}, } { rec := httptest.NewRecorder() req := httptest.NewRequest(tc.method, tc.path, nil) @@ -160,3 +161,22 @@ func TestLauncherDashboardAuth_CookieAndBearer(t *testing.T) { t.Fatalf("bearer auth: status = %d", rec2.Code) } } + +func TestLauncherDashboardAuth_WebSocketUnauthorizedDoesNotRedirect(t *testing.T) { + cfg := LauncherDashboardAuthConfig{ExpectedCookie: "deadbeef", Token: "x"} + next := http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) { + t.Fatal("next handler should not run without auth") + }) + h := LauncherDashboardAuth(cfg, next) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/pico/ws", nil) + h.ServeHTTP(rec, req) + + if rec.Code != http.StatusUnauthorized { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusUnauthorized) + } + if got := rec.Header().Get("Location"); got != "" { + t.Fatalf("Location = %q, want empty", got) + } +} diff --git a/web/frontend/src/api/pico.ts b/web/frontend/src/api/pico.ts index 6b8ceb49a..ca98a06da 100644 --- a/web/frontend/src/api/pico.ts +++ b/web/frontend/src/api/pico.ts @@ -2,16 +2,16 @@ import { launcherFetch } from "@/api/http" // API client for Pico Channel configuration. -interface PicoTokenResponse { - token: string +interface PicoInfoResponse { ws_url: string enabled: boolean + configured?: boolean } interface PicoSetupResponse { - token: string ws_url: string enabled: boolean + configured?: boolean changed: boolean } @@ -25,16 +25,16 @@ async function request(path: string, options?: RequestInit): Promise { return res.json() as Promise } -export async function getPicoToken(): Promise { - return request("/api/pico/token") +export async function getPicoInfo(): Promise { + return request("/api/pico/info") } -export async function regenPicoToken(): Promise { - return request("/api/pico/token", { method: "POST" }) +export async function regenPicoToken(): Promise { + return request("/api/pico/token", { method: "POST" }) } export async function setupPico(): Promise { return request("/api/pico/setup", { method: "POST" }) } -export type { PicoTokenResponse, PicoSetupResponse } +export type { PicoInfoResponse, PicoSetupResponse } diff --git a/web/frontend/src/features/chat/controller.ts b/web/frontend/src/features/chat/controller.ts index 28ef491fa..183b1ba6f 100644 --- a/web/frontend/src/features/chat/controller.ts +++ b/web/frontend/src/features/chat/controller.ts @@ -1,7 +1,6 @@ import { getDefaultStore } from "jotai" import { toast } from "sonner" -import { getPicoToken } from "@/api/pico" import { loadSessionMessages, mergeHistoryMessages, @@ -131,7 +130,6 @@ export async function connectChat() { updateChatStore({ connectionState: "connecting" }) try { - const { token } = await getPicoToken() const sessionId = activeSessionIdRef if (generation !== connectionGeneration) { @@ -139,18 +137,10 @@ export async function connectChat() { return } - if (!token) { - console.error("No pico token available") - updateChatStore({ connectionState: "error" }) - isConnecting = false - scheduleReconnect(generation, sessionId) - return - } - const wsScheme = window.location.protocol === "https:" ? "wss:" : "ws:" const wsUrl = `${wsScheme}//${window.location.host}/pico/ws` const url = `${wsUrl}?session_id=${encodeURIComponent(sessionId)}` - const socket = new WebSocket(url, [`token.${token}`]) + const socket = new WebSocket(url) if (generation !== connectionGeneration) { isConnecting = false diff --git a/web/frontend/vite.config.ts b/web/frontend/vite.config.ts index 0ef4e1415..57512c8b9 100644 --- a/web/frontend/vite.config.ts +++ b/web/frontend/vite.config.ts @@ -29,7 +29,7 @@ export default defineConfig({ target: "http://localhost:18800", changeOrigin: true, }, - "/ws": { + "/pico/ws": { target: "ws://localhost:18800", ws: true, }, From d002e1517ba1670d094a3752a6ff469e7c8cd00e Mon Sep 17 00:00:00 2001 From: wenjie Date: Thu, 16 Apr 2026 18:31:42 +0800 Subject: [PATCH 02/31] fix(web): improve Pico URL and origin handling behind proxies - read client scheme from X-Forwarded-Proto and RFC 7239 Forwarded - derive client-visible ports from forwarded host information - add coverage for HTTPS origins without explicit ports - verify behavior when proxies omit forwarded protocol headers --- web/backend/api/gateway_host.go | 50 ++++++++++++++++++++-------- web/backend/api/gateway_host_test.go | 29 ++++++++++++---- web/backend/api/pico_test.go | 27 +++++++++++++++ 3 files changed, 86 insertions(+), 20 deletions(-) diff --git a/web/backend/api/gateway_host.go b/web/backend/api/gateway_host.go index c6c2073e2..03af7a9d3 100644 --- a/web/backend/api/gateway_host.go +++ b/web/backend/api/gateway_host.go @@ -85,8 +85,22 @@ func requestHostName(r *http.Request) string { return netbind.ResolveAdaptiveLoopbackHost() } +func forwardedProtoFirst(r *http.Request) string { + raw := strings.TrimSpace(r.Header.Get("X-Forwarded-Proto")) + if raw == "" { + raw = forwardedRFC7239Proto(r) + } + if raw == "" { + return "" + } + if i := strings.IndexByte(raw, ','); i >= 0 { + raw = strings.TrimSpace(raw[:i]) + } + return strings.ToLower(raw) +} + func requestWSScheme(r *http.Request) string { - if forwarded := strings.TrimSpace(r.Header.Get("X-Forwarded-Proto")); forwarded != "" { + if forwarded := forwardedProtoFirst(r); forwarded != "" { proto := strings.ToLower(strings.TrimSpace(strings.Split(forwarded, ",")[0])) if proto == "https" || proto == "wss" { return "wss" @@ -105,7 +119,7 @@ func requestWSScheme(r *http.Request) string { // requestHTTPScheme returns http or https for URLs that are not WebSockets (e.g. SSE). func requestHTTPScheme(r *http.Request) string { - if forwarded := strings.TrimSpace(r.Header.Get("X-Forwarded-Proto")); forwarded != "" { + if forwarded := forwardedProtoFirst(r); forwarded != "" { proto := strings.ToLower(strings.TrimSpace(strings.Split(forwarded, ",")[0])) if proto == "https" || proto == "wss" { return "https" @@ -117,6 +131,7 @@ func requestHTTPScheme(r *http.Request) string { if r.TLS != nil { return "https" } + return "http" } @@ -138,6 +153,14 @@ func forwardedHostFirst(r *http.Request) string { // forwardedRFC7239Host parses host= from the first Forwarded header element (RFC 7239). func forwardedRFC7239Host(r *http.Request) string { + return forwardedRFC7239Param(r, "host") +} + +func forwardedRFC7239Proto(r *http.Request) string { + return forwardedRFC7239Param(r, "proto") +} + +func forwardedRFC7239Param(r *http.Request, key string) string { v := strings.TrimSpace(r.Header.Get("Forwarded")) if v == "" { return "" @@ -146,7 +169,7 @@ func forwardedRFC7239Host(r *http.Request) string { for _, part := range strings.Split(first, ";") { part = strings.TrimSpace(part) low := strings.ToLower(part) - if !strings.HasPrefix(low, "host=") { + if !strings.HasPrefix(low, key+"=") { continue } val := strings.TrimSpace(part[strings.IndexByte(part, '=')+1:]) @@ -177,13 +200,21 @@ func clientVisiblePort(r *http.Request, serverListenPort int) string { if p := forwardedPortFirst(r); p != "" { return p } + if fwdHost := forwardedHostFirst(r); fwdHost != "" { + if _, port, err := net.SplitHostPort(fwdHost); err == nil && port != "" { + return port + } + } if _, port, err := net.SplitHostPort(r.Host); err == nil && port != "" { return port } + if strings.TrimSpace(r.Host) == "" && forwardedHostFirst(r) == "" { + return strconv.Itoa(serverListenPort) + } if requestHTTPScheme(r) == "https" { return "443" } - return strconv.Itoa(serverListenPort) + return "80" } // joinClientVisibleHostPort builds host:port for absolute URLs returned to the browser. @@ -205,16 +236,7 @@ func (h *Handler) picoWebUIAddr(r *http.Request) string { if fwdHost := forwardedHostFirst(r); fwdHost != "" { return joinClientVisibleHostPort(r, fwdHost, wsPort) } - host := requestHostName(r) - // Use clientVisiblePort only when an explicit port is present in headers - // or Host header — do not infer from TLS/scheme, as serverPort takes priority. - if p := forwardedPortFirst(r); p != "" { - return net.JoinHostPort(host, p) - } - if _, port, err := net.SplitHostPort(r.Host); err == nil && port != "" { - return net.JoinHostPort(host, port) - } - return net.JoinHostPort(host, strconv.Itoa(wsPort)) + return joinClientVisibleHostPort(r, requestHostName(r), wsPort) } func (h *Handler) buildWsURL(r *http.Request) string { diff --git a/web/backend/api/gateway_host_test.go b/web/backend/api/gateway_host_test.go index c9802b30b..54d1010d2 100644 --- a/web/backend/api/gateway_host_test.go +++ b/web/backend/api/gateway_host_test.go @@ -185,8 +185,8 @@ func TestBuildWsURLUsesWSSWhenForwardedProtoIsHTTPS(t *testing.T) { req.Host = "chat.example.com" req.Header.Set("X-Forwarded-Proto", "https") - if got := h.buildWsURL(req); got != "wss://chat.example.com:18800/pico/ws" { - t.Fatalf("buildWsURL() = %q, want %q", got, "wss://chat.example.com:18800/pico/ws") + if got := h.buildWsURL(req); got != "wss://chat.example.com:443/pico/ws" { + t.Fatalf("buildWsURL() = %q, want %q", got, "wss://chat.example.com:443/pico/ws") } } @@ -202,8 +202,8 @@ func TestBuildWsURLUsesWSSWhenRequestIsTLS(t *testing.T) { req.Host = "secure.example.com" req.TLS = &tls.ConnectionState{} - if got := h.buildWsURL(req); got != "wss://secure.example.com:18800/pico/ws" { - t.Fatalf("buildWsURL() = %q, want %q", got, "wss://secure.example.com:18800/pico/ws") + if got := h.buildWsURL(req); got != "wss://secure.example.com:443/pico/ws" { + t.Fatalf("buildWsURL() = %q, want %q", got, "wss://secure.example.com:443/pico/ws") } } @@ -254,8 +254,25 @@ func TestBuildWsURLPrefersForwardedHTTPOverTLS(t *testing.T) { req.TLS = &tls.ConnectionState{} req.Header.Set("X-Forwarded-Proto", "http") - if got := h.buildWsURL(req); got != "ws://chat.example.com:18800/pico/ws" { - t.Fatalf("buildWsURL() = %q, want %q", got, "ws://chat.example.com:18800/pico/ws") + if got := h.buildWsURL(req); got != "ws://chat.example.com:80/pico/ws" { + t.Fatalf("buildWsURL() = %q, want %q", got, "ws://chat.example.com:80/pico/ws") + } +} + +func TestBuildWsURLDoesNotTrustOriginWhenProxyOmitsForwardedProto(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "config.json") + h := NewHandler(configPath) + + req := httptest.NewRequest("GET", "http://launcher.local/api/pico/info", nil) + req.Host = "fs-952210-xwj.picoclaw.lan.sipeed.com" + req.Header.Set("Origin", "https://fs-952210-xwj.picoclaw.lan.sipeed.com") + + if got := h.buildWsURL(req); got != "ws://fs-952210-xwj.picoclaw.lan.sipeed.com:80/pico/ws" { + t.Fatalf( + "buildWsURL() = %q, want %q", + got, + "ws://fs-952210-xwj.picoclaw.lan.sipeed.com:80/pico/ws", + ) } } diff --git a/web/backend/api/pico_test.go b/web/backend/api/pico_test.go index 146f9e697..34b011127 100644 --- a/web/backend/api/pico_test.go +++ b/web/backend/api/pico_test.go @@ -756,6 +756,33 @@ func TestHandleWebSocketProxyRejectsInvalidOrigin(t *testing.T) { } } +func TestValidPicoProxyOriginAcceptsHTTPSOriginWithoutExplicitPort(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "config.json") + h := NewHandler(configPath) + + req := httptest.NewRequest(http.MethodGet, "http://launcher.local/pico/ws", nil) + req.Host = "fs-952210-xwj.picoclaw.lan.sipeed.com" + req.Header.Set("X-Forwarded-Proto", "https") + req.Header.Set("Origin", "https://fs-952210-xwj.picoclaw.lan.sipeed.com") + + if !h.validPicoProxyOrigin(req) { + t.Fatal("validPicoProxyOrigin() = false, want true") + } +} + +func TestValidPicoProxyOriginRejectsHTTPSOriginWhenProxyOmitsForwardedProto(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "config.json") + h := NewHandler(configPath) + + req := httptest.NewRequest(http.MethodGet, "http://launcher.local/pico/ws", nil) + req.Host = "fs-952210-xwj.picoclaw.lan.sipeed.com" + req.Header.Set("Origin", "https://fs-952210-xwj.picoclaw.lan.sipeed.com") + + if h.validPicoProxyOrigin(req) { + t.Fatal("validPicoProxyOrigin() = true, want false") + } +} + func mustGatewayTestPort(t *testing.T, rawURL string) int { t.Helper() From f8190f04b7db62556a8e0cdb63a0a552b60752ce Mon Sep 17 00:00:00 2001 From: wenjie Date: Thu, 16 Apr 2026 19:04:47 +0800 Subject: [PATCH 03/31] fix(web): stop pinning Pico WebSocket origins during setup - remove request-origin seeding from `EnsurePicoChannel` - keep `allow_origins` empty by default for auto-configured Pico channels - relax launcher Pico WebSocket proxy origin validation - update Pico backend tests for the new setup and proxy behavior --- web/backend/api/gateway.go | 2 +- web/backend/api/pico.go | 74 +--------------- web/backend/api/pico_test.go | 159 +++++++++++++++++------------------ web/backend/main.go | 2 +- 4 files changed, 85 insertions(+), 152 deletions(-) diff --git a/web/backend/api/gateway.go b/web/backend/api/gateway.go index ea43789d3..201000ff3 100644 --- a/web/backend/api/gateway.go +++ b/web/backend/api/gateway.go @@ -732,7 +732,7 @@ func (h *Handler) startGatewayLocked(initialStatus string, existingPid int) (int gateway.logs.Reset() // Ensure Pico Channel is configured before starting gateway - changed, err := h.EnsurePicoChannel("") + changed, err := h.EnsurePicoChannel() if err != nil { logger.ErrorC("gateway", fmt.Sprintf("Warning: failed to ensure pico channel: %v", err)) // Non-fatal: gateway can still start without pico channel diff --git a/web/backend/api/pico.go b/web/backend/api/pico.go index 5e4848b01..ffd0796c7 100644 --- a/web/backend/api/pico.go +++ b/web/backend/api/pico.go @@ -5,11 +5,8 @@ import ( "encoding/hex" "encoding/json" "fmt" - "net" "net/http" "net/http/httputil" - "net/url" - "strings" "time" "github.com/sipeed/picoclaw/pkg/config" @@ -58,52 +55,6 @@ func (h *Handler) createWsProxy(origProtocol string, upstreamProtocol string) *h return wsProxy } -func canonicalOrigin(raw string) (string, bool) { - u, err := url.Parse(strings.TrimSpace(raw)) - if err != nil || u == nil { - return "", false - } - - scheme := strings.ToLower(strings.TrimSpace(u.Scheme)) - if scheme != "http" && scheme != "https" { - return "", false - } - - host := strings.TrimSpace(u.Hostname()) - if host == "" { - return "", false - } - - port := u.Port() - if port == "" { - if scheme == "https" { - port = "443" - } else { - port = "80" - } - } - - return scheme + "://" + net.JoinHostPort(host, port), true -} - -func (h *Handler) expectedPicoProxyOrigin(r *http.Request) string { - return requestHTTPScheme(r) + "://" + h.picoWebUIAddr(r) -} - -func (h *Handler) validPicoProxyOrigin(r *http.Request) bool { - want, ok := canonicalOrigin(h.expectedPicoProxyOrigin(r)) - if !ok { - return false - } - - got, ok := canonicalOrigin(r.Header.Get("Origin")) - if !ok { - return false - } - - return got == want -} - func decodePicoSettings(cfg *config.Config) (config.PicoSettings, bool) { if cfg == nil { return config.PicoSettings{}, false @@ -146,16 +97,10 @@ func (h *Handler) writePicoInfoResponse( } // handleWebSocketProxy wraps a reverse proxy to handle WebSocket connections. -// It relies on launcher dashboard auth and same-origin browser access, then -// injects the raw pico token only on the upstream gateway request. +// It relies on launcher dashboard auth, then injects the raw pico token only +// on the upstream gateway request. func (h *Handler) handleWebSocketProxy() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - if !h.validPicoProxyOrigin(r) { - logger.Warnf("Invalid Pico WebSocket origin: %q", r.Header.Get("Origin")) - http.Error(w, "Forbidden", http.StatusForbidden) - return - } - gateway.mu.Lock() ensurePicoTokenCachedLocked(h.configPath) cachedPID := gateway.pidData @@ -252,12 +197,7 @@ func (h *Handler) handleRegenPicoToken(w http.ResponseWriter, r *http.Request) { // EnsurePicoChannel enables the Pico channel with sane defaults if it isn't // already configured. Returns true when the config was modified. -// -// callerOrigin is the Origin header from the setup request. If non-empty and -// no origins are configured yet, it's written as the allowed origin so the -// WebSocket handshake works for whatever host the caller is on (LAN, custom -// port, etc.). Pass "" when there's no request context. -func (h *Handler) EnsurePicoChannel(callerOrigin string) (bool, error) { +func (h *Handler) EnsurePicoChannel() (bool, error) { cfg, err := config.LoadConfig(h.configPath) if err != nil { return false, fmt.Errorf("failed to load config: %w", err) @@ -282,12 +222,6 @@ func (h *Handler) EnsurePicoChannel(callerOrigin string) (bool, error) { picoCfg.Token = *config.NewSecureString(generateSecureToken()) changed = true } - - // Seed origins from the request instead of hardcoding ports. - if len(picoCfg.AllowOrigins) == 0 && callerOrigin != "" { - picoCfg.AllowOrigins = []string{callerOrigin} - changed = true - } } } @@ -304,7 +238,7 @@ func (h *Handler) EnsurePicoChannel(callerOrigin string) (bool, error) { // // POST /api/pico/setup func (h *Handler) handlePicoSetup(w http.ResponseWriter, r *http.Request) { - changed, err := h.EnsurePicoChannel(r.Header.Get("Origin")) + changed, err := h.EnsurePicoChannel() if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return diff --git a/web/backend/api/pico_test.go b/web/backend/api/pico_test.go index 34b011127..a56cd9ba2 100644 --- a/web/backend/api/pico_test.go +++ b/web/backend/api/pico_test.go @@ -25,7 +25,7 @@ func TestEnsurePicoChannel_FreshConfig(t *testing.T) { configPath := filepath.Join(t.TempDir(), "config.json") h := NewHandler(configPath) - changed, err := h.EnsurePicoChannel("") + changed, err := h.EnsurePicoChannel() if err != nil { t.Fatalf("EnsurePicoChannel() error = %v", err) } @@ -56,7 +56,7 @@ func TestEnsurePicoChannel_DoesNotEnableTokenQuery(t *testing.T) { configPath := filepath.Join(t.TempDir(), "config.json") h := NewHandler(configPath) - if _, err := h.EnsurePicoChannel(""); err != nil { + if _, err := h.EnsurePicoChannel(); err != nil { t.Fatalf("EnsurePicoChannel() error = %v", err) } @@ -76,11 +76,11 @@ func TestEnsurePicoChannel_DoesNotEnableTokenQuery(t *testing.T) { } } -func TestEnsurePicoChannel_DoesNotSetWildcardOrigins(t *testing.T) { +func TestEnsurePicoChannel_LeavesAllowOriginsEmptyByDefault(t *testing.T) { configPath := filepath.Join(t.TempDir(), "config.json") h := NewHandler(configPath) - if _, err := h.EnsurePicoChannel("http://localhost:18800"); err != nil { + if _, err := h.EnsurePicoChannel(); err != nil { t.Fatalf("EnsurePicoChannel() error = %v", err) } @@ -95,45 +95,16 @@ func TestEnsurePicoChannel_DoesNotSetWildcardOrigins(t *testing.T) { t.Fatalf("GetDecoded() error = %v", err) } picoCfg := decoded.(*config.PicoSettings) - for _, origin := range picoCfg.AllowOrigins { - if origin == "*" { - t.Error("setup must not set wildcard origin '*'") - } - } -} - -func TestEnsurePicoChannel_NoOriginWithoutCaller(t *testing.T) { - configPath := filepath.Join(t.TempDir(), "config.json") - h := NewHandler(configPath) - - if _, err := h.EnsurePicoChannel(""); err != nil { - t.Fatalf("EnsurePicoChannel() error = %v", err) - } - - cfg, err := config.LoadConfig(configPath) - if err != nil { - t.Fatalf("LoadConfig() error = %v", err) - } - - bc := cfg.Channels["pico"] - decoded, err := bc.GetDecoded() - if err != nil { - t.Fatalf("GetDecoded() error = %v", err) - } - picoCfg := decoded.(*config.PicoSettings) - // Without a caller origin, allow_origins stays empty (CheckOrigin - // allows all when the list is empty, so the channel still works). if len(picoCfg.AllowOrigins) != 0 { - t.Errorf("allow_origins = %v, want empty when no caller origin", picoCfg.AllowOrigins) + t.Errorf("allow_origins = %v, want empty", picoCfg.AllowOrigins) } } -func TestEnsurePicoChannel_SetsCallerOrigin(t *testing.T) { +func TestEnsurePicoChannel_NoOriginConfigurationRequired(t *testing.T) { configPath := filepath.Join(t.TempDir(), "config.json") h := NewHandler(configPath) - lanOrigin := "http://192.168.1.9:18800" - if _, err := h.EnsurePicoChannel(lanOrigin); err != nil { + if _, err := h.EnsurePicoChannel(); err != nil { t.Fatalf("EnsurePicoChannel() error = %v", err) } @@ -148,8 +119,8 @@ func TestEnsurePicoChannel_SetsCallerOrigin(t *testing.T) { t.Fatalf("GetDecoded() error = %v", err) } picoCfg := decoded.(*config.PicoSettings) - if len(picoCfg.AllowOrigins) != 1 || picoCfg.AllowOrigins[0] != lanOrigin { - t.Errorf("allow_origins = %v, want [%s]", picoCfg.AllowOrigins, lanOrigin) + if len(picoCfg.AllowOrigins) != 0 { + t.Errorf("allow_origins = %v, want empty", picoCfg.AllowOrigins) } } @@ -174,7 +145,7 @@ func TestEnsurePicoChannel_PreservesUserSettings(t *testing.T) { h := NewHandler(configPath) - changed, err := h.EnsurePicoChannel("") + changed, err := h.EnsurePicoChannel() if err != nil { t.Fatalf("EnsurePicoChannel() error = %v", err) } @@ -218,7 +189,7 @@ func TestEnsurePicoChannel_ExistingConfigWithoutSecurityFile(t *testing.T) { h := NewHandler(configPath) - changed, err := h.EnsurePicoChannel("") + changed, err := h.EnsurePicoChannel() if err != nil { t.Fatalf("EnsurePicoChannel() error = %v", err) } @@ -258,7 +229,7 @@ func TestEnsurePicoChannel_ConfiguresPicoWithoutGateway(t *testing.T) { } h := NewHandler(configPath) - if _, err := h.EnsurePicoChannel(""); err != nil { + if _, err := h.EnsurePicoChannel(); err != nil { t.Fatalf("EnsurePicoChannel() error = %v", err) } @@ -285,10 +256,8 @@ func TestEnsurePicoChannel_Idempotent(t *testing.T) { configPath := filepath.Join(t.TempDir(), "config.json") h := NewHandler(configPath) - origin := "http://localhost:18800" - // First call sets things up - if _, err := h.EnsurePicoChannel(origin); err != nil { + if _, err := h.EnsurePicoChannel(); err != nil { t.Fatalf("first EnsurePicoChannel() error = %v", err) } @@ -302,7 +271,7 @@ func TestEnsurePicoChannel_Idempotent(t *testing.T) { token1 := picoCfg.Token.String() // Second call should be a no-op - changed, err := h.EnsurePicoChannel(origin) + changed, err := h.EnsurePicoChannel() if err != nil { t.Fatalf("second EnsurePicoChannel() error = %v", err) } @@ -322,7 +291,7 @@ func TestEnsurePicoChannel_Idempotent(t *testing.T) { } } -func TestHandlePicoSetup_IncludesRequestOrigin(t *testing.T) { +func TestHandlePicoSetup_DoesNotPersistRequestOrigin(t *testing.T) { configPath := filepath.Join(t.TempDir(), "config.json") h := NewHandler(configPath) @@ -347,8 +316,8 @@ func TestHandlePicoSetup_IncludesRequestOrigin(t *testing.T) { t.Fatalf("GetDecoded() error = %v", err) } picoCfg := decoded.(*config.PicoSettings) - if len(picoCfg.AllowOrigins) != 1 || picoCfg.AllowOrigins[0] != "http://10.0.0.5:3000" { - t.Errorf("allow_origins = %v, want [http://10.0.0.5:3000]", picoCfg.AllowOrigins) + if len(picoCfg.AllowOrigins) != 0 { + t.Errorf("allow_origins = %v, want empty", picoCfg.AllowOrigins) } } @@ -391,7 +360,7 @@ func TestHandleGetPicoInfo_OmitsToken(t *testing.T) { configPath := filepath.Join(t.TempDir(), "config.json") h := NewHandler(configPath) - if _, err := h.EnsurePicoChannel(""); err != nil { + if _, err := h.EnsurePicoChannel(); err != nil { t.Fatalf("EnsurePicoChannel() error = %v", err) } @@ -741,45 +710,75 @@ func TestHandleWebSocketProxyRejectsStalePidDataAfterProcessExit(t *testing.T) { } } -func TestHandleWebSocketProxyRejectsInvalidOrigin(t *testing.T) { +func TestHandleWebSocketProxy_AllowsArbitraryOrigin(t *testing.T) { + origMatcher := gatewayProcessMatcher + gatewayProcessMatcher = func(int) (bool, bool) { return true, true } + t.Cleanup(func() { gatewayProcessMatcher = origMatcher }) + + home := t.TempDir() + t.Setenv("PICOCLAW_HOME", home) + configPath := filepath.Join(t.TempDir(), "config.json") h := NewHandler(configPath) handler := h.handleWebSocketProxy() - req := httptest.NewRequest(http.MethodGet, "http://launcher.local/pico/ws", nil) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/pico/ws" { + t.Fatalf("path = %q, want %q", r.URL.Path, "/pico/ws") + } + w.WriteHeader(http.StatusOK) + _, _ = io.WriteString(w, "proxied") + })) + defer server.Close() + + cfg := config.DefaultConfig() + cfg.Gateway.Host = "127.0.0.1" + cfg.Gateway.Port = mustGatewayTestPort(t, server.URL) + bc := cfg.Channels["pico"] + bc.Enabled = true + decoded, err := bc.GetDecoded() + if err != nil { + t.Fatalf("GetDecoded() error = %v", err) + } + decoded.(*config.PicoSettings).SetToken("ui-token") + if err := config.SaveConfig(configPath, cfg); err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + cmd := startGatewayLikeProcess(t) + t.Cleanup(func() { + if cmd.Process != nil { + _ = cmd.Process.Kill() + } + _ = cmd.Wait() + }) + writeTestPidFile(t, ppid.PidFileData{ + PID: cmd.Process.Pid, + Token: "test-token", + Host: cfg.Gateway.Host, + Port: cfg.Gateway.Port, + }) + t.Cleanup(func() { + ppid.RemovePidFile(globalConfigDir()) + }) + + origPidData := gateway.pidData + origPicoToken := gateway.picoToken + t.Cleanup(func() { + gateway.pidData = origPidData + gateway.picoToken = origPicoToken + }) + + gateway.pidData = &ppid.PidFileData{} + gateway.picoToken = "ui-token" + + req := httptest.NewRequest(http.MethodGet, "http://launcher.local/pico/ws?session_id=test-session", nil) req.Header.Set("Origin", "http://evil.example") rec := httptest.NewRecorder() handler(rec, req) - if rec.Code != http.StatusForbidden { - t.Fatalf("status = %d, want %d", rec.Code, http.StatusForbidden) - } -} - -func TestValidPicoProxyOriginAcceptsHTTPSOriginWithoutExplicitPort(t *testing.T) { - configPath := filepath.Join(t.TempDir(), "config.json") - h := NewHandler(configPath) - - req := httptest.NewRequest(http.MethodGet, "http://launcher.local/pico/ws", nil) - req.Host = "fs-952210-xwj.picoclaw.lan.sipeed.com" - req.Header.Set("X-Forwarded-Proto", "https") - req.Header.Set("Origin", "https://fs-952210-xwj.picoclaw.lan.sipeed.com") - - if !h.validPicoProxyOrigin(req) { - t.Fatal("validPicoProxyOrigin() = false, want true") - } -} - -func TestValidPicoProxyOriginRejectsHTTPSOriginWhenProxyOmitsForwardedProto(t *testing.T) { - configPath := filepath.Join(t.TempDir(), "config.json") - h := NewHandler(configPath) - - req := httptest.NewRequest(http.MethodGet, "http://launcher.local/pico/ws", nil) - req.Host = "fs-952210-xwj.picoclaw.lan.sipeed.com" - req.Header.Set("Origin", "https://fs-952210-xwj.picoclaw.lan.sipeed.com") - - if h.validPicoProxyOrigin(req) { - t.Fatal("validPicoProxyOrigin() = true, want false") + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) } } diff --git a/web/backend/main.go b/web/backend/main.go index 01ef5edf0..e42558398 100644 --- a/web/backend/main.go +++ b/web/backend/main.go @@ -544,7 +544,7 @@ func main() { // API Routes (e.g. /api/status) apiHandler = api.NewHandler(absPath) apiHandler.SetDebug(debug) - if _, err = apiHandler.EnsurePicoChannel(""); err != nil { + if _, err = apiHandler.EnsurePicoChannel(); err != nil { logger.ErrorC("web", fmt.Sprintf("Warning: failed to ensure pico channel on startup: %v", err)) } apiHandler.SetServerOptions(portNum, effectivePublic, explicitPublic, launcherCfg.AllowedCIDRs) From 8461c996e5ad2f20801622a8eeec931f8966a066 Mon Sep 17 00:00:00 2001 From: wenjie Date: Mon, 20 Apr 2026 11:18:42 +0800 Subject: [PATCH 04/31] chore(web): update linting and router dependencies (#2592) Bump TanStack Router, ESLint, React Hooks plugin, TypeScript ESLint, and Prettier packages. Disable the react-hooks/set-state-in-effect rule in the frontend ESLint config. --- web/frontend/eslint.config.js | 1 + web/frontend/package.json | 14 +- web/frontend/pnpm-lock.yaml | 334 +++++++++++++++++----------------- 3 files changed, 171 insertions(+), 178 deletions(-) diff --git a/web/frontend/eslint.config.js b/web/frontend/eslint.config.js index 85d380c4f..884649e41 100644 --- a/web/frontend/eslint.config.js +++ b/web/frontend/eslint.config.js @@ -22,6 +22,7 @@ export default defineConfig([ globals: globals.browser, }, rules: { + "react-hooks/set-state-in-effect": "off", "react-refresh/only-export-components": [ "warn", { allowConstantExport: true }, diff --git a/web/frontend/package.json b/web/frontend/package.json index ad8ccbf26..835682617 100644 --- a/web/frontend/package.json +++ b/web/frontend/package.json @@ -21,8 +21,8 @@ "@tabler/icons-react": "^3.40.0", "@tailwindcss/vite": "^4.2.2", "@tanstack/react-query": "^5.99.0", - "@tanstack/react-router": "^1.168.22", - "@tanstack/react-router-devtools": "^1.163.3", + "@tanstack/react-router": "^1.168.23", + "@tanstack/react-router-devtools": "^1.166.13", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "dayjs": "^1.11.20", @@ -55,17 +55,17 @@ "@types/node": "^25.6.0", "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", - "@typescript-eslint/eslint-plugin": "^8.57.1", + "@typescript-eslint/eslint-plugin": "^8.58.2", "@vitejs/plugin-react": "^6.0.1", - "eslint": "^10.1.0", + "eslint": "^10.2.1", "eslint-config-prettier": "^10.1.8", - "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-refresh": "^0.5.2", "globals": "^17.5.0", - "prettier": "^3.8.1", + "prettier": "^3.8.3", "prettier-plugin-tailwindcss": "^0.7.2", "typescript": "~5.9.3", - "typescript-eslint": "^8.57.1", + "typescript-eslint": "^8.58.2", "vite": "^8.0.8" } } diff --git a/web/frontend/pnpm-lock.yaml b/web/frontend/pnpm-lock.yaml index 6f01c8003..210c111c5 100644 --- a/web/frontend/pnpm-lock.yaml +++ b/web/frontend/pnpm-lock.yaml @@ -21,11 +21,11 @@ importers: specifier: ^5.99.0 version: 5.99.0(react@19.2.5) '@tanstack/react-router': - specifier: ^1.168.22 - version: 1.168.22(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + specifier: ^1.168.23 + version: 1.168.23(react-dom@19.2.5(react@19.2.5))(react@19.2.5) '@tanstack/react-router-devtools': - specifier: ^1.163.3 - version: 1.166.11(@tanstack/react-router@1.168.22(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(@tanstack/router-core@1.168.15)(csstype@3.2.3)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + specifier: ^1.166.13 + version: 1.166.13(@tanstack/react-router@1.168.23(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(@tanstack/router-core@1.168.15)(csstype@3.2.3)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -98,16 +98,16 @@ importers: devDependencies: '@eslint/js': specifier: ^10.0.1 - version: 10.0.1(eslint@10.1.0(jiti@2.6.1)) + version: 10.0.1(eslint@10.2.1(jiti@2.6.1)) '@tailwindcss/typography': specifier: ^0.5.19 version: 0.5.19(tailwindcss@4.2.2) '@tanstack/router-plugin': specifier: ^1.164.0 - version: 1.167.9(@tanstack/react-router@1.168.22(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)) + version: 1.167.9(@tanstack/react-router@1.168.23(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)) '@trivago/prettier-plugin-sort-imports': specifier: ^6.0.2 - version: 6.0.2(prettier@3.8.1) + version: 6.0.2(prettier@3.8.3) '@types/node': specifier: ^25.6.0 version: 25.6.0 @@ -118,38 +118,38 @@ importers: specifier: ^19.2.3 version: 19.2.3(@types/react@19.2.14) '@typescript-eslint/eslint-plugin': - specifier: ^8.57.1 - version: 8.57.2(@typescript-eslint/parser@8.57.2(eslint@10.1.0(jiti@2.6.1))(typescript@5.9.3))(eslint@10.1.0(jiti@2.6.1))(typescript@5.9.3) + specifier: ^8.58.2 + version: 8.58.2(@typescript-eslint/parser@8.58.2(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3))(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3) '@vitejs/plugin-react': specifier: ^6.0.1 version: 6.0.1(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)) eslint: - specifier: ^10.1.0 - version: 10.1.0(jiti@2.6.1) + specifier: ^10.2.1 + version: 10.2.1(jiti@2.6.1) eslint-config-prettier: specifier: ^10.1.8 - version: 10.1.8(eslint@10.1.0(jiti@2.6.1)) + version: 10.1.8(eslint@10.2.1(jiti@2.6.1)) eslint-plugin-react-hooks: - specifier: ^7.0.1 - version: 7.0.1(eslint@10.1.0(jiti@2.6.1)) + specifier: ^7.1.1 + version: 7.1.1(eslint@10.2.1(jiti@2.6.1)) eslint-plugin-react-refresh: specifier: ^0.5.2 - version: 0.5.2(eslint@10.1.0(jiti@2.6.1)) + version: 0.5.2(eslint@10.2.1(jiti@2.6.1)) globals: specifier: ^17.5.0 version: 17.5.0 prettier: - specifier: ^3.8.1 - version: 3.8.1 + specifier: ^3.8.3 + version: 3.8.3 prettier-plugin-tailwindcss: specifier: ^0.7.2 - version: 0.7.2(@trivago/prettier-plugin-sort-imports@6.0.2(prettier@3.8.1))(prettier@3.8.1) + version: 0.7.2(@trivago/prettier-plugin-sort-imports@6.0.2(prettier@3.8.3))(prettier@3.8.3) typescript: specifier: ~5.9.3 version: 5.9.3 typescript-eslint: - specifier: ^8.57.1 - version: 8.57.2(eslint@10.1.0(jiti@2.6.1))(typescript@5.9.3) + specifier: ^8.58.2 + version: 8.58.2(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3) vite: specifier: ^8.0.8 version: 8.0.8(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0) @@ -474,16 +474,16 @@ packages: resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} - '@eslint/config-array@0.23.3': - resolution: {integrity: sha512-j+eEWmB6YYLwcNOdlwQ6L2OsptI/LO6lNBuLIqe5R7RetD658HLoF+Mn7LzYmAWWNNzdC6cqP+L6r8ujeYXWLw==} + '@eslint/config-array@0.23.5': + resolution: {integrity: sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} - '@eslint/config-helpers@0.5.3': - resolution: {integrity: sha512-lzGN0onllOZCGroKJmRwY6QcEHxbjBw1gwB8SgRSqK8YbbtEXMvKynsXc3553ckIEBxsbMBU7oOZXKIPGZNeZw==} + '@eslint/config-helpers@0.5.5': + resolution: {integrity: sha512-eIJYKTCECbP/nsKaaruF6LW967mtbQbsw4JTtSVkUQc9MneSkbrgPJAbKl9nWr0ZeowV8BfsarBmPpBzGelA2w==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} - '@eslint/core@1.1.1': - resolution: {integrity: sha512-QUPblTtE51/7/Zhfv8BDwO0qkkzQL7P/aWWbqcf4xWLEYn1oKjdO0gglQBB4GAsu7u6wjijbCmzsUTy6mnk6oQ==} + '@eslint/core@1.2.1': + resolution: {integrity: sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} '@eslint/js@10.0.1': @@ -495,12 +495,12 @@ packages: eslint: optional: true - '@eslint/object-schema@3.0.3': - resolution: {integrity: sha512-iM869Pugn9Nsxbh/YHRqYiqd23AmIbxJOcpUMOuWCVNdoQJ5ZtwL6h3t0bcZzJUlC3Dq9jCFCESBZnX0GTv7iQ==} + '@eslint/object-schema@3.0.5': + resolution: {integrity: sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} - '@eslint/plugin-kit@0.6.1': - resolution: {integrity: sha512-iH1B076HoAshH1mLpHMgwdGeTs0CYwL0SPMkGuSebZrwBp16v415e9NZXg2jtrqPVQjf6IANe2Vtlr5KswtcZQ==} + '@eslint/plugin-kit@0.7.1': + resolution: {integrity: sha512-rZAP3aVgB9ds9KOeUSL+zZ21hPmo8dh6fnIFwRQj5EAZl9gzR7wxYbYXYysAM8CTqGmUGyp2S4kUdV17MnGuWQ==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} '@floating-ui/core@1.7.5': @@ -1570,20 +1570,20 @@ packages: peerDependencies: react: ^18 || ^19 - '@tanstack/react-router-devtools@1.166.11': - resolution: {integrity: sha512-WYR3q4Xui5yPT/5PXtQh8i03iUA7q8dONBjWpV3nsGdM8Cs1FxpfhLstW0wZO1dOvSyElscwTRCJ6nO5N8r3Lg==} + '@tanstack/react-router-devtools@1.166.13': + resolution: {integrity: sha512-6yKRFFJrEEOiGp5RAAuGCYsl81M4XAhJmLcu9PKj+HZle4A3dsP60lwHoqQYWHMK9nKKFkdXR+D8qxzxqtQbEA==} engines: {node: '>=20.19'} peerDependencies: - '@tanstack/react-router': ^1.168.2 - '@tanstack/router-core': ^1.168.2 + '@tanstack/react-router': ^1.168.15 + '@tanstack/router-core': ^1.168.11 react: '>=18.0.0 || >=19.0.0' react-dom: '>=18.0.0 || >=19.0.0' peerDependenciesMeta: '@tanstack/router-core': optional: true - '@tanstack/react-router@1.168.22': - resolution: {integrity: sha512-W2LyfkfJtDCf//jOjZeUBWwOVl8iDRVTECpGHa2M28MT3T5/VVnjgicYNHR/ax0Filk1iU67MRjcjHheTYvK1Q==} + '@tanstack/react-router@1.168.23': + resolution: {integrity: sha512-+GblieDnutG6oipJJPNtRJjrWF8QTZEG/l0532+BngFkVK48oHNOcvIkSoAFYftK1egAwM7KBxXsb0Ou+X6/MQ==} engines: {node: '>=20.19'} peerDependencies: react: '>=18.0.0 || >=19.0.0' @@ -1605,11 +1605,11 @@ packages: engines: {node: '>=20.19'} hasBin: true - '@tanstack/router-devtools-core@1.167.1': - resolution: {integrity: sha512-ECMM47J4KmifUvJguGituSiBpfN8SyCUEoxQks5RY09hpIBfR2eswCv2e6cJimjkKwBQXOVTPkTUk/yRvER+9w==} + '@tanstack/router-devtools-core@1.167.3': + resolution: {integrity: sha512-fJ1VMhyQgnoashTrP763c2HRc9kofgF61L7Jb3F6eTHAmCKtGVx8BRtiFt37sr3U0P0jmaaiiSPGP6nT5JtVNg==} engines: {node: '>=20.19'} peerDependencies: - '@tanstack/router-core': ^1.168.2 + '@tanstack/router-core': ^1.168.11 csstype: ^3.0.10 peerDependenciesMeta: csstype: @@ -1728,63 +1728,63 @@ packages: '@types/validate-npm-package-name@4.0.2': resolution: {integrity: sha512-lrpDziQipxCEeK5kWxvljWYhUvOiB2A9izZd9B2AFarYAkqZshb4lPbRs7zKEic6eGtH8V/2qJW+dPp9OtF6bw==} - '@typescript-eslint/eslint-plugin@8.57.2': - resolution: {integrity: sha512-NZZgp0Fm2IkD+La5PR81sd+g+8oS6JwJje+aRWsDocxHkjyRw0J5L5ZTlN3LI1LlOcGL7ph3eaIUmTXMIjLk0w==} + '@typescript-eslint/eslint-plugin@8.58.2': + resolution: {integrity: sha512-aC2qc5thQahutKjP+cl8cgN9DWe3ZUqVko30CMSZHnFEHyhOYoZSzkGtAI2mcwZ38xeImDucI4dnqsHiOYuuCw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - '@typescript-eslint/parser': ^8.57.2 + '@typescript-eslint/parser': ^8.58.2 eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 - typescript: '>=4.8.4 <6.0.0' + typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/parser@8.57.2': - resolution: {integrity: sha512-30ScMRHIAD33JJQkgfGW1t8CURZtjc2JpTrq5n2HFhOefbAhb7ucc7xJwdWcrEtqUIYJ73Nybpsggii6GtAHjA==} + '@typescript-eslint/parser@8.58.2': + resolution: {integrity: sha512-/Zb/xaIDfxeJnvishjGdcR4jmr7S+bda8PKNhRGdljDM+elXhlvN0FyPSsMnLmJUrVG9aPO6dof80wjMawsASg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 - typescript: '>=4.8.4 <6.0.0' + typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/project-service@8.57.2': - resolution: {integrity: sha512-FuH0wipFywXRTHf+bTTjNyuNQQsQC3qh/dYzaM4I4W0jrCqjCVuUh99+xd9KamUfmCGPvbO8NDngo/vsnNVqgw==} + '@typescript-eslint/project-service@8.58.2': + resolution: {integrity: sha512-Cq6UfpZZk15+r87BkIh5rDpi38W4b+Sjnb8wQCPPDDweS/LRCFjCyViEbzHk5Ck3f2QDfgmlxqSa7S7clDtlfg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - typescript: '>=4.8.4 <6.0.0' + typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/scope-manager@8.57.2': - resolution: {integrity: sha512-snZKH+W4WbWkrBqj4gUNRIGb/jipDW3qMqVJ4C9rzdFc+wLwruxk+2a5D+uoFcKPAqyqEnSb4l2ULuZf95eSkw==} + '@typescript-eslint/scope-manager@8.58.2': + resolution: {integrity: sha512-SgmyvDPexWETQek+qzZnrG6844IaO02UVyOLhI4wpo82dpZJY9+6YZCKAMFzXb7qhx37mFK1QcPQ18tud+vo6Q==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/tsconfig-utils@8.57.2': - resolution: {integrity: sha512-3Lm5DSM+DCowsUOJC+YqHHnKEfFh5CoGkj5Z31NQSNF4l5wdOwqGn99wmwN/LImhfY3KJnmordBq/4+VDe2eKw==} + '@typescript-eslint/tsconfig-utils@8.58.2': + resolution: {integrity: sha512-3SR+RukipDvkkKp/d0jP0dyzuls3DbGmwDpVEc5wqk5f38KFThakqAAO0XMirWAE+kT00oTauTbzMFGPoAzB0A==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - typescript: '>=4.8.4 <6.0.0' + typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/type-utils@8.57.2': - resolution: {integrity: sha512-Co6ZCShm6kIbAM/s+oYVpKFfW7LBc6FXoPXjTRQ449PPNBY8U0KZXuevz5IFuuUj2H9ss40atTaf9dlGLzbWZg==} + '@typescript-eslint/type-utils@8.58.2': + resolution: {integrity: sha512-Z7EloNR/B389FvabdGeTo2XMs4W9TjtPiO9DAsmT0yom0bwlPyRjkJ1uCdW1DvrrrYP50AJZ9Xc3sByZA9+dcg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 - typescript: '>=4.8.4 <6.0.0' + typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/types@8.57.2': - resolution: {integrity: sha512-/iZM6FnM4tnx9csuTxspMW4BOSegshwX5oBDznJ7S4WggL7Vczz5d2W11ecc4vRrQMQHXRSxzrCsyG5EsPPTbA==} + '@typescript-eslint/types@8.58.2': + resolution: {integrity: sha512-9TukXyATBQf/Jq9AMQXfvurk+G5R2MwfqQGDR2GzGz28HvY/lXNKGhkY+6IOubwcquikWk5cjlgPvD2uAA7htQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/typescript-estree@8.57.2': - resolution: {integrity: sha512-2MKM+I6g8tJxfSmFKOnHv2t8Sk3T6rF20A1Puk0svLK+uVapDZB/4pfAeB7nE83uAZrU6OxW+HmOd5wHVdXwXA==} + '@typescript-eslint/typescript-estree@8.58.2': + resolution: {integrity: sha512-ELGuoofuhhoCvNbQjFFiobFcGgcDCEm0ThWdmO4Z0UzLqPXS3KFvnEZ+SHewwOYHjM09tkzOWXNTv9u6Gqtyuw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - typescript: '>=4.8.4 <6.0.0' + typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/utils@8.57.2': - resolution: {integrity: sha512-krRIbvPK1ju1WBKIefiX+bngPs+odIQUtR7kymzPfo1POVw3jlF+nLkmexdSSd4UCbDcQn+wMBATOOmpBbqgKg==} + '@typescript-eslint/utils@8.58.2': + resolution: {integrity: sha512-QZfjHNEzPY8+l0+fIXMvuQ2sJlplB4zgDZvA+NmvZsZv3EQwOcc1DuIU1VJUTWZ/RKouBMhDyNaBMx4sWvrzRA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 - typescript: '>=4.8.4 <6.0.0' + typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/visitor-keys@8.57.2': - resolution: {integrity: sha512-zhahknjobV2FiD6Ee9iLbS7OV9zi10rG26odsQdfBO/hjSzUQbkIYgda+iNKK1zNiW2ey+Lf8MU5btN17V3dUw==} + '@typescript-eslint/visitor-keys@8.58.2': + resolution: {integrity: sha512-f1WO2Lx8a9t8DARmcWAUPJbu0G20bJlj8L4z72K00TMeJAoyLr/tHhI/pzYBLrR4dXWkcxO1cWYZEOX8DKHTqA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@ungap/structured-clone@1.3.0': @@ -2205,11 +2205,11 @@ packages: peerDependencies: eslint: '>=7.0.0' - eslint-plugin-react-hooks@7.0.1: - resolution: {integrity: sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA==} + eslint-plugin-react-hooks@7.1.1: + resolution: {integrity: sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==} engines: {node: '>=18'} peerDependencies: - eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 + eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0 eslint-plugin-react-refresh@0.5.2: resolution: {integrity: sha512-hmgTH57GfzoTFjVN0yBwTggnsVUF2tcqi7RJZHqi9lIezSs4eFyAMktA68YD4r5kNw1mxyY4dmkyoFDb3FIqrA==} @@ -2228,8 +2228,8 @@ packages: resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} - eslint@10.1.0: - resolution: {integrity: sha512-S9jlY/ELKEUwwQnqWDO+f+m6sercqOPSqXM5Go94l7DOmxHVDgmSFGWEzeE/gwgTAr0W103BWt0QLe/7mabIvA==} + eslint@10.2.1: + resolution: {integrity: sha512-wiyGaKsDgqXvF40P8mDwiUp/KQjE1FdrIEJsM8PZ3XCiniTMXS3OHWWUe5FI5agoCnr8x4xPrTDZuxsBlNHl+Q==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} hasBin: true peerDependencies: @@ -3040,10 +3040,6 @@ packages: resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} engines: {node: '>=18'} - minimatch@10.2.4: - resolution: {integrity: sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==} - engines: {node: 18 || 20 || >=22} - minimatch@10.2.5: resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} engines: {node: 18 || 20 || >=22} @@ -3304,8 +3300,8 @@ packages: prettier-plugin-svelte: optional: true - prettier@3.8.1: - resolution: {integrity: sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==} + prettier@3.8.3: + resolution: {integrity: sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==} engines: {node: '>=14'} hasBin: true @@ -3740,12 +3736,12 @@ packages: resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==} engines: {node: '>= 0.6'} - typescript-eslint@8.57.2: - resolution: {integrity: sha512-VEPQ0iPgWO/sBaZOU1xo4nuNdODVOajPnTIbog2GKYr31nIlZ0fWPoCQgGfF3ETyBl1vn63F/p50Um9Z4J8O8A==} + typescript-eslint@8.58.2: + resolution: {integrity: sha512-V8iSng9mRbdZjl54VJ9NKr6ZB+dW0J3TzRXRGcSbLIej9jV86ZRtlYeTKDR/QLxXykocJ5icNzbsl2+5TzIvcQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 - typescript: '>=4.8.4 <6.0.0' + typescript: '>=4.8.4 <6.1.0' typescript@5.9.3: resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} @@ -4310,38 +4306,38 @@ snapshots: '@esbuild/win32-x64@0.27.4': optional: true - '@eslint-community/eslint-utils@4.9.1(eslint@10.1.0(jiti@2.6.1))': + '@eslint-community/eslint-utils@4.9.1(eslint@10.2.1(jiti@2.6.1))': dependencies: - eslint: 10.1.0(jiti@2.6.1) + eslint: 10.2.1(jiti@2.6.1) eslint-visitor-keys: 3.4.3 '@eslint-community/regexpp@4.12.2': {} - '@eslint/config-array@0.23.3': + '@eslint/config-array@0.23.5': dependencies: - '@eslint/object-schema': 3.0.3 + '@eslint/object-schema': 3.0.5 debug: 4.4.3 - minimatch: 10.2.4 + minimatch: 10.2.5 transitivePeerDependencies: - supports-color - '@eslint/config-helpers@0.5.3': + '@eslint/config-helpers@0.5.5': dependencies: - '@eslint/core': 1.1.1 + '@eslint/core': 1.2.1 - '@eslint/core@1.1.1': + '@eslint/core@1.2.1': dependencies: '@types/json-schema': 7.0.15 - '@eslint/js@10.0.1(eslint@10.1.0(jiti@2.6.1))': + '@eslint/js@10.0.1(eslint@10.2.1(jiti@2.6.1))': optionalDependencies: - eslint: 10.1.0(jiti@2.6.1) + eslint: 10.2.1(jiti@2.6.1) - '@eslint/object-schema@3.0.3': {} + '@eslint/object-schema@3.0.5': {} - '@eslint/plugin-kit@0.6.1': + '@eslint/plugin-kit@0.7.1': dependencies: - '@eslint/core': 1.1.1 + '@eslint/core': 1.2.1 levn: 0.4.1 '@floating-ui/core@1.7.5': @@ -5388,10 +5384,10 @@ snapshots: '@tanstack/query-core': 5.99.0 react: 19.2.5 - '@tanstack/react-router-devtools@1.166.11(@tanstack/react-router@1.168.22(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(@tanstack/router-core@1.168.15)(csstype@3.2.3)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + '@tanstack/react-router-devtools@1.166.13(@tanstack/react-router@1.168.23(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(@tanstack/router-core@1.168.15)(csstype@3.2.3)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': dependencies: - '@tanstack/react-router': 1.168.22(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@tanstack/router-devtools-core': 1.167.1(@tanstack/router-core@1.168.15)(csstype@3.2.3) + '@tanstack/react-router': 1.168.23(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@tanstack/router-devtools-core': 1.167.3(@tanstack/router-core@1.168.15)(csstype@3.2.3) react: 19.2.5 react-dom: 19.2.5(react@19.2.5) optionalDependencies: @@ -5399,7 +5395,7 @@ snapshots: transitivePeerDependencies: - csstype - '@tanstack/react-router@1.168.22(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + '@tanstack/react-router@1.168.23(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': dependencies: '@tanstack/history': 1.161.6 '@tanstack/react-store': 0.9.3(react-dom@19.2.5(react@19.2.5))(react@19.2.5) @@ -5429,7 +5425,7 @@ snapshots: seroval: 1.5.1 seroval-plugins: 1.5.1(seroval@1.5.1) - '@tanstack/router-devtools-core@1.167.1(@tanstack/router-core@1.168.15)(csstype@3.2.3)': + '@tanstack/router-devtools-core@1.167.3(@tanstack/router-core@1.168.15)(csstype@3.2.3)': dependencies: '@tanstack/router-core': 1.168.15 clsx: 2.1.1 @@ -5442,7 +5438,7 @@ snapshots: '@tanstack/router-core': 1.168.7 '@tanstack/router-utils': 1.161.6 '@tanstack/virtual-file-routes': 1.161.7 - prettier: 3.8.1 + prettier: 3.8.3 recast: 0.23.11 source-map: 0.7.6 tsx: 4.21.0 @@ -5450,7 +5446,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@tanstack/router-plugin@1.167.9(@tanstack/react-router@1.168.22(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0))': + '@tanstack/router-plugin@1.167.9(@tanstack/react-router@1.168.23(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0))': dependencies: '@babel/core': 7.29.0 '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.0) @@ -5466,7 +5462,7 @@ snapshots: unplugin: 2.3.11 zod: 3.25.76 optionalDependencies: - '@tanstack/react-router': 1.168.22(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@tanstack/react-router': 1.168.23(react-dom@19.2.5(react@19.2.5))(react@19.2.5) vite: 8.0.8(@types/node@25.6.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0) transitivePeerDependencies: - supports-color @@ -5489,7 +5485,7 @@ snapshots: '@tanstack/virtual-file-routes@1.161.7': {} - '@trivago/prettier-plugin-sort-imports@6.0.2(prettier@3.8.1)': + '@trivago/prettier-plugin-sort-imports@6.0.2(prettier@3.8.3)': dependencies: '@babel/generator': 7.29.1 '@babel/parser': 7.29.2 @@ -5499,7 +5495,7 @@ snapshots: lodash-es: 4.17.23 minimatch: 9.0.9 parse-imports-exports: 0.2.4 - prettier: 3.8.1 + prettier: 3.8.3 transitivePeerDependencies: - supports-color @@ -5562,15 +5558,15 @@ snapshots: '@types/validate-npm-package-name@4.0.2': {} - '@typescript-eslint/eslint-plugin@8.57.2(@typescript-eslint/parser@8.57.2(eslint@10.1.0(jiti@2.6.1))(typescript@5.9.3))(eslint@10.1.0(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/eslint-plugin@8.58.2(@typescript-eslint/parser@8.58.2(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3))(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.57.2(eslint@10.1.0(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/scope-manager': 8.57.2 - '@typescript-eslint/type-utils': 8.57.2(eslint@10.1.0(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/utils': 8.57.2(eslint@10.1.0(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 8.57.2 - eslint: 10.1.0(jiti@2.6.1) + '@typescript-eslint/parser': 8.58.2(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.58.2 + '@typescript-eslint/type-utils': 8.58.2(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/utils': 8.58.2(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.58.2 + eslint: 10.2.1(jiti@2.6.1) ignore: 7.0.5 natural-compare: 1.4.0 ts-api-utils: 2.5.0(typescript@5.9.3) @@ -5578,58 +5574,58 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.57.2(eslint@10.1.0(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/parser@8.58.2(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3)': dependencies: - '@typescript-eslint/scope-manager': 8.57.2 - '@typescript-eslint/types': 8.57.2 - '@typescript-eslint/typescript-estree': 8.57.2(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 8.57.2 + '@typescript-eslint/scope-manager': 8.58.2 + '@typescript-eslint/types': 8.58.2 + '@typescript-eslint/typescript-estree': 8.58.2(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.58.2 debug: 4.4.3 - eslint: 10.1.0(jiti@2.6.1) + eslint: 10.2.1(jiti@2.6.1) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/project-service@8.57.2(typescript@5.9.3)': + '@typescript-eslint/project-service@8.58.2(typescript@5.9.3)': dependencies: - '@typescript-eslint/tsconfig-utils': 8.57.2(typescript@5.9.3) - '@typescript-eslint/types': 8.57.2 + '@typescript-eslint/tsconfig-utils': 8.58.2(typescript@5.9.3) + '@typescript-eslint/types': 8.58.2 debug: 4.4.3 typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/scope-manager@8.57.2': + '@typescript-eslint/scope-manager@8.58.2': dependencies: - '@typescript-eslint/types': 8.57.2 - '@typescript-eslint/visitor-keys': 8.57.2 + '@typescript-eslint/types': 8.58.2 + '@typescript-eslint/visitor-keys': 8.58.2 - '@typescript-eslint/tsconfig-utils@8.57.2(typescript@5.9.3)': + '@typescript-eslint/tsconfig-utils@8.58.2(typescript@5.9.3)': dependencies: typescript: 5.9.3 - '@typescript-eslint/type-utils@8.57.2(eslint@10.1.0(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/type-utils@8.58.2(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3)': dependencies: - '@typescript-eslint/types': 8.57.2 - '@typescript-eslint/typescript-estree': 8.57.2(typescript@5.9.3) - '@typescript-eslint/utils': 8.57.2(eslint@10.1.0(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/types': 8.58.2 + '@typescript-eslint/typescript-estree': 8.58.2(typescript@5.9.3) + '@typescript-eslint/utils': 8.58.2(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3) debug: 4.4.3 - eslint: 10.1.0(jiti@2.6.1) + eslint: 10.2.1(jiti@2.6.1) ts-api-utils: 2.5.0(typescript@5.9.3) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/types@8.57.2': {} + '@typescript-eslint/types@8.58.2': {} - '@typescript-eslint/typescript-estree@8.57.2(typescript@5.9.3)': + '@typescript-eslint/typescript-estree@8.58.2(typescript@5.9.3)': dependencies: - '@typescript-eslint/project-service': 8.57.2(typescript@5.9.3) - '@typescript-eslint/tsconfig-utils': 8.57.2(typescript@5.9.3) - '@typescript-eslint/types': 8.57.2 - '@typescript-eslint/visitor-keys': 8.57.2 + '@typescript-eslint/project-service': 8.58.2(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.58.2(typescript@5.9.3) + '@typescript-eslint/types': 8.58.2 + '@typescript-eslint/visitor-keys': 8.58.2 debug: 4.4.3 - minimatch: 10.2.4 + minimatch: 10.2.5 semver: 7.7.4 tinyglobby: 0.2.16 ts-api-utils: 2.5.0(typescript@5.9.3) @@ -5637,20 +5633,20 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.57.2(eslint@10.1.0(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/utils@8.58.2(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3)': dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@10.1.0(jiti@2.6.1)) - '@typescript-eslint/scope-manager': 8.57.2 - '@typescript-eslint/types': 8.57.2 - '@typescript-eslint/typescript-estree': 8.57.2(typescript@5.9.3) - eslint: 10.1.0(jiti@2.6.1) + '@eslint-community/eslint-utils': 4.9.1(eslint@10.2.1(jiti@2.6.1)) + '@typescript-eslint/scope-manager': 8.58.2 + '@typescript-eslint/types': 8.58.2 + '@typescript-eslint/typescript-estree': 8.58.2(typescript@5.9.3) + eslint: 10.2.1(jiti@2.6.1) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/visitor-keys@8.57.2': + '@typescript-eslint/visitor-keys@8.58.2': dependencies: - '@typescript-eslint/types': 8.57.2 + '@typescript-eslint/types': 8.58.2 eslint-visitor-keys: 5.0.1 '@ungap/structured-clone@1.3.0': {} @@ -6013,24 +6009,24 @@ snapshots: escape-string-regexp@5.0.0: {} - eslint-config-prettier@10.1.8(eslint@10.1.0(jiti@2.6.1)): + eslint-config-prettier@10.1.8(eslint@10.2.1(jiti@2.6.1)): dependencies: - eslint: 10.1.0(jiti@2.6.1) + eslint: 10.2.1(jiti@2.6.1) - eslint-plugin-react-hooks@7.0.1(eslint@10.1.0(jiti@2.6.1)): + eslint-plugin-react-hooks@7.1.1(eslint@10.2.1(jiti@2.6.1)): dependencies: '@babel/core': 7.29.0 '@babel/parser': 7.29.2 - eslint: 10.1.0(jiti@2.6.1) + eslint: 10.2.1(jiti@2.6.1) hermes-parser: 0.25.1 zod: 4.3.6 zod-validation-error: 4.0.2(zod@4.3.6) transitivePeerDependencies: - supports-color - eslint-plugin-react-refresh@0.5.2(eslint@10.1.0(jiti@2.6.1)): + eslint-plugin-react-refresh@0.5.2(eslint@10.2.1(jiti@2.6.1)): dependencies: - eslint: 10.1.0(jiti@2.6.1) + eslint: 10.2.1(jiti@2.6.1) eslint-scope@9.1.2: dependencies: @@ -6043,14 +6039,14 @@ snapshots: eslint-visitor-keys@5.0.1: {} - eslint@10.1.0(jiti@2.6.1): + eslint@10.2.1(jiti@2.6.1): dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@10.1.0(jiti@2.6.1)) + '@eslint-community/eslint-utils': 4.9.1(eslint@10.2.1(jiti@2.6.1)) '@eslint-community/regexpp': 4.12.2 - '@eslint/config-array': 0.23.3 - '@eslint/config-helpers': 0.5.3 - '@eslint/core': 1.1.1 - '@eslint/plugin-kit': 0.6.1 + '@eslint/config-array': 0.23.5 + '@eslint/config-helpers': 0.5.5 + '@eslint/core': 1.2.1 + '@eslint/plugin-kit': 0.7.1 '@humanfs/node': 0.16.7 '@humanwhocodes/module-importer': 1.0.1 '@humanwhocodes/retry': 0.4.3 @@ -6072,7 +6068,7 @@ snapshots: imurmurhash: 0.1.4 is-glob: 4.0.3 json-stable-stringify-without-jsonify: 1.0.1 - minimatch: 10.2.4 + minimatch: 10.2.5 natural-compare: 1.4.0 optionator: 0.9.4 optionalDependencies: @@ -7070,10 +7066,6 @@ snapshots: mimic-function@5.0.1: {} - minimatch@10.2.4: - dependencies: - brace-expansion: 5.0.5 - minimatch@10.2.5: dependencies: brace-expansion: 5.0.5 @@ -7285,13 +7277,13 @@ snapshots: prelude-ls@1.2.1: {} - prettier-plugin-tailwindcss@0.7.2(@trivago/prettier-plugin-sort-imports@6.0.2(prettier@3.8.1))(prettier@3.8.1): + prettier-plugin-tailwindcss@0.7.2(@trivago/prettier-plugin-sort-imports@6.0.2(prettier@3.8.3))(prettier@3.8.3): dependencies: - prettier: 3.8.1 + prettier: 3.8.3 optionalDependencies: - '@trivago/prettier-plugin-sort-imports': 6.0.2(prettier@3.8.1) + '@trivago/prettier-plugin-sort-imports': 6.0.2(prettier@3.8.3) - prettier@3.8.1: {} + prettier@3.8.3: {} pretty-ms@9.3.0: dependencies: @@ -7856,13 +7848,13 @@ snapshots: media-typer: 1.1.0 mime-types: 3.0.2 - typescript-eslint@8.57.2(eslint@10.1.0(jiti@2.6.1))(typescript@5.9.3): + typescript-eslint@8.58.2(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3): dependencies: - '@typescript-eslint/eslint-plugin': 8.57.2(@typescript-eslint/parser@8.57.2(eslint@10.1.0(jiti@2.6.1))(typescript@5.9.3))(eslint@10.1.0(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/parser': 8.57.2(eslint@10.1.0(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/typescript-estree': 8.57.2(typescript@5.9.3) - '@typescript-eslint/utils': 8.57.2(eslint@10.1.0(jiti@2.6.1))(typescript@5.9.3) - eslint: 10.1.0(jiti@2.6.1) + '@typescript-eslint/eslint-plugin': 8.58.2(@typescript-eslint/parser@8.58.2(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3))(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/parser': 8.58.2(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.58.2(typescript@5.9.3) + '@typescript-eslint/utils': 8.58.2(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3) + eslint: 10.2.1(jiti@2.6.1) typescript: 5.9.3 transitivePeerDependencies: - supports-color From e556a816e4db4c158ab3a455693018f452f63eba Mon Sep 17 00:00:00 2001 From: lxowalle <83055338+lxowalle@users.noreply.github.com> Date: Mon, 20 Apr 2026 15:20:26 +0800 Subject: [PATCH 05/31] Feat/channel tool feedback animation (#2569) * feat(channels): unify tool feedback animation across discord telegram and feishu * fix(tool-feedback): unify fallback and single-message delivery * fix(channels): finalize tool feedback in place * fix ci * feat: improve tool feedback --- cmd/picoclaw/internal/auth/wecom_test.go | 20 +- docs/channels/discord/README.md | 44 +- pkg/agent/loop.go | 1 + pkg/agent/loop_test.go | 366 ++++++++++++++- pkg/agent/loop_turn.go | 51 ++- pkg/agent/loop_utils.go | 93 ++++ pkg/channels/discord/discord.go | 190 +++++++- pkg/channels/discord/discord_test.go | 245 ++++++++++ pkg/channels/feishu/feishu_64.go | 175 +++++++- pkg/channels/feishu/feishu_64_test.go | 85 ++++ pkg/channels/manager.go | 112 ++++- pkg/channels/manager_test.go | 424 +++++++++++++++++- pkg/channels/matrix/matrix.go | 133 +++++- pkg/channels/matrix/matrix_test.go | 29 ++ pkg/channels/pico/pico.go | 118 ++++- pkg/channels/pico/pico_test.go | 28 ++ pkg/channels/telegram/command_registration.go | 6 +- .../telegram/command_registration_test.go | 16 +- pkg/channels/telegram/telegram.go | 180 +++++++- .../telegram_group_command_filter_test.go | 2 +- pkg/channels/telegram/telegram_test.go | 102 ++++- pkg/channels/tool_feedback_animator.go | 240 ++++++++++ pkg/channels/tool_feedback_animator_test.go | 121 +++++ pkg/config/config.go | 2 +- pkg/providers/cli/toolcall_utils.go | 17 +- pkg/providers/common/common.go | 93 +++- pkg/providers/common/common_test.go | 119 +++++ pkg/providers/protocoltypes/types.go | 3 +- pkg/providers/toolcall_utils_test.go | 24 + pkg/utils/tool_feedback.go | 58 ++- pkg/utils/tool_feedback_test.go | 42 +- web/backend/api/session.go | 89 +++- web/backend/api/session_test.go | 246 +++++++++- web/frontend/src/i18n/locales/en.json | 6 +- web/frontend/src/i18n/locales/zh.json | 6 +- 35 files changed, 3317 insertions(+), 169 deletions(-) create mode 100644 pkg/channels/tool_feedback_animator.go create mode 100644 pkg/channels/tool_feedback_animator_test.go create mode 100644 pkg/providers/toolcall_utils_test.go diff --git a/cmd/picoclaw/internal/auth/wecom_test.go b/cmd/picoclaw/internal/auth/wecom_test.go index c152481be..aafd39e69 100644 --- a/cmd/picoclaw/internal/auth/wecom_test.go +++ b/cmd/picoclaw/internal/auth/wecom_test.go @@ -3,6 +3,7 @@ package auth import ( "bytes" "context" + "net" "net/http" "net/http/httptest" "net/url" @@ -19,6 +20,19 @@ import ( "github.com/sipeed/picoclaw/pkg/config" ) +func newIPv4TestServer(t *testing.T, handler http.Handler) *httptest.Server { + t.Helper() + + server := httptest.NewUnstartedServer(handler) + listener, err := net.Listen("tcp4", "127.0.0.1:0") + require.NoError(t, err) + + server.Listener = listener + server.Start() + t.Cleanup(server.Close) + return server +} + func TestNewWeComCommand(t *testing.T) { cmd := newWeComCommand() @@ -53,7 +67,7 @@ func TestBuildWeComQRCodePageURL(t *testing.T) { } func TestFetchWeComQRCode(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + server := newIPv4TestServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { assert.Equal(t, "/generate", r.URL.Path) assert.Equal(t, wecomQRSourceID, r.URL.Query().Get("source")) assert.Equal(t, wecomQRSourceID, r.URL.Query().Get("sourceID")) @@ -61,7 +75,6 @@ func TestFetchWeComQRCode(t *testing.T) { w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"data":{"scode":"scode-1","auth_url":"https://example.com/qr"}}`)) })) - defer server.Close() opts := normalizeWeComQRFlowOptions(wecomQRFlowOptions{ HTTPClient: server.Client(), @@ -78,7 +91,7 @@ func TestFetchWeComQRCode(t *testing.T) { func TestPollWeComQRCodeResult(t *testing.T) { var calls atomic.Int32 - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + server := newIPv4TestServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { call := calls.Add(1) assert.Equal(t, "/query", r.URL.Path) assert.Equal(t, "scode-1", r.URL.Query().Get("scode")) @@ -92,7 +105,6 @@ func TestPollWeComQRCodeResult(t *testing.T) { _, _ = w.Write([]byte(`{"data":{"status":"success","bot_info":{"botid":"bot-1","secret":"secret-1"}}}`)) } })) - defer server.Close() var output bytes.Buffer opts := normalizeWeComQRFlowOptions(wecomQRFlowOptions{ diff --git a/docs/channels/discord/README.md b/docs/channels/discord/README.md index 771289d28..741bc64a1 100644 --- a/docs/channels/discord/README.md +++ b/docs/channels/discord/README.md @@ -8,26 +8,56 @@ Discord is a free voice, video, and text chat application designed for communiti ```json { + "agents": { + "defaults": { + "tool_feedback": { + "enabled": true, + "max_args_length": 300 + } + } + }, "channel_list": { "discord": { "enabled": true, "type": "discord", "token": "YOUR_BOT_TOKEN", "allow_from": ["YOUR_USER_ID"], + "placeholder": { + "enabled": true, + "text": ["Thinking... 💭"] + }, "group_trigger": { "mention_only": false - } + }, + "reasoning_channel_id": "" } } } ``` -| Field | Type | Required | Description | -| ------------- | ------ | -------- | --------------------------------------------------------------------------- | -| enabled | bool | Yes | Whether to enable the Discord channel | -| token | string | Yes | Discord Bot Token | -| allow_from | array | No | Allowlist of user IDs; empty means all users are allowed | -| group_trigger | object | No | Group trigger settings (example: { "mention_only": false }) | +| Field | Type | Required | Description | +| -------------------- | ------ | -------- | --------------------------------------------------------------------------- | +| enabled | bool | Yes | Whether to enable the Discord channel | +| token | string | Yes | Discord Bot Token | +| allow_from | array | No | Allowlist of user IDs; empty means all users are allowed | +| placeholder | object | No | Placeholder message config shown while the agent is working | +| group_trigger | object | No | Group trigger settings (example: { "mention_only": false }) | +| reasoning_channel_id | string | No | Optional target channel ID for reasoning/thinking output | + +## Visible Execution Feedback + +Discord can show three different kinds of "working" feedback: + +1. Typing indicator: automatic, no extra config needed. +2. Placeholder message: enable `channel_list.discord.placeholder.enabled` to send a visible `Thinking...` message that is later edited into the final reply. +3. Tool execution feedback: enable `agents.defaults.tool_feedback.enabled` to send a short message before each tool call, for example: + +```text +🔧 `web_search` +Checking the latest PicoClaw release notes before I answer. +``` + +If you only see `Bot is typing`, check that `placeholder.enabled` or `tool_feedback.enabled` is actually set in your runtime config. ## Setup diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index fb6f95edf..f0c287ee2 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -112,6 +112,7 @@ const ( pendingTurnPrefix = "pending-" metadataKeyMessageKind = "message_kind" messageKindThought = "thought" + messageKindToolFeedback = "tool_feedback" metadataKeyAccountID = "account_id" metadataKeyGuildID = "guild_id" metadataKeyTeamID = "team_id" diff --git a/pkg/agent/loop_test.go b/pkg/agent/loop_test.go index 5cdac186c..a2d4ea7aa 100644 --- a/pkg/agent/loop_test.go +++ b/pkg/agent/loop_test.go @@ -24,6 +24,7 @@ import ( "github.com/sipeed/picoclaw/pkg/routing" "github.com/sipeed/picoclaw/pkg/session" "github.com/sipeed/picoclaw/pkg/tools" + "github.com/sipeed/picoclaw/pkg/utils" ) type fakeChannel struct{ id string } @@ -1758,6 +1759,157 @@ func (m *toolFeedbackProvider) GetDefaultModel() string { return "heartbeat-tool-feedback-model" } +type toolFeedbackReasoningProvider struct { + filePath string + calls int +} + +func (m *toolFeedbackReasoningProvider) Chat( + ctx context.Context, + messages []providers.Message, + tools []providers.ToolDefinition, + model string, + opts map[string]any, +) (*providers.LLMResponse, error) { + m.calls++ + if m.calls == 1 { + return &providers.LLMResponse{ + ReasoningContent: "Read README.md first to confirm the context that needs to be changed.", + ToolCalls: []providers.ToolCall{{ + ID: "call_reasoning_read_file", + Type: "function", + Name: "read_file", + Arguments: map[string]any{"path": m.filePath}, + }}, + }, nil + } + + return &providers.LLMResponse{ + Content: "DONE", + ToolCalls: []providers.ToolCall{}, + }, nil +} + +func (m *toolFeedbackReasoningProvider) GetDefaultModel() string { + return "tool-feedback-reasoning-model" +} + +func TestToolFeedbackExplanationFromResponse_UsesCurrentContentFirst(t *testing.T) { + response := &providers.LLMResponse{ + Content: "Read README.md first", + ReasoningContent: "current reasoning fallback", + } + messages := []providers.Message{ + {Role: "user", Content: "check file"}, + {Role: "assistant", Content: "Previous turn explanation"}, + {Role: "tool", Content: "tool output", ToolCallID: "call_1"}, + } + + got := toolFeedbackExplanationFromResponse(response, messages, 300) + if got != "Read README.md first" { + t.Fatalf("toolFeedbackExplanationFromResponse() = %q, want current content", got) + } +} + +func TestToolFeedbackExplanationFromResponse_UsesExplicitToolCallExtraContent(t *testing.T) { + response := &providers.LLMResponse{ + ToolCalls: []providers.ToolCall{{ + ID: "call_1", + Name: "read_file", + ExtraContent: &providers.ExtraContent{ + ToolFeedbackExplanation: "Read README.md first to confirm the current project structure.", + }, + }}, + } + messages := []providers.Message{ + {Role: "user", Content: "check file"}, + {Role: "assistant", Content: ""}, + {Role: "tool", Content: "tool output", ToolCallID: "call_1"}, + } + + got := toolFeedbackExplanationFromResponse(response, messages, 300) + if got != "Read README.md first to confirm the current project structure." { + t.Fatalf("toolFeedbackExplanationFromResponse() = %q, want explicit tool feedback explanation", got) + } +} + +func TestToolFeedbackExplanationForToolCall_PrefersToolSpecificExtraContent(t *testing.T) { + response := &providers.LLMResponse{ + Content: "Shared explanation", + ToolCalls: []providers.ToolCall{ + { + ID: "call_1", + Name: "read_file", + ExtraContent: &providers.ExtraContent{ + ToolFeedbackExplanation: "Read README.md first.", + }, + }, + { + ID: "call_2", + Name: "edit_file", + ExtraContent: &providers.ExtraContent{ + ToolFeedbackExplanation: "Update config example after reading it.", + }, + }, + }, + } + + got1 := toolFeedbackExplanationForToolCall(response, response.ToolCalls[0], nil, 300) + got2 := toolFeedbackExplanationForToolCall(response, response.ToolCalls[1], nil, 300) + if got1 != "Read README.md first." { + t.Fatalf("toolFeedbackExplanationForToolCall() first = %q, want tool-specific explanation", got1) + } + if got2 != "Update config example after reading it." { + t.Fatalf("toolFeedbackExplanationForToolCall() second = %q, want tool-specific explanation", got2) + } +} + +func TestToolFeedbackExplanationForToolCall_DoesNotReuseAnotherToolCallExplanation(t *testing.T) { + response := &providers.LLMResponse{ + ToolCalls: []providers.ToolCall{ + { + ID: "call_1", + Name: "read_file", + }, + { + ID: "call_2", + Name: "edit_file", + ExtraContent: &providers.ExtraContent{ + ToolFeedbackExplanation: "Update config example after reading it.", + }, + }, + }, + } + messages := []providers.Message{ + {Role: "user", Content: "inspect the config and update the example"}, + } + + got := toolFeedbackExplanationForToolCall(response, response.ToolCalls[0], messages, 300) + want := utils.ToolFeedbackContinuationHint + ": inspect the config and update the example" + if got != want { + t.Fatalf("toolFeedbackExplanationForToolCall() = %q, want %q", got, want) + } +} + +func TestToolFeedbackExplanationFromResponse_DoesNotUseReasoningContent(t *testing.T) { + response := &providers.LLMResponse{ + Content: "", + ReasoningContent: "hidden reasoning should not be shown", + } + messages := []providers.Message{ + {Role: "user", Content: "check file"}, + {Role: "assistant", Content: "Previous turn explanation"}, + {Role: "user", Content: "Inspect README.md and update the config example."}, + {Role: "tool", Content: "tool output", ToolCallID: "call_1"}, + } + + got := toolFeedbackExplanationFromResponse(response, messages, 300) + want := utils.ToolFeedbackContinuationHint + ": Inspect README.md and update the config example." + if got != want { + t.Fatalf("toolFeedbackExplanationFromResponse() = %q, want latest user content fallback", got) + } +} + type picoInterleavedContentProvider struct { calls int } @@ -3656,7 +3808,16 @@ func TestProcessMessage_PublishesToolFeedbackWhenEnabled(t *testing.T) { t.Fatalf("unexpected tool feedback context: %+v", outbound.Context) } if !strings.Contains(outbound.Content, "`read_file`") { - t.Fatalf("tool feedback content = %q, want read_file preview", outbound.Content) + t.Fatalf("tool feedback content = %q, want read_file summary", outbound.Content) + } + if !strings.Contains(outbound.Content, utils.ToolFeedbackContinuationHint) { + t.Fatalf("tool feedback content = %q, want continuation hint fallback", outbound.Content) + } + if !strings.Contains(outbound.Content, "check tool feedback") { + t.Fatalf("tool feedback content = %q, want current user intent fallback", outbound.Content) + } + if strings.Contains(outbound.Content, "Previous turn explanation") { + t.Fatalf("tool feedback content = %q, want no previous assistant fallback", outbound.Content) } if outbound.AgentID != "main" { t.Fatalf("tool feedback agent_id = %q, want main", outbound.AgentID) @@ -3672,6 +3833,130 @@ func TestProcessMessage_PublishesToolFeedbackWhenEnabled(t *testing.T) { } } +func TestProcessMessage_DoesNotLeakReasoningContentInToolFeedback(t *testing.T) { + tmpDir := t.TempDir() + heartbeatFile := filepath.Join(tmpDir, "tool-feedback-reasoning.txt") + if err := os.WriteFile(heartbeatFile, []byte("tool feedback task"), 0o644); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + ModelName: "test-model", + MaxTokens: 4096, + MaxToolIterations: 10, + ToolFeedback: config.ToolFeedbackConfig{ + Enabled: true, + MaxArgsLength: 300, + }, + }, + }, + Tools: config.ToolsConfig{ + ReadFile: config.ReadFileToolConfig{ + Enabled: true, + }, + }, + } + + msgBus := bus.NewMessageBus() + provider := &toolFeedbackReasoningProvider{filePath: heartbeatFile} + al := NewAgentLoop(cfg, msgBus, provider) + + response, err := al.processMessage(context.Background(), testInboundMessage(bus.InboundMessage{ + Channel: "telegram", + SenderID: "user-1", + ChatID: "chat-1", + Content: "check reasoning fallback", + })) + if err != nil { + t.Fatalf("processMessage() error = %v", err) + } + if response != "DONE" { + t.Fatalf("processMessage() response = %q, want %q", response, "DONE") + } + + select { + case outbound := <-msgBus.OutboundChan(): + if !strings.Contains(outbound.Content, "`read_file`") { + t.Fatalf("tool feedback content = %q, want read_file summary", outbound.Content) + } + if !strings.Contains(outbound.Content, utils.ToolFeedbackContinuationHint) { + t.Fatalf("tool feedback content = %q, want continuation hint fallback", outbound.Content) + } + if !strings.Contains(outbound.Content, "check reasoning fallback") { + t.Fatalf("tool feedback content = %q, want current user intent fallback", outbound.Content) + } + if strings.Contains(outbound.Content, "Read README.md first") { + t.Fatalf("tool feedback content = %q, should not leak hidden reasoning", outbound.Content) + } + case <-time.After(2 * time.Second): + t.Fatal("expected outbound tool feedback without leaking reasoning") + } +} + +func TestProcessMessage_DoesNotPublishToolFeedbackForDiscordWhenDisabled(t *testing.T) { + assertToolFeedbackNotPublishedWhenDisabled(t, "discord") +} + +func assertToolFeedbackNotPublishedWhenDisabled(t *testing.T, channel string) { + t.Helper() + + tmpDir := t.TempDir() + heartbeatFile := filepath.Join(tmpDir, "tool-feedback-"+channel+".txt") + if err := os.WriteFile(heartbeatFile, []byte("tool feedback task"), 0o644); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + ModelName: "test-model", + MaxTokens: 4096, + MaxToolIterations: 10, + }, + }, + Tools: config.ToolsConfig{ + ReadFile: config.ReadFileToolConfig{ + Enabled: true, + }, + }, + } + + msgBus := bus.NewMessageBus() + provider := &toolFeedbackProvider{filePath: heartbeatFile} + al := NewAgentLoop(cfg, msgBus, provider) + + response, err := al.processMessage(context.Background(), testInboundMessage(bus.InboundMessage{ + Channel: channel, + SenderID: "user-1", + ChatID: "chat-1", + Content: "check tool feedback", + })) + if err != nil { + t.Fatalf("processMessage() error = %v", err) + } + if response != "HEARTBEAT_OK" { + t.Fatalf("processMessage() response = %q, want %q", response, "HEARTBEAT_OK") + } + + select { + case outbound := <-msgBus.OutboundChan(): + t.Fatalf("expected no outbound tool feedback for %s when disabled, got %+v", channel, outbound) + case <-time.After(200 * time.Millisecond): + } +} + +func TestProcessMessage_DoesNotPublishToolFeedbackForTelegramWhenDisabled(t *testing.T) { + assertToolFeedbackNotPublishedWhenDisabled(t, "telegram") +} + +func TestProcessMessage_DoesNotPublishToolFeedbackForFeishuWhenDisabled(t *testing.T) { + assertToolFeedbackNotPublishedWhenDisabled(t, "feishu") +} + func TestProcessMessage_MessageToolPublishesOutboundWithTurnMetadata(t *testing.T) { cfg := config.DefaultConfig() cfg.Agents.Defaults.Workspace = t.TempDir() @@ -3846,6 +4131,85 @@ func TestRunAgentLoop_PicoSkipsInterimPublishWhenNotAllowed(t *testing.T) { } } +func TestRun_PicoToolFeedbackSuppressesDuplicateInterimAssistantContent(t *testing.T) { + tmpDir := t.TempDir() + + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + ModelName: "test-model", + MaxTokens: 4096, + MaxToolIterations: 10, + ToolFeedback: config.ToolFeedbackConfig{ + Enabled: true, + }, + }, + }, + } + + msgBus := bus.NewMessageBus() + provider := &picoInterleavedContentProvider{} + al := NewAgentLoop(cfg, msgBus, provider) + + agent := al.GetRegistry().GetDefaultAgent() + if agent == nil { + t.Fatal("expected default agent") + } + agent.Tools.Register(&toolLimitTestTool{}) + + runCtx, runCancel := context.WithCancel(context.Background()) + defer runCancel() + + runDone := make(chan error, 1) + go func() { + runDone <- al.Run(runCtx) + }() + + if err := msgBus.PublishInbound(context.Background(), bus.InboundMessage{ + Channel: "pico", + SenderID: "user-1", + ChatID: "session-1", + Content: "run with tools", + }); err != nil { + t.Fatalf("PublishInbound() error = %v", err) + } + + outputs := make([]string, 0, 2) + deadline := time.After(2 * time.Second) + for len(outputs) < 2 { + select { + case outbound := <-msgBus.OutboundChan(): + outputs = append(outputs, outbound.Content) + case <-deadline: + t.Fatalf("timed out waiting for pico outputs, got %v", outputs) + } + } + + if outputs[0] != "🔧 `tool_limit_test_tool`\nintermediate model text" { + t.Fatalf("first outbound content = %q, want tool feedback summary", outputs[0]) + } + if outputs[1] != "final model text" { + t.Fatalf("second outbound content = %q, want %q", outputs[1], "final model text") + } + + runCancel() + select { + case err := <-runDone: + if err != nil { + t.Fatalf("Run() error = %v", err) + } + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for Run() to exit") + } + + select { + case outbound := <-msgBus.OutboundChan(): + t.Fatalf("unexpected extra pico output after tool feedback + final reply: %+v", outbound) + case <-time.After(200 * time.Millisecond): + } +} + func TestResolveMediaRefs_ResolvesToBase64(t *testing.T) { store := media.NewFileMediaStore() dir := t.TempDir() diff --git a/pkg/agent/loop_turn.go b/pkg/agent/loop_turn.go index 1085ddeae..406120e46 100644 --- a/pkg/agent/loop_turn.go +++ b/pkg/agent/loop_turn.go @@ -635,7 +635,11 @@ turnLoop: } logger.DebugCF("agent", "LLM response", llmResponseFields) - if al.bus != nil && ts.channel == "pico" && len(response.ToolCalls) > 0 && ts.opts.AllowInterimPicoPublish { + if al.bus != nil && + ts.channel == "pico" && + len(response.ToolCalls) > 0 && + ts.opts.AllowInterimPicoPublish && + !shouldPublishToolFeedback(al.cfg, ts) { if strings.TrimSpace(response.Content) != "" { outCtx, outCancel := context.WithTimeout(turnCtx, 3*time.Second) err := al.bus.PublishOutbound(outCtx, bus.OutboundMessage{ @@ -705,7 +709,19 @@ turnLoop: } for _, tc := range normalizedToolCalls { argumentsJSON, _ := json.Marshal(tc.Arguments) + toolFeedbackExplanation := toolFeedbackExplanationForToolCall( + response, + tc, + messages, + al.cfg.Agents.Defaults.GetToolFeedbackMaxArgsLength(), + ) extraContent := tc.ExtraContent + if strings.TrimSpace(toolFeedbackExplanation) != "" { + if extraContent == nil { + extraContent = &providers.ExtraContent{} + } + extraContent.ToolFeedbackExplanation = toolFeedbackExplanation + } thoughtSignature := "" if tc.Function != nil { thoughtSignature = tc.Function.ThoughtSignature @@ -783,21 +799,16 @@ turnLoop: ) // Send tool feedback to chat channel if enabled (same as normal tool execution) - if al.cfg.Agents.Defaults.IsToolFeedbackEnabled() && - ts.channel != "" && - !ts.opts.SuppressToolFeedback { - argsJSON, _ := json.Marshal(toolArgs) - feedbackPreview := utils.Truncate( - string(argsJSON), + if shouldPublishToolFeedback(al.cfg, ts) { + toolFeedbackExplanation := toolFeedbackExplanationForToolCall( + response, + tc, + messages, al.cfg.Agents.Defaults.GetToolFeedbackMaxArgsLength(), ) - feedbackMsg := utils.FormatToolFeedbackMessage(toolName, feedbackPreview) + feedbackMsg := utils.FormatToolFeedbackMessage(toolName, toolFeedbackExplanation) fbCtx, fbCancel := context.WithTimeout(turnCtx, 3*time.Second) - _ = al.bus.PublishOutbound(fbCtx, bus.OutboundMessage{ - Channel: ts.channel, - ChatID: ts.chatID, - Content: feedbackMsg, - }) + _ = al.bus.PublishOutbound(fbCtx, outboundMessageForTurnWithKind(ts, feedbackMsg, messageKindToolFeedback)) fbCancel() } @@ -1067,16 +1078,16 @@ turnLoop: ) // Send tool feedback to chat channel if enabled (from HEAD) - if al.cfg.Agents.Defaults.IsToolFeedbackEnabled() && - ts.channel != "" && - !ts.opts.SuppressToolFeedback { - feedbackPreview := utils.Truncate( - string(argsJSON), + if shouldPublishToolFeedback(al.cfg, ts) { + toolFeedbackExplanation := toolFeedbackExplanationForToolCall( + response, + tc, + messages, al.cfg.Agents.Defaults.GetToolFeedbackMaxArgsLength(), ) - feedbackMsg := utils.FormatToolFeedbackMessage(tc.Name, feedbackPreview) + feedbackMsg := utils.FormatToolFeedbackMessage(tc.Name, toolFeedbackExplanation) fbCtx, fbCancel := context.WithTimeout(turnCtx, 3*time.Second) - _ = al.bus.PublishOutbound(fbCtx, outboundMessageForTurn(ts, feedbackMsg)) + _ = al.bus.PublishOutbound(fbCtx, outboundMessageForTurnWithKind(ts, feedbackMsg, messageKindToolFeedback)) fbCancel() } diff --git a/pkg/agent/loop_utils.go b/pkg/agent/loop_utils.go index 2574f0222..ff98dad68 100644 --- a/pkg/agent/loop_utils.go +++ b/pkg/agent/loop_utils.go @@ -11,6 +11,7 @@ import ( "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/commands" + "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/providers" "github.com/sipeed/picoclaw/pkg/session" "github.com/sipeed/picoclaw/pkg/utils" @@ -84,6 +85,98 @@ func outboundMessageForTurn(ts *turnState, content string) bus.OutboundMessage { } } +func outboundMessageForTurnWithKind(ts *turnState, content, kind string) bus.OutboundMessage { + msg := outboundMessageForTurn(ts, content) + if strings.TrimSpace(kind) == "" { + return msg + } + if msg.Context.Raw == nil { + msg.Context.Raw = make(map[string]string, 1) + } + msg.Context.Raw[metadataKeyMessageKind] = kind + return msg +} + +func latestUserContent(messages []providers.Message) string { + for i := len(messages) - 1; i >= 0; i-- { + msg := messages[i] + if msg.Role != "user" { + continue + } + if content := strings.TrimSpace(msg.Content); content != "" { + return content + } + } + return "" +} + +func toolFeedbackExplanationFromResponse( + response *providers.LLMResponse, + messages []providers.Message, + maxLen int, +) string { + if response == nil { + return "" + } + explanation := strings.TrimSpace(response.Content) + if explanation == "" { + explanation = toolFeedbackExplanationFromToolCalls(response.ToolCalls) + } + if explanation == "" { + explanation = toolFeedbackExplanationFromMessages(messages) + } + return utils.Truncate(explanation, maxLen) +} + +func toolFeedbackExplanationFromToolCalls(toolCalls []providers.ToolCall) string { + for _, tc := range toolCalls { + if tc.ExtraContent == nil { + continue + } + if explanation := strings.TrimSpace(tc.ExtraContent.ToolFeedbackExplanation); explanation != "" { + return explanation + } + } + return "" +} + +func toolFeedbackExplanationForToolCall( + response *providers.LLMResponse, + toolCall providers.ToolCall, + messages []providers.Message, + maxLen int, +) string { + if toolCall.ExtraContent != nil { + if explanation := strings.TrimSpace(toolCall.ExtraContent.ToolFeedbackExplanation); explanation != "" { + return utils.Truncate(explanation, maxLen) + } + } + if response == nil { + return utils.Truncate(toolFeedbackExplanationFromMessages(messages), maxLen) + } + + explanation := strings.TrimSpace(response.Content) + if explanation == "" { + explanation = toolFeedbackExplanationFromMessages(messages) + } + return utils.Truncate(explanation, maxLen) +} + +func toolFeedbackExplanationFromMessages(messages []providers.Message) string { + explanation := latestUserContent(messages) + if explanation != "" { + return utils.ToolFeedbackContinuationHint + ": " + explanation + } + return "" +} + +func shouldPublishToolFeedback(cfg *config.Config, ts *turnState) bool { + if ts == nil || ts.channel == "" || ts.opts.SuppressToolFeedback { + return false + } + return cfg != nil && cfg.Agents.Defaults.IsToolFeedbackEnabled() +} + func cloneEventArguments(args map[string]any) map[string]any { if len(args) == 0 { return nil diff --git a/pkg/channels/discord/discord.go b/pkg/channels/discord/discord.go index 28f7277d3..514b9b3b1 100644 --- a/pkg/channels/discord/discord.go +++ b/pkg/channels/discord/discord.go @@ -45,9 +45,12 @@ type DiscordChannel struct { cancel context.CancelFunc typingMu sync.Mutex typingStop map[string]chan struct{} // chatID → stop signal - botUserID string // stored for mention checking + progress *channels.ToolFeedbackAnimator + botUserID string // stored for mention checking bus *bus.MessageBus tts tts.TTSProvider + playTTSFn func(context.Context, *discordgo.VoiceConnection, string, uint64) + ttsVoiceFn func(string) (*discordgo.VoiceConnection, bool) voiceMu sync.RWMutex voiceSSRC map[string]map[uint32]string // guildID -> ssrc -> userID @@ -84,7 +87,7 @@ func NewDiscordChannel( channels.WithReasoningChannelID(bc.ReasoningChannelID), ) - return &DiscordChannel{ + ch := &DiscordChannel{ BaseChannel: base, bc: bc, session: session, @@ -93,7 +96,11 @@ func NewDiscordChannel( typingStop: make(map[string]chan struct{}), bus: bus, voiceSSRC: make(map[string]map[uint32]string), - }, nil + } + ch.playTTSFn = ch.playTTS + ch.ttsVoiceFn = ch.voiceConnectionForTTS + ch.progress = channels.NewToolFeedbackAnimator(ch.EditMessage) + return ch, nil } func (c *DiscordChannel) Start(ctx context.Context) error { @@ -142,6 +149,9 @@ func (c *DiscordChannel) Stop(ctx context.Context) error { if c.cancel != nil { c.cancel() } + if c.progress != nil { + c.progress.StopAll() + } if err := c.session.Close(); err != nil { return fmt.Errorf("failed to close discord session: %w", err) @@ -164,32 +174,88 @@ func (c *DiscordChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]s return nil, nil } - if c.tts != nil { - if ch, err := c.session.State.Channel(channelID); err == nil && ch.GuildID != "" { - if vc, ok := c.session.VoiceConnections[ch.GuildID]; ok && vc != nil { - // Cancel any previous TTS playback - c.ttsMu.Lock() - if c.cancelTTS != nil { - c.cancelTTS() - } - ttsCtx, ttsCancel := context.WithCancel(c.ctx) - c.ttsPlayID++ - playID := c.ttsPlayID - c.cancelTTS = ttsCancel - c.ttsMu.Unlock() - - go c.playTTS(ttsCtx, vc, msg.Content, playID) + isToolFeedback := outboundMessageIsToolFeedback(msg) + if isToolFeedback { + if msgID, handled, err := c.progress.Update(ctx, channelID, msg.Content); handled { + if err != nil { + return nil, err } + return []string{msgID}, nil + } + } + trackedMsgID, hasTrackedMsg := c.currentToolFeedbackMessage(channelID) + c.maybeStartTTS(channelID, msg.Content, isToolFeedback) + if !isToolFeedback { + if msgIDs, handled := c.FinalizeToolFeedbackMessage(ctx, msg); handled { + return msgIDs, nil } } - msgID, err := c.sendChunk(ctx, channelID, msg.Content, msg.ReplyToMessageID) + content := msg.Content + if isToolFeedback { + content = channels.InitialAnimatedToolFeedbackContent(msg.Content) + } + msgID, err := c.sendChunk(ctx, channelID, content, msg.ReplyToMessageID) if err != nil { return nil, err } + if isToolFeedback { + c.RecordToolFeedbackMessage(channelID, msgID, msg.Content) + } else if hasTrackedMsg { + c.dismissTrackedToolFeedbackMessage(ctx, channelID, trackedMsgID) + } return []string{msgID}, nil } +func (c *DiscordChannel) maybeStartTTS(channelID, content string, isToolFeedback bool) { + if c.tts == nil || isToolFeedback { + return + } + + voiceFn := c.ttsVoiceFn + if voiceFn == nil { + voiceFn = c.voiceConnectionForTTS + } + vc, ok := voiceFn(channelID) + if !ok || vc == nil { + return + } + + // Cancel any previous TTS playback. + c.ttsMu.Lock() + if c.cancelTTS != nil { + c.cancelTTS() + } + ttsCtx, ttsCancel := context.WithCancel(c.ctx) + c.ttsPlayID++ + playID := c.ttsPlayID + c.cancelTTS = ttsCancel + playFn := c.playTTSFn + c.ttsMu.Unlock() + + if playFn == nil { + playFn = c.playTTS + } + go playFn(ttsCtx, vc, content, playID) +} + +func (c *DiscordChannel) voiceConnectionForTTS(channelID string) (*discordgo.VoiceConnection, bool) { + if c.session == nil || c.session.State == nil { + return nil, false + } + + ch, err := c.session.State.Channel(channelID) + if err != nil || ch == nil || ch.GuildID == "" { + return nil, false + } + + vc, ok := c.session.VoiceConnections[ch.GuildID] + if !ok || vc == nil { + return nil, false + } + return vc, true +} + // SendMedia implements the channels.MediaSender interface. func (c *DiscordChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) ([]string, error) { if !c.IsRunning() { @@ -200,6 +266,7 @@ func (c *DiscordChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMes if channelID == "" { return nil, fmt.Errorf("channel ID is empty") } + trackedMsgID, hasTrackedMsg := c.currentToolFeedbackMessage(channelID) store := c.GetMediaStore() if store == nil { @@ -281,6 +348,9 @@ func (c *DiscordChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMes if r.err != nil { return nil, fmt.Errorf("discord send media: %w", channels.ErrTemporary) } + if hasTrackedMsg { + c.dismissTrackedToolFeedbackMessage(ctx, channelID, trackedMsgID) + } return []string{r.id}, nil case <-sendCtx.Done(): // Close all file readers @@ -295,10 +365,15 @@ func (c *DiscordChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMes // EditMessage implements channels.MessageEditor. func (c *DiscordChannel) EditMessage(ctx context.Context, chatID string, messageID string, content string) error { - _, err := c.session.ChannelMessageEdit(chatID, messageID, content) + _, err := c.session.ChannelMessageEdit(chatID, messageID, content, discordgo.WithContext(ctx)) return err } +// DeleteMessage implements channels.MessageDeleter. +func (c *DiscordChannel) DeleteMessage(ctx context.Context, chatID string, messageID string) error { + return c.session.ChannelMessageDelete(chatID, messageID, discordgo.WithContext(ctx)) +} + // SendPlaceholder implements channels.PlaceholderCapable. // It sends a placeholder message that will later be edited to the actual // response via EditMessage (channels.MessageEditor). @@ -317,6 +392,81 @@ func (c *DiscordChannel) SendPlaceholder(ctx context.Context, chatID string) (st return msg.ID, nil } +func outboundMessageIsToolFeedback(msg bus.OutboundMessage) bool { + if len(msg.Context.Raw) == 0 { + return false + } + return strings.EqualFold(strings.TrimSpace(msg.Context.Raw["message_kind"]), "tool_feedback") +} + +func (c *DiscordChannel) currentToolFeedbackMessage(chatID string) (string, bool) { + if c.progress == nil { + return "", false + } + return c.progress.Current(chatID) +} + +func (c *DiscordChannel) takeToolFeedbackMessage(chatID string) (string, string, bool) { + if c.progress == nil { + return "", "", false + } + return c.progress.Take(chatID) +} + +func (c *DiscordChannel) RecordToolFeedbackMessage(chatID, messageID, content string) { + if c.progress == nil { + return + } + c.progress.Record(chatID, messageID, content) +} + +func (c *DiscordChannel) ClearToolFeedbackMessage(chatID string) { + if c.progress == nil { + return + } + c.progress.Clear(chatID) +} + +func (c *DiscordChannel) DismissToolFeedbackMessage(ctx context.Context, chatID string) { + msgID, ok := c.currentToolFeedbackMessage(chatID) + if !ok { + return + } + c.dismissTrackedToolFeedbackMessage(ctx, chatID, msgID) +} + +func (c *DiscordChannel) dismissTrackedToolFeedbackMessage(ctx context.Context, chatID, messageID string) { + if strings.TrimSpace(chatID) == "" || strings.TrimSpace(messageID) == "" { + return + } + c.ClearToolFeedbackMessage(chatID) + _ = c.DeleteMessage(ctx, chatID, messageID) +} + +func (c *DiscordChannel) finalizeTrackedToolFeedbackMessage( + ctx context.Context, + chatID string, + content string, + editFn func(context.Context, string, string, string) error, +) ([]string, bool) { + msgID, baseContent, ok := c.takeToolFeedbackMessage(chatID) + if !ok || editFn == nil { + return nil, false + } + if err := editFn(ctx, chatID, msgID, content); err != nil { + c.RecordToolFeedbackMessage(chatID, msgID, baseContent) + return nil, false + } + return []string{msgID}, true +} + +func (c *DiscordChannel) FinalizeToolFeedbackMessage(ctx context.Context, msg bus.OutboundMessage) ([]string, bool) { + if outboundMessageIsToolFeedback(msg) { + return nil, false + } + return c.finalizeTrackedToolFeedbackMessage(ctx, msg.ChatID, msg.Content, c.EditMessage) +} + func (c *DiscordChannel) sendChunk(ctx context.Context, channelID, content, replyToID string) (string, error) { // Use the passed ctx for timeout control sendCtx, cancel := context.WithTimeout(ctx, sendTimeout) diff --git a/pkg/channels/discord/discord_test.go b/pkg/channels/discord/discord_test.go index 0cd5328f4..d42b0bc52 100644 --- a/pkg/channels/discord/discord_test.go +++ b/pkg/channels/discord/discord_test.go @@ -1,13 +1,37 @@ package discord import ( + "context" + "io" "net/http" + "net/http/httptest" "net/url" + "reflect" + "sync" "testing" + "time" "github.com/bwmarrin/discordgo" + + "github.com/sipeed/picoclaw/pkg/audio/tts" + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/channels" ) +type stubTTSProvider struct{} + +func (stubTTSProvider) Name() string { return "stub-tts" } + +func (stubTTSProvider) Synthesize(context.Context, string) (io.ReadCloser, error) { + return io.NopCloser(&noopReader{}), nil +} + +type noopReader struct{} + +func (*noopReader) Read(p []byte) (int, error) { + return 0, io.EOF +} + func TestApplyDiscordProxy_CustomProxy(t *testing.T) { session, err := discordgo.New("Bot test-token") if err != nil { @@ -89,3 +113,224 @@ func TestApplyDiscordProxy_InvalidProxyURL(t *testing.T) { t.Fatal("applyDiscordProxy() expected error for invalid proxy URL, got nil") } } + +func TestSend_NonToolFeedbackDeletesTrackedProgressMessage(t *testing.T) { + var ( + mu sync.Mutex + requests []string + ) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + requests = append(requests, r.Method+" "+r.URL.Path) + mu.Unlock() + + switch { + case r.Method == http.MethodPatch && r.URL.Path == "/channels/chat-1/messages/prog-1": + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"id":"prog-1"}`) + default: + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + })) + defer server.Close() + + origChannels := discordgo.EndpointChannels + discordgo.EndpointChannels = server.URL + "/channels/" + defer func() { + discordgo.EndpointChannels = origChannels + }() + + session, err := discordgo.New("Bot test-token") + if err != nil { + t.Fatalf("discordgo.New() error: %v", err) + } + session.Client = server.Client() + + ch := &DiscordChannel{ + BaseChannel: channels.NewBaseChannel("discord", nil, bus.NewMessageBus(), nil), + session: session, + ctx: context.Background(), + typingStop: make(map[string]chan struct{}), + voiceSSRC: make(map[string]map[uint32]string), + } + ch.progress = channels.NewToolFeedbackAnimator(ch.EditMessage) + ch.SetRunning(true) + ch.RecordToolFeedbackMessage("chat-1", "prog-1", "🔧 `read_file`") + + ids, err := ch.Send(context.Background(), bus.OutboundMessage{ + ChatID: "chat-1", + Content: "final reply", + Context: bus.InboundContext{ + Channel: "discord", + ChatID: "chat-1", + }, + }) + if err != nil { + t.Fatalf("Send() error = %v", err) + } + if got, want := ids, []string{"prog-1"}; !reflect.DeepEqual(got, want) { + t.Fatalf("Send() ids = %v, want %v", got, want) + } + if _, ok := ch.currentToolFeedbackMessage("chat-1"); ok { + t.Fatal("expected tracked tool feedback message to be cleared") + } + + mu.Lock() + defer mu.Unlock() + wantRequests := []string{ + "PATCH /channels/chat-1/messages/prog-1", + } + if !reflect.DeepEqual(requests, wantRequests) { + t.Fatalf("requests = %v, want %v", requests, wantRequests) + } +} + +func TestEditMessage_UsesContextCancellation(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + select { + case <-r.Context().Done(): + return + case <-time.After(time.Second): + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"id":"msg-1"}`) + } + })) + defer server.Close() + + origChannels := discordgo.EndpointChannels + discordgo.EndpointChannels = server.URL + "/channels/" + defer func() { + discordgo.EndpointChannels = origChannels + }() + + session, err := discordgo.New("Bot test-token") + if err != nil { + t.Fatalf("discordgo.New() error: %v", err) + } + session.Client = server.Client() + + ch := &DiscordChannel{ + BaseChannel: channels.NewBaseChannel("discord", nil, bus.NewMessageBus(), nil), + session: session, + } + + ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) + defer cancel() + + start := time.Now() + err = ch.EditMessage(ctx, "chat-1", "msg-1", "still running") + elapsed := time.Since(start) + + if err == nil { + t.Fatal("expected EditMessage() to fail when context times out") + } + if elapsed >= 500*time.Millisecond { + t.Fatalf("EditMessage() ignored context timeout, elapsed=%v", elapsed) + } +} + +func TestFinalizeTrackedToolFeedbackMessage_StopsTrackingBeforeEdit(t *testing.T) { + ch := &DiscordChannel{ + progress: channels.NewToolFeedbackAnimator(nil), + } + ch.RecordToolFeedbackMessage("chat-1", "msg-1", "🔧 `read_file`") + + msgIDs, handled := ch.finalizeTrackedToolFeedbackMessage( + context.Background(), + "chat-1", + "final reply", + func(_ context.Context, chatID, messageID, content string) error { + if _, ok := ch.currentToolFeedbackMessage(chatID); ok { + t.Fatal("expected tracked tool feedback to be stopped before edit") + } + if chatID != "chat-1" || messageID != "msg-1" || content != "final reply" { + t.Fatalf("unexpected edit args: %s %s %s", chatID, messageID, content) + } + return nil + }, + ) + if !handled { + t.Fatal("expected finalizeTrackedToolFeedbackMessage to handle tracked message") + } + if got, want := msgIDs, []string{"msg-1"}; !reflect.DeepEqual(got, want) { + t.Fatalf("finalizeTrackedToolFeedbackMessage() ids = %v, want %v", got, want) + } +} + +func TestSend_NonToolFeedbackFinalizerStillStartsTTS(t *testing.T) { + var ( + mu sync.Mutex + requests []string + ) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + requests = append(requests, r.Method+" "+r.URL.Path) + mu.Unlock() + + switch { + case r.Method == http.MethodPatch && r.URL.Path == "/channels/chat-1/messages/prog-1": + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"id":"prog-1"}`) + default: + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + })) + defer server.Close() + + origChannels := discordgo.EndpointChannels + discordgo.EndpointChannels = server.URL + "/channels/" + defer func() { + discordgo.EndpointChannels = origChannels + }() + + session, err := discordgo.New("Bot test-token") + if err != nil { + t.Fatalf("discordgo.New() error: %v", err) + } + session.Client = server.Client() + + ttsStarted := make(chan string, 1) + ch := &DiscordChannel{ + BaseChannel: channels.NewBaseChannel("discord", nil, bus.NewMessageBus(), nil), + session: session, + ctx: context.Background(), + typingStop: make(map[string]chan struct{}), + voiceSSRC: make(map[string]map[uint32]string), + tts: tts.TTSProvider(stubTTSProvider{}), + } + ch.ttsVoiceFn = func(string) (*discordgo.VoiceConnection, bool) { + return &discordgo.VoiceConnection{}, true + } + ch.playTTSFn = func(_ context.Context, _ *discordgo.VoiceConnection, text string, _ uint64) { + ttsStarted <- text + } + ch.progress = channels.NewToolFeedbackAnimator(ch.EditMessage) + ch.SetRunning(true) + ch.RecordToolFeedbackMessage("chat-1", "prog-1", "🔧 `read_file`") + + ids, err := ch.Send(context.Background(), bus.OutboundMessage{ + ChatID: "chat-1", + Content: "final reply", + Context: bus.InboundContext{ + Channel: "discord", + ChatID: "chat-1", + }, + }) + if err != nil { + t.Fatalf("Send() error = %v", err) + } + if got, want := ids, []string{"prog-1"}; !reflect.DeepEqual(got, want) { + t.Fatalf("Send() ids = %v, want %v", got, want) + } + + select { + case got := <-ttsStarted: + if got != "final reply" { + t.Fatalf("TTS content = %q, want final reply", got) + } + case <-time.After(2 * time.Second): + t.Fatal("expected TTS to start for finalized tracked tool feedback reply") + } +} diff --git a/pkg/channels/feishu/feishu_64.go b/pkg/channels/feishu/feishu_64.go index 02ee47d69..49b8dd8e5 100644 --- a/pkg/channels/feishu/feishu_64.go +++ b/pkg/channels/feishu/feishu_64.go @@ -49,6 +49,8 @@ type FeishuChannel struct { mu sync.Mutex cancel context.CancelFunc + + progress *channels.ToolFeedbackAnimator } type cachedMessage struct { @@ -74,6 +76,7 @@ func NewFeishuChannel(bc *config.Channel, cfg *config.FeishuSettings, bus *bus.M tokenCache: tc, client: lark.NewClient(cfg.AppID, cfg.AppSecret.String(), opts...), } + ch.progress = channels.NewToolFeedbackAnimator(ch.EditMessage) ch.SetOwner(ch) return ch, nil } @@ -132,6 +135,9 @@ func (c *FeishuChannel) Stop(ctx context.Context) error { } c.wsClient = nil c.mu.Unlock() + if c.progress != nil { + c.progress.StopAll() + } c.SetRunning(false) logger.InfoC("feishu", "Feishu channel stopped") @@ -149,17 +155,50 @@ func (c *FeishuChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]st return nil, fmt.Errorf("chat ID is empty: %w", channels.ErrSendFailed) } + isToolFeedback := outboundMessageIsToolFeedback(msg) + trackedMsgID, hasTrackedMsg := c.currentToolFeedbackMessage(msg.ChatID) + if isToolFeedback { + if msgID, handled, err := c.progress.Update(ctx, msg.ChatID, msg.Content); handled { + if err != nil { + return nil, err + } + return []string{msgID}, nil + } + } else { + if msgIDs, handled := c.FinalizeToolFeedbackMessage(ctx, msg); handled { + return msgIDs, nil + } + } + // Build interactive card with markdown content - cardContent, err := buildMarkdownCard(msg.Content) + sendContent := msg.Content + if isToolFeedback { + sendContent = channels.InitialAnimatedToolFeedbackContent(msg.Content) + } + cardContent, err := buildMarkdownCard(sendContent) if err != nil { // If card build fails, fall back to plain text - return nil, c.sendText(ctx, msg.ChatID, msg.Content) + msgID, sendErr := c.sendText(ctx, msg.ChatID, sendContent) + if sendErr != nil { + return nil, sendErr + } + if isToolFeedback { + c.RecordToolFeedbackMessage(msg.ChatID, msgID, msg.Content) + } else if hasTrackedMsg { + c.dismissTrackedToolFeedbackMessage(ctx, msg.ChatID, trackedMsgID) + } + return []string{msgID}, nil } // First attempt: try sending as interactive card - err = c.sendCard(ctx, msg.ChatID, cardContent) + msgID, err := c.sendCard(ctx, msg.ChatID, cardContent) if err == nil { - return nil, nil + if isToolFeedback { + c.RecordToolFeedbackMessage(msg.ChatID, msgID, msg.Content) + } else if hasTrackedMsg { + c.dismissTrackedToolFeedbackMessage(ctx, msg.ChatID, trackedMsgID) + } + return []string{msgID}, nil } // Check if error is due to card table limit (error code 11310) @@ -174,9 +213,14 @@ func (c *FeishuChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]st }) // Second attempt: fall back to plain text message - textErr := c.sendText(ctx, msg.ChatID, msg.Content) + msgID, textErr := c.sendText(ctx, msg.ChatID, sendContent) if textErr == nil { - return nil, nil + if isToolFeedback { + c.RecordToolFeedbackMessage(msg.ChatID, msgID, msg.Content) + } else if hasTrackedMsg { + c.dismissTrackedToolFeedbackMessage(ctx, msg.ChatID, trackedMsgID) + } + return []string{msgID}, nil } // If text also fails, return the text error return nil, textErr @@ -210,6 +254,23 @@ func (c *FeishuChannel) EditMessage(ctx context.Context, chatID, messageID, cont return nil } +// DeleteMessage implements channels.MessageDeleter. +func (c *FeishuChannel) DeleteMessage(ctx context.Context, chatID, messageID string) error { + req := larkim.NewDeleteMessageReqBuilder(). + MessageId(messageID). + Build() + + resp, err := c.client.Im.V1.Message.Delete(ctx, req) + if err != nil { + return fmt.Errorf("feishu delete: %w", err) + } + if !resp.Success() { + c.invalidateTokenOnAuthError(resp.Code) + return fmt.Errorf("feishu delete api error (code=%d msg=%s)", resp.Code, resp.Msg) + } + return nil +} + // SendPlaceholder implements channels.PlaceholderCapable. // Sends an interactive card with placeholder text and returns its message ID. func (c *FeishuChannel) SendPlaceholder(ctx context.Context, chatID string) (string, error) { @@ -251,6 +312,81 @@ func (c *FeishuChannel) SendPlaceholder(ctx context.Context, chatID string) (str return "", nil } +func outboundMessageIsToolFeedback(msg bus.OutboundMessage) bool { + if len(msg.Context.Raw) == 0 { + return false + } + return strings.EqualFold(strings.TrimSpace(msg.Context.Raw["message_kind"]), "tool_feedback") +} + +func (c *FeishuChannel) currentToolFeedbackMessage(chatID string) (string, bool) { + if c.progress == nil { + return "", false + } + return c.progress.Current(chatID) +} + +func (c *FeishuChannel) takeToolFeedbackMessage(chatID string) (string, string, bool) { + if c.progress == nil { + return "", "", false + } + return c.progress.Take(chatID) +} + +func (c *FeishuChannel) RecordToolFeedbackMessage(chatID, messageID, content string) { + if c.progress == nil { + return + } + c.progress.Record(chatID, messageID, content) +} + +func (c *FeishuChannel) ClearToolFeedbackMessage(chatID string) { + if c.progress == nil { + return + } + c.progress.Clear(chatID) +} + +func (c *FeishuChannel) DismissToolFeedbackMessage(ctx context.Context, chatID string) { + msgID, ok := c.currentToolFeedbackMessage(chatID) + if !ok { + return + } + c.dismissTrackedToolFeedbackMessage(ctx, chatID, msgID) +} + +func (c *FeishuChannel) dismissTrackedToolFeedbackMessage(ctx context.Context, chatID, messageID string) { + if strings.TrimSpace(chatID) == "" || strings.TrimSpace(messageID) == "" { + return + } + c.ClearToolFeedbackMessage(chatID) + _ = c.DeleteMessage(ctx, chatID, messageID) +} + +func (c *FeishuChannel) finalizeTrackedToolFeedbackMessage( + ctx context.Context, + chatID string, + content string, + editFn func(context.Context, string, string, string) error, +) ([]string, bool) { + msgID, baseContent, ok := c.takeToolFeedbackMessage(chatID) + if !ok || editFn == nil { + return nil, false + } + if err := editFn(ctx, chatID, msgID, content); err != nil { + c.RecordToolFeedbackMessage(chatID, msgID, baseContent) + return nil, false + } + return []string{msgID}, true +} + +func (c *FeishuChannel) FinalizeToolFeedbackMessage(ctx context.Context, msg bus.OutboundMessage) ([]string, bool) { + if outboundMessageIsToolFeedback(msg) { + return nil, false + } + return c.finalizeTrackedToolFeedbackMessage(ctx, msg.ChatID, msg.Content, c.EditMessage) +} + // ReactToMessage implements channels.ReactionCapable. // Adds a reaction (randomly chosen from config) and returns an undo function to remove it. func (c *FeishuChannel) ReactToMessage(ctx context.Context, chatID, messageID string) (func(), error) { @@ -323,6 +459,7 @@ func (c *FeishuChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMess if !c.IsRunning() { return nil, channels.ErrNotRunning } + trackedMsgID, hasTrackedMsg := c.currentToolFeedbackMessage(msg.ChatID) if msg.ChatID == "" { return nil, fmt.Errorf("chat ID is empty: %w", channels.ErrSendFailed) @@ -339,6 +476,10 @@ func (c *FeishuChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMess } } + if hasTrackedMsg { + c.dismissTrackedToolFeedbackMessage(ctx, msg.ChatID, trackedMsgID) + } + return nil, nil } @@ -801,7 +942,7 @@ func appendMediaTags(content, messageType string, mediaRefs []string) string { } // sendCard sends an interactive card message to a chat. -func (c *FeishuChannel) sendCard(ctx context.Context, chatID, cardContent string) error { +func (c *FeishuChannel) sendCard(ctx context.Context, chatID, cardContent string) (string, error) { req := larkim.NewCreateMessageReqBuilder(). ReceiveIdType(larkim.ReceiveIdTypeChatId). Body(larkim.NewCreateMessageReqBodyBuilder(). @@ -813,23 +954,26 @@ func (c *FeishuChannel) sendCard(ctx context.Context, chatID, cardContent string resp, err := c.client.Im.V1.Message.Create(ctx, req) if err != nil { - return fmt.Errorf("feishu send card: %w", channels.ErrTemporary) + return "", fmt.Errorf("feishu send card: %w", channels.ErrTemporary) } if !resp.Success() { c.invalidateTokenOnAuthError(resp.Code) - return fmt.Errorf("feishu api error (code=%d msg=%s): %w", resp.Code, resp.Msg, channels.ErrTemporary) + return "", fmt.Errorf("feishu api error (code=%d msg=%s): %w", resp.Code, resp.Msg, channels.ErrTemporary) } logger.DebugCF("feishu", "Feishu card message sent", map[string]any{ "chat_id": chatID, }) - return nil + if resp.Data != nil && resp.Data.MessageId != nil { + return *resp.Data.MessageId, nil + } + return "", nil } // sendText sends a plain text message to a chat (fallback when card fails). -func (c *FeishuChannel) sendText(ctx context.Context, chatID, text string) error { +func (c *FeishuChannel) sendText(ctx context.Context, chatID, text string) (string, error) { content, _ := json.Marshal(map[string]string{"text": text}) req := larkim.NewCreateMessageReqBuilder(). @@ -843,18 +987,21 @@ func (c *FeishuChannel) sendText(ctx context.Context, chatID, text string) error resp, err := c.client.Im.V1.Message.Create(ctx, req) if err != nil { - return fmt.Errorf("feishu send text: %w", channels.ErrTemporary) + return "", fmt.Errorf("feishu send text: %w", channels.ErrTemporary) } if !resp.Success() { - return fmt.Errorf("feishu text api error (code=%d msg=%s): %w", resp.Code, resp.Msg, channels.ErrTemporary) + return "", fmt.Errorf("feishu text api error (code=%d msg=%s): %w", resp.Code, resp.Msg, channels.ErrTemporary) } logger.DebugCF("feishu", "Feishu text message sent (fallback)", map[string]any{ "chat_id": chatID, }) - return nil + if resp.Data != nil && resp.Data.MessageId != nil { + return *resp.Data.MessageId, nil + } + return "", nil } // sendImage uploads an image and sends it as a message. diff --git a/pkg/channels/feishu/feishu_64_test.go b/pkg/channels/feishu/feishu_64_test.go index 9010abf69..0bdac0352 100644 --- a/pkg/channels/feishu/feishu_64_test.go +++ b/pkg/channels/feishu/feishu_64_test.go @@ -3,9 +3,13 @@ package feishu import ( + "context" + "errors" "testing" larkim "github.com/larksuite/oapi-sdk-go/v3/service/im/v1" + + "github.com/sipeed/picoclaw/pkg/channels" ) func TestExtractContent(t *testing.T) { @@ -279,3 +283,84 @@ func TestExtractFeishuSenderID(t *testing.T) { }) } } + +func TestFinalizeTrackedToolFeedbackMessage_ClearAfterSuccessfulEdit(t *testing.T) { + ch := &FeishuChannel{ + progress: channels.NewToolFeedbackAnimator(nil), + } + ch.RecordToolFeedbackMessage("chat-1", "msg-1", "🔧 `read_file`") + + msgIDs, handled := ch.finalizeTrackedToolFeedbackMessage( + context.Background(), + "chat-1", + "final reply", + func(_ context.Context, chatID, messageID, content string) error { + if chatID != "chat-1" || messageID != "msg-1" || content != "final reply" { + t.Fatalf("unexpected edit args: %s %s %s", chatID, messageID, content) + } + return nil + }, + ) + if !handled { + t.Fatal("expected finalizeTrackedToolFeedbackMessage to handle tracked message") + } + if len(msgIDs) != 1 || msgIDs[0] != "msg-1" { + t.Fatalf("unexpected msgIDs: %v", msgIDs) + } + if _, ok := ch.currentToolFeedbackMessage("chat-1"); ok { + t.Fatal("expected tracked tool feedback to be cleared after successful edit") + } +} + +func TestFinalizeTrackedToolFeedbackMessage_StopsTrackingBeforeEdit(t *testing.T) { + ch := &FeishuChannel{ + progress: channels.NewToolFeedbackAnimator(nil), + } + ch.RecordToolFeedbackMessage("chat-1", "msg-1", "🔧 `read_file`") + + msgIDs, handled := ch.finalizeTrackedToolFeedbackMessage( + context.Background(), + "chat-1", + "final reply", + func(_ context.Context, chatID, messageID, content string) error { + if _, ok := ch.currentToolFeedbackMessage(chatID); ok { + t.Fatal("expected tracked tool feedback to be stopped before edit") + } + if chatID != "chat-1" || messageID != "msg-1" || content != "final reply" { + t.Fatalf("unexpected edit args: %s %s %s", chatID, messageID, content) + } + return nil + }, + ) + if !handled { + t.Fatal("expected finalizeTrackedToolFeedbackMessage to handle tracked message") + } + if len(msgIDs) != 1 || msgIDs[0] != "msg-1" { + t.Fatalf("unexpected msgIDs: %v", msgIDs) + } +} + +func TestFinalizeTrackedToolFeedbackMessage_EditFailureKeepsTrackedMessage(t *testing.T) { + ch := &FeishuChannel{ + progress: channels.NewToolFeedbackAnimator(nil), + } + ch.RecordToolFeedbackMessage("chat-1", "msg-1", "🔧 `read_file`") + + msgIDs, handled := ch.finalizeTrackedToolFeedbackMessage( + context.Background(), + "chat-1", + "final reply", + func(context.Context, string, string, string) error { + return errors.New("edit failed") + }, + ) + if handled { + t.Fatal("expected finalizeTrackedToolFeedbackMessage to report unhandled on edit failure") + } + if len(msgIDs) != 0 { + t.Fatalf("unexpected msgIDs: %v", msgIDs) + } + if msgID, ok := ch.currentToolFeedbackMessage("chat-1"); !ok || msgID != "msg-1" { + t.Fatalf("expected tracked tool feedback to remain after failed edit, got (%q, %v)", msgID, ok) + } +} diff --git a/pkg/channels/manager.go b/pkg/channels/manager.go index 928676cbc..6aec966d6 100644 --- a/pkg/channels/manager.go +++ b/pkg/channels/manager.go @@ -14,6 +14,7 @@ import ( "net" "net/http" "sort" + "strings" "sync" "time" @@ -25,6 +26,7 @@ import ( "github.com/sipeed/picoclaw/pkg/health" "github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/media" + "github.com/sipeed/picoclaw/pkg/utils" ) const ( @@ -96,6 +98,15 @@ type Manager struct { channelHashes map[string]string // channel name → config hash } +type toolFeedbackMessageTracker interface { + RecordToolFeedbackMessage(chatID, messageID, content string) + ClearToolFeedbackMessage(chatID string) +} + +type toolFeedbackMessageCleaner interface { + DismissToolFeedbackMessage(ctx context.Context, chatID string) +} + type asyncTask struct { cancel context.CancelFunc } @@ -108,6 +119,13 @@ func outboundMessageChatID(msg bus.OutboundMessage) string { return msg.ChatID } +func outboundMessageIsToolFeedback(msg bus.OutboundMessage) bool { + if len(msg.Context.Raw) == 0 { + return false + } + return strings.EqualFold(strings.TrimSpace(msg.Context.Raw["message_kind"]), "tool_feedback") +} + func outboundMediaChannel(msg bus.OutboundMediaMessage) string { return msg.Context.Channel } @@ -116,6 +134,16 @@ func outboundMediaChatID(msg bus.OutboundMediaMessage) string { return msg.ChatID } +func dismissTrackedToolFeedbackMessage(ctx context.Context, ch Channel, chatID string) { + if cleaner, ok := ch.(toolFeedbackMessageCleaner); ok { + cleaner.DismissToolFeedbackMessage(ctx, chatID) + return + } + if tracker, ok := ch.(toolFeedbackMessageTracker); ok { + tracker.ClearToolFeedbackMessage(chatID) + } +} + // RecordPlaceholder registers a placeholder message for later editing. // Implements PlaceholderRecorder. func (m *Manager) RecordPlaceholder(channel, chatID, placeholderID string) { @@ -196,7 +224,19 @@ func (m *Manager) preSend(ctx context.Context, name string, msg bus.OutboundMess } } - // 3. If a stream already finalized this message, delete the placeholder and skip send + isToolFeedback := outboundMessageIsToolFeedback(msg) + + // 3. If a stream already finalized this chat, stale tool feedback must be + // dropped without consuming the final-response marker. Streaming finalization + // bypasses the worker queue, so older queued feedback can arrive before the + // normal final outbound message that cleans up the marker and placeholder. + if isToolFeedback { + if _, loaded := m.streamActive.Load(key); loaded { + return nil, true + } + } + + // 4. If a stream already finalized this message, delete the placeholder and skip send if _, loaded := m.streamActive.LoadAndDelete(key); loaded { if v, loaded := m.placeholders.LoadAndDelete(key); loaded { if entry, ok := v.(placeholderEntry); ok && entry.id != "" { @@ -208,14 +248,26 @@ func (m *Manager) preSend(ctx context.Context, name string, msg bus.OutboundMess } } } + if !isToolFeedback { + dismissTrackedToolFeedbackMessage(ctx, ch, chatID) + } return nil, true } - // 4. Try editing placeholder + // 5. Try editing placeholder if v, loaded := m.placeholders.LoadAndDelete(key); loaded { if entry, ok := v.(placeholderEntry); ok && entry.id != "" { if editor, ok := ch.(MessageEditor); ok { - if err := editor.EditMessage(ctx, chatID, entry.id, msg.Content); err == nil { + content := msg.Content + if isToolFeedback { + content = InitialAnimatedToolFeedbackContent(msg.Content) + } + if err := editor.EditMessage(ctx, chatID, entry.id, content); err == nil { + if tracker, ok := ch.(toolFeedbackMessageTracker); ok && isToolFeedback { + tracker.RecordToolFeedbackMessage(chatID, entry.id, msg.Content) + } else if !isToolFeedback { + dismissTrackedToolFeedbackMessage(ctx, ch, chatID) + } return []string{entry.id}, true } // edit failed → fall through to normal Send @@ -312,22 +364,27 @@ func (m *Manager) GetStreamer(ctx context.Context, channelName, chatID string) ( // Mark streamActive on Finalize so preSend knows to clean up the placeholder key := channelName + ":" + chatID return &finalizeHookStreamer{ - Streamer: streamer, - onFinalize: func() { m.streamActive.Store(key, true) }, + Streamer: streamer, + onFinalize: func(finalizeCtx context.Context) { + dismissTrackedToolFeedbackMessage(finalizeCtx, ch, chatID) + m.streamActive.Store(key, true) + }, }, true } // finalizeHookStreamer wraps a Streamer to run a hook on Finalize. type finalizeHookStreamer struct { Streamer - onFinalize func() + onFinalize func(context.Context) } func (s *finalizeHookStreamer) Finalize(ctx context.Context, content string) error { if err := s.Streamer.Finalize(ctx, content); err != nil { return err } - s.onFinalize() + if s.onFinalize != nil { + s.onFinalize(ctx) + } return nil } @@ -769,18 +826,21 @@ func (m *Manager) runWorker(ctx context.Context, name string, w *channelWorker) // Collect all message chunks to send var chunks []string - // Step 1: Try marker-based splitting if enabled - if m.config != nil && m.config.Agents.Defaults.SplitOnMarker { + // Step 1: Try marker-based splitting if enabled. + // Tool feedback must stay a single message, so it skips marker splitting. + if m.config != nil && m.config.Agents.Defaults.SplitOnMarker && !outboundMessageIsToolFeedback(msg) { if markerChunks := SplitByMarker(msg.Content); len(markerChunks) > 1 { for _, chunk := range markerChunks { - chunks = append(chunks, splitByLength(chunk, maxLen)...) + chunkMsg := msg + chunkMsg.Content = chunk + chunks = append(chunks, splitOutboundMessageContent(chunkMsg, maxLen)...) } } } // Step 2: Fallback to length-based splitting if no chunks from marker if len(chunks) == 0 { - chunks = splitByLength(msg.Content, maxLen) + chunks = splitOutboundMessageContent(msg, maxLen) } // Step 3: Send all chunks @@ -795,12 +855,25 @@ func (m *Manager) runWorker(ctx context.Context, name string, w *channelWorker) } } -// splitByLength splits content by maxLen if needed, otherwise returns single chunk. -func splitByLength(content string, maxLen int) []string { - if maxLen > 0 && len([]rune(content)) > maxLen { - return SplitMessage(content, maxLen) +// splitOutboundMessageContent splits regular outbound content by maxLen, but +// keeps tool feedback in a single message by truncating the explanation body. +func splitOutboundMessageContent(msg bus.OutboundMessage, maxLen int) []string { + if maxLen > 0 { + if outboundMessageIsToolFeedback(msg) { + animationSafeLen := maxLen - MaxToolFeedbackAnimationFrameLength() + if animationSafeLen <= 0 { + animationSafeLen = maxLen + } + if len([]rune(msg.Content)) > animationSafeLen { + return []string{utils.FitToolFeedbackMessage(msg.Content, animationSafeLen)} + } + return []string{msg.Content} + } + if len([]rune(msg.Content)) > maxLen { + return SplitMessage(msg.Content, maxLen) + } } - return []string{content} + return []string{msg.Content} } // sendWithRetry sends a message through the channel with rate limiting and @@ -1264,13 +1337,16 @@ func (m *Manager) SendMessage(ctx context.Context, msg bus.OutboundMessage) erro if mlp, ok := w.ch.(MessageLengthProvider); ok { maxLen = mlp.MaxMessageLength() } - if maxLen > 0 && len([]rune(msg.Content)) > maxLen { - for _, chunk := range SplitMessage(msg.Content, maxLen) { + if chunks := splitOutboundMessageContent(msg, maxLen); len(chunks) > 1 { + for _, chunk := range chunks { chunkMsg := msg chunkMsg.Content = chunk m.sendWithRetry(ctx, channelName, w, chunkMsg) } } else { + if len(chunks) == 1 { + msg.Content = chunks[0] + } m.sendWithRetry(ctx, channelName, w, msg) } return nil diff --git a/pkg/channels/manager_test.go b/pkg/channels/manager_test.go index 881993d9c..4f6a7dcf4 100644 --- a/pkg/channels/manager_test.go +++ b/pkg/channels/manager_test.go @@ -13,6 +13,8 @@ import ( "golang.org/x/time/rate" "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/utils" ) // mockChannel is a test double that delegates Send to a configurable function. @@ -76,8 +78,9 @@ func (m *mockMediaChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaM type mockDeletingMediaChannel struct { mockMediaChannel - deleteCalls int - lastDeleted struct { + deleteCalls int + dismissedChatID string + lastDeleted struct { chatID string messageID string } @@ -94,6 +97,37 @@ func (m *mockDeletingMediaChannel) DeleteMessage( return nil } +func (m *mockDeletingMediaChannel) DismissToolFeedbackMessage(_ context.Context, chatID string) { + m.dismissedChatID = chatID +} + +type mockStreamer struct { + finalizeFn func(context.Context, string) error +} + +func (m *mockStreamer) Update(context.Context, string) error { return nil } + +func (m *mockStreamer) Finalize(ctx context.Context, content string) error { + if m.finalizeFn != nil { + return m.finalizeFn(ctx, content) + } + return nil +} + +func (m *mockStreamer) Cancel(context.Context) {} + +type mockStreamingChannel struct { + mockMessageEditor + streamer Streamer +} + +func (m *mockStreamingChannel) BeginStream(context.Context, string) (Streamer, error) { + if m.streamer == nil { + return nil, errors.New("missing streamer") + } + return m.streamer, nil +} + // newTestManager creates a minimal Manager suitable for unit tests. func newTestManager() *Manager { return &Manager{ @@ -715,13 +749,43 @@ func TestSendWithRetry_ExponentialBackoff(t *testing.T) { // mockMessageEditor is a channel that supports MessageEditor. type mockMessageEditor struct { mockChannel - editFn func(ctx context.Context, chatID, messageID, content string) error + editFn func(ctx context.Context, chatID, messageID, content string) error + finalizeFn func(ctx context.Context, msg bus.OutboundMessage) ([]string, bool) + finalizeCalled bool + recordedChatID string + recordedMessageID string + clearedChatID string + dismissedChatID string } func (m *mockMessageEditor) EditMessage(ctx context.Context, chatID, messageID, content string) error { return m.editFn(ctx, chatID, messageID, content) } +func (m *mockMessageEditor) RecordToolFeedbackMessage(chatID, messageID, _ string) { + m.recordedChatID = chatID + m.recordedMessageID = messageID +} + +func (m *mockMessageEditor) ClearToolFeedbackMessage(chatID string) { + m.clearedChatID = chatID +} + +func (m *mockMessageEditor) DismissToolFeedbackMessage(_ context.Context, chatID string) { + m.dismissedChatID = chatID +} + +func (m *mockMessageEditor) FinalizeToolFeedbackMessage( + ctx context.Context, + msg bus.OutboundMessage, +) ([]string, bool) { + m.finalizeCalled = true + if m.finalizeFn == nil { + return nil, false + } + return m.finalizeFn(ctx, msg) +} + func TestPreSend_PlaceholderEditSuccess(t *testing.T) { m := newTestManager() var sendCalled bool @@ -766,6 +830,360 @@ func TestPreSend_PlaceholderEditSuccess(t *testing.T) { } } +func TestPreSend_ToolFeedbackPlaceholderEditRecordsTrackedMessage(t *testing.T) { + m := newTestManager() + + ch := &mockMessageEditor{ + editFn: func(_ context.Context, chatID, messageID, content string) error { + if chatID != "123" || messageID != "456" || content != "hello" { + t.Fatalf("unexpected edit args: %s %s %s", chatID, messageID, content) + } + return nil + }, + } + + m.RecordPlaceholder("test", "123", "456") + + msg := testOutboundMessage(bus.OutboundMessage{ + Channel: "test", + ChatID: "123", + Content: "hello", + Context: bus.InboundContext{ + Channel: "test", + ChatID: "123", + Raw: map[string]string{ + "message_kind": "tool_feedback", + }, + }, + }) + _, edited := m.preSend(context.Background(), "test", msg, ch) + if !edited { + t.Fatal("expected preSend to edit placeholder") + } + if ch.recordedChatID != "123" || ch.recordedMessageID != "456" { + t.Fatalf("expected tracked message 123/456, got %q/%q", ch.recordedChatID, ch.recordedMessageID) + } +} + +func TestPreSend_NonToolFeedbackLeavesTrackedMessageForChannelSend(t *testing.T) { + m := newTestManager() + ch := &mockMessageEditor{} + + msg := testOutboundMessage(bus.OutboundMessage{ + Channel: "test", + ChatID: "123", + Content: "final reply", + Context: bus.InboundContext{ + Channel: "test", + ChatID: "123", + }, + }) + + _, edited := m.preSend(context.Background(), "test", msg, ch) + if edited { + t.Fatal("expected preSend to fall through when no placeholder exists") + } + if ch.dismissedChatID != "" { + t.Fatalf("expected tracked tool feedback cleanup to be deferred to channel send, got %q", ch.dismissedChatID) + } +} + +func TestPreSend_NonToolFeedbackDefersTrackedMessageFinalizationToChannelSend(t *testing.T) { + m := newTestManager() + ch := &mockMessageEditor{ + finalizeFn: func(_ context.Context, msg bus.OutboundMessage) ([]string, bool) { + if msg.ChatID != "123" || msg.Content != "final reply" { + t.Fatalf("unexpected finalize msg: %+v", msg) + } + return []string{"tool-msg-1"}, true + }, + } + + msg := testOutboundMessage(bus.OutboundMessage{ + Channel: "test", + ChatID: "123", + Content: "final reply", + Context: bus.InboundContext{ + Channel: "test", + ChatID: "123", + }, + }) + + msgIDs, handled := m.preSend(context.Background(), "test", msg, ch) + if handled { + t.Fatalf("expected preSend to defer to channel Send, got msgIDs=%v", msgIDs) + } + if len(msgIDs) != 0 { + t.Fatalf("expected no msgIDs from preSend, got %v", msgIDs) + } + if ch.dismissedChatID != "" { + t.Fatalf("expected tracked cleanup to remain in channel Send, got %q", ch.dismissedChatID) + } + if ch.finalizeCalled { + t.Fatal("expected preSend to skip channel tool feedback finalization") + } +} + +func TestPreSend_StaleToolFeedbackDoesNotConsumeStreamActiveMarker(t *testing.T) { + m := newTestManager() + m.streamActive.Store("test:123", true) + m.RecordPlaceholder("test", "123", "placeholder-1") + + var editedContent string + ch := &mockMessageEditor{ + editFn: func(_ context.Context, chatID, messageID, content string) error { + if chatID != "123" || messageID != "placeholder-1" { + t.Fatalf("unexpected edit target: %s/%s", chatID, messageID) + } + editedContent = content + return nil + }, + } + + toolFeedback := testOutboundMessage(bus.OutboundMessage{ + Channel: "test", + ChatID: "123", + Content: "🔧 `read_file`\nReading config", + Context: bus.InboundContext{ + Channel: "test", + ChatID: "123", + Raw: map[string]string{ + "message_kind": "tool_feedback", + }, + }, + }) + + msgIDs, handled := m.preSend(context.Background(), "test", toolFeedback, ch) + if !handled { + t.Fatal("expected stale tool feedback to be dropped after stream finalize") + } + if len(msgIDs) != 0 { + t.Fatalf("expected no delivered message IDs for stale feedback, got %v", msgIDs) + } + if _, ok := m.streamActive.Load("test:123"); !ok { + t.Fatal("expected streamActive marker to remain for the final outbound message") + } + if _, ok := m.placeholders.Load("test:123"); !ok { + t.Fatal("expected placeholder cleanup to remain deferred to the final outbound message") + } + if ch.editedMessages != 0 { + t.Fatalf("expected no placeholder edit for stale feedback, got %d edits", ch.editedMessages) + } + + finalMsg := testOutboundMessage(bus.OutboundMessage{ + Channel: "test", + ChatID: "123", + Content: "final streamed reply", + Context: bus.InboundContext{ + Channel: "test", + ChatID: "123", + }, + }) + + _, handled = m.preSend(context.Background(), "test", finalMsg, ch) + if !handled { + t.Fatal("expected final outbound message to consume streamActive marker") + } + if _, ok := m.streamActive.Load("test:123"); ok { + t.Fatal("expected streamActive marker to be cleared by final outbound message") + } + if _, ok := m.placeholders.Load("test:123"); ok { + t.Fatal("expected placeholder to be cleaned up by final outbound message") + } + if editedContent != "final streamed reply" { + t.Fatalf("editedContent = %q, want final streamed reply", editedContent) + } +} + +func TestPreSendMedia_LeavesTrackedMessageForChannelSend(t *testing.T) { + m := newTestManager() + ch := &mockDeletingMediaChannel{} + + m.preSendMedia(context.Background(), "test", bus.OutboundMediaMessage{ + ChatID: "123", + Context: bus.InboundContext{ + Channel: "test", + ChatID: "123", + }, + }, ch) + + if ch.dismissedChatID != "" { + t.Fatalf( + "expected tracked tool feedback cleanup to be deferred to channel media send, got %q", + ch.dismissedChatID, + ) + } +} + +func TestSplitOutboundMessageContent_ToolFeedbackTruncatesInsteadOfSplitting(t *testing.T) { + msg := testOutboundMessage(bus.OutboundMessage{ + Channel: "test", + ChatID: "123", + Content: "\U0001f527 `read_file`\nRead README.md first to confirm the current project structure before editing the config example.", + Context: bus.InboundContext{ + Channel: "test", + ChatID: "123", + Raw: map[string]string{ + "message_kind": "tool_feedback", + }, + }, + }) + + chunks := splitOutboundMessageContent(msg, 40) + if len(chunks) != 1 { + t.Fatalf("len(chunks) = %d, want 1", len(chunks)) + } + want := utils.FitToolFeedbackMessage(msg.Content, 40-MaxToolFeedbackAnimationFrameLength()) + if chunks[0] != want { + t.Fatalf("chunk = %q, want %q", chunks[0], want) + } +} + +func TestSplitOutboundMessageContent_ToolFeedbackReservesAnimationFrame(t *testing.T) { + msg := testOutboundMessage(bus.OutboundMessage{ + Channel: "test", + ChatID: "123", + Content: "🔧 `read_file`\n1234567890", + Context: bus.InboundContext{ + Channel: "test", + ChatID: "123", + Raw: map[string]string{ + "message_kind": "tool_feedback", + }, + }, + }) + + chunks := splitOutboundMessageContent(msg, len([]rune(msg.Content))) + if len(chunks) != 1 { + t.Fatalf("len(chunks) = %d, want 1", len(chunks)) + } + + animated := formatAnimatedToolFeedbackContent(chunks[0], strings.Repeat(".", MaxToolFeedbackAnimationFrameLength())) + if got, maxLen := len([]rune(animated)), len([]rune(msg.Content)); got > maxLen { + t.Fatalf("animated len = %d, want <= %d; content=%q", got, maxLen, animated) + } +} + +func TestGetStreamer_FinalizeDismissesTrackedToolFeedback(t *testing.T) { + m := newTestManager() + ch := &mockStreamingChannel{ + mockMessageEditor: mockMessageEditor{}, + streamer: &mockStreamer{ + finalizeFn: func(_ context.Context, content string) error { + if content != "final reply" { + t.Fatalf("unexpected finalize content: %q", content) + } + return nil + }, + }, + } + m.channels["test"] = ch + + streamer, ok := m.GetStreamer(context.Background(), "test", "123") + if !ok { + t.Fatal("expected streamer to be available") + } + if err := streamer.Finalize(context.Background(), "final reply"); err != nil { + t.Fatalf("Finalize() error = %v", err) + } + if ch.dismissedChatID != "123" { + t.Fatalf("expected tracked tool feedback to be dismissed for chat 123, got %q", ch.dismissedChatID) + } + if _, ok := m.streamActive.Load("test:123"); !ok { + t.Fatal("expected streamActive marker to be recorded after finalize") + } +} + +func TestGetStreamer_FinalizeFailureDoesNotDismissTrackedToolFeedback(t *testing.T) { + m := newTestManager() + ch := &mockStreamingChannel{ + mockMessageEditor: mockMessageEditor{}, + streamer: &mockStreamer{ + finalizeFn: func(context.Context, string) error { + return errors.New("finalize failed") + }, + }, + } + m.channels["test"] = ch + + streamer, ok := m.GetStreamer(context.Background(), "test", "123") + if !ok { + t.Fatal("expected streamer to be available") + } + if err := streamer.Finalize(context.Background(), "final reply"); err == nil { + t.Fatal("expected Finalize() to fail") + } + if ch.dismissedChatID != "" { + t.Fatalf("expected no tool feedback dismissal on finalize failure, got %q", ch.dismissedChatID) + } + if _, ok := m.streamActive.Load("test:123"); ok { + t.Fatal("expected no streamActive marker after finalize failure") + } +} + +func TestRunWorker_ToolFeedbackSkipsMarkerSplitting(t *testing.T) { + m := newTestManager() + m.config = &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + SplitOnMarker: true, + }, + }, + } + + var ( + mu sync.Mutex + received []string + ) + ch := &mockChannelWithLength{ + mockChannel: mockChannel{ + sendFn: func(_ context.Context, msg bus.OutboundMessage) error { + mu.Lock() + received = append(received, msg.Content) + mu.Unlock() + return nil + }, + }, + maxLen: 200, + } + + w := &channelWorker{ + ch: ch, + queue: make(chan bus.OutboundMessage, 1), + done: make(chan struct{}), + limiter: rate.NewLimiter(rate.Inf, 1), + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go m.runWorker(ctx, "test", w) + + content := "🔧 `read_file`\nRead current config first.<|[SPLIT]|>Then update the example." + w.queue <- testOutboundMessage(bus.OutboundMessage{ + Channel: "test", + ChatID: "123", + Content: content, + Context: bus.InboundContext{ + Channel: "test", + ChatID: "123", + Raw: map[string]string{ + "message_kind": "tool_feedback", + }, + }, + }) + + time.Sleep(100 * time.Millisecond) + + mu.Lock() + defer mu.Unlock() + if len(received) != 1 { + t.Fatalf("len(received) = %d, want 1", len(received)) + } + if received[0] != content { + t.Fatalf("received[0] = %q, want %q", received[0], content) + } +} + func TestPreSend_PlaceholderEditFails_FallsThrough(t *testing.T) { m := newTestManager() diff --git a/pkg/channels/matrix/matrix.go b/pkg/channels/matrix/matrix.go index 40e1b0a36..04599d6d2 100644 --- a/pkg/channels/matrix/matrix.go +++ b/pkg/channels/matrix/matrix.go @@ -46,6 +46,13 @@ const ( var matrixMentionHrefRegexp = regexp.MustCompile(`(?i)]+href=["']([^"']+)["']`) +func outboundMessageIsToolFeedback(msg bus.OutboundMessage) bool { + if len(msg.Context.Raw) == 0 { + return false + } + return strings.EqualFold(strings.TrimSpace(msg.Context.Raw["message_kind"]), "tool_feedback") +} + type roomKindCacheEntry struct { isGroup bool expiresAt time.Time @@ -192,6 +199,7 @@ type MatrixChannel struct { cryptoHelper *cryptohelper.CryptoHelper cryptoDbPath string + progress *channels.ToolFeedbackAnimator } func NewMatrixChannel( @@ -236,7 +244,7 @@ func NewMatrixChannel( channels.WithReasoningChannelID(bc.ReasoningChannelID), ) - return &MatrixChannel{ + ch := &MatrixChannel{ BaseChannel: base, bc: bc, client: client, @@ -248,7 +256,9 @@ func NewMatrixChannel( localpartMentionR: localpartMentionRegexp(matrixLocalpart(client.UserID)), typingMu: sync.Mutex{}, cryptoDbPath: cryptoDatabasePath, - }, nil + } + ch.progress = channels.NewToolFeedbackAnimator(ch.EditMessage) + return ch, nil } func (c *MatrixChannel) Start(ctx context.Context) error { @@ -297,6 +307,9 @@ func (c *MatrixChannel) Stop(ctx context.Context) error { c.cancel() } c.stopTypingSessions(ctx) + if c.progress != nil { + c.progress.StopAll() + } // Close crypto helper if initialized if c.cryptoHelper != nil { @@ -398,11 +411,36 @@ func (c *MatrixChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]st return nil, nil } + isToolFeedback := outboundMessageIsToolFeedback(msg) + if isToolFeedback { + if msgID, handled, err := c.progress.Update(ctx, msg.ChatID, content); handled { + if err != nil { + return nil, err + } + return []string{msgID}, nil + } + } + trackedMsgID, hasTrackedMsg := c.currentToolFeedbackMessage(msg.ChatID) + if !isToolFeedback { + if msgIDs, handled := c.FinalizeToolFeedbackMessage(ctx, msg); handled { + return msgIDs, nil + } + } + if isToolFeedback { + content = channels.InitialAnimatedToolFeedbackContent(content) + } + resp, err := c.client.SendMessageEvent(ctx, roomID, event.EventMessage, c.messageContent(content)) if err != nil { return nil, fmt.Errorf("matrix send: %w", channels.ErrTemporary) } - return []string{resp.EventID.String()}, nil + msgID := resp.EventID.String() + if isToolFeedback { + c.RecordToolFeedbackMessage(msg.ChatID, msgID, msg.Content) + } else if hasTrackedMsg { + c.dismissTrackedToolFeedbackMessage(ctx, msg.ChatID, trackedMsgID) + } + return []string{msgID}, nil } func (c *MatrixChannel) messageContent(text string) *event.MessageEventContent { @@ -419,6 +457,8 @@ func (c *MatrixChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMess if !c.IsRunning() { return nil, channels.ErrNotRunning } + trackedMsgID, hasTrackedMsg := c.currentToolFeedbackMessage(msg.ChatID) + sendCtx := ctx if sendCtx == nil { sendCtx = context.Background() @@ -529,6 +569,10 @@ func (c *MatrixChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMess } } + if hasTrackedMsg { + c.dismissTrackedToolFeedbackMessage(ctx, msg.ChatID, trackedMsgID) + } + return eventIDs, nil } @@ -612,6 +656,89 @@ func (c *MatrixChannel) EditMessage(ctx context.Context, chatID string, messageI return err } +// DeleteMessage implements channels.MessageDeleter. +func (c *MatrixChannel) DeleteMessage(ctx context.Context, chatID string, messageID string) error { + roomID := id.RoomID(strings.TrimSpace(chatID)) + if roomID == "" { + return fmt.Errorf("matrix room ID is empty") + } + eventID := id.EventID(strings.TrimSpace(messageID)) + if eventID == "" { + return fmt.Errorf("matrix message ID is empty") + } + + _, err := c.client.RedactEvent(ctx, roomID, eventID) + return err +} + +func (c *MatrixChannel) currentToolFeedbackMessage(chatID string) (string, bool) { + if c.progress == nil { + return "", false + } + return c.progress.Current(chatID) +} + +func (c *MatrixChannel) takeToolFeedbackMessage(chatID string) (string, string, bool) { + if c.progress == nil { + return "", "", false + } + return c.progress.Take(chatID) +} + +func (c *MatrixChannel) RecordToolFeedbackMessage(chatID, messageID, content string) { + if c.progress == nil { + return + } + c.progress.Record(chatID, messageID, content) +} + +func (c *MatrixChannel) ClearToolFeedbackMessage(chatID string) { + if c.progress == nil { + return + } + c.progress.Clear(chatID) +} + +func (c *MatrixChannel) DismissToolFeedbackMessage(ctx context.Context, chatID string) { + msgID, ok := c.currentToolFeedbackMessage(chatID) + if !ok { + return + } + c.dismissTrackedToolFeedbackMessage(ctx, chatID, msgID) +} + +func (c *MatrixChannel) dismissTrackedToolFeedbackMessage(ctx context.Context, chatID, messageID string) { + if strings.TrimSpace(chatID) == "" || strings.TrimSpace(messageID) == "" { + return + } + c.ClearToolFeedbackMessage(chatID) + _ = c.DeleteMessage(ctx, chatID, messageID) +} + +func (c *MatrixChannel) finalizeTrackedToolFeedbackMessage( + ctx context.Context, + chatID string, + content string, + editFn func(context.Context, string, string, string) error, +) ([]string, bool) { + msgID, baseContent, ok := c.takeToolFeedbackMessage(chatID) + if !ok || editFn == nil { + return nil, false + } + if err := editFn(ctx, chatID, msgID, content); err != nil { + c.RecordToolFeedbackMessage(chatID, msgID, baseContent) + return nil, false + } + return []string{msgID}, true +} + +func (c *MatrixChannel) FinalizeToolFeedbackMessage(ctx context.Context, msg bus.OutboundMessage) ([]string, bool) { + if outboundMessageIsToolFeedback(msg) { + return nil, false + } + return c.finalizeTrackedToolFeedbackMessage(ctx, msg.ChatID, msg.Content, c.EditMessage) +} + func (c *MatrixChannel) handleMemberEvent(ctx context.Context, evt *event.Event) { if !c.config.JoinOnInvite { return diff --git a/pkg/channels/matrix/matrix_test.go b/pkg/channels/matrix/matrix_test.go index 07f08f32b..066f08059 100644 --- a/pkg/channels/matrix/matrix_test.go +++ b/pkg/channels/matrix/matrix_test.go @@ -14,6 +14,7 @@ import ( "maunium.net/go/mautrix/event" "maunium.net/go/mautrix/id" + "github.com/sipeed/picoclaw/pkg/channels" "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/media" ) @@ -41,6 +42,34 @@ func TestMatrixLocalpartMentionRegexp(t *testing.T) { } } +func TestFinalizeTrackedToolFeedbackMessage_StopsTrackingBeforeEdit(t *testing.T) { + ch := &MatrixChannel{ + progress: channels.NewToolFeedbackAnimator(nil), + } + ch.RecordToolFeedbackMessage("!room:matrix.org", "$event1", "🔧 `read_file`") + + msgIDs, handled := ch.finalizeTrackedToolFeedbackMessage( + context.Background(), + "!room:matrix.org", + "final reply", + func(_ context.Context, chatID, messageID, content string) error { + if _, ok := ch.currentToolFeedbackMessage(chatID); ok { + t.Fatal("expected tracked tool feedback to be stopped before edit") + } + if chatID != "!room:matrix.org" || messageID != "$event1" || content != "final reply" { + t.Fatalf("unexpected edit args: %s %s %s", chatID, messageID, content) + } + return nil + }, + ) + if !handled { + t.Fatal("expected finalizeTrackedToolFeedbackMessage to handle tracked message") + } + if len(msgIDs) != 1 || msgIDs[0] != "$event1" { + t.Fatalf("finalizeTrackedToolFeedbackMessage() ids = %v, want [$event1]", msgIDs) + } +} + func TestStripUserMention(t *testing.T) { userID := id.UserID("@picoclaw:matrix.org") diff --git a/pkg/channels/pico/pico.go b/pkg/channels/pico/pico.go index f998712c8..5d7bd0fa1 100644 --- a/pkg/channels/pico/pico.go +++ b/pkg/channels/pico/pico.go @@ -46,6 +46,13 @@ func outboundMessageIsThought(msg bus.OutboundMessage) bool { return strings.EqualFold(strings.TrimSpace(msg.Context.Raw["message_kind"]), MessageKindThought) } +func outboundMessageIsToolFeedback(msg bus.OutboundMessage) bool { + if len(msg.Context.Raw) == 0 { + return false + } + return strings.EqualFold(strings.TrimSpace(msg.Context.Raw["message_kind"]), "tool_feedback") +} + // writeJSON sends a JSON message to the connection with write locking. func (pc *picoConn) writeJSON(v any) error { if pc.closed.Load() { @@ -78,6 +85,7 @@ type PicoChannel struct { connsMu sync.RWMutex ctx context.Context cancel context.CancelFunc + progress *channels.ToolFeedbackAnimator } // NewPicoChannel creates a new Pico Protocol channel. @@ -106,7 +114,7 @@ func NewPicoChannel( return false } - return &PicoChannel{ + ch := &PicoChannel{ BaseChannel: base, bc: bc, config: cfg, @@ -117,7 +125,9 @@ func NewPicoChannel( }, connections: make(map[string]*picoConn), sessionConnections: make(map[string]map[string]*picoConn), - }, nil + } + ch.progress = channels.NewToolFeedbackAnimator(ch.EditMessage) + return ch, nil } // createAndAddConnection checks MaxConnections and registers a connection atomically. @@ -235,6 +245,9 @@ func (c *PicoChannel) Stop(ctx context.Context) error { if c.cancel != nil { c.cancel() } + if c.progress != nil { + c.progress.StopAll() + } logger.InfoC("pico", "Pico Protocol channel stopped") return nil @@ -261,13 +274,43 @@ func (c *PicoChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]stri return nil, channels.ErrNotRunning } isThought := outboundMessageIsThought(msg) + isToolFeedback := outboundMessageIsToolFeedback(msg) + if isToolFeedback { + if msgID, handled, err := c.progress.Update(ctx, msg.ChatID, msg.Content); handled { + if err != nil { + return nil, err + } + return []string{msgID}, nil + } + } + trackedMsgID, hasTrackedMsg := c.currentToolFeedbackMessage(msg.ChatID) + if !isToolFeedback { + if msgIDs, handled := c.FinalizeToolFeedbackMessage(ctx, msg); handled { + return msgIDs, nil + } + } + + content := msg.Content + if isToolFeedback { + content = channels.InitialAnimatedToolFeedbackContent(msg.Content) + } + msgID := uuid.New().String() outMsg := newMessage(TypeMessageCreate, map[string]any{ - PayloadKeyContent: msg.Content, + PayloadKeyContent: content, PayloadKeyThought: isThought, + "message_id": msgID, }) - return nil, c.broadcastToSession(msg.ChatID, outMsg) + if err := c.broadcastToSession(msg.ChatID, outMsg); err != nil { + return nil, err + } + if isToolFeedback { + c.RecordToolFeedbackMessage(msg.ChatID, msgID, msg.Content) + } else if hasTrackedMsg { + c.dismissTrackedToolFeedbackMessage(ctx, msg.ChatID, trackedMsgID) + } + return []string{msgID}, nil } // EditMessage implements channels.MessageEditor. @@ -279,6 +322,73 @@ func (c *PicoChannel) EditMessage(ctx context.Context, chatID string, messageID return c.broadcastToSession(chatID, outMsg) } +func (c *PicoChannel) currentToolFeedbackMessage(chatID string) (string, bool) { + if c.progress == nil { + return "", false + } + return c.progress.Current(chatID) +} + +func (c *PicoChannel) takeToolFeedbackMessage(chatID string) (string, string, bool) { + if c.progress == nil { + return "", "", false + } + return c.progress.Take(chatID) +} + +func (c *PicoChannel) RecordToolFeedbackMessage(chatID, messageID, content string) { + if c.progress == nil { + return + } + c.progress.Record(chatID, messageID, content) +} + +func (c *PicoChannel) ClearToolFeedbackMessage(chatID string) { + if c.progress == nil { + return + } + c.progress.Clear(chatID) +} + +func (c *PicoChannel) DismissToolFeedbackMessage(ctx context.Context, chatID string) { + msgID, ok := c.currentToolFeedbackMessage(chatID) + if !ok { + return + } + c.dismissTrackedToolFeedbackMessage(ctx, chatID, msgID) +} + +func (c *PicoChannel) dismissTrackedToolFeedbackMessage(ctx context.Context, chatID, messageID string) { + if strings.TrimSpace(chatID) == "" || strings.TrimSpace(messageID) == "" { + return + } + c.ClearToolFeedbackMessage(chatID) +} + +func (c *PicoChannel) finalizeTrackedToolFeedbackMessage( + ctx context.Context, + chatID string, + content string, + editFn func(context.Context, string, string, string) error, +) ([]string, bool) { + msgID, baseContent, ok := c.takeToolFeedbackMessage(chatID) + if !ok || editFn == nil { + return nil, false + } + if err := editFn(ctx, chatID, msgID, content); err != nil { + c.RecordToolFeedbackMessage(chatID, msgID, baseContent) + return nil, false + } + return []string{msgID}, true +} + +func (c *PicoChannel) FinalizeToolFeedbackMessage(ctx context.Context, msg bus.OutboundMessage) ([]string, bool) { + if outboundMessageIsToolFeedback(msg) { + return nil, false + } + return c.finalizeTrackedToolFeedbackMessage(ctx, msg.ChatID, msg.Content, c.EditMessage) +} + // StartTyping implements channels.TypingCapable. func (c *PicoChannel) StartTyping(ctx context.Context, chatID string) (func(), error) { startMsg := newMessage(TypeTypingStart, nil) diff --git a/pkg/channels/pico/pico_test.go b/pkg/channels/pico/pico_test.go index 59db705eb..77a146f34 100644 --- a/pkg/channels/pico/pico_test.go +++ b/pkg/channels/pico/pico_test.go @@ -27,6 +27,34 @@ func newTestPicoChannel(t *testing.T) *PicoChannel { return ch } +func TestFinalizeTrackedToolFeedbackMessage_StopsTrackingBeforeEdit(t *testing.T) { + ch := &PicoChannel{ + progress: channels.NewToolFeedbackAnimator(nil), + } + ch.RecordToolFeedbackMessage("pico:chat-1", "msg-1", "🔧 `read_file`") + + msgIDs, handled := ch.finalizeTrackedToolFeedbackMessage( + context.Background(), + "pico:chat-1", + "final reply", + func(_ context.Context, chatID, messageID, content string) error { + if _, ok := ch.currentToolFeedbackMessage(chatID); ok { + t.Fatal("expected tracked tool feedback to be stopped before edit") + } + if chatID != "pico:chat-1" || messageID != "msg-1" || content != "final reply" { + t.Fatalf("unexpected edit args: %s %s %s", chatID, messageID, content) + } + return nil + }, + ) + if !handled { + t.Fatal("expected finalizeTrackedToolFeedbackMessage to handle tracked message") + } + if len(msgIDs) != 1 || msgIDs[0] != "msg-1" { + t.Fatalf("finalizeTrackedToolFeedbackMessage() ids = %v, want [msg-1]", msgIDs) + } +} + func TestCreateAndAddConnection_RespectsMaxConnectionsConcurrently(t *testing.T) { ch := newTestPicoChannel(t) diff --git a/pkg/channels/telegram/command_registration.go b/pkg/channels/telegram/command_registration.go index d3152ec3d..c6b362601 100644 --- a/pkg/channels/telegram/command_registration.go +++ b/pkg/channels/telegram/command_registration.go @@ -66,6 +66,10 @@ func (c *TelegramChannel) startCommandRegistration(ctx context.Context, defs []c if register == nil { register = c.RegisterCommands } + delayFn := c.commandRegDelayFn + if delayFn == nil { + delayFn = commandRegistrationDelay + } regCtx, cancel := context.WithCancel(ctx) c.commandRegCancel = cancel @@ -91,7 +95,7 @@ func (c *TelegramChannel) startCommandRegistration(ctx context.Context, defs []c return } - delay := commandRegistrationDelay(attempt) + delay := delayFn(attempt) logger.WarnCF("telegram", "Telegram command registration failed; will retry", map[string]any{ "error": err.Error(), "retry_after": delay.String(), diff --git a/pkg/channels/telegram/command_registration_test.go b/pkg/channels/telegram/command_registration_test.go index 26f891b2e..c30c6f68d 100644 --- a/pkg/channels/telegram/command_registration_test.go +++ b/pkg/channels/telegram/command_registration_test.go @@ -31,14 +31,12 @@ func TestStartCommandRegistration_DoesNotBlock(t *testing.T) { } func TestStartCommandRegistration_RetriesUntilSuccessThenStops(t *testing.T) { - ch := &TelegramChannel{} + ch := &TelegramChannel{ + commandRegDelayFn: func(int) time.Duration { return 5 * time.Millisecond }, + } ctx, cancel := context.WithCancel(context.Background()) defer cancel() - origBackoff := commandRegistrationBackoff - commandRegistrationBackoff = []time.Duration{5 * time.Millisecond} - defer func() { commandRegistrationBackoff = origBackoff }() - var attempts atomic.Int32 ch.registerFunc = func(context.Context, []commands.Definition) error { n := attempts.Add(1) @@ -69,12 +67,10 @@ func TestStartCommandRegistration_RetriesUntilSuccessThenStops(t *testing.T) { } func TestStartCommandRegistration_StopsAfterCancel(t *testing.T) { - ch := &TelegramChannel{} + ch := &TelegramChannel{ + commandRegDelayFn: func(int) time.Duration { return 5 * time.Millisecond }, + } ctx, cancel := context.WithCancel(context.Background()) - - origBackoff := commandRegistrationBackoff - commandRegistrationBackoff = []time.Duration{5 * time.Millisecond} - defer func() { commandRegistrationBackoff = origBackoff }() defer cancel() var attempts atomic.Int32 diff --git a/pkg/channels/telegram/telegram.go b/pkg/channels/telegram/telegram.go index 2a9cfe4ae..8bec7856d 100644 --- a/pkg/channels/telegram/telegram.go +++ b/pkg/channels/telegram/telegram.go @@ -45,16 +45,18 @@ var ( type TelegramChannel struct { *channels.BaseChannel - bot *telego.Bot - bh *th.BotHandler - bc *config.Channel - chatIDs map[string]int64 - ctx context.Context - cancel context.CancelFunc - tgCfg *config.TelegramSettings + bot *telego.Bot + bh *th.BotHandler + bc *config.Channel + chatIDs map[string]int64 + ctx context.Context + cancel context.CancelFunc + tgCfg *config.TelegramSettings + progress *channels.ToolFeedbackAnimator - registerFunc func(context.Context, []commands.Definition) error - commandRegCancel context.CancelFunc + registerFunc func(context.Context, []commands.Definition) error + commandRegDelayFn func(int) time.Duration + commandRegCancel context.CancelFunc } func NewTelegramChannel( @@ -104,13 +106,15 @@ func NewTelegramChannel( channels.WithReasoningChannelID(bc.ReasoningChannelID), ) - return &TelegramChannel{ + ch := &TelegramChannel{ BaseChannel: base, bot: bot, bc: bc, chatIDs: make(map[string]int64), tgCfg: telegramCfg, - }, nil + } + ch.progress = channels.NewToolFeedbackAnimator(ch.EditMessage) + return ch, nil } func (c *TelegramChannel) Start(ctx context.Context) error { @@ -168,6 +172,9 @@ func (c *TelegramChannel) Stop(ctx context.Context) error { if c.cancel != nil { c.cancel() } + if c.progress != nil { + c.progress.StopAll() + } if c.commandRegCancel != nil { c.commandRegCancel() } @@ -191,12 +198,35 @@ func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([] return nil, nil } + isToolFeedback := outboundMessageIsToolFeedback(msg) + toolFeedbackContent := msg.Content + if isToolFeedback { + toolFeedbackContent = fitToolFeedbackForTelegram(msg.Content, useMarkdownV2, 4096) + } + if isToolFeedback { + if msgID, handled, err := c.progress.Update(ctx, msg.ChatID, toolFeedbackContent); handled { + if err != nil { + return nil, err + } + return []string{msgID}, nil + } + } + trackedMsgID, hasTrackedMsg := c.currentToolFeedbackMessage(msg.ChatID) + if !isToolFeedback { + if msgIDs, handled := c.FinalizeToolFeedbackMessage(ctx, msg); handled { + return msgIDs, nil + } + } + // The Manager already splits messages to ≤4000 chars (WithMaxMessageLength), // so msg.Content is guaranteed to be within that limit. We still need to // check if HTML expansion pushes it beyond Telegram's 4096-char API limit. replyToID := msg.ReplyToMessageID var messageIDs []string queue := []string{msg.Content} + if isToolFeedback { + queue = []string{channels.InitialAnimatedToolFeedbackContent(toolFeedbackContent)} + } for len(queue) > 0 { chunk := queue[0] queue = queue[1:] @@ -204,6 +234,13 @@ func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([] content := parseContent(chunk, useMarkdownV2) if len([]rune(content)) > 4096 { + if isToolFeedback { + fittedChunk := fitToolFeedbackForTelegram(chunk, useMarkdownV2, 4096) + if fittedChunk != "" && fittedChunk != chunk { + queue = append([]string{fittedChunk}, queue...) + continue + } + } runeChunk := []rune(chunk) ratio := float64(len(runeChunk)) / float64(len([]rune(content))) smallerLen := int(float64(4096) * ratio * 0.95) // 5% safety margin @@ -270,6 +307,12 @@ func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([] replyToID = "" } + if isToolFeedback && len(messageIDs) > 0 { + c.RecordToolFeedbackMessage(msg.ChatID, messageIDs[0], toolFeedbackContent) + } else if !isToolFeedback && hasTrackedMsg { + c.dismissTrackedToolFeedbackMessage(ctx, msg.ChatID, trackedMsgID) + } + return messageIDs, nil } @@ -437,6 +480,81 @@ func (c *TelegramChannel) DeleteMessage(ctx context.Context, chatID string, mess }) } +func outboundMessageIsToolFeedback(msg bus.OutboundMessage) bool { + if len(msg.Context.Raw) == 0 { + return false + } + return strings.EqualFold(strings.TrimSpace(msg.Context.Raw["message_kind"]), "tool_feedback") +} + +func (c *TelegramChannel) currentToolFeedbackMessage(chatID string) (string, bool) { + if c.progress == nil { + return "", false + } + return c.progress.Current(chatID) +} + +func (c *TelegramChannel) takeToolFeedbackMessage(chatID string) (string, string, bool) { + if c.progress == nil { + return "", "", false + } + return c.progress.Take(chatID) +} + +func (c *TelegramChannel) RecordToolFeedbackMessage(chatID, messageID, content string) { + if c.progress == nil { + return + } + c.progress.Record(chatID, messageID, content) +} + +func (c *TelegramChannel) ClearToolFeedbackMessage(chatID string) { + if c.progress == nil { + return + } + c.progress.Clear(chatID) +} + +func (c *TelegramChannel) DismissToolFeedbackMessage(ctx context.Context, chatID string) { + msgID, ok := c.currentToolFeedbackMessage(chatID) + if !ok { + return + } + c.dismissTrackedToolFeedbackMessage(ctx, chatID, msgID) +} + +func (c *TelegramChannel) dismissTrackedToolFeedbackMessage(ctx context.Context, chatID, messageID string) { + if strings.TrimSpace(chatID) == "" || strings.TrimSpace(messageID) == "" { + return + } + c.ClearToolFeedbackMessage(chatID) + _ = c.DeleteMessage(ctx, chatID, messageID) +} + +func (c *TelegramChannel) finalizeTrackedToolFeedbackMessage( + ctx context.Context, + chatID string, + content string, + editFn func(context.Context, string, string, string) error, +) ([]string, bool) { + msgID, baseContent, ok := c.takeToolFeedbackMessage(chatID) + if !ok || editFn == nil { + return nil, false + } + if err := editFn(ctx, chatID, msgID, content); err != nil { + c.RecordToolFeedbackMessage(chatID, msgID, baseContent) + return nil, false + } + return []string{msgID}, true +} + +func (c *TelegramChannel) FinalizeToolFeedbackMessage(ctx context.Context, msg bus.OutboundMessage) ([]string, bool) { + if outboundMessageIsToolFeedback(msg) { + return nil, false + } + return c.finalizeTrackedToolFeedbackMessage(ctx, msg.ChatID, msg.Content, c.EditMessage) +} + // SendPlaceholder implements channels.PlaceholderCapable. // It sends a placeholder message (e.g. "Thinking... 💭") that will later be // edited to the actual response via EditMessage (channels.MessageEditor). @@ -468,6 +586,7 @@ func (c *TelegramChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMe if !c.IsRunning() { return nil, channels.ErrNotRunning } + trackedMsgID, hasTrackedMsg := c.currentToolFeedbackMessage(msg.ChatID) chatID, threadID, err := resolveTelegramOutboundTarget(msg.ChatID, &msg.Context) if err != nil { @@ -576,6 +695,10 @@ func (c *TelegramChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMe } } + if hasTrackedMsg { + c.dismissTrackedToolFeedbackMessage(ctx, msg.ChatID, trackedMsgID) + } + return messageIDs, nil } @@ -947,6 +1070,41 @@ func parseContent(text string, useMarkdownV2 bool) string { return markdownToTelegramHTML(text) } +func fitToolFeedbackForTelegram(content string, useMarkdownV2 bool, maxParsedLen int) string { + content = strings.TrimSpace(content) + if content == "" || maxParsedLen <= 0 { + return "" + } + animationSafeLen := maxParsedLen - channels.MaxToolFeedbackAnimationFrameLength() + if animationSafeLen <= 0 { + animationSafeLen = maxParsedLen + } + if len([]rune(parseContent(content, useMarkdownV2))) <= animationSafeLen { + return content + } + + low := 1 + high := len([]rune(content)) + best := utils.Truncate(content, 1) + + for low <= high { + mid := (low + high) / 2 + candidate := utils.FitToolFeedbackMessage(content, mid) + if candidate == "" { + high = mid - 1 + continue + } + if len([]rune(parseContent(candidate, useMarkdownV2))) <= animationSafeLen { + best = candidate + low = mid + 1 + continue + } + high = mid - 1 + } + + return best +} + // parseTelegramChatID splits "chatID/threadID" into its components. // Returns threadID=0 when no "/" is present (non-forum messages). func parseTelegramChatID(chatID string) (int64, int, error) { diff --git a/pkg/channels/telegram/telegram_group_command_filter_test.go b/pkg/channels/telegram/telegram_group_command_filter_test.go index 614b2ca7f..20b2004a9 100644 --- a/pkg/channels/telegram/telegram_group_command_filter_test.go +++ b/pkg/channels/telegram/telegram_group_command_filter_test.go @@ -108,7 +108,7 @@ func TestHandleMessage_GroupMentionOnly_BotCommandEntity(t *testing.T) { t.Fatalf("handleMessage error: %v", err) } - ctx, cancel := context.WithTimeout(context.Background(), 200*time.Microsecond) + ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) defer cancel() select { case <-ctx.Done(): diff --git a/pkg/channels/telegram/telegram_test.go b/pkg/channels/telegram/telegram_test.go index 3d147b337..f3974723d 100644 --- a/pkg/channels/telegram/telegram_test.go +++ b/pkg/channels/telegram/telegram_test.go @@ -98,8 +98,12 @@ func (s *multipartRecordingConstructor) MultipartRequest( // successResponse returns a ta.Response that telego will treat as a successful SendMessage. func successResponse(t *testing.T) *ta.Response { + return successResponseWithMessageID(t, 1) +} + +func successResponseWithMessageID(t *testing.T, messageID int) *ta.Response { t.Helper() - msg := &telego.Message{MessageID: 1} + msg := &telego.Message{MessageID: messageID} b, err := json.Marshal(msg) require.NoError(t, err) return &ta.Response{Ok: true, Result: b} @@ -142,6 +146,7 @@ func newTestChannelWithConstructor( chatIDs: make(map[string]int64), bc: &config.Channel{Type: config.ChannelTelegram, Enabled: true}, tgCfg: &config.TelegramSettings{}, + progress: channels.NewToolFeedbackAnimator(nil), } } @@ -266,6 +271,101 @@ func TestSend_ShortMessage_SingleCall(t *testing.T) { assert.Len(t, caller.calls, 1, "short message should result in exactly one SendMessage call") } +func TestSend_NonToolFeedbackDeletesTrackedProgressMessage(t *testing.T) { + caller := &stubCaller{ + callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) { + switch { + case strings.Contains(url, "editMessageText"): + return successResponseWithMessageID(t, 1), nil + default: + t.Fatalf("unexpected API call: %s", url) + return nil, nil + } + }, + } + ch := newTestChannel(t, caller) + ch.RecordToolFeedbackMessage("12345", "1", "🔧 `read_file`") + + ids, err := ch.Send(context.Background(), bus.OutboundMessage{ + ChatID: "12345", + Content: "final reply", + }) + + assert.NoError(t, err) + assert.Equal(t, []string{"1"}, ids) + require.Len(t, caller.calls, 1) + assert.Contains(t, caller.calls[0].URL, "editMessageText") + _, ok := ch.currentToolFeedbackMessage("12345") + assert.False(t, ok, "tracked tool feedback should be cleared after final reply") +} + +func TestFinalizeTrackedToolFeedbackMessage_StopsTrackingBeforeEdit(t *testing.T) { + ch := newTestChannel(t, &stubCaller{ + callFn: func(context.Context, string, *ta.RequestData) (*ta.Response, error) { + t.Fatal("unexpected API call") + return nil, nil + }, + }) + ch.RecordToolFeedbackMessage("12345", "1", "🔧 `read_file`") + + msgIDs, handled := ch.finalizeTrackedToolFeedbackMessage( + context.Background(), + "12345", + "final reply", + func(_ context.Context, chatID, messageID, content string) error { + _, ok := ch.currentToolFeedbackMessage(chatID) + assert.False(t, ok, "tracked tool feedback should be stopped before edit") + assert.Equal(t, "12345", chatID) + assert.Equal(t, "1", messageID) + assert.Equal(t, "final reply", content) + return nil + }, + ) + + assert.True(t, handled) + assert.Equal(t, []string{"1"}, msgIDs) +} + +func TestSend_ToolFeedbackStaysSingleMessageAfterHTMLExpansion(t *testing.T) { + caller := &stubCaller{ + callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) { + return successResponse(t), nil + }, + } + ch := newTestChannel(t, caller) + + _, err := ch.Send(context.Background(), bus.OutboundMessage{ + ChatID: "12345", + Content: "🔧 `read_file`\n" + strings.Repeat("<", 2000), + Context: bus.InboundContext{ + Channel: "telegram", + ChatID: "12345", + Raw: map[string]string{ + "message_kind": "tool_feedback", + }, + }, + }) + + assert.NoError(t, err) + assert.Len(t, caller.calls, 1, "tool feedback should stay a single Telegram message after HTML escaping") +} + +func TestFitToolFeedbackForTelegram_ReservesAnimationFrame(t *testing.T) { + content := "🔧 `read_file`\n" + strings.Repeat("a", 4096) + + fitted := fitToolFeedbackForTelegram(content, false, 4096) + animated := strings.Replace( + fitted, + "`\n", + strings.Repeat(".", channels.MaxToolFeedbackAnimationFrameLength())+"`\n", + 1, + ) + + if got := len([]rune(parseContent(animated, false))); got > 4096 { + t.Fatalf("animated parsed length = %d, want <= 4096", got) + } +} + func TestSend_LongMessage_SingleCall(t *testing.T) { // With WithMaxMessageLength(4000), the Manager pre-splits messages before // they reach Send(). A message at exactly 4000 chars should go through diff --git a/pkg/channels/tool_feedback_animator.go b/pkg/channels/tool_feedback_animator.go new file mode 100644 index 000000000..b424612bf --- /dev/null +++ b/pkg/channels/tool_feedback_animator.go @@ -0,0 +1,240 @@ +package channels + +import ( + "context" + "strings" + "sync" + "time" +) + +const toolFeedbackAnimationInterval = 3 * time.Second + +const initialToolFeedbackAnimationFrame = "" + +var toolFeedbackAnimationFrames = []string{"..", "."} + +// MaxToolFeedbackAnimationFrameLength returns the largest frame suffix length +// so callers can reserve room before sending messages to length-limited APIs. +func MaxToolFeedbackAnimationFrameLength() int { + maxLen := len([]rune(initialToolFeedbackAnimationFrame)) + for _, frame := range toolFeedbackAnimationFrames { + if frameLen := len([]rune(frame)); frameLen > maxLen { + maxLen = frameLen + } + } + return maxLen +} + +type toolFeedbackAnimationState struct { + messageID string + baseContent string + stop chan struct{} + done chan struct{} +} + +type ToolFeedbackAnimator struct { + mu sync.Mutex + editFn func(ctx context.Context, chatID, messageID, content string) error + entries map[string]*toolFeedbackAnimationState +} + +func NewToolFeedbackAnimator( + editFn func(ctx context.Context, chatID, messageID, content string) error, +) *ToolFeedbackAnimator { + return &ToolFeedbackAnimator{ + editFn: editFn, + entries: make(map[string]*toolFeedbackAnimationState), + } +} + +func (a *ToolFeedbackAnimator) Current(chatID string) (string, bool) { + if a == nil || strings.TrimSpace(chatID) == "" { + return "", false + } + a.mu.Lock() + defer a.mu.Unlock() + entry, ok := a.entries[chatID] + if !ok || strings.TrimSpace(entry.messageID) == "" { + return "", false + } + return entry.messageID, true +} + +func (a *ToolFeedbackAnimator) Record(chatID, messageID, content string) { + if a == nil { + return + } + chatID = strings.TrimSpace(chatID) + messageID = strings.TrimSpace(messageID) + content = strings.TrimSpace(content) + if chatID == "" || messageID == "" || content == "" { + return + } + + entry := &toolFeedbackAnimationState{ + messageID: messageID, + baseContent: content, + stop: make(chan struct{}), + done: make(chan struct{}), + } + + var previous *toolFeedbackAnimationState + a.mu.Lock() + if old, ok := a.entries[chatID]; ok { + previous = old + } + a.entries[chatID] = entry + a.mu.Unlock() + + stopToolFeedbackAnimation(previous) + go a.run(chatID, entry) +} + +func (a *ToolFeedbackAnimator) Clear(chatID string) { + if a == nil || strings.TrimSpace(chatID) == "" { + return + } + entry := a.detach(chatID) + stopToolFeedbackAnimation(entry) +} + +func (a *ToolFeedbackAnimator) Take(chatID string) (string, string, bool) { + if a == nil || strings.TrimSpace(chatID) == "" { + return "", "", false + } + entry := a.detach(chatID) + if entry == nil || strings.TrimSpace(entry.messageID) == "" { + return "", "", false + } + stopToolFeedbackAnimation(entry) + return entry.messageID, entry.baseContent, true +} + +// Update edits an existing tracked feedback message. If the edit fails, the +// previous feedback state is restored so callers can retry without orphaning +// the old progress message. +func (a *ToolFeedbackAnimator) Update(ctx context.Context, chatID, content string) (string, bool, error) { + if a == nil || a.editFn == nil { + return "", false, nil + } + msgID, baseContent, ok := a.Take(chatID) + if !ok { + return "", false, nil + } + + animatedContent := InitialAnimatedToolFeedbackContent(content) + if err := a.editFn(ctx, strings.TrimSpace(chatID), msgID, animatedContent); err != nil { + a.Record(chatID, msgID, baseContent) + return "", true, err + } + + a.Record(chatID, msgID, content) + return msgID, true, nil +} + +func (a *ToolFeedbackAnimator) StopAll() { + if a == nil { + return + } + a.mu.Lock() + entries := make([]*toolFeedbackAnimationState, 0, len(a.entries)) + for chatID, entry := range a.entries { + entries = append(entries, entry) + delete(a.entries, chatID) + } + a.mu.Unlock() + + for _, entry := range entries { + stopToolFeedbackAnimation(entry) + } +} + +func (a *ToolFeedbackAnimator) detach(chatID string) *toolFeedbackAnimationState { + if a == nil || strings.TrimSpace(chatID) == "" { + return nil + } + a.mu.Lock() + defer a.mu.Unlock() + entry := a.entries[chatID] + delete(a.entries, chatID) + return entry +} + +func (a *ToolFeedbackAnimator) run(chatID string, entry *toolFeedbackAnimationState) { + defer close(entry.done) + + ticker := time.NewTicker(toolFeedbackAnimationInterval) + defer ticker.Stop() + + frameIdx := 1 + + for { + select { + case <-entry.stop: + return + case <-ticker.C: + if a.editFn == nil { + continue + } + frame := toolFeedbackAnimationFrames[frameIdx%len(toolFeedbackAnimationFrames)] + content := formatAnimatedToolFeedbackContent(entry.baseContent, frame) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + _ = a.editFn(ctx, chatID, entry.messageID, content) + cancel() + frameIdx++ + } + } +} + +func InitialAnimatedToolFeedbackContent(baseContent string) string { + return formatAnimatedToolFeedbackContent(baseContent, initialToolFeedbackAnimationFrame) +} + +func formatAnimatedToolFeedbackContent(baseContent, frame string) string { + baseContent = strings.TrimSpace(baseContent) + frame = strings.TrimSpace(frame) + if baseContent == "" { + return "" + } + if frame == "" { + return baseContent + } + lineBreak := strings.IndexByte(baseContent, '\n') + if lineBreak < 0 { + return appendToolFeedbackFrame(baseContent, frame) + } + return appendToolFeedbackFrame(baseContent[:lineBreak], frame) + baseContent[lineBreak:] +} + +func appendToolFeedbackFrame(firstLine, frame string) string { + firstLine = strings.TrimSpace(firstLine) + frame = strings.TrimSpace(frame) + if firstLine == "" { + return "" + } + if frame == "" { + return firstLine + } + + openTick := strings.IndexByte(firstLine, '`') + if openTick >= 0 { + if closeOffset := strings.IndexByte(firstLine[openTick+1:], '`'); closeOffset >= 0 { + closeTick := openTick + 1 + closeOffset + return firstLine[:closeTick] + frame + firstLine[closeTick:] + } + } + + return firstLine + frame +} + +func stopToolFeedbackAnimation(entry *toolFeedbackAnimationState) { + if entry == nil { + return + } + select { + case <-entry.stop: + default: + close(entry.stop) + } + <-entry.done +} diff --git a/pkg/channels/tool_feedback_animator_test.go b/pkg/channels/tool_feedback_animator_test.go new file mode 100644 index 000000000..a23284548 --- /dev/null +++ b/pkg/channels/tool_feedback_animator_test.go @@ -0,0 +1,121 @@ +package channels + +import ( + "context" + "errors" + "testing" +) + +func TestFormatAnimatedToolFeedbackContent(t *testing.T) { + got := formatAnimatedToolFeedbackContent("🔧 `read_file`\nReading config file", "running..") + want := "🔧 `read_filerunning..`\nReading config file" + if got != want { + t.Fatalf("formatAnimatedToolFeedbackContent() = %q, want %q", got, want) + } +} + +func TestInitialAnimatedToolFeedbackContent(t *testing.T) { + got := InitialAnimatedToolFeedbackContent("🔧 `exec`\nRunning command") + want := "🔧 `exec`\nRunning command" + if got != want { + t.Fatalf("InitialAnimatedToolFeedbackContent() = %q, want %q", got, want) + } +} + +func TestFormatAnimatedToolFeedbackContent_WithoutCodeSpan(t *testing.T) { + got := formatAnimatedToolFeedbackContent("hello", "running..") + want := "hellorunning.." + if got != want { + t.Fatalf("formatAnimatedToolFeedbackContent() without code span = %q, want %q", got, want) + } +} + +func TestToolFeedbackAnimator_RecordCurrentAndClear(t *testing.T) { + animator := NewToolFeedbackAnimator(nil) + animator.Record("chat-1", "msg-1", "🔧 `read_file`") + + msgID, ok := animator.Current("chat-1") + if !ok || msgID != "msg-1" { + t.Fatalf("Current() = (%q, %v), want (msg-1, true)", msgID, ok) + } + + animator.Clear("chat-1") + + msgID, ok = animator.Current("chat-1") + if ok || msgID != "" { + t.Fatalf("Current() after Clear = (%q, %v), want (\"\", false)", msgID, ok) + } +} + +func TestToolFeedbackAnimator_TakeStopsTrackingAndReturnsState(t *testing.T) { + animator := NewToolFeedbackAnimator(nil) + animator.Record("chat-1", "msg-1", "🔧 `read_file`\nChecking config") + + msgID, baseContent, ok := animator.Take("chat-1") + if !ok { + t.Fatal("Take() = not found, want tracked message") + } + if msgID != "msg-1" { + t.Fatalf("Take() msgID = %q, want msg-1", msgID) + } + if baseContent != "🔧 `read_file`\nChecking config" { + t.Fatalf("Take() baseContent = %q", baseContent) + } + if _, ok := animator.Current("chat-1"); ok { + t.Fatal("expected tracked message to be removed after Take()") + } +} + +func TestToolFeedbackAnimator_UpdateStopsTrackingBeforeEdit(t *testing.T) { + var animator *ToolFeedbackAnimator + animator = NewToolFeedbackAnimator(func(_ context.Context, chatID, messageID, content string) error { + if _, ok := animator.Current(chatID); ok { + t.Fatal("expected tracked tool feedback to be stopped before edit") + } + if messageID != "msg-1" { + t.Fatalf("messageID = %q, want msg-1", messageID) + } + if content != "🔧 `write_file`\nUpdating config" { + t.Fatalf("content = %q, want updated animated content", content) + } + return nil + }) + defer animator.StopAll() + + animator.Record("chat-1", "msg-1", "🔧 `read_file`\nChecking config") + + msgID, handled, err := animator.Update(context.Background(), "chat-1", "🔧 `write_file`\nUpdating config") + if err != nil { + t.Fatalf("Update() error = %v", err) + } + if !handled { + t.Fatal("Update() handled = false, want true") + } + if msgID != "msg-1" { + t.Fatalf("Update() msgID = %q, want msg-1", msgID) + } +} + +func TestToolFeedbackAnimator_UpdateFailureRestoresTracking(t *testing.T) { + editErr := errors.New("edit failed") + animator := NewToolFeedbackAnimator(func(context.Context, string, string, string) error { + return editErr + }) + defer animator.StopAll() + + animator.Record("chat-1", "msg-1", "🔧 `read_file`\nChecking config") + + msgID, handled, err := animator.Update(context.Background(), "chat-1", "🔧 `write_file`\nUpdating config") + if !handled { + t.Fatal("Update() handled = false, want true") + } + if !errors.Is(err, editErr) { + t.Fatalf("Update() error = %v, want editErr", err) + } + if msgID != "" { + t.Fatalf("Update() msgID = %q, want empty on failed edit", msgID) + } + if currentID, ok := animator.Current("chat-1"); !ok || currentID != "msg-1" { + t.Fatalf("Current() after failed Update = (%q, %v), want (msg-1, true)", currentID, ok) + } +} diff --git a/pkg/config/config.go b/pkg/config/config.go index 5bc96fb12..547060bd6 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -286,7 +286,7 @@ func (d *AgentDefaults) GetMaxMediaSize() int { return DefaultMaxMediaSize } -// GetToolFeedbackMaxArgsLength returns the max args preview length for tool feedback messages. +// GetToolFeedbackMaxArgsLength returns the max visible text length for tool feedback messages. func (d *AgentDefaults) GetToolFeedbackMaxArgsLength() int { if d.ToolFeedback.MaxArgsLength > 0 { return d.ToolFeedback.MaxArgsLength diff --git a/pkg/providers/cli/toolcall_utils.go b/pkg/providers/cli/toolcall_utils.go index b480082eb..1f58c9a26 100644 --- a/pkg/providers/cli/toolcall_utils.go +++ b/pkg/providers/cli/toolcall_utils.go @@ -55,6 +55,12 @@ func buildCLIToolsPrompt(tools []ToolDefinition) string { func NormalizeToolCall(tc ToolCall) ToolCall { normalized := tc + if normalized.ThoughtSignature == "" && + normalized.ExtraContent != nil && + normalized.ExtraContent.Google != nil { + normalized.ThoughtSignature = normalized.ExtraContent.Google.ThoughtSignature + } + // Ensure Name is populated from Function if not set if normalized.Name == "" && normalized.Function != nil { normalized.Name = normalized.Function.Name @@ -77,8 +83,9 @@ func NormalizeToolCall(tc ToolCall) ToolCall { argsJSON, _ := json.Marshal(normalized.Arguments) if normalized.Function == nil { normalized.Function = &FunctionCall{ - Name: normalized.Name, - Arguments: string(argsJSON), + Name: normalized.Name, + Arguments: string(argsJSON), + ThoughtSignature: normalized.ThoughtSignature, } } else { if normalized.Function.Name == "" { @@ -90,6 +97,12 @@ func NormalizeToolCall(tc ToolCall) ToolCall { if normalized.Function.Arguments == "" { normalized.Function.Arguments = string(argsJSON) } + if normalized.Function.ThoughtSignature == "" { + normalized.Function.ThoughtSignature = normalized.ThoughtSignature + } + if normalized.ThoughtSignature == "" { + normalized.ThoughtSignature = normalized.Function.ThoughtSignature + } } return normalized diff --git a/pkg/providers/common/common.go b/pkg/providers/common/common.go index 90142fb8b..c167b1ffd 100644 --- a/pkg/providers/common/common.go +++ b/pkg/providers/common/common.go @@ -70,11 +70,23 @@ func NewHTTPClient(proxy string) *http.Client { // It mirrors protocoltypes.Message but omits SystemParts, which is an // internal field that would be unknown to third-party endpoints. type openaiMessage struct { - Role string `json:"role"` - Content string `json:"content"` - ReasoningContent string `json:"reasoning_content,omitempty"` - ToolCalls []ToolCall `json:"tool_calls,omitempty"` - ToolCallID string `json:"tool_call_id,omitempty"` + Role string `json:"role"` + Content string `json:"content"` + ReasoningContent string `json:"reasoning_content,omitempty"` + ToolCalls []openaiToolCall `json:"tool_calls,omitempty"` + ToolCallID string `json:"tool_call_id,omitempty"` +} + +type openaiToolCall struct { + ID string `json:"id"` + Type string `json:"type,omitempty"` + Function *openaiFunctionCall `json:"function,omitempty"` +} + +type openaiFunctionCall struct { + Name string `json:"name"` + Arguments string `json:"arguments"` + ThoughtSignature string `json:"thought_signature,omitempty"` } // SerializeMessages converts internal Message structs to the OpenAI wire format. @@ -84,12 +96,13 @@ type openaiMessage struct { func SerializeMessages(messages []Message) []any { out := make([]any, 0, len(messages)) for _, m := range messages { + toolCalls := serializeToolCalls(m.ToolCalls) if len(m.Media) == 0 { out = append(out, openaiMessage{ Role: m.Role, Content: m.Content, ReasoningContent: m.ReasoningContent, - ToolCalls: m.ToolCalls, + ToolCalls: toolCalls, ToolCallID: m.ToolCallID, }) continue @@ -132,8 +145,8 @@ func SerializeMessages(messages []Message) []any { if m.ToolCallID != "" { msg["tool_call_id"] = m.ToolCallID } - if len(m.ToolCalls) > 0 { - msg["tool_calls"] = m.ToolCalls + if len(toolCalls) > 0 { + msg["tool_calls"] = toolCalls } if m.ReasoningContent != "" { msg["reasoning_content"] = m.ReasoningContent @@ -143,6 +156,55 @@ func SerializeMessages(messages []Message) []any { return out } +func serializeToolCalls(toolCalls []ToolCall) []openaiToolCall { + if len(toolCalls) == 0 { + return nil + } + + out := make([]openaiToolCall, 0, len(toolCalls)) + for _, tc := range toolCalls { + wireCall := openaiToolCall{ + ID: tc.ID, + Type: tc.Type, + } + + if tc.Function != nil { + thoughtSignature := tc.Function.ThoughtSignature + if thoughtSignature == "" { + thoughtSignature = tc.ThoughtSignature + } + if thoughtSignature == "" && tc.ExtraContent != nil && tc.ExtraContent.Google != nil { + thoughtSignature = tc.ExtraContent.Google.ThoughtSignature + } + wireCall.Function = &openaiFunctionCall{ + Name: tc.Function.Name, + Arguments: tc.Function.Arguments, + ThoughtSignature: thoughtSignature, + } + } else if tc.Name != "" || len(tc.Arguments) > 0 || tc.ThoughtSignature != "" { + thoughtSignature := tc.ThoughtSignature + if thoughtSignature == "" && tc.ExtraContent != nil && tc.ExtraContent.Google != nil { + thoughtSignature = tc.ExtraContent.Google.ThoughtSignature + } + argsJSON := "{}" + if len(tc.Arguments) > 0 { + if encoded, err := json.Marshal(tc.Arguments); err == nil { + argsJSON = string(encoded) + } + } + wireCall.Function = &openaiFunctionCall{ + Name: tc.Name, + Arguments: argsJSON, + ThoughtSignature: thoughtSignature, + } + } + + out = append(out, wireCall) + } + + return out +} + func parseDataAudioURL(mediaURL string) (format, data string, ok bool) { if !strings.HasPrefix(mediaURL, "data:audio/") { return "", "", false @@ -185,6 +247,7 @@ func ParseResponse(body io.Reader) (*LLMResponse, error) { Google *struct { ThoughtSignature string `json:"thought_signature"` } `json:"google"` + ToolFeedbackExplanation string `json:"tool_feedback_explanation"` } `json:"extra_content"` } `json:"tool_calls"` } `json:"message"` @@ -228,11 +291,17 @@ func ParseResponse(body io.Reader) (*LLMResponse, error) { ThoughtSignature: thoughtSignature, } - if thoughtSignature != "" { - toolCall.ExtraContent = &ExtraContent{ - Google: &GoogleExtra{ + if tc.ExtraContent != nil { + extraContent := &ExtraContent{ + ToolFeedbackExplanation: tc.ExtraContent.ToolFeedbackExplanation, + } + if thoughtSignature != "" { + extraContent.Google = &GoogleExtra{ ThoughtSignature: thoughtSignature, - }, + } + } + if extraContent.Google != nil || strings.TrimSpace(extraContent.ToolFeedbackExplanation) != "" { + toolCall.ExtraContent = extraContent } } diff --git a/pkg/providers/common/common_test.go b/pkg/providers/common/common_test.go index c107bb665..affb91e6f 100644 --- a/pkg/providers/common/common_test.go +++ b/pkg/providers/common/common_test.go @@ -162,6 +162,104 @@ func TestSerializeMessages_StripsSystemParts(t *testing.T) { } } +func TestSerializeMessages_StripsInternalToolCallExtraContent(t *testing.T) { + messages := []Message{ + { + Role: "assistant", + ToolCalls: []ToolCall{{ + ID: "call_1", + Type: "function", + Function: &FunctionCall{ + Name: "read_file", + Arguments: `{"path":"README.md"}`, + ThoughtSignature: "sig-1", + }, + ExtraContent: &ExtraContent{ + Google: &GoogleExtra{ + ThoughtSignature: "sig-ignored-here", + }, + ToolFeedbackExplanation: "Read README.md first.", + }, + }}, + }, + } + + result := SerializeMessages(messages) + + data, err := json.Marshal(result) + if err != nil { + t.Fatalf("json.Marshal() error = %v", err) + } + payload := string(data) + if strings.Contains(payload, "extra_content") { + t.Fatalf("serialized payload should not include internal extra_content: %s", payload) + } + if !strings.Contains(payload, "thought_signature") { + t.Fatalf("serialized payload should preserve function thought_signature: %s", payload) + } +} + +func TestSerializeMessages_PreservesTopLevelThoughtSignature(t *testing.T) { + messages := []Message{ + { + Role: "assistant", + ToolCalls: []ToolCall{{ + ID: "call_1", + Type: "function", + ThoughtSignature: "sig-1", + Function: &FunctionCall{ + Name: "read_file", + Arguments: `{"path":"README.md"}`, + }, + }}, + }, + } + + result := SerializeMessages(messages) + + data, err := json.Marshal(result) + if err != nil { + t.Fatalf("json.Marshal() error = %v", err) + } + payload := string(data) + if !strings.Contains(payload, `"thought_signature":"sig-1"`) { + t.Fatalf("serialized payload should preserve top-level thought signature: %s", payload) + } +} + +func TestSerializeMessages_PreservesGoogleExtraThoughtSignature(t *testing.T) { + messages := []Message{ + { + Role: "assistant", + ToolCalls: []ToolCall{{ + ID: "call_1", + Type: "function", + Function: &FunctionCall{ + Name: "read_file", + Arguments: `{"path":"README.md"}`, + }, + ExtraContent: &ExtraContent{ + Google: &GoogleExtra{ThoughtSignature: "sig-1"}, + }, + }}, + }, + } + + result := SerializeMessages(messages) + + data, err := json.Marshal(result) + if err != nil { + t.Fatalf("json.Marshal() error = %v", err) + } + payload := string(data) + if strings.Contains(payload, "extra_content") { + t.Fatalf("serialized payload should not include extra_content: %s", payload) + } + if !strings.Contains(payload, `"thought_signature":"sig-1"`) { + t.Fatalf("serialized payload should preserve google thought signature: %s", payload) + } +} + // --- ParseResponse tests --- func TestParseResponse_BasicContent(t *testing.T) { @@ -234,6 +332,27 @@ func TestParseResponse_WithReasoningContent(t *testing.T) { } } +func TestParseResponse_WithToolFeedbackExplanationExtraContent(t *testing.T) { + body := `{"choices":[{"message":{"content":"","tool_calls":[{"id":"call_1","type":"function","function":{"name":"test_tool","arguments":"{}"},"extra_content":{"tool_feedback_explanation":"Check the current config before editing."}}]},"finish_reason":"tool_calls"}]}` + out, err := ParseResponse(strings.NewReader(body)) + if err != nil { + t.Fatalf("ParseResponse() error = %v", err) + } + if len(out.ToolCalls) != 1 { + t.Fatalf("len(ToolCalls) = %d, want 1", len(out.ToolCalls)) + } + if out.ToolCalls[0].ExtraContent == nil { + t.Fatal("ExtraContent is nil") + } + if out.ToolCalls[0].ExtraContent.ToolFeedbackExplanation != "Check the current config before editing." { + t.Fatalf( + "ToolFeedbackExplanation = %q, want %q", + out.ToolCalls[0].ExtraContent.ToolFeedbackExplanation, + "Check the current config before editing.", + ) + } +} + func TestParseResponse_InvalidJSON(t *testing.T) { _, err := ParseResponse(strings.NewReader("not json")) if err == nil { diff --git a/pkg/providers/protocoltypes/types.go b/pkg/providers/protocoltypes/types.go index 194c1aa6f..1189577f1 100644 --- a/pkg/providers/protocoltypes/types.go +++ b/pkg/providers/protocoltypes/types.go @@ -11,7 +11,8 @@ type ToolCall struct { } type ExtraContent struct { - Google *GoogleExtra `json:"google,omitempty"` + Google *GoogleExtra `json:"google,omitempty"` + ToolFeedbackExplanation string `json:"tool_feedback_explanation,omitempty"` } type GoogleExtra struct { diff --git a/pkg/providers/toolcall_utils_test.go b/pkg/providers/toolcall_utils_test.go new file mode 100644 index 000000000..a4bb03c2e --- /dev/null +++ b/pkg/providers/toolcall_utils_test.go @@ -0,0 +1,24 @@ +package providers + +import "testing" + +func TestNormalizeToolCall_PreservesExtraContentGoogleThoughtSignature(t *testing.T) { + tc := NormalizeToolCall(ToolCall{ + ID: "call_1", + Name: "search", + Arguments: map[string]any{"q": "pico"}, + ExtraContent: &ExtraContent{ + Google: &GoogleExtra{ThoughtSignature: "sig-1"}, + }, + }) + + if tc.ThoughtSignature != "sig-1" { + t.Fatalf("ThoughtSignature = %q, want sig-1", tc.ThoughtSignature) + } + if tc.Function == nil { + t.Fatal("Function is nil") + } + if tc.Function.ThoughtSignature != "sig-1" { + t.Fatalf("Function.ThoughtSignature = %q, want sig-1", tc.Function.ThoughtSignature) + } +} diff --git a/pkg/utils/tool_feedback.go b/pkg/utils/tool_feedback.go index a6c8895b8..1a8b6c747 100644 --- a/pkg/utils/tool_feedback.go +++ b/pkg/utils/tool_feedback.go @@ -1,9 +1,57 @@ package utils -import "fmt" +import ( + "fmt" + "strings" +) -// FormatToolFeedbackMessage renders the tool name and arguments preview in the -// same markdown shape used by live tool feedback and session reconstruction. -func FormatToolFeedbackMessage(toolName, argsPreview string) string { - return fmt.Sprintf("\U0001f527 `%s`\n```\n%s\n```", toolName, argsPreview) +const ToolFeedbackContinuationHint = "Continuing the current task." + +// FormatToolFeedbackMessage renders the model-provided explanation for why a +// tool is being executed. When the model does not provide one, it keeps only +// the tool line and does not expose raw arguments or fallback text. +func FormatToolFeedbackMessage(toolName, explanation string) string { + toolName = strings.TrimSpace(toolName) + explanation = strings.TrimSpace(explanation) + + if toolName == "" { + return explanation + } + if explanation == "" { + return fmt.Sprintf("\U0001f527 `%s`", toolName) + } + + return fmt.Sprintf("\U0001f527 `%s`\n%s", toolName, explanation) +} + +// FitToolFeedbackMessage keeps tool feedback within a single outbound message. +// It preserves the first line when possible and truncates the explanation body +// instead of letting the message be split into multiple chunks. +func FitToolFeedbackMessage(content string, maxLen int) string { + content = strings.TrimSpace(content) + if content == "" || maxLen <= 0 { + return "" + } + if len([]rune(content)) <= maxLen { + return content + } + + firstLine, rest, hasRest := strings.Cut(content, "\n") + firstLine = strings.TrimSpace(firstLine) + rest = strings.TrimSpace(rest) + + if !hasRest || rest == "" { + return Truncate(firstLine, maxLen) + } + + if len([]rune(firstLine)) >= maxLen { + return Truncate(firstLine, maxLen) + } + + remaining := maxLen - len([]rune(firstLine)) - 1 + if remaining <= 0 { + return Truncate(firstLine, maxLen) + } + + return firstLine + "\n" + Truncate(rest, remaining) } diff --git a/pkg/utils/tool_feedback_test.go b/pkg/utils/tool_feedback_test.go index d7a55ce6b..316ce2408 100644 --- a/pkg/utils/tool_feedback_test.go +++ b/pkg/utils/tool_feedback_test.go @@ -3,9 +3,47 @@ package utils import "testing" func TestFormatToolFeedbackMessage(t *testing.T) { - got := FormatToolFeedbackMessage("read_file", "{\"path\":\"README.md\"}") - want := "\U0001f527 `read_file`\n```\n{\"path\":\"README.md\"}\n```" + got := FormatToolFeedbackMessage( + "read_file", + "I will read README.md first to confirm the current project structure.", + ) + want := "\U0001f527 `read_file`\nI will read README.md first to confirm the current project structure." if got != want { t.Fatalf("FormatToolFeedbackMessage() = %q, want %q", got, want) } } + +func TestFormatToolFeedbackMessage_EmptyExplanationKeepsOnlyToolLine(t *testing.T) { + got := FormatToolFeedbackMessage("read_file", "") + want := "\U0001f527 `read_file`" + if got != want { + t.Fatalf("FormatToolFeedbackMessage() = %q, want %q", got, want) + } +} + +func TestFormatToolFeedbackMessage_EmptyToolNameOmitsToolLine(t *testing.T) { + got := FormatToolFeedbackMessage("", "Continue drafting the final response.") + want := "Continue drafting the final response." + if got != want { + t.Fatalf("FormatToolFeedbackMessage() = %q, want %q", got, want) + } +} + +func TestFitToolFeedbackMessage_TruncatesBodyWithinSingleMessage(t *testing.T) { + got := FitToolFeedbackMessage( + "\U0001f527 `read_file`\nRead README.md first to confirm the current project structure.", + 40, + ) + want := "\U0001f527 `read_file`\nRead README.md first to..." + if got != want { + t.Fatalf("FitToolFeedbackMessage() = %q, want %q", got, want) + } +} + +func TestFitToolFeedbackMessage_TruncatesSingleLineMessage(t *testing.T) { + got := FitToolFeedbackMessage("\U0001f527 `read_file`", 10) + want := "\U0001f527 `read..." + if got != want { + t.Fatalf("FitToolFeedbackMessage() = %q, want %q", got, want) + } +} diff --git a/web/backend/api/session.go b/web/backend/api/session.go index 054b78b73..2a16fe183 100644 --- a/web/backend/api/session.go +++ b/web/backend/api/session.go @@ -486,6 +486,15 @@ func visibleSessionMessages(messages []providers.Message, toolFeedbackMaxArgsLen transcript = append(transcript, visibleToolMessages...) } + // When assistant content exactly matches the rendered tool summary or + // tool-delivered message, skip it to avoid duplicates. Distinct content + // must remain visible in restored session history. + if len(msg.ToolCalls) > 0 && + len(msg.Media) == 0 && + assistantToolCallContentDuplicated(msg.Content, toolSummaryMessages, visibleToolMessages) { + continue + } + // Pico web chat can persist both visible `message` tool output and a // later plain assistant reply in the same turn. Hide only the fixed // internal summary that marks handled tool delivery. @@ -504,6 +513,43 @@ func visibleSessionMessages(messages []providers.Message, toolFeedbackMaxArgsLen return transcript } +func assistantToolCallContentDuplicated( + content string, + toolSummaryMessages []sessionChatMessage, + visibleToolMessages []sessionChatMessage, +) bool { + content = strings.TrimSpace(content) + if content == "" { + return false + } + + for _, msg := range toolSummaryMessages { + if toolSummaryContainsContent(msg.Content, content) { + return true + } + } + for _, msg := range visibleToolMessages { + if strings.TrimSpace(msg.Content) == content { + return true + } + } + return false +} + +func toolSummaryContainsContent(summary, content string) bool { + summary = strings.TrimSpace(summary) + content = strings.TrimSpace(content) + if summary == "" || content == "" { + return false + } + if summary == content { + return true + } + + _, body, hasBody := strings.Cut(summary, "\n") + return hasBody && strings.TrimSpace(body) == content +} + func assistantMessageTransientThought(msg providers.Message) bool { return strings.TrimSpace(msg.Content) == "" && strings.TrimSpace(msg.ReasoningContent) != "" && @@ -529,38 +575,51 @@ func visibleAssistantToolSummaryMessages( messages := make([]sessionChatMessage, 0, len(toolCalls)) for _, tc := range toolCalls { name := tc.Name - argsJSON := "" if tc.Function != nil { if name == "" { name = tc.Function.Name } - argsJSON = tc.Function.Arguments } if strings.TrimSpace(name) == "" { continue } - if strings.TrimSpace(argsJSON) == "" && len(tc.Arguments) > 0 { - if encodedArgs, err := json.Marshal(tc.Arguments); err == nil { - argsJSON = string(encodedArgs) - } - } - - argsPreview := strings.TrimSpace(argsJSON) - if argsPreview == "" { - argsPreview = "{}" - } - messages = append(messages, sessionChatMessage{ - Role: "assistant", - Content: utils.FormatToolFeedbackMessage(name, utils.Truncate(argsPreview, toolFeedbackMaxArgsLength)), + Role: "assistant", + Content: utils.FormatToolFeedbackMessage( + name, + visibleAssistantToolSummaryText(tc, toolFeedbackMaxArgsLength), + ), }) } return messages } +func visibleAssistantToolSummaryText( + tc providers.ToolCall, + toolFeedbackMaxArgsLength int, +) string { + if tc.ExtraContent != nil { + if explanation := strings.TrimSpace(tc.ExtraContent.ToolFeedbackExplanation); explanation != "" { + return utils.Truncate(explanation, toolFeedbackMaxArgsLength) + } + } + + argsJSON := "" + if tc.Function != nil { + argsJSON = tc.Function.Arguments + } + if strings.TrimSpace(argsJSON) == "" && len(tc.Arguments) > 0 { + if encodedArgs, err := json.Marshal(tc.Arguments); err == nil { + argsJSON = string(encodedArgs) + } + } + + return utils.Truncate(strings.TrimSpace(argsJSON), toolFeedbackMaxArgsLength) +} + func visibleAssistantToolMessages(toolCalls []providers.ToolCall) []sessionChatMessage { if len(toolCalls) == 0 { return nil diff --git a/web/backend/api/session_test.go b/web/backend/api/session_test.go index e40a8c77c..b0bab0baa 100644 --- a/web/backend/api/session_test.go +++ b/web/backend/api/session_test.go @@ -540,7 +540,7 @@ func TestHandleListSessions_MessageCountUsesVisibleTranscript(t *testing.T) { } } -func TestHandleGetSession_PreservesToolSummaryAndAssistantContent(t *testing.T) { +func TestHandleGetSession_DoesNotDuplicateAssistantToolCallContent(t *testing.T) { configPath, cleanup := setupOAuthTestEnv(t) defer cleanup() @@ -555,7 +555,7 @@ func TestHandleGetSession_PreservesToolSummaryAndAssistantContent(t *testing.T) {Role: "user", Content: "check file"}, { Role: "assistant", - Content: "model final reply", + Content: "Read the file before replying.", ToolCalls: []providers.ToolCall{ { ID: "call_1", @@ -564,6 +564,9 @@ func TestHandleGetSession_PreservesToolSummaryAndAssistantContent(t *testing.T) Name: "read_file", Arguments: `{"path":"README.md","start_line":1,"end_line":10}`, }, + ExtraContent: &providers.ExtraContent{ + ToolFeedbackExplanation: "Read the file before replying.", + }, }, }, }, @@ -594,8 +597,8 @@ func TestHandleGetSession_PreservesToolSummaryAndAssistantContent(t *testing.T) if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { t.Fatalf("Unmarshal() error = %v", err) } - if len(resp.Messages) != 3 { - t.Fatalf("len(resp.Messages) = %d, want 3", len(resp.Messages)) + if len(resp.Messages) != 2 { + t.Fatalf("len(resp.Messages) = %d, want 2", len(resp.Messages)) } if resp.Messages[0].Role != "user" || resp.Messages[0].Content != "check file" { t.Fatalf("first message = %#v, want user/check file", resp.Messages[0]) @@ -603,8 +606,153 @@ func TestHandleGetSession_PreservesToolSummaryAndAssistantContent(t *testing.T) if !strings.Contains(resp.Messages[1].Content, "`read_file`") { t.Fatalf("tool summary message = %#v, want read_file summary", resp.Messages[1]) } - if resp.Messages[2].Role != "assistant" || resp.Messages[2].Content != "model final reply" { - t.Fatalf("assistant message = %#v, want model final reply", resp.Messages[2]) + if !strings.Contains(resp.Messages[1].Content, "Read the file before replying.") { + t.Fatalf("tool summary message = %#v, want tool explanation", resp.Messages[1]) + } +} + +func TestHandleGetSession_PreservesDistinctAssistantToolCallContent(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + dir := sessionsTestDir(t, configPath) + store, err := memory.NewJSONLStore(dir) + if err != nil { + t.Fatalf("NewJSONLStore() error = %v", err) + } + + sessionKey := picoSessionPrefix + "detail-tool-summary-distinct-content" + for _, msg := range []providers.Message{ + {Role: "user", Content: "check file"}, + { + Role: "assistant", + Content: "I will summarize the findings after reading the file.", + ToolCalls: []providers.ToolCall{ + { + ID: "call_1", + Type: "function", + Function: &providers.FunctionCall{ + Name: "read_file", + Arguments: `{"path":"README.md","start_line":1,"end_line":10}`, + }, + ExtraContent: &providers.ExtraContent{ + ToolFeedbackExplanation: "Read the file before replying.", + }, + }, + }, + }, + } { + if err := store.AddFullMessage(nil, sessionKey, msg); err != nil { + t.Fatalf("AddFullMessage() error = %v", err) + } + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/sessions/detail-tool-summary-distinct-content", nil) + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + var resp struct { + Messages []struct { + Role string `json:"role"` + Content string `json:"content"` + } `json:"messages"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + if len(resp.Messages) != 3 { + t.Fatalf("len(resp.Messages) = %d, want 3", len(resp.Messages)) + } + if !strings.Contains(resp.Messages[1].Content, "`read_file`") { + t.Fatalf("tool summary message = %#v, want read_file summary", resp.Messages[1]) + } + if resp.Messages[2].Role != "assistant" || + resp.Messages[2].Content != "I will summarize the findings after reading the file." { + t.Fatalf("assistant content = %#v, want preserved distinct content", resp.Messages[2]) + } +} + +func TestHandleGetSession_PreservesMediaWhenAssistantToolCallContentDuplicatesSummary(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + dir := sessionsTestDir(t, configPath) + store, err := memory.NewJSONLStore(dir) + if err != nil { + t.Fatalf("NewJSONLStore() error = %v", err) + } + + sessionKey := picoSessionPrefix + "detail-tool-summary-duplicate-content-with-media" + for _, msg := range []providers.Message{ + {Role: "user", Content: "check screenshot"}, + { + Role: "assistant", + Content: "Reviewing the generated screenshot.", + Media: []string{"data:image/png;base64,abc123"}, + ToolCalls: []providers.ToolCall{ + { + ID: "call_1", + Type: "function", + Function: &providers.FunctionCall{ + Name: "view_image", + Arguments: `{"path":"artifact.png"}`, + }, + ExtraContent: &providers.ExtraContent{ + ToolFeedbackExplanation: "Reviewing the generated screenshot.", + }, + }, + }, + }, + } { + if err := store.AddFullMessage(nil, sessionKey, msg); err != nil { + t.Fatalf("AddFullMessage() error = %v", err) + } + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/sessions/detail-tool-summary-duplicate-content-with-media", nil) + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + var resp struct { + Messages []struct { + Role string `json:"role"` + Content string `json:"content"` + Media []string `json:"media"` + } `json:"messages"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + if len(resp.Messages) != 3 { + t.Fatalf("len(resp.Messages) = %d, want 3", len(resp.Messages)) + } + if !strings.Contains(resp.Messages[1].Content, "`view_image`") { + t.Fatalf("tool summary message = %#v, want view_image summary", resp.Messages[1]) + } + if resp.Messages[2].Role != "assistant" { + t.Fatalf("assistant message role = %q, want assistant", resp.Messages[2].Role) + } + if resp.Messages[2].Content != "Reviewing the generated screenshot." { + t.Fatalf("assistant content = %q, want preserved duplicated content with media", resp.Messages[2].Content) + } + if len(resp.Messages[2].Media) != 1 || resp.Messages[2].Media[0] != "data:image/png;base64,abc123" { + t.Fatalf("assistant media = %#v, want preserved media", resp.Messages[2].Media) } } @@ -629,6 +777,7 @@ func TestHandleGetSession_UsesConfiguredToolFeedbackMaxArgsLength(t *testing.T) } argsJSON := `{"path":"README.md","start_line":1,"end_line":10,"extra":"abcdefghijklmnopqrstuvwxyz"}` + explanation := "Read README.md first to confirm the current project structure before editing the config example." sessionKey := picoSessionPrefix + "detail-tool-summary-max-args" err = store.AddFullMessage(nil, sessionKey, providers.Message{Role: "user", Content: "check file"}) if err != nil { @@ -643,6 +792,9 @@ func TestHandleGetSession_UsesConfiguredToolFeedbackMaxArgsLength(t *testing.T) Name: "read_file", Arguments: argsJSON, }, + ExtraContent: &providers.ExtraContent{ + ToolFeedbackExplanation: explanation, + }, }}, }) if err != nil { @@ -675,13 +827,93 @@ func TestHandleGetSession_UsesConfiguredToolFeedbackMaxArgsLength(t *testing.T) t.Fatalf("len(resp.Messages) = %d, want at least 2", len(resp.Messages)) } - wantPreview := utils.Truncate(argsJSON, 20) + wantPreview := utils.Truncate(explanation, 20) if !strings.Contains(resp.Messages[1].Content, wantPreview) { t.Fatalf("tool summary = %q, want preview %q", resp.Messages[1].Content, wantPreview) } if strings.Contains(resp.Messages[1].Content, argsJSON) { t.Fatalf("tool summary = %q, expected configured truncation", resp.Messages[1].Content) } + if !strings.Contains(resp.Messages[1].Content, "`read_file`") { + t.Fatalf("tool summary = %q, want read_file summary", resp.Messages[1].Content) + } +} + +func TestHandleGetSession_FallsBackToLegacyToolArgumentsWhenExplanationMissing(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + cfg.Agents.Defaults.ToolFeedback.MaxArgsLength = 20 + err = config.SaveConfig(configPath, cfg) + if err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + dir := sessionsTestDir(t, configPath) + store, err := memory.NewJSONLStore(dir) + if err != nil { + t.Fatalf("NewJSONLStore() error = %v", err) + } + + argsJSON := `{"path":"README.md","start_line":1,"end_line":10,"extra":"abcdefghijklmnopqrstuvwxyz"}` + sessionKey := picoSessionPrefix + "detail-tool-summary-legacy-args" + if err := store.AddFullMessage( + nil, + sessionKey, + providers.Message{Role: "user", Content: "check file"}, + ); err != nil { + t.Fatalf("AddFullMessage(user) error = %v", err) + } + if err := store.AddFullMessage(nil, sessionKey, providers.Message{ + Role: "assistant", + ToolCalls: []providers.ToolCall{{ + ID: "call_1", + Type: "function", + Function: &providers.FunctionCall{ + Name: "read_file", + Arguments: argsJSON, + }, + }}, + }); err != nil { + t.Fatalf("AddFullMessage(assistant) error = %v", err) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/sessions/detail-tool-summary-legacy-args", nil) + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + var resp struct { + Messages []struct { + Role string `json:"role"` + Content string `json:"content"` + } `json:"messages"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + if len(resp.Messages) < 2 { + t.Fatalf("len(resp.Messages) = %d, want at least 2", len(resp.Messages)) + } + + wantPreview := utils.Truncate(argsJSON, 20) + if !strings.Contains(resp.Messages[1].Content, "`read_file`") { + t.Fatalf("tool summary = %q, want read_file summary", resp.Messages[1].Content) + } + if !strings.Contains(resp.Messages[1].Content, wantPreview) { + t.Fatalf("tool summary = %q, want legacy args preview %q", resp.Messages[1].Content, wantPreview) + } } func TestHandleGetSession_IncludesMediaOnlyMessages(t *testing.T) { diff --git a/web/frontend/src/i18n/locales/en.json b/web/frontend/src/i18n/locales/en.json index c96d4b71b..7a5c58b30 100644 --- a/web/frontend/src/i18n/locales/en.json +++ b/web/frontend/src/i18n/locales/en.json @@ -592,9 +592,9 @@ "split_on_marker": "Chatty Mode", "split_on_marker_hint": "Split long messages into short ones like real human chatting.", "tool_feedback_enabled": "Tool Feedback", - "tool_feedback_enabled_hint": "Send a short tool-call preview into the current chat before each tool execution.", - "tool_feedback_max_args_length": "Tool Feedback Args Preview Length", - "tool_feedback_max_args_length_hint": "Maximum number of argument characters shown in each tool feedback message. Set to 0 to use the default.", + "tool_feedback_enabled_hint": "Send a short execution note into the current chat before each tool runs.", + "tool_feedback_max_args_length": "Tool Feedback Length", + "tool_feedback_max_args_length_hint": "Maximum number of characters shown in each tool feedback message. Set to 0 to use the default.", "exec_enabled": "Allow Commands", "exec_enabled_hint": "Enable or disable command execution for the app. When disabled, no command requests will run.", "allow_remote": "Allow Remote Commands", diff --git a/web/frontend/src/i18n/locales/zh.json b/web/frontend/src/i18n/locales/zh.json index 4a9e59cf4..aaebfa625 100644 --- a/web/frontend/src/i18n/locales/zh.json +++ b/web/frontend/src/i18n/locales/zh.json @@ -592,9 +592,9 @@ "split_on_marker": "连续短消息", "split_on_marker_hint": "像真人聊天一样,把长难句拆成多条短消息快速发出", "tool_feedback_enabled": "工具反馈", - "tool_feedback_enabled_hint": "在每次执行工具前,先向当前会话发送一条简短的工具调用预览", - "tool_feedback_max_args_length": "工具反馈参数预览长度", - "tool_feedback_max_args_length_hint": "每条工具反馈消息中展示的参数字符上限。设为 0 时使用默认值", + "tool_feedback_enabled_hint": "在每次执行工具前,先向当前会话发送一条简短的执行说明", + "tool_feedback_max_args_length": "工具反馈长度", + "tool_feedback_max_args_length_hint": "每条工具反馈消息中展示的字符上限。设为 0 时使用默认值", "exec_enabled": "允许命令执行", "exec_enabled_hint": "控制应用是否允许执行命令。关闭后,所有命令请求都不会执行", "allow_remote": "允许远程命令执行", From 6421f146a99df1bebcd4b1ca8de2a289dfca3622 Mon Sep 17 00:00:00 2001 From: lxowalle <83055338+lxowalle@users.noreply.github.com> Date: Mon, 20 Apr 2026 18:30:29 +0800 Subject: [PATCH 06/31] Revert "Feat/channel tool feedback animation (#2569)" (#2596) This reverts commit e556a816e4db4c158ab3a455693018f452f63eba. --- cmd/picoclaw/internal/auth/wecom_test.go | 20 +- docs/channels/discord/README.md | 44 +- pkg/agent/loop.go | 1 - pkg/agent/loop_test.go | 366 +-------------- pkg/agent/loop_turn.go | 51 +-- pkg/agent/loop_utils.go | 93 ---- pkg/channels/discord/discord.go | 190 +------- pkg/channels/discord/discord_test.go | 245 ---------- pkg/channels/feishu/feishu_64.go | 175 +------- pkg/channels/feishu/feishu_64_test.go | 85 ---- pkg/channels/manager.go | 112 +---- pkg/channels/manager_test.go | 424 +----------------- pkg/channels/matrix/matrix.go | 133 +----- pkg/channels/matrix/matrix_test.go | 29 -- pkg/channels/pico/pico.go | 118 +---- pkg/channels/pico/pico_test.go | 28 -- pkg/channels/telegram/command_registration.go | 6 +- .../telegram/command_registration_test.go | 16 +- pkg/channels/telegram/telegram.go | 180 +------- .../telegram_group_command_filter_test.go | 2 +- pkg/channels/telegram/telegram_test.go | 102 +---- pkg/channels/tool_feedback_animator.go | 240 ---------- pkg/channels/tool_feedback_animator_test.go | 121 ----- pkg/config/config.go | 2 +- pkg/providers/cli/toolcall_utils.go | 17 +- pkg/providers/common/common.go | 93 +--- pkg/providers/common/common_test.go | 119 ----- pkg/providers/protocoltypes/types.go | 3 +- pkg/providers/toolcall_utils_test.go | 24 - pkg/utils/tool_feedback.go | 58 +-- pkg/utils/tool_feedback_test.go | 42 +- web/backend/api/session.go | 89 +--- web/backend/api/session_test.go | 246 +--------- web/frontend/src/i18n/locales/en.json | 6 +- web/frontend/src/i18n/locales/zh.json | 6 +- 35 files changed, 169 insertions(+), 3317 deletions(-) delete mode 100644 pkg/channels/tool_feedback_animator.go delete mode 100644 pkg/channels/tool_feedback_animator_test.go delete mode 100644 pkg/providers/toolcall_utils_test.go diff --git a/cmd/picoclaw/internal/auth/wecom_test.go b/cmd/picoclaw/internal/auth/wecom_test.go index aafd39e69..c152481be 100644 --- a/cmd/picoclaw/internal/auth/wecom_test.go +++ b/cmd/picoclaw/internal/auth/wecom_test.go @@ -3,7 +3,6 @@ package auth import ( "bytes" "context" - "net" "net/http" "net/http/httptest" "net/url" @@ -20,19 +19,6 @@ import ( "github.com/sipeed/picoclaw/pkg/config" ) -func newIPv4TestServer(t *testing.T, handler http.Handler) *httptest.Server { - t.Helper() - - server := httptest.NewUnstartedServer(handler) - listener, err := net.Listen("tcp4", "127.0.0.1:0") - require.NoError(t, err) - - server.Listener = listener - server.Start() - t.Cleanup(server.Close) - return server -} - func TestNewWeComCommand(t *testing.T) { cmd := newWeComCommand() @@ -67,7 +53,7 @@ func TestBuildWeComQRCodePageURL(t *testing.T) { } func TestFetchWeComQRCode(t *testing.T) { - server := newIPv4TestServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { assert.Equal(t, "/generate", r.URL.Path) assert.Equal(t, wecomQRSourceID, r.URL.Query().Get("source")) assert.Equal(t, wecomQRSourceID, r.URL.Query().Get("sourceID")) @@ -75,6 +61,7 @@ func TestFetchWeComQRCode(t *testing.T) { w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"data":{"scode":"scode-1","auth_url":"https://example.com/qr"}}`)) })) + defer server.Close() opts := normalizeWeComQRFlowOptions(wecomQRFlowOptions{ HTTPClient: server.Client(), @@ -91,7 +78,7 @@ func TestFetchWeComQRCode(t *testing.T) { func TestPollWeComQRCodeResult(t *testing.T) { var calls atomic.Int32 - server := newIPv4TestServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { call := calls.Add(1) assert.Equal(t, "/query", r.URL.Path) assert.Equal(t, "scode-1", r.URL.Query().Get("scode")) @@ -105,6 +92,7 @@ func TestPollWeComQRCodeResult(t *testing.T) { _, _ = w.Write([]byte(`{"data":{"status":"success","bot_info":{"botid":"bot-1","secret":"secret-1"}}}`)) } })) + defer server.Close() var output bytes.Buffer opts := normalizeWeComQRFlowOptions(wecomQRFlowOptions{ diff --git a/docs/channels/discord/README.md b/docs/channels/discord/README.md index 741bc64a1..771289d28 100644 --- a/docs/channels/discord/README.md +++ b/docs/channels/discord/README.md @@ -8,56 +8,26 @@ Discord is a free voice, video, and text chat application designed for communiti ```json { - "agents": { - "defaults": { - "tool_feedback": { - "enabled": true, - "max_args_length": 300 - } - } - }, "channel_list": { "discord": { "enabled": true, "type": "discord", "token": "YOUR_BOT_TOKEN", "allow_from": ["YOUR_USER_ID"], - "placeholder": { - "enabled": true, - "text": ["Thinking... 💭"] - }, "group_trigger": { "mention_only": false - }, - "reasoning_channel_id": "" + } } } } ``` -| Field | Type | Required | Description | -| -------------------- | ------ | -------- | --------------------------------------------------------------------------- | -| enabled | bool | Yes | Whether to enable the Discord channel | -| token | string | Yes | Discord Bot Token | -| allow_from | array | No | Allowlist of user IDs; empty means all users are allowed | -| placeholder | object | No | Placeholder message config shown while the agent is working | -| group_trigger | object | No | Group trigger settings (example: { "mention_only": false }) | -| reasoning_channel_id | string | No | Optional target channel ID for reasoning/thinking output | - -## Visible Execution Feedback - -Discord can show three different kinds of "working" feedback: - -1. Typing indicator: automatic, no extra config needed. -2. Placeholder message: enable `channel_list.discord.placeholder.enabled` to send a visible `Thinking...` message that is later edited into the final reply. -3. Tool execution feedback: enable `agents.defaults.tool_feedback.enabled` to send a short message before each tool call, for example: - -```text -🔧 `web_search` -Checking the latest PicoClaw release notes before I answer. -``` - -If you only see `Bot is typing`, check that `placeholder.enabled` or `tool_feedback.enabled` is actually set in your runtime config. +| Field | Type | Required | Description | +| ------------- | ------ | -------- | --------------------------------------------------------------------------- | +| enabled | bool | Yes | Whether to enable the Discord channel | +| token | string | Yes | Discord Bot Token | +| allow_from | array | No | Allowlist of user IDs; empty means all users are allowed | +| group_trigger | object | No | Group trigger settings (example: { "mention_only": false }) | ## Setup diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index f0c287ee2..fb6f95edf 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -112,7 +112,6 @@ const ( pendingTurnPrefix = "pending-" metadataKeyMessageKind = "message_kind" messageKindThought = "thought" - messageKindToolFeedback = "tool_feedback" metadataKeyAccountID = "account_id" metadataKeyGuildID = "guild_id" metadataKeyTeamID = "team_id" diff --git a/pkg/agent/loop_test.go b/pkg/agent/loop_test.go index a2d4ea7aa..5cdac186c 100644 --- a/pkg/agent/loop_test.go +++ b/pkg/agent/loop_test.go @@ -24,7 +24,6 @@ import ( "github.com/sipeed/picoclaw/pkg/routing" "github.com/sipeed/picoclaw/pkg/session" "github.com/sipeed/picoclaw/pkg/tools" - "github.com/sipeed/picoclaw/pkg/utils" ) type fakeChannel struct{ id string } @@ -1759,157 +1758,6 @@ func (m *toolFeedbackProvider) GetDefaultModel() string { return "heartbeat-tool-feedback-model" } -type toolFeedbackReasoningProvider struct { - filePath string - calls int -} - -func (m *toolFeedbackReasoningProvider) Chat( - ctx context.Context, - messages []providers.Message, - tools []providers.ToolDefinition, - model string, - opts map[string]any, -) (*providers.LLMResponse, error) { - m.calls++ - if m.calls == 1 { - return &providers.LLMResponse{ - ReasoningContent: "Read README.md first to confirm the context that needs to be changed.", - ToolCalls: []providers.ToolCall{{ - ID: "call_reasoning_read_file", - Type: "function", - Name: "read_file", - Arguments: map[string]any{"path": m.filePath}, - }}, - }, nil - } - - return &providers.LLMResponse{ - Content: "DONE", - ToolCalls: []providers.ToolCall{}, - }, nil -} - -func (m *toolFeedbackReasoningProvider) GetDefaultModel() string { - return "tool-feedback-reasoning-model" -} - -func TestToolFeedbackExplanationFromResponse_UsesCurrentContentFirst(t *testing.T) { - response := &providers.LLMResponse{ - Content: "Read README.md first", - ReasoningContent: "current reasoning fallback", - } - messages := []providers.Message{ - {Role: "user", Content: "check file"}, - {Role: "assistant", Content: "Previous turn explanation"}, - {Role: "tool", Content: "tool output", ToolCallID: "call_1"}, - } - - got := toolFeedbackExplanationFromResponse(response, messages, 300) - if got != "Read README.md first" { - t.Fatalf("toolFeedbackExplanationFromResponse() = %q, want current content", got) - } -} - -func TestToolFeedbackExplanationFromResponse_UsesExplicitToolCallExtraContent(t *testing.T) { - response := &providers.LLMResponse{ - ToolCalls: []providers.ToolCall{{ - ID: "call_1", - Name: "read_file", - ExtraContent: &providers.ExtraContent{ - ToolFeedbackExplanation: "Read README.md first to confirm the current project structure.", - }, - }}, - } - messages := []providers.Message{ - {Role: "user", Content: "check file"}, - {Role: "assistant", Content: ""}, - {Role: "tool", Content: "tool output", ToolCallID: "call_1"}, - } - - got := toolFeedbackExplanationFromResponse(response, messages, 300) - if got != "Read README.md first to confirm the current project structure." { - t.Fatalf("toolFeedbackExplanationFromResponse() = %q, want explicit tool feedback explanation", got) - } -} - -func TestToolFeedbackExplanationForToolCall_PrefersToolSpecificExtraContent(t *testing.T) { - response := &providers.LLMResponse{ - Content: "Shared explanation", - ToolCalls: []providers.ToolCall{ - { - ID: "call_1", - Name: "read_file", - ExtraContent: &providers.ExtraContent{ - ToolFeedbackExplanation: "Read README.md first.", - }, - }, - { - ID: "call_2", - Name: "edit_file", - ExtraContent: &providers.ExtraContent{ - ToolFeedbackExplanation: "Update config example after reading it.", - }, - }, - }, - } - - got1 := toolFeedbackExplanationForToolCall(response, response.ToolCalls[0], nil, 300) - got2 := toolFeedbackExplanationForToolCall(response, response.ToolCalls[1], nil, 300) - if got1 != "Read README.md first." { - t.Fatalf("toolFeedbackExplanationForToolCall() first = %q, want tool-specific explanation", got1) - } - if got2 != "Update config example after reading it." { - t.Fatalf("toolFeedbackExplanationForToolCall() second = %q, want tool-specific explanation", got2) - } -} - -func TestToolFeedbackExplanationForToolCall_DoesNotReuseAnotherToolCallExplanation(t *testing.T) { - response := &providers.LLMResponse{ - ToolCalls: []providers.ToolCall{ - { - ID: "call_1", - Name: "read_file", - }, - { - ID: "call_2", - Name: "edit_file", - ExtraContent: &providers.ExtraContent{ - ToolFeedbackExplanation: "Update config example after reading it.", - }, - }, - }, - } - messages := []providers.Message{ - {Role: "user", Content: "inspect the config and update the example"}, - } - - got := toolFeedbackExplanationForToolCall(response, response.ToolCalls[0], messages, 300) - want := utils.ToolFeedbackContinuationHint + ": inspect the config and update the example" - if got != want { - t.Fatalf("toolFeedbackExplanationForToolCall() = %q, want %q", got, want) - } -} - -func TestToolFeedbackExplanationFromResponse_DoesNotUseReasoningContent(t *testing.T) { - response := &providers.LLMResponse{ - Content: "", - ReasoningContent: "hidden reasoning should not be shown", - } - messages := []providers.Message{ - {Role: "user", Content: "check file"}, - {Role: "assistant", Content: "Previous turn explanation"}, - {Role: "user", Content: "Inspect README.md and update the config example."}, - {Role: "tool", Content: "tool output", ToolCallID: "call_1"}, - } - - got := toolFeedbackExplanationFromResponse(response, messages, 300) - want := utils.ToolFeedbackContinuationHint + ": Inspect README.md and update the config example." - if got != want { - t.Fatalf("toolFeedbackExplanationFromResponse() = %q, want latest user content fallback", got) - } -} - type picoInterleavedContentProvider struct { calls int } @@ -3808,16 +3656,7 @@ func TestProcessMessage_PublishesToolFeedbackWhenEnabled(t *testing.T) { t.Fatalf("unexpected tool feedback context: %+v", outbound.Context) } if !strings.Contains(outbound.Content, "`read_file`") { - t.Fatalf("tool feedback content = %q, want read_file summary", outbound.Content) - } - if !strings.Contains(outbound.Content, utils.ToolFeedbackContinuationHint) { - t.Fatalf("tool feedback content = %q, want continuation hint fallback", outbound.Content) - } - if !strings.Contains(outbound.Content, "check tool feedback") { - t.Fatalf("tool feedback content = %q, want current user intent fallback", outbound.Content) - } - if strings.Contains(outbound.Content, "Previous turn explanation") { - t.Fatalf("tool feedback content = %q, want no previous assistant fallback", outbound.Content) + t.Fatalf("tool feedback content = %q, want read_file preview", outbound.Content) } if outbound.AgentID != "main" { t.Fatalf("tool feedback agent_id = %q, want main", outbound.AgentID) @@ -3833,130 +3672,6 @@ func TestProcessMessage_PublishesToolFeedbackWhenEnabled(t *testing.T) { } } -func TestProcessMessage_DoesNotLeakReasoningContentInToolFeedback(t *testing.T) { - tmpDir := t.TempDir() - heartbeatFile := filepath.Join(tmpDir, "tool-feedback-reasoning.txt") - if err := os.WriteFile(heartbeatFile, []byte("tool feedback task"), 0o644); err != nil { - t.Fatalf("WriteFile() error = %v", err) - } - - cfg := &config.Config{ - Agents: config.AgentsConfig{ - Defaults: config.AgentDefaults{ - Workspace: tmpDir, - ModelName: "test-model", - MaxTokens: 4096, - MaxToolIterations: 10, - ToolFeedback: config.ToolFeedbackConfig{ - Enabled: true, - MaxArgsLength: 300, - }, - }, - }, - Tools: config.ToolsConfig{ - ReadFile: config.ReadFileToolConfig{ - Enabled: true, - }, - }, - } - - msgBus := bus.NewMessageBus() - provider := &toolFeedbackReasoningProvider{filePath: heartbeatFile} - al := NewAgentLoop(cfg, msgBus, provider) - - response, err := al.processMessage(context.Background(), testInboundMessage(bus.InboundMessage{ - Channel: "telegram", - SenderID: "user-1", - ChatID: "chat-1", - Content: "check reasoning fallback", - })) - if err != nil { - t.Fatalf("processMessage() error = %v", err) - } - if response != "DONE" { - t.Fatalf("processMessage() response = %q, want %q", response, "DONE") - } - - select { - case outbound := <-msgBus.OutboundChan(): - if !strings.Contains(outbound.Content, "`read_file`") { - t.Fatalf("tool feedback content = %q, want read_file summary", outbound.Content) - } - if !strings.Contains(outbound.Content, utils.ToolFeedbackContinuationHint) { - t.Fatalf("tool feedback content = %q, want continuation hint fallback", outbound.Content) - } - if !strings.Contains(outbound.Content, "check reasoning fallback") { - t.Fatalf("tool feedback content = %q, want current user intent fallback", outbound.Content) - } - if strings.Contains(outbound.Content, "Read README.md first") { - t.Fatalf("tool feedback content = %q, should not leak hidden reasoning", outbound.Content) - } - case <-time.After(2 * time.Second): - t.Fatal("expected outbound tool feedback without leaking reasoning") - } -} - -func TestProcessMessage_DoesNotPublishToolFeedbackForDiscordWhenDisabled(t *testing.T) { - assertToolFeedbackNotPublishedWhenDisabled(t, "discord") -} - -func assertToolFeedbackNotPublishedWhenDisabled(t *testing.T, channel string) { - t.Helper() - - tmpDir := t.TempDir() - heartbeatFile := filepath.Join(tmpDir, "tool-feedback-"+channel+".txt") - if err := os.WriteFile(heartbeatFile, []byte("tool feedback task"), 0o644); err != nil { - t.Fatalf("WriteFile() error = %v", err) - } - - cfg := &config.Config{ - Agents: config.AgentsConfig{ - Defaults: config.AgentDefaults{ - Workspace: tmpDir, - ModelName: "test-model", - MaxTokens: 4096, - MaxToolIterations: 10, - }, - }, - Tools: config.ToolsConfig{ - ReadFile: config.ReadFileToolConfig{ - Enabled: true, - }, - }, - } - - msgBus := bus.NewMessageBus() - provider := &toolFeedbackProvider{filePath: heartbeatFile} - al := NewAgentLoop(cfg, msgBus, provider) - - response, err := al.processMessage(context.Background(), testInboundMessage(bus.InboundMessage{ - Channel: channel, - SenderID: "user-1", - ChatID: "chat-1", - Content: "check tool feedback", - })) - if err != nil { - t.Fatalf("processMessage() error = %v", err) - } - if response != "HEARTBEAT_OK" { - t.Fatalf("processMessage() response = %q, want %q", response, "HEARTBEAT_OK") - } - - select { - case outbound := <-msgBus.OutboundChan(): - t.Fatalf("expected no outbound tool feedback for %s when disabled, got %+v", channel, outbound) - case <-time.After(200 * time.Millisecond): - } -} - -func TestProcessMessage_DoesNotPublishToolFeedbackForTelegramWhenDisabled(t *testing.T) { - assertToolFeedbackNotPublishedWhenDisabled(t, "telegram") -} - -func TestProcessMessage_DoesNotPublishToolFeedbackForFeishuWhenDisabled(t *testing.T) { - assertToolFeedbackNotPublishedWhenDisabled(t, "feishu") -} - func TestProcessMessage_MessageToolPublishesOutboundWithTurnMetadata(t *testing.T) { cfg := config.DefaultConfig() cfg.Agents.Defaults.Workspace = t.TempDir() @@ -4131,85 +3846,6 @@ func TestRunAgentLoop_PicoSkipsInterimPublishWhenNotAllowed(t *testing.T) { } } -func TestRun_PicoToolFeedbackSuppressesDuplicateInterimAssistantContent(t *testing.T) { - tmpDir := t.TempDir() - - cfg := &config.Config{ - Agents: config.AgentsConfig{ - Defaults: config.AgentDefaults{ - Workspace: tmpDir, - ModelName: "test-model", - MaxTokens: 4096, - MaxToolIterations: 10, - ToolFeedback: config.ToolFeedbackConfig{ - Enabled: true, - }, - }, - }, - } - - msgBus := bus.NewMessageBus() - provider := &picoInterleavedContentProvider{} - al := NewAgentLoop(cfg, msgBus, provider) - - agent := al.GetRegistry().GetDefaultAgent() - if agent == nil { - t.Fatal("expected default agent") - } - agent.Tools.Register(&toolLimitTestTool{}) - - runCtx, runCancel := context.WithCancel(context.Background()) - defer runCancel() - - runDone := make(chan error, 1) - go func() { - runDone <- al.Run(runCtx) - }() - - if err := msgBus.PublishInbound(context.Background(), bus.InboundMessage{ - Channel: "pico", - SenderID: "user-1", - ChatID: "session-1", - Content: "run with tools", - }); err != nil { - t.Fatalf("PublishInbound() error = %v", err) - } - - outputs := make([]string, 0, 2) - deadline := time.After(2 * time.Second) - for len(outputs) < 2 { - select { - case outbound := <-msgBus.OutboundChan(): - outputs = append(outputs, outbound.Content) - case <-deadline: - t.Fatalf("timed out waiting for pico outputs, got %v", outputs) - } - } - - if outputs[0] != "🔧 `tool_limit_test_tool`\nintermediate model text" { - t.Fatalf("first outbound content = %q, want tool feedback summary", outputs[0]) - } - if outputs[1] != "final model text" { - t.Fatalf("second outbound content = %q, want %q", outputs[1], "final model text") - } - - runCancel() - select { - case err := <-runDone: - if err != nil { - t.Fatalf("Run() error = %v", err) - } - case <-time.After(2 * time.Second): - t.Fatal("timed out waiting for Run() to exit") - } - - select { - case outbound := <-msgBus.OutboundChan(): - t.Fatalf("unexpected extra pico output after tool feedback + final reply: %+v", outbound) - case <-time.After(200 * time.Millisecond): - } -} - func TestResolveMediaRefs_ResolvesToBase64(t *testing.T) { store := media.NewFileMediaStore() dir := t.TempDir() diff --git a/pkg/agent/loop_turn.go b/pkg/agent/loop_turn.go index 406120e46..1085ddeae 100644 --- a/pkg/agent/loop_turn.go +++ b/pkg/agent/loop_turn.go @@ -635,11 +635,7 @@ turnLoop: } logger.DebugCF("agent", "LLM response", llmResponseFields) - if al.bus != nil && - ts.channel == "pico" && - len(response.ToolCalls) > 0 && - ts.opts.AllowInterimPicoPublish && - !shouldPublishToolFeedback(al.cfg, ts) { + if al.bus != nil && ts.channel == "pico" && len(response.ToolCalls) > 0 && ts.opts.AllowInterimPicoPublish { if strings.TrimSpace(response.Content) != "" { outCtx, outCancel := context.WithTimeout(turnCtx, 3*time.Second) err := al.bus.PublishOutbound(outCtx, bus.OutboundMessage{ @@ -709,19 +705,7 @@ turnLoop: } for _, tc := range normalizedToolCalls { argumentsJSON, _ := json.Marshal(tc.Arguments) - toolFeedbackExplanation := toolFeedbackExplanationForToolCall( - response, - tc, - messages, - al.cfg.Agents.Defaults.GetToolFeedbackMaxArgsLength(), - ) extraContent := tc.ExtraContent - if strings.TrimSpace(toolFeedbackExplanation) != "" { - if extraContent == nil { - extraContent = &providers.ExtraContent{} - } - extraContent.ToolFeedbackExplanation = toolFeedbackExplanation - } thoughtSignature := "" if tc.Function != nil { thoughtSignature = tc.Function.ThoughtSignature @@ -799,16 +783,21 @@ turnLoop: ) // Send tool feedback to chat channel if enabled (same as normal tool execution) - if shouldPublishToolFeedback(al.cfg, ts) { - toolFeedbackExplanation := toolFeedbackExplanationForToolCall( - response, - tc, - messages, + if al.cfg.Agents.Defaults.IsToolFeedbackEnabled() && + ts.channel != "" && + !ts.opts.SuppressToolFeedback { + argsJSON, _ := json.Marshal(toolArgs) + feedbackPreview := utils.Truncate( + string(argsJSON), al.cfg.Agents.Defaults.GetToolFeedbackMaxArgsLength(), ) - feedbackMsg := utils.FormatToolFeedbackMessage(toolName, toolFeedbackExplanation) + feedbackMsg := utils.FormatToolFeedbackMessage(toolName, feedbackPreview) fbCtx, fbCancel := context.WithTimeout(turnCtx, 3*time.Second) - _ = al.bus.PublishOutbound(fbCtx, outboundMessageForTurnWithKind(ts, feedbackMsg, messageKindToolFeedback)) + _ = al.bus.PublishOutbound(fbCtx, bus.OutboundMessage{ + Channel: ts.channel, + ChatID: ts.chatID, + Content: feedbackMsg, + }) fbCancel() } @@ -1078,16 +1067,16 @@ turnLoop: ) // Send tool feedback to chat channel if enabled (from HEAD) - if shouldPublishToolFeedback(al.cfg, ts) { - toolFeedbackExplanation := toolFeedbackExplanationForToolCall( - response, - tc, - messages, + if al.cfg.Agents.Defaults.IsToolFeedbackEnabled() && + ts.channel != "" && + !ts.opts.SuppressToolFeedback { + feedbackPreview := utils.Truncate( + string(argsJSON), al.cfg.Agents.Defaults.GetToolFeedbackMaxArgsLength(), ) - feedbackMsg := utils.FormatToolFeedbackMessage(tc.Name, toolFeedbackExplanation) + feedbackMsg := utils.FormatToolFeedbackMessage(tc.Name, feedbackPreview) fbCtx, fbCancel := context.WithTimeout(turnCtx, 3*time.Second) - _ = al.bus.PublishOutbound(fbCtx, outboundMessageForTurnWithKind(ts, feedbackMsg, messageKindToolFeedback)) + _ = al.bus.PublishOutbound(fbCtx, outboundMessageForTurn(ts, feedbackMsg)) fbCancel() } diff --git a/pkg/agent/loop_utils.go b/pkg/agent/loop_utils.go index ff98dad68..2574f0222 100644 --- a/pkg/agent/loop_utils.go +++ b/pkg/agent/loop_utils.go @@ -11,7 +11,6 @@ import ( "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/commands" - "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/providers" "github.com/sipeed/picoclaw/pkg/session" "github.com/sipeed/picoclaw/pkg/utils" @@ -85,98 +84,6 @@ func outboundMessageForTurn(ts *turnState, content string) bus.OutboundMessage { } } -func outboundMessageForTurnWithKind(ts *turnState, content, kind string) bus.OutboundMessage { - msg := outboundMessageForTurn(ts, content) - if strings.TrimSpace(kind) == "" { - return msg - } - if msg.Context.Raw == nil { - msg.Context.Raw = make(map[string]string, 1) - } - msg.Context.Raw[metadataKeyMessageKind] = kind - return msg -} - -func latestUserContent(messages []providers.Message) string { - for i := len(messages) - 1; i >= 0; i-- { - msg := messages[i] - if msg.Role != "user" { - continue - } - if content := strings.TrimSpace(msg.Content); content != "" { - return content - } - } - return "" -} - -func toolFeedbackExplanationFromResponse( - response *providers.LLMResponse, - messages []providers.Message, - maxLen int, -) string { - if response == nil { - return "" - } - explanation := strings.TrimSpace(response.Content) - if explanation == "" { - explanation = toolFeedbackExplanationFromToolCalls(response.ToolCalls) - } - if explanation == "" { - explanation = toolFeedbackExplanationFromMessages(messages) - } - return utils.Truncate(explanation, maxLen) -} - -func toolFeedbackExplanationFromToolCalls(toolCalls []providers.ToolCall) string { - for _, tc := range toolCalls { - if tc.ExtraContent == nil { - continue - } - if explanation := strings.TrimSpace(tc.ExtraContent.ToolFeedbackExplanation); explanation != "" { - return explanation - } - } - return "" -} - -func toolFeedbackExplanationForToolCall( - response *providers.LLMResponse, - toolCall providers.ToolCall, - messages []providers.Message, - maxLen int, -) string { - if toolCall.ExtraContent != nil { - if explanation := strings.TrimSpace(toolCall.ExtraContent.ToolFeedbackExplanation); explanation != "" { - return utils.Truncate(explanation, maxLen) - } - } - if response == nil { - return utils.Truncate(toolFeedbackExplanationFromMessages(messages), maxLen) - } - - explanation := strings.TrimSpace(response.Content) - if explanation == "" { - explanation = toolFeedbackExplanationFromMessages(messages) - } - return utils.Truncate(explanation, maxLen) -} - -func toolFeedbackExplanationFromMessages(messages []providers.Message) string { - explanation := latestUserContent(messages) - if explanation != "" { - return utils.ToolFeedbackContinuationHint + ": " + explanation - } - return "" -} - -func shouldPublishToolFeedback(cfg *config.Config, ts *turnState) bool { - if ts == nil || ts.channel == "" || ts.opts.SuppressToolFeedback { - return false - } - return cfg != nil && cfg.Agents.Defaults.IsToolFeedbackEnabled() -} - func cloneEventArguments(args map[string]any) map[string]any { if len(args) == 0 { return nil diff --git a/pkg/channels/discord/discord.go b/pkg/channels/discord/discord.go index 514b9b3b1..28f7277d3 100644 --- a/pkg/channels/discord/discord.go +++ b/pkg/channels/discord/discord.go @@ -45,12 +45,9 @@ type DiscordChannel struct { cancel context.CancelFunc typingMu sync.Mutex typingStop map[string]chan struct{} // chatID → stop signal - progress *channels.ToolFeedbackAnimator - botUserID string // stored for mention checking + botUserID string // stored for mention checking bus *bus.MessageBus tts tts.TTSProvider - playTTSFn func(context.Context, *discordgo.VoiceConnection, string, uint64) - ttsVoiceFn func(string) (*discordgo.VoiceConnection, bool) voiceMu sync.RWMutex voiceSSRC map[string]map[uint32]string // guildID -> ssrc -> userID @@ -87,7 +84,7 @@ func NewDiscordChannel( channels.WithReasoningChannelID(bc.ReasoningChannelID), ) - ch := &DiscordChannel{ + return &DiscordChannel{ BaseChannel: base, bc: bc, session: session, @@ -96,11 +93,7 @@ func NewDiscordChannel( typingStop: make(map[string]chan struct{}), bus: bus, voiceSSRC: make(map[string]map[uint32]string), - } - ch.playTTSFn = ch.playTTS - ch.ttsVoiceFn = ch.voiceConnectionForTTS - ch.progress = channels.NewToolFeedbackAnimator(ch.EditMessage) - return ch, nil + }, nil } func (c *DiscordChannel) Start(ctx context.Context) error { @@ -149,9 +142,6 @@ func (c *DiscordChannel) Stop(ctx context.Context) error { if c.cancel != nil { c.cancel() } - if c.progress != nil { - c.progress.StopAll() - } if err := c.session.Close(); err != nil { return fmt.Errorf("failed to close discord session: %w", err) @@ -174,88 +164,32 @@ func (c *DiscordChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]s return nil, nil } - isToolFeedback := outboundMessageIsToolFeedback(msg) - if isToolFeedback { - if msgID, handled, err := c.progress.Update(ctx, channelID, msg.Content); handled { - if err != nil { - return nil, err + if c.tts != nil { + if ch, err := c.session.State.Channel(channelID); err == nil && ch.GuildID != "" { + if vc, ok := c.session.VoiceConnections[ch.GuildID]; ok && vc != nil { + // Cancel any previous TTS playback + c.ttsMu.Lock() + if c.cancelTTS != nil { + c.cancelTTS() + } + ttsCtx, ttsCancel := context.WithCancel(c.ctx) + c.ttsPlayID++ + playID := c.ttsPlayID + c.cancelTTS = ttsCancel + c.ttsMu.Unlock() + + go c.playTTS(ttsCtx, vc, msg.Content, playID) } - return []string{msgID}, nil - } - } - trackedMsgID, hasTrackedMsg := c.currentToolFeedbackMessage(channelID) - c.maybeStartTTS(channelID, msg.Content, isToolFeedback) - if !isToolFeedback { - if msgIDs, handled := c.FinalizeToolFeedbackMessage(ctx, msg); handled { - return msgIDs, nil } } - content := msg.Content - if isToolFeedback { - content = channels.InitialAnimatedToolFeedbackContent(msg.Content) - } - msgID, err := c.sendChunk(ctx, channelID, content, msg.ReplyToMessageID) + msgID, err := c.sendChunk(ctx, channelID, msg.Content, msg.ReplyToMessageID) if err != nil { return nil, err } - if isToolFeedback { - c.RecordToolFeedbackMessage(channelID, msgID, msg.Content) - } else if hasTrackedMsg { - c.dismissTrackedToolFeedbackMessage(ctx, channelID, trackedMsgID) - } return []string{msgID}, nil } -func (c *DiscordChannel) maybeStartTTS(channelID, content string, isToolFeedback bool) { - if c.tts == nil || isToolFeedback { - return - } - - voiceFn := c.ttsVoiceFn - if voiceFn == nil { - voiceFn = c.voiceConnectionForTTS - } - vc, ok := voiceFn(channelID) - if !ok || vc == nil { - return - } - - // Cancel any previous TTS playback. - c.ttsMu.Lock() - if c.cancelTTS != nil { - c.cancelTTS() - } - ttsCtx, ttsCancel := context.WithCancel(c.ctx) - c.ttsPlayID++ - playID := c.ttsPlayID - c.cancelTTS = ttsCancel - playFn := c.playTTSFn - c.ttsMu.Unlock() - - if playFn == nil { - playFn = c.playTTS - } - go playFn(ttsCtx, vc, content, playID) -} - -func (c *DiscordChannel) voiceConnectionForTTS(channelID string) (*discordgo.VoiceConnection, bool) { - if c.session == nil || c.session.State == nil { - return nil, false - } - - ch, err := c.session.State.Channel(channelID) - if err != nil || ch == nil || ch.GuildID == "" { - return nil, false - } - - vc, ok := c.session.VoiceConnections[ch.GuildID] - if !ok || vc == nil { - return nil, false - } - return vc, true -} - // SendMedia implements the channels.MediaSender interface. func (c *DiscordChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) ([]string, error) { if !c.IsRunning() { @@ -266,7 +200,6 @@ func (c *DiscordChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMes if channelID == "" { return nil, fmt.Errorf("channel ID is empty") } - trackedMsgID, hasTrackedMsg := c.currentToolFeedbackMessage(channelID) store := c.GetMediaStore() if store == nil { @@ -348,9 +281,6 @@ func (c *DiscordChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMes if r.err != nil { return nil, fmt.Errorf("discord send media: %w", channels.ErrTemporary) } - if hasTrackedMsg { - c.dismissTrackedToolFeedbackMessage(ctx, channelID, trackedMsgID) - } return []string{r.id}, nil case <-sendCtx.Done(): // Close all file readers @@ -365,15 +295,10 @@ func (c *DiscordChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMes // EditMessage implements channels.MessageEditor. func (c *DiscordChannel) EditMessage(ctx context.Context, chatID string, messageID string, content string) error { - _, err := c.session.ChannelMessageEdit(chatID, messageID, content, discordgo.WithContext(ctx)) + _, err := c.session.ChannelMessageEdit(chatID, messageID, content) return err } -// DeleteMessage implements channels.MessageDeleter. -func (c *DiscordChannel) DeleteMessage(ctx context.Context, chatID string, messageID string) error { - return c.session.ChannelMessageDelete(chatID, messageID, discordgo.WithContext(ctx)) -} - // SendPlaceholder implements channels.PlaceholderCapable. // It sends a placeholder message that will later be edited to the actual // response via EditMessage (channels.MessageEditor). @@ -392,81 +317,6 @@ func (c *DiscordChannel) SendPlaceholder(ctx context.Context, chatID string) (st return msg.ID, nil } -func outboundMessageIsToolFeedback(msg bus.OutboundMessage) bool { - if len(msg.Context.Raw) == 0 { - return false - } - return strings.EqualFold(strings.TrimSpace(msg.Context.Raw["message_kind"]), "tool_feedback") -} - -func (c *DiscordChannel) currentToolFeedbackMessage(chatID string) (string, bool) { - if c.progress == nil { - return "", false - } - return c.progress.Current(chatID) -} - -func (c *DiscordChannel) takeToolFeedbackMessage(chatID string) (string, string, bool) { - if c.progress == nil { - return "", "", false - } - return c.progress.Take(chatID) -} - -func (c *DiscordChannel) RecordToolFeedbackMessage(chatID, messageID, content string) { - if c.progress == nil { - return - } - c.progress.Record(chatID, messageID, content) -} - -func (c *DiscordChannel) ClearToolFeedbackMessage(chatID string) { - if c.progress == nil { - return - } - c.progress.Clear(chatID) -} - -func (c *DiscordChannel) DismissToolFeedbackMessage(ctx context.Context, chatID string) { - msgID, ok := c.currentToolFeedbackMessage(chatID) - if !ok { - return - } - c.dismissTrackedToolFeedbackMessage(ctx, chatID, msgID) -} - -func (c *DiscordChannel) dismissTrackedToolFeedbackMessage(ctx context.Context, chatID, messageID string) { - if strings.TrimSpace(chatID) == "" || strings.TrimSpace(messageID) == "" { - return - } - c.ClearToolFeedbackMessage(chatID) - _ = c.DeleteMessage(ctx, chatID, messageID) -} - -func (c *DiscordChannel) finalizeTrackedToolFeedbackMessage( - ctx context.Context, - chatID string, - content string, - editFn func(context.Context, string, string, string) error, -) ([]string, bool) { - msgID, baseContent, ok := c.takeToolFeedbackMessage(chatID) - if !ok || editFn == nil { - return nil, false - } - if err := editFn(ctx, chatID, msgID, content); err != nil { - c.RecordToolFeedbackMessage(chatID, msgID, baseContent) - return nil, false - } - return []string{msgID}, true -} - -func (c *DiscordChannel) FinalizeToolFeedbackMessage(ctx context.Context, msg bus.OutboundMessage) ([]string, bool) { - if outboundMessageIsToolFeedback(msg) { - return nil, false - } - return c.finalizeTrackedToolFeedbackMessage(ctx, msg.ChatID, msg.Content, c.EditMessage) -} - func (c *DiscordChannel) sendChunk(ctx context.Context, channelID, content, replyToID string) (string, error) { // Use the passed ctx for timeout control sendCtx, cancel := context.WithTimeout(ctx, sendTimeout) diff --git a/pkg/channels/discord/discord_test.go b/pkg/channels/discord/discord_test.go index d42b0bc52..0cd5328f4 100644 --- a/pkg/channels/discord/discord_test.go +++ b/pkg/channels/discord/discord_test.go @@ -1,37 +1,13 @@ package discord import ( - "context" - "io" "net/http" - "net/http/httptest" "net/url" - "reflect" - "sync" "testing" - "time" "github.com/bwmarrin/discordgo" - - "github.com/sipeed/picoclaw/pkg/audio/tts" - "github.com/sipeed/picoclaw/pkg/bus" - "github.com/sipeed/picoclaw/pkg/channels" ) -type stubTTSProvider struct{} - -func (stubTTSProvider) Name() string { return "stub-tts" } - -func (stubTTSProvider) Synthesize(context.Context, string) (io.ReadCloser, error) { - return io.NopCloser(&noopReader{}), nil -} - -type noopReader struct{} - -func (*noopReader) Read(p []byte) (int, error) { - return 0, io.EOF -} - func TestApplyDiscordProxy_CustomProxy(t *testing.T) { session, err := discordgo.New("Bot test-token") if err != nil { @@ -113,224 +89,3 @@ func TestApplyDiscordProxy_InvalidProxyURL(t *testing.T) { t.Fatal("applyDiscordProxy() expected error for invalid proxy URL, got nil") } } - -func TestSend_NonToolFeedbackDeletesTrackedProgressMessage(t *testing.T) { - var ( - mu sync.Mutex - requests []string - ) - - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - mu.Lock() - requests = append(requests, r.Method+" "+r.URL.Path) - mu.Unlock() - - switch { - case r.Method == http.MethodPatch && r.URL.Path == "/channels/chat-1/messages/prog-1": - w.Header().Set("Content-Type", "application/json") - _, _ = io.WriteString(w, `{"id":"prog-1"}`) - default: - t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) - } - })) - defer server.Close() - - origChannels := discordgo.EndpointChannels - discordgo.EndpointChannels = server.URL + "/channels/" - defer func() { - discordgo.EndpointChannels = origChannels - }() - - session, err := discordgo.New("Bot test-token") - if err != nil { - t.Fatalf("discordgo.New() error: %v", err) - } - session.Client = server.Client() - - ch := &DiscordChannel{ - BaseChannel: channels.NewBaseChannel("discord", nil, bus.NewMessageBus(), nil), - session: session, - ctx: context.Background(), - typingStop: make(map[string]chan struct{}), - voiceSSRC: make(map[string]map[uint32]string), - } - ch.progress = channels.NewToolFeedbackAnimator(ch.EditMessage) - ch.SetRunning(true) - ch.RecordToolFeedbackMessage("chat-1", "prog-1", "🔧 `read_file`") - - ids, err := ch.Send(context.Background(), bus.OutboundMessage{ - ChatID: "chat-1", - Content: "final reply", - Context: bus.InboundContext{ - Channel: "discord", - ChatID: "chat-1", - }, - }) - if err != nil { - t.Fatalf("Send() error = %v", err) - } - if got, want := ids, []string{"prog-1"}; !reflect.DeepEqual(got, want) { - t.Fatalf("Send() ids = %v, want %v", got, want) - } - if _, ok := ch.currentToolFeedbackMessage("chat-1"); ok { - t.Fatal("expected tracked tool feedback message to be cleared") - } - - mu.Lock() - defer mu.Unlock() - wantRequests := []string{ - "PATCH /channels/chat-1/messages/prog-1", - } - if !reflect.DeepEqual(requests, wantRequests) { - t.Fatalf("requests = %v, want %v", requests, wantRequests) - } -} - -func TestEditMessage_UsesContextCancellation(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - select { - case <-r.Context().Done(): - return - case <-time.After(time.Second): - w.Header().Set("Content-Type", "application/json") - _, _ = io.WriteString(w, `{"id":"msg-1"}`) - } - })) - defer server.Close() - - origChannels := discordgo.EndpointChannels - discordgo.EndpointChannels = server.URL + "/channels/" - defer func() { - discordgo.EndpointChannels = origChannels - }() - - session, err := discordgo.New("Bot test-token") - if err != nil { - t.Fatalf("discordgo.New() error: %v", err) - } - session.Client = server.Client() - - ch := &DiscordChannel{ - BaseChannel: channels.NewBaseChannel("discord", nil, bus.NewMessageBus(), nil), - session: session, - } - - ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) - defer cancel() - - start := time.Now() - err = ch.EditMessage(ctx, "chat-1", "msg-1", "still running") - elapsed := time.Since(start) - - if err == nil { - t.Fatal("expected EditMessage() to fail when context times out") - } - if elapsed >= 500*time.Millisecond { - t.Fatalf("EditMessage() ignored context timeout, elapsed=%v", elapsed) - } -} - -func TestFinalizeTrackedToolFeedbackMessage_StopsTrackingBeforeEdit(t *testing.T) { - ch := &DiscordChannel{ - progress: channels.NewToolFeedbackAnimator(nil), - } - ch.RecordToolFeedbackMessage("chat-1", "msg-1", "🔧 `read_file`") - - msgIDs, handled := ch.finalizeTrackedToolFeedbackMessage( - context.Background(), - "chat-1", - "final reply", - func(_ context.Context, chatID, messageID, content string) error { - if _, ok := ch.currentToolFeedbackMessage(chatID); ok { - t.Fatal("expected tracked tool feedback to be stopped before edit") - } - if chatID != "chat-1" || messageID != "msg-1" || content != "final reply" { - t.Fatalf("unexpected edit args: %s %s %s", chatID, messageID, content) - } - return nil - }, - ) - if !handled { - t.Fatal("expected finalizeTrackedToolFeedbackMessage to handle tracked message") - } - if got, want := msgIDs, []string{"msg-1"}; !reflect.DeepEqual(got, want) { - t.Fatalf("finalizeTrackedToolFeedbackMessage() ids = %v, want %v", got, want) - } -} - -func TestSend_NonToolFeedbackFinalizerStillStartsTTS(t *testing.T) { - var ( - mu sync.Mutex - requests []string - ) - - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - mu.Lock() - requests = append(requests, r.Method+" "+r.URL.Path) - mu.Unlock() - - switch { - case r.Method == http.MethodPatch && r.URL.Path == "/channels/chat-1/messages/prog-1": - w.Header().Set("Content-Type", "application/json") - _, _ = io.WriteString(w, `{"id":"prog-1"}`) - default: - t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) - } - })) - defer server.Close() - - origChannels := discordgo.EndpointChannels - discordgo.EndpointChannels = server.URL + "/channels/" - defer func() { - discordgo.EndpointChannels = origChannels - }() - - session, err := discordgo.New("Bot test-token") - if err != nil { - t.Fatalf("discordgo.New() error: %v", err) - } - session.Client = server.Client() - - ttsStarted := make(chan string, 1) - ch := &DiscordChannel{ - BaseChannel: channels.NewBaseChannel("discord", nil, bus.NewMessageBus(), nil), - session: session, - ctx: context.Background(), - typingStop: make(map[string]chan struct{}), - voiceSSRC: make(map[string]map[uint32]string), - tts: tts.TTSProvider(stubTTSProvider{}), - } - ch.ttsVoiceFn = func(string) (*discordgo.VoiceConnection, bool) { - return &discordgo.VoiceConnection{}, true - } - ch.playTTSFn = func(_ context.Context, _ *discordgo.VoiceConnection, text string, _ uint64) { - ttsStarted <- text - } - ch.progress = channels.NewToolFeedbackAnimator(ch.EditMessage) - ch.SetRunning(true) - ch.RecordToolFeedbackMessage("chat-1", "prog-1", "🔧 `read_file`") - - ids, err := ch.Send(context.Background(), bus.OutboundMessage{ - ChatID: "chat-1", - Content: "final reply", - Context: bus.InboundContext{ - Channel: "discord", - ChatID: "chat-1", - }, - }) - if err != nil { - t.Fatalf("Send() error = %v", err) - } - if got, want := ids, []string{"prog-1"}; !reflect.DeepEqual(got, want) { - t.Fatalf("Send() ids = %v, want %v", got, want) - } - - select { - case got := <-ttsStarted: - if got != "final reply" { - t.Fatalf("TTS content = %q, want final reply", got) - } - case <-time.After(2 * time.Second): - t.Fatal("expected TTS to start for finalized tracked tool feedback reply") - } -} diff --git a/pkg/channels/feishu/feishu_64.go b/pkg/channels/feishu/feishu_64.go index 49b8dd8e5..02ee47d69 100644 --- a/pkg/channels/feishu/feishu_64.go +++ b/pkg/channels/feishu/feishu_64.go @@ -49,8 +49,6 @@ type FeishuChannel struct { mu sync.Mutex cancel context.CancelFunc - - progress *channels.ToolFeedbackAnimator } type cachedMessage struct { @@ -76,7 +74,6 @@ func NewFeishuChannel(bc *config.Channel, cfg *config.FeishuSettings, bus *bus.M tokenCache: tc, client: lark.NewClient(cfg.AppID, cfg.AppSecret.String(), opts...), } - ch.progress = channels.NewToolFeedbackAnimator(ch.EditMessage) ch.SetOwner(ch) return ch, nil } @@ -135,9 +132,6 @@ func (c *FeishuChannel) Stop(ctx context.Context) error { } c.wsClient = nil c.mu.Unlock() - if c.progress != nil { - c.progress.StopAll() - } c.SetRunning(false) logger.InfoC("feishu", "Feishu channel stopped") @@ -155,50 +149,17 @@ func (c *FeishuChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]st return nil, fmt.Errorf("chat ID is empty: %w", channels.ErrSendFailed) } - isToolFeedback := outboundMessageIsToolFeedback(msg) - trackedMsgID, hasTrackedMsg := c.currentToolFeedbackMessage(msg.ChatID) - if isToolFeedback { - if msgID, handled, err := c.progress.Update(ctx, msg.ChatID, msg.Content); handled { - if err != nil { - return nil, err - } - return []string{msgID}, nil - } - } else { - if msgIDs, handled := c.FinalizeToolFeedbackMessage(ctx, msg); handled { - return msgIDs, nil - } - } - // Build interactive card with markdown content - sendContent := msg.Content - if isToolFeedback { - sendContent = channels.InitialAnimatedToolFeedbackContent(msg.Content) - } - cardContent, err := buildMarkdownCard(sendContent) + cardContent, err := buildMarkdownCard(msg.Content) if err != nil { // If card build fails, fall back to plain text - msgID, sendErr := c.sendText(ctx, msg.ChatID, sendContent) - if sendErr != nil { - return nil, sendErr - } - if isToolFeedback { - c.RecordToolFeedbackMessage(msg.ChatID, msgID, msg.Content) - } else if hasTrackedMsg { - c.dismissTrackedToolFeedbackMessage(ctx, msg.ChatID, trackedMsgID) - } - return []string{msgID}, nil + return nil, c.sendText(ctx, msg.ChatID, msg.Content) } // First attempt: try sending as interactive card - msgID, err := c.sendCard(ctx, msg.ChatID, cardContent) + err = c.sendCard(ctx, msg.ChatID, cardContent) if err == nil { - if isToolFeedback { - c.RecordToolFeedbackMessage(msg.ChatID, msgID, msg.Content) - } else if hasTrackedMsg { - c.dismissTrackedToolFeedbackMessage(ctx, msg.ChatID, trackedMsgID) - } - return []string{msgID}, nil + return nil, nil } // Check if error is due to card table limit (error code 11310) @@ -213,14 +174,9 @@ func (c *FeishuChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]st }) // Second attempt: fall back to plain text message - msgID, textErr := c.sendText(ctx, msg.ChatID, sendContent) + textErr := c.sendText(ctx, msg.ChatID, msg.Content) if textErr == nil { - if isToolFeedback { - c.RecordToolFeedbackMessage(msg.ChatID, msgID, msg.Content) - } else if hasTrackedMsg { - c.dismissTrackedToolFeedbackMessage(ctx, msg.ChatID, trackedMsgID) - } - return []string{msgID}, nil + return nil, nil } // If text also fails, return the text error return nil, textErr @@ -254,23 +210,6 @@ func (c *FeishuChannel) EditMessage(ctx context.Context, chatID, messageID, cont return nil } -// DeleteMessage implements channels.MessageDeleter. -func (c *FeishuChannel) DeleteMessage(ctx context.Context, chatID, messageID string) error { - req := larkim.NewDeleteMessageReqBuilder(). - MessageId(messageID). - Build() - - resp, err := c.client.Im.V1.Message.Delete(ctx, req) - if err != nil { - return fmt.Errorf("feishu delete: %w", err) - } - if !resp.Success() { - c.invalidateTokenOnAuthError(resp.Code) - return fmt.Errorf("feishu delete api error (code=%d msg=%s)", resp.Code, resp.Msg) - } - return nil -} - // SendPlaceholder implements channels.PlaceholderCapable. // Sends an interactive card with placeholder text and returns its message ID. func (c *FeishuChannel) SendPlaceholder(ctx context.Context, chatID string) (string, error) { @@ -312,81 +251,6 @@ func (c *FeishuChannel) SendPlaceholder(ctx context.Context, chatID string) (str return "", nil } -func outboundMessageIsToolFeedback(msg bus.OutboundMessage) bool { - if len(msg.Context.Raw) == 0 { - return false - } - return strings.EqualFold(strings.TrimSpace(msg.Context.Raw["message_kind"]), "tool_feedback") -} - -func (c *FeishuChannel) currentToolFeedbackMessage(chatID string) (string, bool) { - if c.progress == nil { - return "", false - } - return c.progress.Current(chatID) -} - -func (c *FeishuChannel) takeToolFeedbackMessage(chatID string) (string, string, bool) { - if c.progress == nil { - return "", "", false - } - return c.progress.Take(chatID) -} - -func (c *FeishuChannel) RecordToolFeedbackMessage(chatID, messageID, content string) { - if c.progress == nil { - return - } - c.progress.Record(chatID, messageID, content) -} - -func (c *FeishuChannel) ClearToolFeedbackMessage(chatID string) { - if c.progress == nil { - return - } - c.progress.Clear(chatID) -} - -func (c *FeishuChannel) DismissToolFeedbackMessage(ctx context.Context, chatID string) { - msgID, ok := c.currentToolFeedbackMessage(chatID) - if !ok { - return - } - c.dismissTrackedToolFeedbackMessage(ctx, chatID, msgID) -} - -func (c *FeishuChannel) dismissTrackedToolFeedbackMessage(ctx context.Context, chatID, messageID string) { - if strings.TrimSpace(chatID) == "" || strings.TrimSpace(messageID) == "" { - return - } - c.ClearToolFeedbackMessage(chatID) - _ = c.DeleteMessage(ctx, chatID, messageID) -} - -func (c *FeishuChannel) finalizeTrackedToolFeedbackMessage( - ctx context.Context, - chatID string, - content string, - editFn func(context.Context, string, string, string) error, -) ([]string, bool) { - msgID, baseContent, ok := c.takeToolFeedbackMessage(chatID) - if !ok || editFn == nil { - return nil, false - } - if err := editFn(ctx, chatID, msgID, content); err != nil { - c.RecordToolFeedbackMessage(chatID, msgID, baseContent) - return nil, false - } - return []string{msgID}, true -} - -func (c *FeishuChannel) FinalizeToolFeedbackMessage(ctx context.Context, msg bus.OutboundMessage) ([]string, bool) { - if outboundMessageIsToolFeedback(msg) { - return nil, false - } - return c.finalizeTrackedToolFeedbackMessage(ctx, msg.ChatID, msg.Content, c.EditMessage) -} - // ReactToMessage implements channels.ReactionCapable. // Adds a reaction (randomly chosen from config) and returns an undo function to remove it. func (c *FeishuChannel) ReactToMessage(ctx context.Context, chatID, messageID string) (func(), error) { @@ -459,7 +323,6 @@ func (c *FeishuChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMess if !c.IsRunning() { return nil, channels.ErrNotRunning } - trackedMsgID, hasTrackedMsg := c.currentToolFeedbackMessage(msg.ChatID) if msg.ChatID == "" { return nil, fmt.Errorf("chat ID is empty: %w", channels.ErrSendFailed) @@ -476,10 +339,6 @@ func (c *FeishuChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMess } } - if hasTrackedMsg { - c.dismissTrackedToolFeedbackMessage(ctx, msg.ChatID, trackedMsgID) - } - return nil, nil } @@ -942,7 +801,7 @@ func appendMediaTags(content, messageType string, mediaRefs []string) string { } // sendCard sends an interactive card message to a chat. -func (c *FeishuChannel) sendCard(ctx context.Context, chatID, cardContent string) (string, error) { +func (c *FeishuChannel) sendCard(ctx context.Context, chatID, cardContent string) error { req := larkim.NewCreateMessageReqBuilder(). ReceiveIdType(larkim.ReceiveIdTypeChatId). Body(larkim.NewCreateMessageReqBodyBuilder(). @@ -954,26 +813,23 @@ func (c *FeishuChannel) sendCard(ctx context.Context, chatID, cardContent string resp, err := c.client.Im.V1.Message.Create(ctx, req) if err != nil { - return "", fmt.Errorf("feishu send card: %w", channels.ErrTemporary) + return fmt.Errorf("feishu send card: %w", channels.ErrTemporary) } if !resp.Success() { c.invalidateTokenOnAuthError(resp.Code) - return "", fmt.Errorf("feishu api error (code=%d msg=%s): %w", resp.Code, resp.Msg, channels.ErrTemporary) + return fmt.Errorf("feishu api error (code=%d msg=%s): %w", resp.Code, resp.Msg, channels.ErrTemporary) } logger.DebugCF("feishu", "Feishu card message sent", map[string]any{ "chat_id": chatID, }) - if resp.Data != nil && resp.Data.MessageId != nil { - return *resp.Data.MessageId, nil - } - return "", nil + return nil } // sendText sends a plain text message to a chat (fallback when card fails). -func (c *FeishuChannel) sendText(ctx context.Context, chatID, text string) (string, error) { +func (c *FeishuChannel) sendText(ctx context.Context, chatID, text string) error { content, _ := json.Marshal(map[string]string{"text": text}) req := larkim.NewCreateMessageReqBuilder(). @@ -987,21 +843,18 @@ func (c *FeishuChannel) sendText(ctx context.Context, chatID, text string) (stri resp, err := c.client.Im.V1.Message.Create(ctx, req) if err != nil { - return "", fmt.Errorf("feishu send text: %w", channels.ErrTemporary) + return fmt.Errorf("feishu send text: %w", channels.ErrTemporary) } if !resp.Success() { - return "", fmt.Errorf("feishu text api error (code=%d msg=%s): %w", resp.Code, resp.Msg, channels.ErrTemporary) + return fmt.Errorf("feishu text api error (code=%d msg=%s): %w", resp.Code, resp.Msg, channels.ErrTemporary) } logger.DebugCF("feishu", "Feishu text message sent (fallback)", map[string]any{ "chat_id": chatID, }) - if resp.Data != nil && resp.Data.MessageId != nil { - return *resp.Data.MessageId, nil - } - return "", nil + return nil } // sendImage uploads an image and sends it as a message. diff --git a/pkg/channels/feishu/feishu_64_test.go b/pkg/channels/feishu/feishu_64_test.go index 0bdac0352..9010abf69 100644 --- a/pkg/channels/feishu/feishu_64_test.go +++ b/pkg/channels/feishu/feishu_64_test.go @@ -3,13 +3,9 @@ package feishu import ( - "context" - "errors" "testing" larkim "github.com/larksuite/oapi-sdk-go/v3/service/im/v1" - - "github.com/sipeed/picoclaw/pkg/channels" ) func TestExtractContent(t *testing.T) { @@ -283,84 +279,3 @@ func TestExtractFeishuSenderID(t *testing.T) { }) } } - -func TestFinalizeTrackedToolFeedbackMessage_ClearAfterSuccessfulEdit(t *testing.T) { - ch := &FeishuChannel{ - progress: channels.NewToolFeedbackAnimator(nil), - } - ch.RecordToolFeedbackMessage("chat-1", "msg-1", "🔧 `read_file`") - - msgIDs, handled := ch.finalizeTrackedToolFeedbackMessage( - context.Background(), - "chat-1", - "final reply", - func(_ context.Context, chatID, messageID, content string) error { - if chatID != "chat-1" || messageID != "msg-1" || content != "final reply" { - t.Fatalf("unexpected edit args: %s %s %s", chatID, messageID, content) - } - return nil - }, - ) - if !handled { - t.Fatal("expected finalizeTrackedToolFeedbackMessage to handle tracked message") - } - if len(msgIDs) != 1 || msgIDs[0] != "msg-1" { - t.Fatalf("unexpected msgIDs: %v", msgIDs) - } - if _, ok := ch.currentToolFeedbackMessage("chat-1"); ok { - t.Fatal("expected tracked tool feedback to be cleared after successful edit") - } -} - -func TestFinalizeTrackedToolFeedbackMessage_StopsTrackingBeforeEdit(t *testing.T) { - ch := &FeishuChannel{ - progress: channels.NewToolFeedbackAnimator(nil), - } - ch.RecordToolFeedbackMessage("chat-1", "msg-1", "🔧 `read_file`") - - msgIDs, handled := ch.finalizeTrackedToolFeedbackMessage( - context.Background(), - "chat-1", - "final reply", - func(_ context.Context, chatID, messageID, content string) error { - if _, ok := ch.currentToolFeedbackMessage(chatID); ok { - t.Fatal("expected tracked tool feedback to be stopped before edit") - } - if chatID != "chat-1" || messageID != "msg-1" || content != "final reply" { - t.Fatalf("unexpected edit args: %s %s %s", chatID, messageID, content) - } - return nil - }, - ) - if !handled { - t.Fatal("expected finalizeTrackedToolFeedbackMessage to handle tracked message") - } - if len(msgIDs) != 1 || msgIDs[0] != "msg-1" { - t.Fatalf("unexpected msgIDs: %v", msgIDs) - } -} - -func TestFinalizeTrackedToolFeedbackMessage_EditFailureKeepsTrackedMessage(t *testing.T) { - ch := &FeishuChannel{ - progress: channels.NewToolFeedbackAnimator(nil), - } - ch.RecordToolFeedbackMessage("chat-1", "msg-1", "🔧 `read_file`") - - msgIDs, handled := ch.finalizeTrackedToolFeedbackMessage( - context.Background(), - "chat-1", - "final reply", - func(context.Context, string, string, string) error { - return errors.New("edit failed") - }, - ) - if handled { - t.Fatal("expected finalizeTrackedToolFeedbackMessage to report unhandled on edit failure") - } - if len(msgIDs) != 0 { - t.Fatalf("unexpected msgIDs: %v", msgIDs) - } - if msgID, ok := ch.currentToolFeedbackMessage("chat-1"); !ok || msgID != "msg-1" { - t.Fatalf("expected tracked tool feedback to remain after failed edit, got (%q, %v)", msgID, ok) - } -} diff --git a/pkg/channels/manager.go b/pkg/channels/manager.go index 6aec966d6..928676cbc 100644 --- a/pkg/channels/manager.go +++ b/pkg/channels/manager.go @@ -14,7 +14,6 @@ import ( "net" "net/http" "sort" - "strings" "sync" "time" @@ -26,7 +25,6 @@ import ( "github.com/sipeed/picoclaw/pkg/health" "github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/media" - "github.com/sipeed/picoclaw/pkg/utils" ) const ( @@ -98,15 +96,6 @@ type Manager struct { channelHashes map[string]string // channel name → config hash } -type toolFeedbackMessageTracker interface { - RecordToolFeedbackMessage(chatID, messageID, content string) - ClearToolFeedbackMessage(chatID string) -} - -type toolFeedbackMessageCleaner interface { - DismissToolFeedbackMessage(ctx context.Context, chatID string) -} - type asyncTask struct { cancel context.CancelFunc } @@ -119,13 +108,6 @@ func outboundMessageChatID(msg bus.OutboundMessage) string { return msg.ChatID } -func outboundMessageIsToolFeedback(msg bus.OutboundMessage) bool { - if len(msg.Context.Raw) == 0 { - return false - } - return strings.EqualFold(strings.TrimSpace(msg.Context.Raw["message_kind"]), "tool_feedback") -} - func outboundMediaChannel(msg bus.OutboundMediaMessage) string { return msg.Context.Channel } @@ -134,16 +116,6 @@ func outboundMediaChatID(msg bus.OutboundMediaMessage) string { return msg.ChatID } -func dismissTrackedToolFeedbackMessage(ctx context.Context, ch Channel, chatID string) { - if cleaner, ok := ch.(toolFeedbackMessageCleaner); ok { - cleaner.DismissToolFeedbackMessage(ctx, chatID) - return - } - if tracker, ok := ch.(toolFeedbackMessageTracker); ok { - tracker.ClearToolFeedbackMessage(chatID) - } -} - // RecordPlaceholder registers a placeholder message for later editing. // Implements PlaceholderRecorder. func (m *Manager) RecordPlaceholder(channel, chatID, placeholderID string) { @@ -224,19 +196,7 @@ func (m *Manager) preSend(ctx context.Context, name string, msg bus.OutboundMess } } - isToolFeedback := outboundMessageIsToolFeedback(msg) - - // 3. If a stream already finalized this chat, stale tool feedback must be - // dropped without consuming the final-response marker. Streaming finalization - // bypasses the worker queue, so older queued feedback can arrive before the - // normal final outbound message that cleans up the marker and placeholder. - if isToolFeedback { - if _, loaded := m.streamActive.Load(key); loaded { - return nil, true - } - } - - // 4. If a stream already finalized this message, delete the placeholder and skip send + // 3. If a stream already finalized this message, delete the placeholder and skip send if _, loaded := m.streamActive.LoadAndDelete(key); loaded { if v, loaded := m.placeholders.LoadAndDelete(key); loaded { if entry, ok := v.(placeholderEntry); ok && entry.id != "" { @@ -248,26 +208,14 @@ func (m *Manager) preSend(ctx context.Context, name string, msg bus.OutboundMess } } } - if !isToolFeedback { - dismissTrackedToolFeedbackMessage(ctx, ch, chatID) - } return nil, true } - // 5. Try editing placeholder + // 4. Try editing placeholder if v, loaded := m.placeholders.LoadAndDelete(key); loaded { if entry, ok := v.(placeholderEntry); ok && entry.id != "" { if editor, ok := ch.(MessageEditor); ok { - content := msg.Content - if isToolFeedback { - content = InitialAnimatedToolFeedbackContent(msg.Content) - } - if err := editor.EditMessage(ctx, chatID, entry.id, content); err == nil { - if tracker, ok := ch.(toolFeedbackMessageTracker); ok && isToolFeedback { - tracker.RecordToolFeedbackMessage(chatID, entry.id, msg.Content) - } else if !isToolFeedback { - dismissTrackedToolFeedbackMessage(ctx, ch, chatID) - } + if err := editor.EditMessage(ctx, chatID, entry.id, msg.Content); err == nil { return []string{entry.id}, true } // edit failed → fall through to normal Send @@ -364,27 +312,22 @@ func (m *Manager) GetStreamer(ctx context.Context, channelName, chatID string) ( // Mark streamActive on Finalize so preSend knows to clean up the placeholder key := channelName + ":" + chatID return &finalizeHookStreamer{ - Streamer: streamer, - onFinalize: func(finalizeCtx context.Context) { - dismissTrackedToolFeedbackMessage(finalizeCtx, ch, chatID) - m.streamActive.Store(key, true) - }, + Streamer: streamer, + onFinalize: func() { m.streamActive.Store(key, true) }, }, true } // finalizeHookStreamer wraps a Streamer to run a hook on Finalize. type finalizeHookStreamer struct { Streamer - onFinalize func(context.Context) + onFinalize func() } func (s *finalizeHookStreamer) Finalize(ctx context.Context, content string) error { if err := s.Streamer.Finalize(ctx, content); err != nil { return err } - if s.onFinalize != nil { - s.onFinalize(ctx) - } + s.onFinalize() return nil } @@ -826,21 +769,18 @@ func (m *Manager) runWorker(ctx context.Context, name string, w *channelWorker) // Collect all message chunks to send var chunks []string - // Step 1: Try marker-based splitting if enabled. - // Tool feedback must stay a single message, so it skips marker splitting. - if m.config != nil && m.config.Agents.Defaults.SplitOnMarker && !outboundMessageIsToolFeedback(msg) { + // Step 1: Try marker-based splitting if enabled + if m.config != nil && m.config.Agents.Defaults.SplitOnMarker { if markerChunks := SplitByMarker(msg.Content); len(markerChunks) > 1 { for _, chunk := range markerChunks { - chunkMsg := msg - chunkMsg.Content = chunk - chunks = append(chunks, splitOutboundMessageContent(chunkMsg, maxLen)...) + chunks = append(chunks, splitByLength(chunk, maxLen)...) } } } // Step 2: Fallback to length-based splitting if no chunks from marker if len(chunks) == 0 { - chunks = splitOutboundMessageContent(msg, maxLen) + chunks = splitByLength(msg.Content, maxLen) } // Step 3: Send all chunks @@ -855,25 +795,12 @@ func (m *Manager) runWorker(ctx context.Context, name string, w *channelWorker) } } -// splitOutboundMessageContent splits regular outbound content by maxLen, but -// keeps tool feedback in a single message by truncating the explanation body. -func splitOutboundMessageContent(msg bus.OutboundMessage, maxLen int) []string { - if maxLen > 0 { - if outboundMessageIsToolFeedback(msg) { - animationSafeLen := maxLen - MaxToolFeedbackAnimationFrameLength() - if animationSafeLen <= 0 { - animationSafeLen = maxLen - } - if len([]rune(msg.Content)) > animationSafeLen { - return []string{utils.FitToolFeedbackMessage(msg.Content, animationSafeLen)} - } - return []string{msg.Content} - } - if len([]rune(msg.Content)) > maxLen { - return SplitMessage(msg.Content, maxLen) - } +// splitByLength splits content by maxLen if needed, otherwise returns single chunk. +func splitByLength(content string, maxLen int) []string { + if maxLen > 0 && len([]rune(content)) > maxLen { + return SplitMessage(content, maxLen) } - return []string{msg.Content} + return []string{content} } // sendWithRetry sends a message through the channel with rate limiting and @@ -1337,16 +1264,13 @@ func (m *Manager) SendMessage(ctx context.Context, msg bus.OutboundMessage) erro if mlp, ok := w.ch.(MessageLengthProvider); ok { maxLen = mlp.MaxMessageLength() } - if chunks := splitOutboundMessageContent(msg, maxLen); len(chunks) > 1 { - for _, chunk := range chunks { + if maxLen > 0 && len([]rune(msg.Content)) > maxLen { + for _, chunk := range SplitMessage(msg.Content, maxLen) { chunkMsg := msg chunkMsg.Content = chunk m.sendWithRetry(ctx, channelName, w, chunkMsg) } } else { - if len(chunks) == 1 { - msg.Content = chunks[0] - } m.sendWithRetry(ctx, channelName, w, msg) } return nil diff --git a/pkg/channels/manager_test.go b/pkg/channels/manager_test.go index 4f6a7dcf4..881993d9c 100644 --- a/pkg/channels/manager_test.go +++ b/pkg/channels/manager_test.go @@ -13,8 +13,6 @@ import ( "golang.org/x/time/rate" "github.com/sipeed/picoclaw/pkg/bus" - "github.com/sipeed/picoclaw/pkg/config" - "github.com/sipeed/picoclaw/pkg/utils" ) // mockChannel is a test double that delegates Send to a configurable function. @@ -78,9 +76,8 @@ func (m *mockMediaChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaM type mockDeletingMediaChannel struct { mockMediaChannel - deleteCalls int - dismissedChatID string - lastDeleted struct { + deleteCalls int + lastDeleted struct { chatID string messageID string } @@ -97,37 +94,6 @@ func (m *mockDeletingMediaChannel) DeleteMessage( return nil } -func (m *mockDeletingMediaChannel) DismissToolFeedbackMessage(_ context.Context, chatID string) { - m.dismissedChatID = chatID -} - -type mockStreamer struct { - finalizeFn func(context.Context, string) error -} - -func (m *mockStreamer) Update(context.Context, string) error { return nil } - -func (m *mockStreamer) Finalize(ctx context.Context, content string) error { - if m.finalizeFn != nil { - return m.finalizeFn(ctx, content) - } - return nil -} - -func (m *mockStreamer) Cancel(context.Context) {} - -type mockStreamingChannel struct { - mockMessageEditor - streamer Streamer -} - -func (m *mockStreamingChannel) BeginStream(context.Context, string) (Streamer, error) { - if m.streamer == nil { - return nil, errors.New("missing streamer") - } - return m.streamer, nil -} - // newTestManager creates a minimal Manager suitable for unit tests. func newTestManager() *Manager { return &Manager{ @@ -749,43 +715,13 @@ func TestSendWithRetry_ExponentialBackoff(t *testing.T) { // mockMessageEditor is a channel that supports MessageEditor. type mockMessageEditor struct { mockChannel - editFn func(ctx context.Context, chatID, messageID, content string) error - finalizeFn func(ctx context.Context, msg bus.OutboundMessage) ([]string, bool) - finalizeCalled bool - recordedChatID string - recordedMessageID string - clearedChatID string - dismissedChatID string + editFn func(ctx context.Context, chatID, messageID, content string) error } func (m *mockMessageEditor) EditMessage(ctx context.Context, chatID, messageID, content string) error { return m.editFn(ctx, chatID, messageID, content) } -func (m *mockMessageEditor) RecordToolFeedbackMessage(chatID, messageID, _ string) { - m.recordedChatID = chatID - m.recordedMessageID = messageID -} - -func (m *mockMessageEditor) ClearToolFeedbackMessage(chatID string) { - m.clearedChatID = chatID -} - -func (m *mockMessageEditor) DismissToolFeedbackMessage(_ context.Context, chatID string) { - m.dismissedChatID = chatID -} - -func (m *mockMessageEditor) FinalizeToolFeedbackMessage( - ctx context.Context, - msg bus.OutboundMessage, -) ([]string, bool) { - m.finalizeCalled = true - if m.finalizeFn == nil { - return nil, false - } - return m.finalizeFn(ctx, msg) -} - func TestPreSend_PlaceholderEditSuccess(t *testing.T) { m := newTestManager() var sendCalled bool @@ -830,360 +766,6 @@ func TestPreSend_PlaceholderEditSuccess(t *testing.T) { } } -func TestPreSend_ToolFeedbackPlaceholderEditRecordsTrackedMessage(t *testing.T) { - m := newTestManager() - - ch := &mockMessageEditor{ - editFn: func(_ context.Context, chatID, messageID, content string) error { - if chatID != "123" || messageID != "456" || content != "hello" { - t.Fatalf("unexpected edit args: %s %s %s", chatID, messageID, content) - } - return nil - }, - } - - m.RecordPlaceholder("test", "123", "456") - - msg := testOutboundMessage(bus.OutboundMessage{ - Channel: "test", - ChatID: "123", - Content: "hello", - Context: bus.InboundContext{ - Channel: "test", - ChatID: "123", - Raw: map[string]string{ - "message_kind": "tool_feedback", - }, - }, - }) - _, edited := m.preSend(context.Background(), "test", msg, ch) - if !edited { - t.Fatal("expected preSend to edit placeholder") - } - if ch.recordedChatID != "123" || ch.recordedMessageID != "456" { - t.Fatalf("expected tracked message 123/456, got %q/%q", ch.recordedChatID, ch.recordedMessageID) - } -} - -func TestPreSend_NonToolFeedbackLeavesTrackedMessageForChannelSend(t *testing.T) { - m := newTestManager() - ch := &mockMessageEditor{} - - msg := testOutboundMessage(bus.OutboundMessage{ - Channel: "test", - ChatID: "123", - Content: "final reply", - Context: bus.InboundContext{ - Channel: "test", - ChatID: "123", - }, - }) - - _, edited := m.preSend(context.Background(), "test", msg, ch) - if edited { - t.Fatal("expected preSend to fall through when no placeholder exists") - } - if ch.dismissedChatID != "" { - t.Fatalf("expected tracked tool feedback cleanup to be deferred to channel send, got %q", ch.dismissedChatID) - } -} - -func TestPreSend_NonToolFeedbackDefersTrackedMessageFinalizationToChannelSend(t *testing.T) { - m := newTestManager() - ch := &mockMessageEditor{ - finalizeFn: func(_ context.Context, msg bus.OutboundMessage) ([]string, bool) { - if msg.ChatID != "123" || msg.Content != "final reply" { - t.Fatalf("unexpected finalize msg: %+v", msg) - } - return []string{"tool-msg-1"}, true - }, - } - - msg := testOutboundMessage(bus.OutboundMessage{ - Channel: "test", - ChatID: "123", - Content: "final reply", - Context: bus.InboundContext{ - Channel: "test", - ChatID: "123", - }, - }) - - msgIDs, handled := m.preSend(context.Background(), "test", msg, ch) - if handled { - t.Fatalf("expected preSend to defer to channel Send, got msgIDs=%v", msgIDs) - } - if len(msgIDs) != 0 { - t.Fatalf("expected no msgIDs from preSend, got %v", msgIDs) - } - if ch.dismissedChatID != "" { - t.Fatalf("expected tracked cleanup to remain in channel Send, got %q", ch.dismissedChatID) - } - if ch.finalizeCalled { - t.Fatal("expected preSend to skip channel tool feedback finalization") - } -} - -func TestPreSend_StaleToolFeedbackDoesNotConsumeStreamActiveMarker(t *testing.T) { - m := newTestManager() - m.streamActive.Store("test:123", true) - m.RecordPlaceholder("test", "123", "placeholder-1") - - var editedContent string - ch := &mockMessageEditor{ - editFn: func(_ context.Context, chatID, messageID, content string) error { - if chatID != "123" || messageID != "placeholder-1" { - t.Fatalf("unexpected edit target: %s/%s", chatID, messageID) - } - editedContent = content - return nil - }, - } - - toolFeedback := testOutboundMessage(bus.OutboundMessage{ - Channel: "test", - ChatID: "123", - Content: "🔧 `read_file`\nReading config", - Context: bus.InboundContext{ - Channel: "test", - ChatID: "123", - Raw: map[string]string{ - "message_kind": "tool_feedback", - }, - }, - }) - - msgIDs, handled := m.preSend(context.Background(), "test", toolFeedback, ch) - if !handled { - t.Fatal("expected stale tool feedback to be dropped after stream finalize") - } - if len(msgIDs) != 0 { - t.Fatalf("expected no delivered message IDs for stale feedback, got %v", msgIDs) - } - if _, ok := m.streamActive.Load("test:123"); !ok { - t.Fatal("expected streamActive marker to remain for the final outbound message") - } - if _, ok := m.placeholders.Load("test:123"); !ok { - t.Fatal("expected placeholder cleanup to remain deferred to the final outbound message") - } - if ch.editedMessages != 0 { - t.Fatalf("expected no placeholder edit for stale feedback, got %d edits", ch.editedMessages) - } - - finalMsg := testOutboundMessage(bus.OutboundMessage{ - Channel: "test", - ChatID: "123", - Content: "final streamed reply", - Context: bus.InboundContext{ - Channel: "test", - ChatID: "123", - }, - }) - - _, handled = m.preSend(context.Background(), "test", finalMsg, ch) - if !handled { - t.Fatal("expected final outbound message to consume streamActive marker") - } - if _, ok := m.streamActive.Load("test:123"); ok { - t.Fatal("expected streamActive marker to be cleared by final outbound message") - } - if _, ok := m.placeholders.Load("test:123"); ok { - t.Fatal("expected placeholder to be cleaned up by final outbound message") - } - if editedContent != "final streamed reply" { - t.Fatalf("editedContent = %q, want final streamed reply", editedContent) - } -} - -func TestPreSendMedia_LeavesTrackedMessageForChannelSend(t *testing.T) { - m := newTestManager() - ch := &mockDeletingMediaChannel{} - - m.preSendMedia(context.Background(), "test", bus.OutboundMediaMessage{ - ChatID: "123", - Context: bus.InboundContext{ - Channel: "test", - ChatID: "123", - }, - }, ch) - - if ch.dismissedChatID != "" { - t.Fatalf( - "expected tracked tool feedback cleanup to be deferred to channel media send, got %q", - ch.dismissedChatID, - ) - } -} - -func TestSplitOutboundMessageContent_ToolFeedbackTruncatesInsteadOfSplitting(t *testing.T) { - msg := testOutboundMessage(bus.OutboundMessage{ - Channel: "test", - ChatID: "123", - Content: "\U0001f527 `read_file`\nRead README.md first to confirm the current project structure before editing the config example.", - Context: bus.InboundContext{ - Channel: "test", - ChatID: "123", - Raw: map[string]string{ - "message_kind": "tool_feedback", - }, - }, - }) - - chunks := splitOutboundMessageContent(msg, 40) - if len(chunks) != 1 { - t.Fatalf("len(chunks) = %d, want 1", len(chunks)) - } - want := utils.FitToolFeedbackMessage(msg.Content, 40-MaxToolFeedbackAnimationFrameLength()) - if chunks[0] != want { - t.Fatalf("chunk = %q, want %q", chunks[0], want) - } -} - -func TestSplitOutboundMessageContent_ToolFeedbackReservesAnimationFrame(t *testing.T) { - msg := testOutboundMessage(bus.OutboundMessage{ - Channel: "test", - ChatID: "123", - Content: "🔧 `read_file`\n1234567890", - Context: bus.InboundContext{ - Channel: "test", - ChatID: "123", - Raw: map[string]string{ - "message_kind": "tool_feedback", - }, - }, - }) - - chunks := splitOutboundMessageContent(msg, len([]rune(msg.Content))) - if len(chunks) != 1 { - t.Fatalf("len(chunks) = %d, want 1", len(chunks)) - } - - animated := formatAnimatedToolFeedbackContent(chunks[0], strings.Repeat(".", MaxToolFeedbackAnimationFrameLength())) - if got, maxLen := len([]rune(animated)), len([]rune(msg.Content)); got > maxLen { - t.Fatalf("animated len = %d, want <= %d; content=%q", got, maxLen, animated) - } -} - -func TestGetStreamer_FinalizeDismissesTrackedToolFeedback(t *testing.T) { - m := newTestManager() - ch := &mockStreamingChannel{ - mockMessageEditor: mockMessageEditor{}, - streamer: &mockStreamer{ - finalizeFn: func(_ context.Context, content string) error { - if content != "final reply" { - t.Fatalf("unexpected finalize content: %q", content) - } - return nil - }, - }, - } - m.channels["test"] = ch - - streamer, ok := m.GetStreamer(context.Background(), "test", "123") - if !ok { - t.Fatal("expected streamer to be available") - } - if err := streamer.Finalize(context.Background(), "final reply"); err != nil { - t.Fatalf("Finalize() error = %v", err) - } - if ch.dismissedChatID != "123" { - t.Fatalf("expected tracked tool feedback to be dismissed for chat 123, got %q", ch.dismissedChatID) - } - if _, ok := m.streamActive.Load("test:123"); !ok { - t.Fatal("expected streamActive marker to be recorded after finalize") - } -} - -func TestGetStreamer_FinalizeFailureDoesNotDismissTrackedToolFeedback(t *testing.T) { - m := newTestManager() - ch := &mockStreamingChannel{ - mockMessageEditor: mockMessageEditor{}, - streamer: &mockStreamer{ - finalizeFn: func(context.Context, string) error { - return errors.New("finalize failed") - }, - }, - } - m.channels["test"] = ch - - streamer, ok := m.GetStreamer(context.Background(), "test", "123") - if !ok { - t.Fatal("expected streamer to be available") - } - if err := streamer.Finalize(context.Background(), "final reply"); err == nil { - t.Fatal("expected Finalize() to fail") - } - if ch.dismissedChatID != "" { - t.Fatalf("expected no tool feedback dismissal on finalize failure, got %q", ch.dismissedChatID) - } - if _, ok := m.streamActive.Load("test:123"); ok { - t.Fatal("expected no streamActive marker after finalize failure") - } -} - -func TestRunWorker_ToolFeedbackSkipsMarkerSplitting(t *testing.T) { - m := newTestManager() - m.config = &config.Config{ - Agents: config.AgentsConfig{ - Defaults: config.AgentDefaults{ - SplitOnMarker: true, - }, - }, - } - - var ( - mu sync.Mutex - received []string - ) - ch := &mockChannelWithLength{ - mockChannel: mockChannel{ - sendFn: func(_ context.Context, msg bus.OutboundMessage) error { - mu.Lock() - received = append(received, msg.Content) - mu.Unlock() - return nil - }, - }, - maxLen: 200, - } - - w := &channelWorker{ - ch: ch, - queue: make(chan bus.OutboundMessage, 1), - done: make(chan struct{}), - limiter: rate.NewLimiter(rate.Inf, 1), - } - - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - go m.runWorker(ctx, "test", w) - - content := "🔧 `read_file`\nRead current config first.<|[SPLIT]|>Then update the example." - w.queue <- testOutboundMessage(bus.OutboundMessage{ - Channel: "test", - ChatID: "123", - Content: content, - Context: bus.InboundContext{ - Channel: "test", - ChatID: "123", - Raw: map[string]string{ - "message_kind": "tool_feedback", - }, - }, - }) - - time.Sleep(100 * time.Millisecond) - - mu.Lock() - defer mu.Unlock() - if len(received) != 1 { - t.Fatalf("len(received) = %d, want 1", len(received)) - } - if received[0] != content { - t.Fatalf("received[0] = %q, want %q", received[0], content) - } -} - func TestPreSend_PlaceholderEditFails_FallsThrough(t *testing.T) { m := newTestManager() diff --git a/pkg/channels/matrix/matrix.go b/pkg/channels/matrix/matrix.go index 04599d6d2..40e1b0a36 100644 --- a/pkg/channels/matrix/matrix.go +++ b/pkg/channels/matrix/matrix.go @@ -46,13 +46,6 @@ const ( var matrixMentionHrefRegexp = regexp.MustCompile(`(?i)]+href=["']([^"']+)["']`) -func outboundMessageIsToolFeedback(msg bus.OutboundMessage) bool { - if len(msg.Context.Raw) == 0 { - return false - } - return strings.EqualFold(strings.TrimSpace(msg.Context.Raw["message_kind"]), "tool_feedback") -} - type roomKindCacheEntry struct { isGroup bool expiresAt time.Time @@ -199,7 +192,6 @@ type MatrixChannel struct { cryptoHelper *cryptohelper.CryptoHelper cryptoDbPath string - progress *channels.ToolFeedbackAnimator } func NewMatrixChannel( @@ -244,7 +236,7 @@ func NewMatrixChannel( channels.WithReasoningChannelID(bc.ReasoningChannelID), ) - ch := &MatrixChannel{ + return &MatrixChannel{ BaseChannel: base, bc: bc, client: client, @@ -256,9 +248,7 @@ func NewMatrixChannel( localpartMentionR: localpartMentionRegexp(matrixLocalpart(client.UserID)), typingMu: sync.Mutex{}, cryptoDbPath: cryptoDatabasePath, - } - ch.progress = channels.NewToolFeedbackAnimator(ch.EditMessage) - return ch, nil + }, nil } func (c *MatrixChannel) Start(ctx context.Context) error { @@ -307,9 +297,6 @@ func (c *MatrixChannel) Stop(ctx context.Context) error { c.cancel() } c.stopTypingSessions(ctx) - if c.progress != nil { - c.progress.StopAll() - } // Close crypto helper if initialized if c.cryptoHelper != nil { @@ -411,36 +398,11 @@ func (c *MatrixChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]st return nil, nil } - isToolFeedback := outboundMessageIsToolFeedback(msg) - if isToolFeedback { - if msgID, handled, err := c.progress.Update(ctx, msg.ChatID, content); handled { - if err != nil { - return nil, err - } - return []string{msgID}, nil - } - } - trackedMsgID, hasTrackedMsg := c.currentToolFeedbackMessage(msg.ChatID) - if !isToolFeedback { - if msgIDs, handled := c.FinalizeToolFeedbackMessage(ctx, msg); handled { - return msgIDs, nil - } - } - if isToolFeedback { - content = channels.InitialAnimatedToolFeedbackContent(content) - } - resp, err := c.client.SendMessageEvent(ctx, roomID, event.EventMessage, c.messageContent(content)) if err != nil { return nil, fmt.Errorf("matrix send: %w", channels.ErrTemporary) } - msgID := resp.EventID.String() - if isToolFeedback { - c.RecordToolFeedbackMessage(msg.ChatID, msgID, msg.Content) - } else if hasTrackedMsg { - c.dismissTrackedToolFeedbackMessage(ctx, msg.ChatID, trackedMsgID) - } - return []string{msgID}, nil + return []string{resp.EventID.String()}, nil } func (c *MatrixChannel) messageContent(text string) *event.MessageEventContent { @@ -457,8 +419,6 @@ func (c *MatrixChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMess if !c.IsRunning() { return nil, channels.ErrNotRunning } - trackedMsgID, hasTrackedMsg := c.currentToolFeedbackMessage(msg.ChatID) - sendCtx := ctx if sendCtx == nil { sendCtx = context.Background() @@ -569,10 +529,6 @@ func (c *MatrixChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMess } } - if hasTrackedMsg { - c.dismissTrackedToolFeedbackMessage(ctx, msg.ChatID, trackedMsgID) - } - return eventIDs, nil } @@ -656,89 +612,6 @@ func (c *MatrixChannel) EditMessage(ctx context.Context, chatID string, messageI return err } -// DeleteMessage implements channels.MessageDeleter. -func (c *MatrixChannel) DeleteMessage(ctx context.Context, chatID string, messageID string) error { - roomID := id.RoomID(strings.TrimSpace(chatID)) - if roomID == "" { - return fmt.Errorf("matrix room ID is empty") - } - eventID := id.EventID(strings.TrimSpace(messageID)) - if eventID == "" { - return fmt.Errorf("matrix message ID is empty") - } - - _, err := c.client.RedactEvent(ctx, roomID, eventID) - return err -} - -func (c *MatrixChannel) currentToolFeedbackMessage(chatID string) (string, bool) { - if c.progress == nil { - return "", false - } - return c.progress.Current(chatID) -} - -func (c *MatrixChannel) takeToolFeedbackMessage(chatID string) (string, string, bool) { - if c.progress == nil { - return "", "", false - } - return c.progress.Take(chatID) -} - -func (c *MatrixChannel) RecordToolFeedbackMessage(chatID, messageID, content string) { - if c.progress == nil { - return - } - c.progress.Record(chatID, messageID, content) -} - -func (c *MatrixChannel) ClearToolFeedbackMessage(chatID string) { - if c.progress == nil { - return - } - c.progress.Clear(chatID) -} - -func (c *MatrixChannel) DismissToolFeedbackMessage(ctx context.Context, chatID string) { - msgID, ok := c.currentToolFeedbackMessage(chatID) - if !ok { - return - } - c.dismissTrackedToolFeedbackMessage(ctx, chatID, msgID) -} - -func (c *MatrixChannel) dismissTrackedToolFeedbackMessage(ctx context.Context, chatID, messageID string) { - if strings.TrimSpace(chatID) == "" || strings.TrimSpace(messageID) == "" { - return - } - c.ClearToolFeedbackMessage(chatID) - _ = c.DeleteMessage(ctx, chatID, messageID) -} - -func (c *MatrixChannel) finalizeTrackedToolFeedbackMessage( - ctx context.Context, - chatID string, - content string, - editFn func(context.Context, string, string, string) error, -) ([]string, bool) { - msgID, baseContent, ok := c.takeToolFeedbackMessage(chatID) - if !ok || editFn == nil { - return nil, false - } - if err := editFn(ctx, chatID, msgID, content); err != nil { - c.RecordToolFeedbackMessage(chatID, msgID, baseContent) - return nil, false - } - return []string{msgID}, true -} - -func (c *MatrixChannel) FinalizeToolFeedbackMessage(ctx context.Context, msg bus.OutboundMessage) ([]string, bool) { - if outboundMessageIsToolFeedback(msg) { - return nil, false - } - return c.finalizeTrackedToolFeedbackMessage(ctx, msg.ChatID, msg.Content, c.EditMessage) -} - func (c *MatrixChannel) handleMemberEvent(ctx context.Context, evt *event.Event) { if !c.config.JoinOnInvite { return diff --git a/pkg/channels/matrix/matrix_test.go b/pkg/channels/matrix/matrix_test.go index 066f08059..07f08f32b 100644 --- a/pkg/channels/matrix/matrix_test.go +++ b/pkg/channels/matrix/matrix_test.go @@ -14,7 +14,6 @@ import ( "maunium.net/go/mautrix/event" "maunium.net/go/mautrix/id" - "github.com/sipeed/picoclaw/pkg/channels" "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/media" ) @@ -42,34 +41,6 @@ func TestMatrixLocalpartMentionRegexp(t *testing.T) { } } -func TestFinalizeTrackedToolFeedbackMessage_StopsTrackingBeforeEdit(t *testing.T) { - ch := &MatrixChannel{ - progress: channels.NewToolFeedbackAnimator(nil), - } - ch.RecordToolFeedbackMessage("!room:matrix.org", "$event1", "🔧 `read_file`") - - msgIDs, handled := ch.finalizeTrackedToolFeedbackMessage( - context.Background(), - "!room:matrix.org", - "final reply", - func(_ context.Context, chatID, messageID, content string) error { - if _, ok := ch.currentToolFeedbackMessage(chatID); ok { - t.Fatal("expected tracked tool feedback to be stopped before edit") - } - if chatID != "!room:matrix.org" || messageID != "$event1" || content != "final reply" { - t.Fatalf("unexpected edit args: %s %s %s", chatID, messageID, content) - } - return nil - }, - ) - if !handled { - t.Fatal("expected finalizeTrackedToolFeedbackMessage to handle tracked message") - } - if len(msgIDs) != 1 || msgIDs[0] != "$event1" { - t.Fatalf("finalizeTrackedToolFeedbackMessage() ids = %v, want [$event1]", msgIDs) - } -} - func TestStripUserMention(t *testing.T) { userID := id.UserID("@picoclaw:matrix.org") diff --git a/pkg/channels/pico/pico.go b/pkg/channels/pico/pico.go index 5d7bd0fa1..f998712c8 100644 --- a/pkg/channels/pico/pico.go +++ b/pkg/channels/pico/pico.go @@ -46,13 +46,6 @@ func outboundMessageIsThought(msg bus.OutboundMessage) bool { return strings.EqualFold(strings.TrimSpace(msg.Context.Raw["message_kind"]), MessageKindThought) } -func outboundMessageIsToolFeedback(msg bus.OutboundMessage) bool { - if len(msg.Context.Raw) == 0 { - return false - } - return strings.EqualFold(strings.TrimSpace(msg.Context.Raw["message_kind"]), "tool_feedback") -} - // writeJSON sends a JSON message to the connection with write locking. func (pc *picoConn) writeJSON(v any) error { if pc.closed.Load() { @@ -85,7 +78,6 @@ type PicoChannel struct { connsMu sync.RWMutex ctx context.Context cancel context.CancelFunc - progress *channels.ToolFeedbackAnimator } // NewPicoChannel creates a new Pico Protocol channel. @@ -114,7 +106,7 @@ func NewPicoChannel( return false } - ch := &PicoChannel{ + return &PicoChannel{ BaseChannel: base, bc: bc, config: cfg, @@ -125,9 +117,7 @@ func NewPicoChannel( }, connections: make(map[string]*picoConn), sessionConnections: make(map[string]map[string]*picoConn), - } - ch.progress = channels.NewToolFeedbackAnimator(ch.EditMessage) - return ch, nil + }, nil } // createAndAddConnection checks MaxConnections and registers a connection atomically. @@ -245,9 +235,6 @@ func (c *PicoChannel) Stop(ctx context.Context) error { if c.cancel != nil { c.cancel() } - if c.progress != nil { - c.progress.StopAll() - } logger.InfoC("pico", "Pico Protocol channel stopped") return nil @@ -274,43 +261,13 @@ func (c *PicoChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]stri return nil, channels.ErrNotRunning } isThought := outboundMessageIsThought(msg) - isToolFeedback := outboundMessageIsToolFeedback(msg) - if isToolFeedback { - if msgID, handled, err := c.progress.Update(ctx, msg.ChatID, msg.Content); handled { - if err != nil { - return nil, err - } - return []string{msgID}, nil - } - } - trackedMsgID, hasTrackedMsg := c.currentToolFeedbackMessage(msg.ChatID) - if !isToolFeedback { - if msgIDs, handled := c.FinalizeToolFeedbackMessage(ctx, msg); handled { - return msgIDs, nil - } - } - - content := msg.Content - if isToolFeedback { - content = channels.InitialAnimatedToolFeedbackContent(msg.Content) - } - msgID := uuid.New().String() outMsg := newMessage(TypeMessageCreate, map[string]any{ - PayloadKeyContent: content, + PayloadKeyContent: msg.Content, PayloadKeyThought: isThought, - "message_id": msgID, }) - if err := c.broadcastToSession(msg.ChatID, outMsg); err != nil { - return nil, err - } - if isToolFeedback { - c.RecordToolFeedbackMessage(msg.ChatID, msgID, msg.Content) - } else if hasTrackedMsg { - c.dismissTrackedToolFeedbackMessage(ctx, msg.ChatID, trackedMsgID) - } - return []string{msgID}, nil + return nil, c.broadcastToSession(msg.ChatID, outMsg) } // EditMessage implements channels.MessageEditor. @@ -322,73 +279,6 @@ func (c *PicoChannel) EditMessage(ctx context.Context, chatID string, messageID return c.broadcastToSession(chatID, outMsg) } -func (c *PicoChannel) currentToolFeedbackMessage(chatID string) (string, bool) { - if c.progress == nil { - return "", false - } - return c.progress.Current(chatID) -} - -func (c *PicoChannel) takeToolFeedbackMessage(chatID string) (string, string, bool) { - if c.progress == nil { - return "", "", false - } - return c.progress.Take(chatID) -} - -func (c *PicoChannel) RecordToolFeedbackMessage(chatID, messageID, content string) { - if c.progress == nil { - return - } - c.progress.Record(chatID, messageID, content) -} - -func (c *PicoChannel) ClearToolFeedbackMessage(chatID string) { - if c.progress == nil { - return - } - c.progress.Clear(chatID) -} - -func (c *PicoChannel) DismissToolFeedbackMessage(ctx context.Context, chatID string) { - msgID, ok := c.currentToolFeedbackMessage(chatID) - if !ok { - return - } - c.dismissTrackedToolFeedbackMessage(ctx, chatID, msgID) -} - -func (c *PicoChannel) dismissTrackedToolFeedbackMessage(ctx context.Context, chatID, messageID string) { - if strings.TrimSpace(chatID) == "" || strings.TrimSpace(messageID) == "" { - return - } - c.ClearToolFeedbackMessage(chatID) -} - -func (c *PicoChannel) finalizeTrackedToolFeedbackMessage( - ctx context.Context, - chatID string, - content string, - editFn func(context.Context, string, string, string) error, -) ([]string, bool) { - msgID, baseContent, ok := c.takeToolFeedbackMessage(chatID) - if !ok || editFn == nil { - return nil, false - } - if err := editFn(ctx, chatID, msgID, content); err != nil { - c.RecordToolFeedbackMessage(chatID, msgID, baseContent) - return nil, false - } - return []string{msgID}, true -} - -func (c *PicoChannel) FinalizeToolFeedbackMessage(ctx context.Context, msg bus.OutboundMessage) ([]string, bool) { - if outboundMessageIsToolFeedback(msg) { - return nil, false - } - return c.finalizeTrackedToolFeedbackMessage(ctx, msg.ChatID, msg.Content, c.EditMessage) -} - // StartTyping implements channels.TypingCapable. func (c *PicoChannel) StartTyping(ctx context.Context, chatID string) (func(), error) { startMsg := newMessage(TypeTypingStart, nil) diff --git a/pkg/channels/pico/pico_test.go b/pkg/channels/pico/pico_test.go index 77a146f34..59db705eb 100644 --- a/pkg/channels/pico/pico_test.go +++ b/pkg/channels/pico/pico_test.go @@ -27,34 +27,6 @@ func newTestPicoChannel(t *testing.T) *PicoChannel { return ch } -func TestFinalizeTrackedToolFeedbackMessage_StopsTrackingBeforeEdit(t *testing.T) { - ch := &PicoChannel{ - progress: channels.NewToolFeedbackAnimator(nil), - } - ch.RecordToolFeedbackMessage("pico:chat-1", "msg-1", "🔧 `read_file`") - - msgIDs, handled := ch.finalizeTrackedToolFeedbackMessage( - context.Background(), - "pico:chat-1", - "final reply", - func(_ context.Context, chatID, messageID, content string) error { - if _, ok := ch.currentToolFeedbackMessage(chatID); ok { - t.Fatal("expected tracked tool feedback to be stopped before edit") - } - if chatID != "pico:chat-1" || messageID != "msg-1" || content != "final reply" { - t.Fatalf("unexpected edit args: %s %s %s", chatID, messageID, content) - } - return nil - }, - ) - if !handled { - t.Fatal("expected finalizeTrackedToolFeedbackMessage to handle tracked message") - } - if len(msgIDs) != 1 || msgIDs[0] != "msg-1" { - t.Fatalf("finalizeTrackedToolFeedbackMessage() ids = %v, want [msg-1]", msgIDs) - } -} - func TestCreateAndAddConnection_RespectsMaxConnectionsConcurrently(t *testing.T) { ch := newTestPicoChannel(t) diff --git a/pkg/channels/telegram/command_registration.go b/pkg/channels/telegram/command_registration.go index c6b362601..d3152ec3d 100644 --- a/pkg/channels/telegram/command_registration.go +++ b/pkg/channels/telegram/command_registration.go @@ -66,10 +66,6 @@ func (c *TelegramChannel) startCommandRegistration(ctx context.Context, defs []c if register == nil { register = c.RegisterCommands } - delayFn := c.commandRegDelayFn - if delayFn == nil { - delayFn = commandRegistrationDelay - } regCtx, cancel := context.WithCancel(ctx) c.commandRegCancel = cancel @@ -95,7 +91,7 @@ func (c *TelegramChannel) startCommandRegistration(ctx context.Context, defs []c return } - delay := delayFn(attempt) + delay := commandRegistrationDelay(attempt) logger.WarnCF("telegram", "Telegram command registration failed; will retry", map[string]any{ "error": err.Error(), "retry_after": delay.String(), diff --git a/pkg/channels/telegram/command_registration_test.go b/pkg/channels/telegram/command_registration_test.go index c30c6f68d..26f891b2e 100644 --- a/pkg/channels/telegram/command_registration_test.go +++ b/pkg/channels/telegram/command_registration_test.go @@ -31,12 +31,14 @@ func TestStartCommandRegistration_DoesNotBlock(t *testing.T) { } func TestStartCommandRegistration_RetriesUntilSuccessThenStops(t *testing.T) { - ch := &TelegramChannel{ - commandRegDelayFn: func(int) time.Duration { return 5 * time.Millisecond }, - } + ch := &TelegramChannel{} ctx, cancel := context.WithCancel(context.Background()) defer cancel() + origBackoff := commandRegistrationBackoff + commandRegistrationBackoff = []time.Duration{5 * time.Millisecond} + defer func() { commandRegistrationBackoff = origBackoff }() + var attempts atomic.Int32 ch.registerFunc = func(context.Context, []commands.Definition) error { n := attempts.Add(1) @@ -67,10 +69,12 @@ func TestStartCommandRegistration_RetriesUntilSuccessThenStops(t *testing.T) { } func TestStartCommandRegistration_StopsAfterCancel(t *testing.T) { - ch := &TelegramChannel{ - commandRegDelayFn: func(int) time.Duration { return 5 * time.Millisecond }, - } + ch := &TelegramChannel{} ctx, cancel := context.WithCancel(context.Background()) + + origBackoff := commandRegistrationBackoff + commandRegistrationBackoff = []time.Duration{5 * time.Millisecond} + defer func() { commandRegistrationBackoff = origBackoff }() defer cancel() var attempts atomic.Int32 diff --git a/pkg/channels/telegram/telegram.go b/pkg/channels/telegram/telegram.go index 8bec7856d..2a9cfe4ae 100644 --- a/pkg/channels/telegram/telegram.go +++ b/pkg/channels/telegram/telegram.go @@ -45,18 +45,16 @@ var ( type TelegramChannel struct { *channels.BaseChannel - bot *telego.Bot - bh *th.BotHandler - bc *config.Channel - chatIDs map[string]int64 - ctx context.Context - cancel context.CancelFunc - tgCfg *config.TelegramSettings - progress *channels.ToolFeedbackAnimator + bot *telego.Bot + bh *th.BotHandler + bc *config.Channel + chatIDs map[string]int64 + ctx context.Context + cancel context.CancelFunc + tgCfg *config.TelegramSettings - registerFunc func(context.Context, []commands.Definition) error - commandRegDelayFn func(int) time.Duration - commandRegCancel context.CancelFunc + registerFunc func(context.Context, []commands.Definition) error + commandRegCancel context.CancelFunc } func NewTelegramChannel( @@ -106,15 +104,13 @@ func NewTelegramChannel( channels.WithReasoningChannelID(bc.ReasoningChannelID), ) - ch := &TelegramChannel{ + return &TelegramChannel{ BaseChannel: base, bot: bot, bc: bc, chatIDs: make(map[string]int64), tgCfg: telegramCfg, - } - ch.progress = channels.NewToolFeedbackAnimator(ch.EditMessage) - return ch, nil + }, nil } func (c *TelegramChannel) Start(ctx context.Context) error { @@ -172,9 +168,6 @@ func (c *TelegramChannel) Stop(ctx context.Context) error { if c.cancel != nil { c.cancel() } - if c.progress != nil { - c.progress.StopAll() - } if c.commandRegCancel != nil { c.commandRegCancel() } @@ -198,35 +191,12 @@ func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([] return nil, nil } - isToolFeedback := outboundMessageIsToolFeedback(msg) - toolFeedbackContent := msg.Content - if isToolFeedback { - toolFeedbackContent = fitToolFeedbackForTelegram(msg.Content, useMarkdownV2, 4096) - } - if isToolFeedback { - if msgID, handled, err := c.progress.Update(ctx, msg.ChatID, toolFeedbackContent); handled { - if err != nil { - return nil, err - } - return []string{msgID}, nil - } - } - trackedMsgID, hasTrackedMsg := c.currentToolFeedbackMessage(msg.ChatID) - if !isToolFeedback { - if msgIDs, handled := c.FinalizeToolFeedbackMessage(ctx, msg); handled { - return msgIDs, nil - } - } - // The Manager already splits messages to ≤4000 chars (WithMaxMessageLength), // so msg.Content is guaranteed to be within that limit. We still need to // check if HTML expansion pushes it beyond Telegram's 4096-char API limit. replyToID := msg.ReplyToMessageID var messageIDs []string queue := []string{msg.Content} - if isToolFeedback { - queue = []string{channels.InitialAnimatedToolFeedbackContent(toolFeedbackContent)} - } for len(queue) > 0 { chunk := queue[0] queue = queue[1:] @@ -234,13 +204,6 @@ func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([] content := parseContent(chunk, useMarkdownV2) if len([]rune(content)) > 4096 { - if isToolFeedback { - fittedChunk := fitToolFeedbackForTelegram(chunk, useMarkdownV2, 4096) - if fittedChunk != "" && fittedChunk != chunk { - queue = append([]string{fittedChunk}, queue...) - continue - } - } runeChunk := []rune(chunk) ratio := float64(len(runeChunk)) / float64(len([]rune(content))) smallerLen := int(float64(4096) * ratio * 0.95) // 5% safety margin @@ -307,12 +270,6 @@ func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([] replyToID = "" } - if isToolFeedback && len(messageIDs) > 0 { - c.RecordToolFeedbackMessage(msg.ChatID, messageIDs[0], toolFeedbackContent) - } else if !isToolFeedback && hasTrackedMsg { - c.dismissTrackedToolFeedbackMessage(ctx, msg.ChatID, trackedMsgID) - } - return messageIDs, nil } @@ -480,81 +437,6 @@ func (c *TelegramChannel) DeleteMessage(ctx context.Context, chatID string, mess }) } -func outboundMessageIsToolFeedback(msg bus.OutboundMessage) bool { - if len(msg.Context.Raw) == 0 { - return false - } - return strings.EqualFold(strings.TrimSpace(msg.Context.Raw["message_kind"]), "tool_feedback") -} - -func (c *TelegramChannel) currentToolFeedbackMessage(chatID string) (string, bool) { - if c.progress == nil { - return "", false - } - return c.progress.Current(chatID) -} - -func (c *TelegramChannel) takeToolFeedbackMessage(chatID string) (string, string, bool) { - if c.progress == nil { - return "", "", false - } - return c.progress.Take(chatID) -} - -func (c *TelegramChannel) RecordToolFeedbackMessage(chatID, messageID, content string) { - if c.progress == nil { - return - } - c.progress.Record(chatID, messageID, content) -} - -func (c *TelegramChannel) ClearToolFeedbackMessage(chatID string) { - if c.progress == nil { - return - } - c.progress.Clear(chatID) -} - -func (c *TelegramChannel) DismissToolFeedbackMessage(ctx context.Context, chatID string) { - msgID, ok := c.currentToolFeedbackMessage(chatID) - if !ok { - return - } - c.dismissTrackedToolFeedbackMessage(ctx, chatID, msgID) -} - -func (c *TelegramChannel) dismissTrackedToolFeedbackMessage(ctx context.Context, chatID, messageID string) { - if strings.TrimSpace(chatID) == "" || strings.TrimSpace(messageID) == "" { - return - } - c.ClearToolFeedbackMessage(chatID) - _ = c.DeleteMessage(ctx, chatID, messageID) -} - -func (c *TelegramChannel) finalizeTrackedToolFeedbackMessage( - ctx context.Context, - chatID string, - content string, - editFn func(context.Context, string, string, string) error, -) ([]string, bool) { - msgID, baseContent, ok := c.takeToolFeedbackMessage(chatID) - if !ok || editFn == nil { - return nil, false - } - if err := editFn(ctx, chatID, msgID, content); err != nil { - c.RecordToolFeedbackMessage(chatID, msgID, baseContent) - return nil, false - } - return []string{msgID}, true -} - -func (c *TelegramChannel) FinalizeToolFeedbackMessage(ctx context.Context, msg bus.OutboundMessage) ([]string, bool) { - if outboundMessageIsToolFeedback(msg) { - return nil, false - } - return c.finalizeTrackedToolFeedbackMessage(ctx, msg.ChatID, msg.Content, c.EditMessage) -} - // SendPlaceholder implements channels.PlaceholderCapable. // It sends a placeholder message (e.g. "Thinking... 💭") that will later be // edited to the actual response via EditMessage (channels.MessageEditor). @@ -586,7 +468,6 @@ func (c *TelegramChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMe if !c.IsRunning() { return nil, channels.ErrNotRunning } - trackedMsgID, hasTrackedMsg := c.currentToolFeedbackMessage(msg.ChatID) chatID, threadID, err := resolveTelegramOutboundTarget(msg.ChatID, &msg.Context) if err != nil { @@ -695,10 +576,6 @@ func (c *TelegramChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMe } } - if hasTrackedMsg { - c.dismissTrackedToolFeedbackMessage(ctx, msg.ChatID, trackedMsgID) - } - return messageIDs, nil } @@ -1070,41 +947,6 @@ func parseContent(text string, useMarkdownV2 bool) string { return markdownToTelegramHTML(text) } -func fitToolFeedbackForTelegram(content string, useMarkdownV2 bool, maxParsedLen int) string { - content = strings.TrimSpace(content) - if content == "" || maxParsedLen <= 0 { - return "" - } - animationSafeLen := maxParsedLen - channels.MaxToolFeedbackAnimationFrameLength() - if animationSafeLen <= 0 { - animationSafeLen = maxParsedLen - } - if len([]rune(parseContent(content, useMarkdownV2))) <= animationSafeLen { - return content - } - - low := 1 - high := len([]rune(content)) - best := utils.Truncate(content, 1) - - for low <= high { - mid := (low + high) / 2 - candidate := utils.FitToolFeedbackMessage(content, mid) - if candidate == "" { - high = mid - 1 - continue - } - if len([]rune(parseContent(candidate, useMarkdownV2))) <= animationSafeLen { - best = candidate - low = mid + 1 - continue - } - high = mid - 1 - } - - return best -} - // parseTelegramChatID splits "chatID/threadID" into its components. // Returns threadID=0 when no "/" is present (non-forum messages). func parseTelegramChatID(chatID string) (int64, int, error) { diff --git a/pkg/channels/telegram/telegram_group_command_filter_test.go b/pkg/channels/telegram/telegram_group_command_filter_test.go index 20b2004a9..614b2ca7f 100644 --- a/pkg/channels/telegram/telegram_group_command_filter_test.go +++ b/pkg/channels/telegram/telegram_group_command_filter_test.go @@ -108,7 +108,7 @@ func TestHandleMessage_GroupMentionOnly_BotCommandEntity(t *testing.T) { t.Fatalf("handleMessage error: %v", err) } - ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) + ctx, cancel := context.WithTimeout(context.Background(), 200*time.Microsecond) defer cancel() select { case <-ctx.Done(): diff --git a/pkg/channels/telegram/telegram_test.go b/pkg/channels/telegram/telegram_test.go index f3974723d..3d147b337 100644 --- a/pkg/channels/telegram/telegram_test.go +++ b/pkg/channels/telegram/telegram_test.go @@ -98,12 +98,8 @@ func (s *multipartRecordingConstructor) MultipartRequest( // successResponse returns a ta.Response that telego will treat as a successful SendMessage. func successResponse(t *testing.T) *ta.Response { - return successResponseWithMessageID(t, 1) -} - -func successResponseWithMessageID(t *testing.T, messageID int) *ta.Response { t.Helper() - msg := &telego.Message{MessageID: messageID} + msg := &telego.Message{MessageID: 1} b, err := json.Marshal(msg) require.NoError(t, err) return &ta.Response{Ok: true, Result: b} @@ -146,7 +142,6 @@ func newTestChannelWithConstructor( chatIDs: make(map[string]int64), bc: &config.Channel{Type: config.ChannelTelegram, Enabled: true}, tgCfg: &config.TelegramSettings{}, - progress: channels.NewToolFeedbackAnimator(nil), } } @@ -271,101 +266,6 @@ func TestSend_ShortMessage_SingleCall(t *testing.T) { assert.Len(t, caller.calls, 1, "short message should result in exactly one SendMessage call") } -func TestSend_NonToolFeedbackDeletesTrackedProgressMessage(t *testing.T) { - caller := &stubCaller{ - callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) { - switch { - case strings.Contains(url, "editMessageText"): - return successResponseWithMessageID(t, 1), nil - default: - t.Fatalf("unexpected API call: %s", url) - return nil, nil - } - }, - } - ch := newTestChannel(t, caller) - ch.RecordToolFeedbackMessage("12345", "1", "🔧 `read_file`") - - ids, err := ch.Send(context.Background(), bus.OutboundMessage{ - ChatID: "12345", - Content: "final reply", - }) - - assert.NoError(t, err) - assert.Equal(t, []string{"1"}, ids) - require.Len(t, caller.calls, 1) - assert.Contains(t, caller.calls[0].URL, "editMessageText") - _, ok := ch.currentToolFeedbackMessage("12345") - assert.False(t, ok, "tracked tool feedback should be cleared after final reply") -} - -func TestFinalizeTrackedToolFeedbackMessage_StopsTrackingBeforeEdit(t *testing.T) { - ch := newTestChannel(t, &stubCaller{ - callFn: func(context.Context, string, *ta.RequestData) (*ta.Response, error) { - t.Fatal("unexpected API call") - return nil, nil - }, - }) - ch.RecordToolFeedbackMessage("12345", "1", "🔧 `read_file`") - - msgIDs, handled := ch.finalizeTrackedToolFeedbackMessage( - context.Background(), - "12345", - "final reply", - func(_ context.Context, chatID, messageID, content string) error { - _, ok := ch.currentToolFeedbackMessage(chatID) - assert.False(t, ok, "tracked tool feedback should be stopped before edit") - assert.Equal(t, "12345", chatID) - assert.Equal(t, "1", messageID) - assert.Equal(t, "final reply", content) - return nil - }, - ) - - assert.True(t, handled) - assert.Equal(t, []string{"1"}, msgIDs) -} - -func TestSend_ToolFeedbackStaysSingleMessageAfterHTMLExpansion(t *testing.T) { - caller := &stubCaller{ - callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) { - return successResponse(t), nil - }, - } - ch := newTestChannel(t, caller) - - _, err := ch.Send(context.Background(), bus.OutboundMessage{ - ChatID: "12345", - Content: "🔧 `read_file`\n" + strings.Repeat("<", 2000), - Context: bus.InboundContext{ - Channel: "telegram", - ChatID: "12345", - Raw: map[string]string{ - "message_kind": "tool_feedback", - }, - }, - }) - - assert.NoError(t, err) - assert.Len(t, caller.calls, 1, "tool feedback should stay a single Telegram message after HTML escaping") -} - -func TestFitToolFeedbackForTelegram_ReservesAnimationFrame(t *testing.T) { - content := "🔧 `read_file`\n" + strings.Repeat("a", 4096) - - fitted := fitToolFeedbackForTelegram(content, false, 4096) - animated := strings.Replace( - fitted, - "`\n", - strings.Repeat(".", channels.MaxToolFeedbackAnimationFrameLength())+"`\n", - 1, - ) - - if got := len([]rune(parseContent(animated, false))); got > 4096 { - t.Fatalf("animated parsed length = %d, want <= 4096", got) - } -} - func TestSend_LongMessage_SingleCall(t *testing.T) { // With WithMaxMessageLength(4000), the Manager pre-splits messages before // they reach Send(). A message at exactly 4000 chars should go through diff --git a/pkg/channels/tool_feedback_animator.go b/pkg/channels/tool_feedback_animator.go deleted file mode 100644 index b424612bf..000000000 --- a/pkg/channels/tool_feedback_animator.go +++ /dev/null @@ -1,240 +0,0 @@ -package channels - -import ( - "context" - "strings" - "sync" - "time" -) - -const toolFeedbackAnimationInterval = 3 * time.Second - -const initialToolFeedbackAnimationFrame = "" - -var toolFeedbackAnimationFrames = []string{"..", "."} - -// MaxToolFeedbackAnimationFrameLength returns the largest frame suffix length -// so callers can reserve room before sending messages to length-limited APIs. -func MaxToolFeedbackAnimationFrameLength() int { - maxLen := len([]rune(initialToolFeedbackAnimationFrame)) - for _, frame := range toolFeedbackAnimationFrames { - if frameLen := len([]rune(frame)); frameLen > maxLen { - maxLen = frameLen - } - } - return maxLen -} - -type toolFeedbackAnimationState struct { - messageID string - baseContent string - stop chan struct{} - done chan struct{} -} - -type ToolFeedbackAnimator struct { - mu sync.Mutex - editFn func(ctx context.Context, chatID, messageID, content string) error - entries map[string]*toolFeedbackAnimationState -} - -func NewToolFeedbackAnimator( - editFn func(ctx context.Context, chatID, messageID, content string) error, -) *ToolFeedbackAnimator { - return &ToolFeedbackAnimator{ - editFn: editFn, - entries: make(map[string]*toolFeedbackAnimationState), - } -} - -func (a *ToolFeedbackAnimator) Current(chatID string) (string, bool) { - if a == nil || strings.TrimSpace(chatID) == "" { - return "", false - } - a.mu.Lock() - defer a.mu.Unlock() - entry, ok := a.entries[chatID] - if !ok || strings.TrimSpace(entry.messageID) == "" { - return "", false - } - return entry.messageID, true -} - -func (a *ToolFeedbackAnimator) Record(chatID, messageID, content string) { - if a == nil { - return - } - chatID = strings.TrimSpace(chatID) - messageID = strings.TrimSpace(messageID) - content = strings.TrimSpace(content) - if chatID == "" || messageID == "" || content == "" { - return - } - - entry := &toolFeedbackAnimationState{ - messageID: messageID, - baseContent: content, - stop: make(chan struct{}), - done: make(chan struct{}), - } - - var previous *toolFeedbackAnimationState - a.mu.Lock() - if old, ok := a.entries[chatID]; ok { - previous = old - } - a.entries[chatID] = entry - a.mu.Unlock() - - stopToolFeedbackAnimation(previous) - go a.run(chatID, entry) -} - -func (a *ToolFeedbackAnimator) Clear(chatID string) { - if a == nil || strings.TrimSpace(chatID) == "" { - return - } - entry := a.detach(chatID) - stopToolFeedbackAnimation(entry) -} - -func (a *ToolFeedbackAnimator) Take(chatID string) (string, string, bool) { - if a == nil || strings.TrimSpace(chatID) == "" { - return "", "", false - } - entry := a.detach(chatID) - if entry == nil || strings.TrimSpace(entry.messageID) == "" { - return "", "", false - } - stopToolFeedbackAnimation(entry) - return entry.messageID, entry.baseContent, true -} - -// Update edits an existing tracked feedback message. If the edit fails, the -// previous feedback state is restored so callers can retry without orphaning -// the old progress message. -func (a *ToolFeedbackAnimator) Update(ctx context.Context, chatID, content string) (string, bool, error) { - if a == nil || a.editFn == nil { - return "", false, nil - } - msgID, baseContent, ok := a.Take(chatID) - if !ok { - return "", false, nil - } - - animatedContent := InitialAnimatedToolFeedbackContent(content) - if err := a.editFn(ctx, strings.TrimSpace(chatID), msgID, animatedContent); err != nil { - a.Record(chatID, msgID, baseContent) - return "", true, err - } - - a.Record(chatID, msgID, content) - return msgID, true, nil -} - -func (a *ToolFeedbackAnimator) StopAll() { - if a == nil { - return - } - a.mu.Lock() - entries := make([]*toolFeedbackAnimationState, 0, len(a.entries)) - for chatID, entry := range a.entries { - entries = append(entries, entry) - delete(a.entries, chatID) - } - a.mu.Unlock() - - for _, entry := range entries { - stopToolFeedbackAnimation(entry) - } -} - -func (a *ToolFeedbackAnimator) detach(chatID string) *toolFeedbackAnimationState { - if a == nil || strings.TrimSpace(chatID) == "" { - return nil - } - a.mu.Lock() - defer a.mu.Unlock() - entry := a.entries[chatID] - delete(a.entries, chatID) - return entry -} - -func (a *ToolFeedbackAnimator) run(chatID string, entry *toolFeedbackAnimationState) { - defer close(entry.done) - - ticker := time.NewTicker(toolFeedbackAnimationInterval) - defer ticker.Stop() - - frameIdx := 1 - - for { - select { - case <-entry.stop: - return - case <-ticker.C: - if a.editFn == nil { - continue - } - frame := toolFeedbackAnimationFrames[frameIdx%len(toolFeedbackAnimationFrames)] - content := formatAnimatedToolFeedbackContent(entry.baseContent, frame) - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - _ = a.editFn(ctx, chatID, entry.messageID, content) - cancel() - frameIdx++ - } - } -} - -func InitialAnimatedToolFeedbackContent(baseContent string) string { - return formatAnimatedToolFeedbackContent(baseContent, initialToolFeedbackAnimationFrame) -} - -func formatAnimatedToolFeedbackContent(baseContent, frame string) string { - baseContent = strings.TrimSpace(baseContent) - frame = strings.TrimSpace(frame) - if baseContent == "" { - return "" - } - if frame == "" { - return baseContent - } - lineBreak := strings.IndexByte(baseContent, '\n') - if lineBreak < 0 { - return appendToolFeedbackFrame(baseContent, frame) - } - return appendToolFeedbackFrame(baseContent[:lineBreak], frame) + baseContent[lineBreak:] -} - -func appendToolFeedbackFrame(firstLine, frame string) string { - firstLine = strings.TrimSpace(firstLine) - frame = strings.TrimSpace(frame) - if firstLine == "" { - return "" - } - if frame == "" { - return firstLine - } - - openTick := strings.IndexByte(firstLine, '`') - if openTick >= 0 { - if closeOffset := strings.IndexByte(firstLine[openTick+1:], '`'); closeOffset >= 0 { - closeTick := openTick + 1 + closeOffset - return firstLine[:closeTick] + frame + firstLine[closeTick:] - } - } - - return firstLine + frame -} - -func stopToolFeedbackAnimation(entry *toolFeedbackAnimationState) { - if entry == nil { - return - } - select { - case <-entry.stop: - default: - close(entry.stop) - } - <-entry.done -} diff --git a/pkg/channels/tool_feedback_animator_test.go b/pkg/channels/tool_feedback_animator_test.go deleted file mode 100644 index a23284548..000000000 --- a/pkg/channels/tool_feedback_animator_test.go +++ /dev/null @@ -1,121 +0,0 @@ -package channels - -import ( - "context" - "errors" - "testing" -) - -func TestFormatAnimatedToolFeedbackContent(t *testing.T) { - got := formatAnimatedToolFeedbackContent("🔧 `read_file`\nReading config file", "running..") - want := "🔧 `read_filerunning..`\nReading config file" - if got != want { - t.Fatalf("formatAnimatedToolFeedbackContent() = %q, want %q", got, want) - } -} - -func TestInitialAnimatedToolFeedbackContent(t *testing.T) { - got := InitialAnimatedToolFeedbackContent("🔧 `exec`\nRunning command") - want := "🔧 `exec`\nRunning command" - if got != want { - t.Fatalf("InitialAnimatedToolFeedbackContent() = %q, want %q", got, want) - } -} - -func TestFormatAnimatedToolFeedbackContent_WithoutCodeSpan(t *testing.T) { - got := formatAnimatedToolFeedbackContent("hello", "running..") - want := "hellorunning.." - if got != want { - t.Fatalf("formatAnimatedToolFeedbackContent() without code span = %q, want %q", got, want) - } -} - -func TestToolFeedbackAnimator_RecordCurrentAndClear(t *testing.T) { - animator := NewToolFeedbackAnimator(nil) - animator.Record("chat-1", "msg-1", "🔧 `read_file`") - - msgID, ok := animator.Current("chat-1") - if !ok || msgID != "msg-1" { - t.Fatalf("Current() = (%q, %v), want (msg-1, true)", msgID, ok) - } - - animator.Clear("chat-1") - - msgID, ok = animator.Current("chat-1") - if ok || msgID != "" { - t.Fatalf("Current() after Clear = (%q, %v), want (\"\", false)", msgID, ok) - } -} - -func TestToolFeedbackAnimator_TakeStopsTrackingAndReturnsState(t *testing.T) { - animator := NewToolFeedbackAnimator(nil) - animator.Record("chat-1", "msg-1", "🔧 `read_file`\nChecking config") - - msgID, baseContent, ok := animator.Take("chat-1") - if !ok { - t.Fatal("Take() = not found, want tracked message") - } - if msgID != "msg-1" { - t.Fatalf("Take() msgID = %q, want msg-1", msgID) - } - if baseContent != "🔧 `read_file`\nChecking config" { - t.Fatalf("Take() baseContent = %q", baseContent) - } - if _, ok := animator.Current("chat-1"); ok { - t.Fatal("expected tracked message to be removed after Take()") - } -} - -func TestToolFeedbackAnimator_UpdateStopsTrackingBeforeEdit(t *testing.T) { - var animator *ToolFeedbackAnimator - animator = NewToolFeedbackAnimator(func(_ context.Context, chatID, messageID, content string) error { - if _, ok := animator.Current(chatID); ok { - t.Fatal("expected tracked tool feedback to be stopped before edit") - } - if messageID != "msg-1" { - t.Fatalf("messageID = %q, want msg-1", messageID) - } - if content != "🔧 `write_file`\nUpdating config" { - t.Fatalf("content = %q, want updated animated content", content) - } - return nil - }) - defer animator.StopAll() - - animator.Record("chat-1", "msg-1", "🔧 `read_file`\nChecking config") - - msgID, handled, err := animator.Update(context.Background(), "chat-1", "🔧 `write_file`\nUpdating config") - if err != nil { - t.Fatalf("Update() error = %v", err) - } - if !handled { - t.Fatal("Update() handled = false, want true") - } - if msgID != "msg-1" { - t.Fatalf("Update() msgID = %q, want msg-1", msgID) - } -} - -func TestToolFeedbackAnimator_UpdateFailureRestoresTracking(t *testing.T) { - editErr := errors.New("edit failed") - animator := NewToolFeedbackAnimator(func(context.Context, string, string, string) error { - return editErr - }) - defer animator.StopAll() - - animator.Record("chat-1", "msg-1", "🔧 `read_file`\nChecking config") - - msgID, handled, err := animator.Update(context.Background(), "chat-1", "🔧 `write_file`\nUpdating config") - if !handled { - t.Fatal("Update() handled = false, want true") - } - if !errors.Is(err, editErr) { - t.Fatalf("Update() error = %v, want editErr", err) - } - if msgID != "" { - t.Fatalf("Update() msgID = %q, want empty on failed edit", msgID) - } - if currentID, ok := animator.Current("chat-1"); !ok || currentID != "msg-1" { - t.Fatalf("Current() after failed Update = (%q, %v), want (msg-1, true)", currentID, ok) - } -} diff --git a/pkg/config/config.go b/pkg/config/config.go index 547060bd6..5bc96fb12 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -286,7 +286,7 @@ func (d *AgentDefaults) GetMaxMediaSize() int { return DefaultMaxMediaSize } -// GetToolFeedbackMaxArgsLength returns the max visible text length for tool feedback messages. +// GetToolFeedbackMaxArgsLength returns the max args preview length for tool feedback messages. func (d *AgentDefaults) GetToolFeedbackMaxArgsLength() int { if d.ToolFeedback.MaxArgsLength > 0 { return d.ToolFeedback.MaxArgsLength diff --git a/pkg/providers/cli/toolcall_utils.go b/pkg/providers/cli/toolcall_utils.go index 1f58c9a26..b480082eb 100644 --- a/pkg/providers/cli/toolcall_utils.go +++ b/pkg/providers/cli/toolcall_utils.go @@ -55,12 +55,6 @@ func buildCLIToolsPrompt(tools []ToolDefinition) string { func NormalizeToolCall(tc ToolCall) ToolCall { normalized := tc - if normalized.ThoughtSignature == "" && - normalized.ExtraContent != nil && - normalized.ExtraContent.Google != nil { - normalized.ThoughtSignature = normalized.ExtraContent.Google.ThoughtSignature - } - // Ensure Name is populated from Function if not set if normalized.Name == "" && normalized.Function != nil { normalized.Name = normalized.Function.Name @@ -83,9 +77,8 @@ func NormalizeToolCall(tc ToolCall) ToolCall { argsJSON, _ := json.Marshal(normalized.Arguments) if normalized.Function == nil { normalized.Function = &FunctionCall{ - Name: normalized.Name, - Arguments: string(argsJSON), - ThoughtSignature: normalized.ThoughtSignature, + Name: normalized.Name, + Arguments: string(argsJSON), } } else { if normalized.Function.Name == "" { @@ -97,12 +90,6 @@ func NormalizeToolCall(tc ToolCall) ToolCall { if normalized.Function.Arguments == "" { normalized.Function.Arguments = string(argsJSON) } - if normalized.Function.ThoughtSignature == "" { - normalized.Function.ThoughtSignature = normalized.ThoughtSignature - } - if normalized.ThoughtSignature == "" { - normalized.ThoughtSignature = normalized.Function.ThoughtSignature - } } return normalized diff --git a/pkg/providers/common/common.go b/pkg/providers/common/common.go index c167b1ffd..90142fb8b 100644 --- a/pkg/providers/common/common.go +++ b/pkg/providers/common/common.go @@ -70,23 +70,11 @@ func NewHTTPClient(proxy string) *http.Client { // It mirrors protocoltypes.Message but omits SystemParts, which is an // internal field that would be unknown to third-party endpoints. type openaiMessage struct { - Role string `json:"role"` - Content string `json:"content"` - ReasoningContent string `json:"reasoning_content,omitempty"` - ToolCalls []openaiToolCall `json:"tool_calls,omitempty"` - ToolCallID string `json:"tool_call_id,omitempty"` -} - -type openaiToolCall struct { - ID string `json:"id"` - Type string `json:"type,omitempty"` - Function *openaiFunctionCall `json:"function,omitempty"` -} - -type openaiFunctionCall struct { - Name string `json:"name"` - Arguments string `json:"arguments"` - ThoughtSignature string `json:"thought_signature,omitempty"` + Role string `json:"role"` + Content string `json:"content"` + ReasoningContent string `json:"reasoning_content,omitempty"` + ToolCalls []ToolCall `json:"tool_calls,omitempty"` + ToolCallID string `json:"tool_call_id,omitempty"` } // SerializeMessages converts internal Message structs to the OpenAI wire format. @@ -96,13 +84,12 @@ type openaiFunctionCall struct { func SerializeMessages(messages []Message) []any { out := make([]any, 0, len(messages)) for _, m := range messages { - toolCalls := serializeToolCalls(m.ToolCalls) if len(m.Media) == 0 { out = append(out, openaiMessage{ Role: m.Role, Content: m.Content, ReasoningContent: m.ReasoningContent, - ToolCalls: toolCalls, + ToolCalls: m.ToolCalls, ToolCallID: m.ToolCallID, }) continue @@ -145,8 +132,8 @@ func SerializeMessages(messages []Message) []any { if m.ToolCallID != "" { msg["tool_call_id"] = m.ToolCallID } - if len(toolCalls) > 0 { - msg["tool_calls"] = toolCalls + if len(m.ToolCalls) > 0 { + msg["tool_calls"] = m.ToolCalls } if m.ReasoningContent != "" { msg["reasoning_content"] = m.ReasoningContent @@ -156,55 +143,6 @@ func SerializeMessages(messages []Message) []any { return out } -func serializeToolCalls(toolCalls []ToolCall) []openaiToolCall { - if len(toolCalls) == 0 { - return nil - } - - out := make([]openaiToolCall, 0, len(toolCalls)) - for _, tc := range toolCalls { - wireCall := openaiToolCall{ - ID: tc.ID, - Type: tc.Type, - } - - if tc.Function != nil { - thoughtSignature := tc.Function.ThoughtSignature - if thoughtSignature == "" { - thoughtSignature = tc.ThoughtSignature - } - if thoughtSignature == "" && tc.ExtraContent != nil && tc.ExtraContent.Google != nil { - thoughtSignature = tc.ExtraContent.Google.ThoughtSignature - } - wireCall.Function = &openaiFunctionCall{ - Name: tc.Function.Name, - Arguments: tc.Function.Arguments, - ThoughtSignature: thoughtSignature, - } - } else if tc.Name != "" || len(tc.Arguments) > 0 || tc.ThoughtSignature != "" { - thoughtSignature := tc.ThoughtSignature - if thoughtSignature == "" && tc.ExtraContent != nil && tc.ExtraContent.Google != nil { - thoughtSignature = tc.ExtraContent.Google.ThoughtSignature - } - argsJSON := "{}" - if len(tc.Arguments) > 0 { - if encoded, err := json.Marshal(tc.Arguments); err == nil { - argsJSON = string(encoded) - } - } - wireCall.Function = &openaiFunctionCall{ - Name: tc.Name, - Arguments: argsJSON, - ThoughtSignature: thoughtSignature, - } - } - - out = append(out, wireCall) - } - - return out -} - func parseDataAudioURL(mediaURL string) (format, data string, ok bool) { if !strings.HasPrefix(mediaURL, "data:audio/") { return "", "", false @@ -247,7 +185,6 @@ func ParseResponse(body io.Reader) (*LLMResponse, error) { Google *struct { ThoughtSignature string `json:"thought_signature"` } `json:"google"` - ToolFeedbackExplanation string `json:"tool_feedback_explanation"` } `json:"extra_content"` } `json:"tool_calls"` } `json:"message"` @@ -291,17 +228,11 @@ func ParseResponse(body io.Reader) (*LLMResponse, error) { ThoughtSignature: thoughtSignature, } - if tc.ExtraContent != nil { - extraContent := &ExtraContent{ - ToolFeedbackExplanation: tc.ExtraContent.ToolFeedbackExplanation, - } - if thoughtSignature != "" { - extraContent.Google = &GoogleExtra{ + if thoughtSignature != "" { + toolCall.ExtraContent = &ExtraContent{ + Google: &GoogleExtra{ ThoughtSignature: thoughtSignature, - } - } - if extraContent.Google != nil || strings.TrimSpace(extraContent.ToolFeedbackExplanation) != "" { - toolCall.ExtraContent = extraContent + }, } } diff --git a/pkg/providers/common/common_test.go b/pkg/providers/common/common_test.go index affb91e6f..c107bb665 100644 --- a/pkg/providers/common/common_test.go +++ b/pkg/providers/common/common_test.go @@ -162,104 +162,6 @@ func TestSerializeMessages_StripsSystemParts(t *testing.T) { } } -func TestSerializeMessages_StripsInternalToolCallExtraContent(t *testing.T) { - messages := []Message{ - { - Role: "assistant", - ToolCalls: []ToolCall{{ - ID: "call_1", - Type: "function", - Function: &FunctionCall{ - Name: "read_file", - Arguments: `{"path":"README.md"}`, - ThoughtSignature: "sig-1", - }, - ExtraContent: &ExtraContent{ - Google: &GoogleExtra{ - ThoughtSignature: "sig-ignored-here", - }, - ToolFeedbackExplanation: "Read README.md first.", - }, - }}, - }, - } - - result := SerializeMessages(messages) - - data, err := json.Marshal(result) - if err != nil { - t.Fatalf("json.Marshal() error = %v", err) - } - payload := string(data) - if strings.Contains(payload, "extra_content") { - t.Fatalf("serialized payload should not include internal extra_content: %s", payload) - } - if !strings.Contains(payload, "thought_signature") { - t.Fatalf("serialized payload should preserve function thought_signature: %s", payload) - } -} - -func TestSerializeMessages_PreservesTopLevelThoughtSignature(t *testing.T) { - messages := []Message{ - { - Role: "assistant", - ToolCalls: []ToolCall{{ - ID: "call_1", - Type: "function", - ThoughtSignature: "sig-1", - Function: &FunctionCall{ - Name: "read_file", - Arguments: `{"path":"README.md"}`, - }, - }}, - }, - } - - result := SerializeMessages(messages) - - data, err := json.Marshal(result) - if err != nil { - t.Fatalf("json.Marshal() error = %v", err) - } - payload := string(data) - if !strings.Contains(payload, `"thought_signature":"sig-1"`) { - t.Fatalf("serialized payload should preserve top-level thought signature: %s", payload) - } -} - -func TestSerializeMessages_PreservesGoogleExtraThoughtSignature(t *testing.T) { - messages := []Message{ - { - Role: "assistant", - ToolCalls: []ToolCall{{ - ID: "call_1", - Type: "function", - Function: &FunctionCall{ - Name: "read_file", - Arguments: `{"path":"README.md"}`, - }, - ExtraContent: &ExtraContent{ - Google: &GoogleExtra{ThoughtSignature: "sig-1"}, - }, - }}, - }, - } - - result := SerializeMessages(messages) - - data, err := json.Marshal(result) - if err != nil { - t.Fatalf("json.Marshal() error = %v", err) - } - payload := string(data) - if strings.Contains(payload, "extra_content") { - t.Fatalf("serialized payload should not include extra_content: %s", payload) - } - if !strings.Contains(payload, `"thought_signature":"sig-1"`) { - t.Fatalf("serialized payload should preserve google thought signature: %s", payload) - } -} - // --- ParseResponse tests --- func TestParseResponse_BasicContent(t *testing.T) { @@ -332,27 +234,6 @@ func TestParseResponse_WithReasoningContent(t *testing.T) { } } -func TestParseResponse_WithToolFeedbackExplanationExtraContent(t *testing.T) { - body := `{"choices":[{"message":{"content":"","tool_calls":[{"id":"call_1","type":"function","function":{"name":"test_tool","arguments":"{}"},"extra_content":{"tool_feedback_explanation":"Check the current config before editing."}}]},"finish_reason":"tool_calls"}]}` - out, err := ParseResponse(strings.NewReader(body)) - if err != nil { - t.Fatalf("ParseResponse() error = %v", err) - } - if len(out.ToolCalls) != 1 { - t.Fatalf("len(ToolCalls) = %d, want 1", len(out.ToolCalls)) - } - if out.ToolCalls[0].ExtraContent == nil { - t.Fatal("ExtraContent is nil") - } - if out.ToolCalls[0].ExtraContent.ToolFeedbackExplanation != "Check the current config before editing." { - t.Fatalf( - "ToolFeedbackExplanation = %q, want %q", - out.ToolCalls[0].ExtraContent.ToolFeedbackExplanation, - "Check the current config before editing.", - ) - } -} - func TestParseResponse_InvalidJSON(t *testing.T) { _, err := ParseResponse(strings.NewReader("not json")) if err == nil { diff --git a/pkg/providers/protocoltypes/types.go b/pkg/providers/protocoltypes/types.go index 1189577f1..194c1aa6f 100644 --- a/pkg/providers/protocoltypes/types.go +++ b/pkg/providers/protocoltypes/types.go @@ -11,8 +11,7 @@ type ToolCall struct { } type ExtraContent struct { - Google *GoogleExtra `json:"google,omitempty"` - ToolFeedbackExplanation string `json:"tool_feedback_explanation,omitempty"` + Google *GoogleExtra `json:"google,omitempty"` } type GoogleExtra struct { diff --git a/pkg/providers/toolcall_utils_test.go b/pkg/providers/toolcall_utils_test.go deleted file mode 100644 index a4bb03c2e..000000000 --- a/pkg/providers/toolcall_utils_test.go +++ /dev/null @@ -1,24 +0,0 @@ -package providers - -import "testing" - -func TestNormalizeToolCall_PreservesExtraContentGoogleThoughtSignature(t *testing.T) { - tc := NormalizeToolCall(ToolCall{ - ID: "call_1", - Name: "search", - Arguments: map[string]any{"q": "pico"}, - ExtraContent: &ExtraContent{ - Google: &GoogleExtra{ThoughtSignature: "sig-1"}, - }, - }) - - if tc.ThoughtSignature != "sig-1" { - t.Fatalf("ThoughtSignature = %q, want sig-1", tc.ThoughtSignature) - } - if tc.Function == nil { - t.Fatal("Function is nil") - } - if tc.Function.ThoughtSignature != "sig-1" { - t.Fatalf("Function.ThoughtSignature = %q, want sig-1", tc.Function.ThoughtSignature) - } -} diff --git a/pkg/utils/tool_feedback.go b/pkg/utils/tool_feedback.go index 1a8b6c747..a6c8895b8 100644 --- a/pkg/utils/tool_feedback.go +++ b/pkg/utils/tool_feedback.go @@ -1,57 +1,9 @@ package utils -import ( - "fmt" - "strings" -) +import "fmt" -const ToolFeedbackContinuationHint = "Continuing the current task." - -// FormatToolFeedbackMessage renders the model-provided explanation for why a -// tool is being executed. When the model does not provide one, it keeps only -// the tool line and does not expose raw arguments or fallback text. -func FormatToolFeedbackMessage(toolName, explanation string) string { - toolName = strings.TrimSpace(toolName) - explanation = strings.TrimSpace(explanation) - - if toolName == "" { - return explanation - } - if explanation == "" { - return fmt.Sprintf("\U0001f527 `%s`", toolName) - } - - return fmt.Sprintf("\U0001f527 `%s`\n%s", toolName, explanation) -} - -// FitToolFeedbackMessage keeps tool feedback within a single outbound message. -// It preserves the first line when possible and truncates the explanation body -// instead of letting the message be split into multiple chunks. -func FitToolFeedbackMessage(content string, maxLen int) string { - content = strings.TrimSpace(content) - if content == "" || maxLen <= 0 { - return "" - } - if len([]rune(content)) <= maxLen { - return content - } - - firstLine, rest, hasRest := strings.Cut(content, "\n") - firstLine = strings.TrimSpace(firstLine) - rest = strings.TrimSpace(rest) - - if !hasRest || rest == "" { - return Truncate(firstLine, maxLen) - } - - if len([]rune(firstLine)) >= maxLen { - return Truncate(firstLine, maxLen) - } - - remaining := maxLen - len([]rune(firstLine)) - 1 - if remaining <= 0 { - return Truncate(firstLine, maxLen) - } - - return firstLine + "\n" + Truncate(rest, remaining) +// FormatToolFeedbackMessage renders the tool name and arguments preview in the +// same markdown shape used by live tool feedback and session reconstruction. +func FormatToolFeedbackMessage(toolName, argsPreview string) string { + return fmt.Sprintf("\U0001f527 `%s`\n```\n%s\n```", toolName, argsPreview) } diff --git a/pkg/utils/tool_feedback_test.go b/pkg/utils/tool_feedback_test.go index 316ce2408..d7a55ce6b 100644 --- a/pkg/utils/tool_feedback_test.go +++ b/pkg/utils/tool_feedback_test.go @@ -3,47 +3,9 @@ package utils import "testing" func TestFormatToolFeedbackMessage(t *testing.T) { - got := FormatToolFeedbackMessage( - "read_file", - "I will read README.md first to confirm the current project structure.", - ) - want := "\U0001f527 `read_file`\nI will read README.md first to confirm the current project structure." + got := FormatToolFeedbackMessage("read_file", "{\"path\":\"README.md\"}") + want := "\U0001f527 `read_file`\n```\n{\"path\":\"README.md\"}\n```" if got != want { t.Fatalf("FormatToolFeedbackMessage() = %q, want %q", got, want) } } - -func TestFormatToolFeedbackMessage_EmptyExplanationKeepsOnlyToolLine(t *testing.T) { - got := FormatToolFeedbackMessage("read_file", "") - want := "\U0001f527 `read_file`" - if got != want { - t.Fatalf("FormatToolFeedbackMessage() = %q, want %q", got, want) - } -} - -func TestFormatToolFeedbackMessage_EmptyToolNameOmitsToolLine(t *testing.T) { - got := FormatToolFeedbackMessage("", "Continue drafting the final response.") - want := "Continue drafting the final response." - if got != want { - t.Fatalf("FormatToolFeedbackMessage() = %q, want %q", got, want) - } -} - -func TestFitToolFeedbackMessage_TruncatesBodyWithinSingleMessage(t *testing.T) { - got := FitToolFeedbackMessage( - "\U0001f527 `read_file`\nRead README.md first to confirm the current project structure.", - 40, - ) - want := "\U0001f527 `read_file`\nRead README.md first to..." - if got != want { - t.Fatalf("FitToolFeedbackMessage() = %q, want %q", got, want) - } -} - -func TestFitToolFeedbackMessage_TruncatesSingleLineMessage(t *testing.T) { - got := FitToolFeedbackMessage("\U0001f527 `read_file`", 10) - want := "\U0001f527 `read..." - if got != want { - t.Fatalf("FitToolFeedbackMessage() = %q, want %q", got, want) - } -} diff --git a/web/backend/api/session.go b/web/backend/api/session.go index 2a16fe183..054b78b73 100644 --- a/web/backend/api/session.go +++ b/web/backend/api/session.go @@ -486,15 +486,6 @@ func visibleSessionMessages(messages []providers.Message, toolFeedbackMaxArgsLen transcript = append(transcript, visibleToolMessages...) } - // When assistant content exactly matches the rendered tool summary or - // tool-delivered message, skip it to avoid duplicates. Distinct content - // must remain visible in restored session history. - if len(msg.ToolCalls) > 0 && - len(msg.Media) == 0 && - assistantToolCallContentDuplicated(msg.Content, toolSummaryMessages, visibleToolMessages) { - continue - } - // Pico web chat can persist both visible `message` tool output and a // later plain assistant reply in the same turn. Hide only the fixed // internal summary that marks handled tool delivery. @@ -513,43 +504,6 @@ func visibleSessionMessages(messages []providers.Message, toolFeedbackMaxArgsLen return transcript } -func assistantToolCallContentDuplicated( - content string, - toolSummaryMessages []sessionChatMessage, - visibleToolMessages []sessionChatMessage, -) bool { - content = strings.TrimSpace(content) - if content == "" { - return false - } - - for _, msg := range toolSummaryMessages { - if toolSummaryContainsContent(msg.Content, content) { - return true - } - } - for _, msg := range visibleToolMessages { - if strings.TrimSpace(msg.Content) == content { - return true - } - } - return false -} - -func toolSummaryContainsContent(summary, content string) bool { - summary = strings.TrimSpace(summary) - content = strings.TrimSpace(content) - if summary == "" || content == "" { - return false - } - if summary == content { - return true - } - - _, body, hasBody := strings.Cut(summary, "\n") - return hasBody && strings.TrimSpace(body) == content -} - func assistantMessageTransientThought(msg providers.Message) bool { return strings.TrimSpace(msg.Content) == "" && strings.TrimSpace(msg.ReasoningContent) != "" && @@ -575,51 +529,38 @@ func visibleAssistantToolSummaryMessages( messages := make([]sessionChatMessage, 0, len(toolCalls)) for _, tc := range toolCalls { name := tc.Name + argsJSON := "" if tc.Function != nil { if name == "" { name = tc.Function.Name } + argsJSON = tc.Function.Arguments } if strings.TrimSpace(name) == "" { continue } + if strings.TrimSpace(argsJSON) == "" && len(tc.Arguments) > 0 { + if encodedArgs, err := json.Marshal(tc.Arguments); err == nil { + argsJSON = string(encodedArgs) + } + } + + argsPreview := strings.TrimSpace(argsJSON) + if argsPreview == "" { + argsPreview = "{}" + } + messages = append(messages, sessionChatMessage{ - Role: "assistant", - Content: utils.FormatToolFeedbackMessage( - name, - visibleAssistantToolSummaryText(tc, toolFeedbackMaxArgsLength), - ), + Role: "assistant", + Content: utils.FormatToolFeedbackMessage(name, utils.Truncate(argsPreview, toolFeedbackMaxArgsLength)), }) } return messages } -func visibleAssistantToolSummaryText( - tc providers.ToolCall, - toolFeedbackMaxArgsLength int, -) string { - if tc.ExtraContent != nil { - if explanation := strings.TrimSpace(tc.ExtraContent.ToolFeedbackExplanation); explanation != "" { - return utils.Truncate(explanation, toolFeedbackMaxArgsLength) - } - } - - argsJSON := "" - if tc.Function != nil { - argsJSON = tc.Function.Arguments - } - if strings.TrimSpace(argsJSON) == "" && len(tc.Arguments) > 0 { - if encodedArgs, err := json.Marshal(tc.Arguments); err == nil { - argsJSON = string(encodedArgs) - } - } - - return utils.Truncate(strings.TrimSpace(argsJSON), toolFeedbackMaxArgsLength) -} - func visibleAssistantToolMessages(toolCalls []providers.ToolCall) []sessionChatMessage { if len(toolCalls) == 0 { return nil diff --git a/web/backend/api/session_test.go b/web/backend/api/session_test.go index b0bab0baa..e40a8c77c 100644 --- a/web/backend/api/session_test.go +++ b/web/backend/api/session_test.go @@ -540,7 +540,7 @@ func TestHandleListSessions_MessageCountUsesVisibleTranscript(t *testing.T) { } } -func TestHandleGetSession_DoesNotDuplicateAssistantToolCallContent(t *testing.T) { +func TestHandleGetSession_PreservesToolSummaryAndAssistantContent(t *testing.T) { configPath, cleanup := setupOAuthTestEnv(t) defer cleanup() @@ -555,7 +555,7 @@ func TestHandleGetSession_DoesNotDuplicateAssistantToolCallContent(t *testing.T) {Role: "user", Content: "check file"}, { Role: "assistant", - Content: "Read the file before replying.", + Content: "model final reply", ToolCalls: []providers.ToolCall{ { ID: "call_1", @@ -564,9 +564,6 @@ func TestHandleGetSession_DoesNotDuplicateAssistantToolCallContent(t *testing.T) Name: "read_file", Arguments: `{"path":"README.md","start_line":1,"end_line":10}`, }, - ExtraContent: &providers.ExtraContent{ - ToolFeedbackExplanation: "Read the file before replying.", - }, }, }, }, @@ -597,8 +594,8 @@ func TestHandleGetSession_DoesNotDuplicateAssistantToolCallContent(t *testing.T) if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { t.Fatalf("Unmarshal() error = %v", err) } - if len(resp.Messages) != 2 { - t.Fatalf("len(resp.Messages) = %d, want 2", len(resp.Messages)) + if len(resp.Messages) != 3 { + t.Fatalf("len(resp.Messages) = %d, want 3", len(resp.Messages)) } if resp.Messages[0].Role != "user" || resp.Messages[0].Content != "check file" { t.Fatalf("first message = %#v, want user/check file", resp.Messages[0]) @@ -606,153 +603,8 @@ func TestHandleGetSession_DoesNotDuplicateAssistantToolCallContent(t *testing.T) if !strings.Contains(resp.Messages[1].Content, "`read_file`") { t.Fatalf("tool summary message = %#v, want read_file summary", resp.Messages[1]) } - if !strings.Contains(resp.Messages[1].Content, "Read the file before replying.") { - t.Fatalf("tool summary message = %#v, want tool explanation", resp.Messages[1]) - } -} - -func TestHandleGetSession_PreservesDistinctAssistantToolCallContent(t *testing.T) { - configPath, cleanup := setupOAuthTestEnv(t) - defer cleanup() - - dir := sessionsTestDir(t, configPath) - store, err := memory.NewJSONLStore(dir) - if err != nil { - t.Fatalf("NewJSONLStore() error = %v", err) - } - - sessionKey := picoSessionPrefix + "detail-tool-summary-distinct-content" - for _, msg := range []providers.Message{ - {Role: "user", Content: "check file"}, - { - Role: "assistant", - Content: "I will summarize the findings after reading the file.", - ToolCalls: []providers.ToolCall{ - { - ID: "call_1", - Type: "function", - Function: &providers.FunctionCall{ - Name: "read_file", - Arguments: `{"path":"README.md","start_line":1,"end_line":10}`, - }, - ExtraContent: &providers.ExtraContent{ - ToolFeedbackExplanation: "Read the file before replying.", - }, - }, - }, - }, - } { - if err := store.AddFullMessage(nil, sessionKey, msg); err != nil { - t.Fatalf("AddFullMessage() error = %v", err) - } - } - - h := NewHandler(configPath) - mux := http.NewServeMux() - h.RegisterRoutes(mux) - - rec := httptest.NewRecorder() - req := httptest.NewRequest(http.MethodGet, "/api/sessions/detail-tool-summary-distinct-content", nil) - mux.ServeHTTP(rec, req) - - if rec.Code != http.StatusOK { - t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) - } - - var resp struct { - Messages []struct { - Role string `json:"role"` - Content string `json:"content"` - } `json:"messages"` - } - if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { - t.Fatalf("Unmarshal() error = %v", err) - } - if len(resp.Messages) != 3 { - t.Fatalf("len(resp.Messages) = %d, want 3", len(resp.Messages)) - } - if !strings.Contains(resp.Messages[1].Content, "`read_file`") { - t.Fatalf("tool summary message = %#v, want read_file summary", resp.Messages[1]) - } - if resp.Messages[2].Role != "assistant" || - resp.Messages[2].Content != "I will summarize the findings after reading the file." { - t.Fatalf("assistant content = %#v, want preserved distinct content", resp.Messages[2]) - } -} - -func TestHandleGetSession_PreservesMediaWhenAssistantToolCallContentDuplicatesSummary(t *testing.T) { - configPath, cleanup := setupOAuthTestEnv(t) - defer cleanup() - - dir := sessionsTestDir(t, configPath) - store, err := memory.NewJSONLStore(dir) - if err != nil { - t.Fatalf("NewJSONLStore() error = %v", err) - } - - sessionKey := picoSessionPrefix + "detail-tool-summary-duplicate-content-with-media" - for _, msg := range []providers.Message{ - {Role: "user", Content: "check screenshot"}, - { - Role: "assistant", - Content: "Reviewing the generated screenshot.", - Media: []string{"data:image/png;base64,abc123"}, - ToolCalls: []providers.ToolCall{ - { - ID: "call_1", - Type: "function", - Function: &providers.FunctionCall{ - Name: "view_image", - Arguments: `{"path":"artifact.png"}`, - }, - ExtraContent: &providers.ExtraContent{ - ToolFeedbackExplanation: "Reviewing the generated screenshot.", - }, - }, - }, - }, - } { - if err := store.AddFullMessage(nil, sessionKey, msg); err != nil { - t.Fatalf("AddFullMessage() error = %v", err) - } - } - - h := NewHandler(configPath) - mux := http.NewServeMux() - h.RegisterRoutes(mux) - - rec := httptest.NewRecorder() - req := httptest.NewRequest(http.MethodGet, "/api/sessions/detail-tool-summary-duplicate-content-with-media", nil) - mux.ServeHTTP(rec, req) - - if rec.Code != http.StatusOK { - t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) - } - - var resp struct { - Messages []struct { - Role string `json:"role"` - Content string `json:"content"` - Media []string `json:"media"` - } `json:"messages"` - } - if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { - t.Fatalf("Unmarshal() error = %v", err) - } - if len(resp.Messages) != 3 { - t.Fatalf("len(resp.Messages) = %d, want 3", len(resp.Messages)) - } - if !strings.Contains(resp.Messages[1].Content, "`view_image`") { - t.Fatalf("tool summary message = %#v, want view_image summary", resp.Messages[1]) - } - if resp.Messages[2].Role != "assistant" { - t.Fatalf("assistant message role = %q, want assistant", resp.Messages[2].Role) - } - if resp.Messages[2].Content != "Reviewing the generated screenshot." { - t.Fatalf("assistant content = %q, want preserved duplicated content with media", resp.Messages[2].Content) - } - if len(resp.Messages[2].Media) != 1 || resp.Messages[2].Media[0] != "data:image/png;base64,abc123" { - t.Fatalf("assistant media = %#v, want preserved media", resp.Messages[2].Media) + if resp.Messages[2].Role != "assistant" || resp.Messages[2].Content != "model final reply" { + t.Fatalf("assistant message = %#v, want model final reply", resp.Messages[2]) } } @@ -777,7 +629,6 @@ func TestHandleGetSession_UsesConfiguredToolFeedbackMaxArgsLength(t *testing.T) } argsJSON := `{"path":"README.md","start_line":1,"end_line":10,"extra":"abcdefghijklmnopqrstuvwxyz"}` - explanation := "Read README.md first to confirm the current project structure before editing the config example." sessionKey := picoSessionPrefix + "detail-tool-summary-max-args" err = store.AddFullMessage(nil, sessionKey, providers.Message{Role: "user", Content: "check file"}) if err != nil { @@ -792,9 +643,6 @@ func TestHandleGetSession_UsesConfiguredToolFeedbackMaxArgsLength(t *testing.T) Name: "read_file", Arguments: argsJSON, }, - ExtraContent: &providers.ExtraContent{ - ToolFeedbackExplanation: explanation, - }, }}, }) if err != nil { @@ -827,93 +675,13 @@ func TestHandleGetSession_UsesConfiguredToolFeedbackMaxArgsLength(t *testing.T) t.Fatalf("len(resp.Messages) = %d, want at least 2", len(resp.Messages)) } - wantPreview := utils.Truncate(explanation, 20) + wantPreview := utils.Truncate(argsJSON, 20) if !strings.Contains(resp.Messages[1].Content, wantPreview) { t.Fatalf("tool summary = %q, want preview %q", resp.Messages[1].Content, wantPreview) } if strings.Contains(resp.Messages[1].Content, argsJSON) { t.Fatalf("tool summary = %q, expected configured truncation", resp.Messages[1].Content) } - if !strings.Contains(resp.Messages[1].Content, "`read_file`") { - t.Fatalf("tool summary = %q, want read_file summary", resp.Messages[1].Content) - } -} - -func TestHandleGetSession_FallsBackToLegacyToolArgumentsWhenExplanationMissing(t *testing.T) { - configPath, cleanup := setupOAuthTestEnv(t) - defer cleanup() - - cfg, err := config.LoadConfig(configPath) - if err != nil { - t.Fatalf("LoadConfig() error = %v", err) - } - cfg.Agents.Defaults.ToolFeedback.MaxArgsLength = 20 - err = config.SaveConfig(configPath, cfg) - if err != nil { - t.Fatalf("SaveConfig() error = %v", err) - } - - dir := sessionsTestDir(t, configPath) - store, err := memory.NewJSONLStore(dir) - if err != nil { - t.Fatalf("NewJSONLStore() error = %v", err) - } - - argsJSON := `{"path":"README.md","start_line":1,"end_line":10,"extra":"abcdefghijklmnopqrstuvwxyz"}` - sessionKey := picoSessionPrefix + "detail-tool-summary-legacy-args" - if err := store.AddFullMessage( - nil, - sessionKey, - providers.Message{Role: "user", Content: "check file"}, - ); err != nil { - t.Fatalf("AddFullMessage(user) error = %v", err) - } - if err := store.AddFullMessage(nil, sessionKey, providers.Message{ - Role: "assistant", - ToolCalls: []providers.ToolCall{{ - ID: "call_1", - Type: "function", - Function: &providers.FunctionCall{ - Name: "read_file", - Arguments: argsJSON, - }, - }}, - }); err != nil { - t.Fatalf("AddFullMessage(assistant) error = %v", err) - } - - h := NewHandler(configPath) - mux := http.NewServeMux() - h.RegisterRoutes(mux) - - rec := httptest.NewRecorder() - req := httptest.NewRequest(http.MethodGet, "/api/sessions/detail-tool-summary-legacy-args", nil) - mux.ServeHTTP(rec, req) - - if rec.Code != http.StatusOK { - t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) - } - - var resp struct { - Messages []struct { - Role string `json:"role"` - Content string `json:"content"` - } `json:"messages"` - } - if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { - t.Fatalf("Unmarshal() error = %v", err) - } - if len(resp.Messages) < 2 { - t.Fatalf("len(resp.Messages) = %d, want at least 2", len(resp.Messages)) - } - - wantPreview := utils.Truncate(argsJSON, 20) - if !strings.Contains(resp.Messages[1].Content, "`read_file`") { - t.Fatalf("tool summary = %q, want read_file summary", resp.Messages[1].Content) - } - if !strings.Contains(resp.Messages[1].Content, wantPreview) { - t.Fatalf("tool summary = %q, want legacy args preview %q", resp.Messages[1].Content, wantPreview) - } } func TestHandleGetSession_IncludesMediaOnlyMessages(t *testing.T) { diff --git a/web/frontend/src/i18n/locales/en.json b/web/frontend/src/i18n/locales/en.json index 7a5c58b30..c96d4b71b 100644 --- a/web/frontend/src/i18n/locales/en.json +++ b/web/frontend/src/i18n/locales/en.json @@ -592,9 +592,9 @@ "split_on_marker": "Chatty Mode", "split_on_marker_hint": "Split long messages into short ones like real human chatting.", "tool_feedback_enabled": "Tool Feedback", - "tool_feedback_enabled_hint": "Send a short execution note into the current chat before each tool runs.", - "tool_feedback_max_args_length": "Tool Feedback Length", - "tool_feedback_max_args_length_hint": "Maximum number of characters shown in each tool feedback message. Set to 0 to use the default.", + "tool_feedback_enabled_hint": "Send a short tool-call preview into the current chat before each tool execution.", + "tool_feedback_max_args_length": "Tool Feedback Args Preview Length", + "tool_feedback_max_args_length_hint": "Maximum number of argument characters shown in each tool feedback message. Set to 0 to use the default.", "exec_enabled": "Allow Commands", "exec_enabled_hint": "Enable or disable command execution for the app. When disabled, no command requests will run.", "allow_remote": "Allow Remote Commands", diff --git a/web/frontend/src/i18n/locales/zh.json b/web/frontend/src/i18n/locales/zh.json index aaebfa625..4a9e59cf4 100644 --- a/web/frontend/src/i18n/locales/zh.json +++ b/web/frontend/src/i18n/locales/zh.json @@ -592,9 +592,9 @@ "split_on_marker": "连续短消息", "split_on_marker_hint": "像真人聊天一样,把长难句拆成多条短消息快速发出", "tool_feedback_enabled": "工具反馈", - "tool_feedback_enabled_hint": "在每次执行工具前,先向当前会话发送一条简短的执行说明", - "tool_feedback_max_args_length": "工具反馈长度", - "tool_feedback_max_args_length_hint": "每条工具反馈消息中展示的字符上限。设为 0 时使用默认值", + "tool_feedback_enabled_hint": "在每次执行工具前,先向当前会话发送一条简短的工具调用预览", + "tool_feedback_max_args_length": "工具反馈参数预览长度", + "tool_feedback_max_args_length_hint": "每条工具反馈消息中展示的参数字符上限。设为 0 时使用默认值", "exec_enabled": "允许命令执行", "exec_enabled_hint": "控制应用是否允许执行命令。关闭后,所有命令请求都不会执行", "allow_remote": "允许远程命令执行", From 4e2f80b79aae2b82a5af26fae3eb586945357f13 Mon Sep 17 00:00:00 2001 From: Guoguo <16666742+imguoguo@users.noreply.github.com> Date: Tue, 21 Apr 2026 09:37:31 +0800 Subject: [PATCH 07/31] docs: update wechat qrcode (#2604) --- assets/wechat.png | Bin 100337 -> 365055 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/assets/wechat.png b/assets/wechat.png index d538f40e644ec6613adc74b2b5a6690d6dceb290..c4128854704b5ae92a296cf2c0cb61b5323a57e4 100644 GIT binary patch literal 365055 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≺nAs>vI(X1-#Pn=;e@V+!3H-&E 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

yar{ONJAR6p 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

&kUZnmK<1e_GGAQpZeodOW2X|NrG^^e9iBAW1# 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

V-Mj*|23v;_Nxyjy?`1!h20=O;ve(w!1 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

g0IaWo5jm-fWthReYZG9nhfj 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≧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 From 329e68e017412718c2fcf9131dfe070feee391ed Mon Sep 17 00:00:00 2001 From: sky5454 Date: Tue, 21 Apr 2026 10:55:50 +0800 Subject: [PATCH 08/31] refactor(agent): Agent Looper refactor phase2, restructure pipeline and rename loop files to agent (#2585) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(agent): introduce interfaces for MessageBus and ChannelManager Phase 2 of loop.go refactor — dependency inversion using adapter pattern. - Add interfaces.MessageBus and interfaces.ChannelManager interfaces - Create adapters/messagebus.go wrapping *bus.MessageBus - Create adapters/channelmanager.go wrapping *channels.Manager - Update AgentLoop to use interfaces instead of concrete types - Update registerSharedTools to accept interfaces.MessageBus Co-Authored-By: Claude Opus 4.6 * refactor(agent): restructure pipeline and rename loop files Pipeline refactoring: - Split pipeline.go (1400 lines) into focused files: - pipeline_setup.go (~115 lines): SetupTurn method - pipeline_llm.go (~519 lines): CallLLM method - pipeline_execute.go (~693 lines): ExecuteTools method - pipeline_finalize.go (~78 lines): Finalize method - Pipeline struct and NewPipeline remain in pipeline.go (~39 lines) Agent file renaming: - Rename loop_*.go to agent_*.go for consistent naming: - loop.go -> agent.go, loop_message.go -> agent_message.go, etc. - Merge turn.go + turn_exec.go into turn_state.go - Rename loop_turn.go -> turn_coord.go Documentation: - Update docs/pipeline-restructuring-plan.md - Add docs/agent-rename-plan.md Co-Authored-By: Claude Opus 4.7 * fix(agent): code format fixed * refactor(agent): code test file added/renamed * docs(agent): update agent refactor docs * fix(agent): fix agent hardAbortX --------- Co-authored-by: Claude Opus 4.6 --- .../agent-refactor/agent-rename-plan.md | 100 + .../agent-refactor/agent-rename-plan.zh.md | 100 + .../architecture/agent-refactor/loop-split.md | 103 +- .../pipeline-restructuring-plan.md | 68 + .../pipeline-restructuring-plan.zh.md | 68 + docs/architecture/routing-system.md | 12 +- docs/architecture/session-system.md | 10 +- pkg/agent/adapters/channelmanager.go | 45 + pkg/agent/adapters/messagebus.go | 36 + pkg/agent/{loop.go => agent.go} | 9 +- .../{loop_command.go => agent_command.go} | 0 pkg/agent/{loop_event.go => agent_event.go} | 18 - pkg/agent/{loop_init.go => agent_init.go} | 3 +- pkg/agent/{loop_inject.go => agent_inject.go} | 0 pkg/agent/{loop_mcp.go => agent_mcp.go} | 0 .../{loop_mcp_test.go => agent_mcp_test.go} | 0 pkg/agent/{loop_media.go => agent_media.go} | 0 .../{loop_message.go => agent_message.go} | 0 .../{loop_outbound.go => agent_outbound.go} | 0 .../{loop_steering.go => agent_steering.go} | 0 pkg/agent/{loop_test.go => agent_test.go} | 0 ...loop_transcribe.go => agent_transcribe.go} | 0 pkg/agent/{loop_utils.go => agent_utils.go} | 0 pkg/agent/hooks_test.go | 7 +- pkg/agent/interfaces/interfaces.go | 47 + pkg/agent/loop_turn.go | 1878 ----------------- pkg/agent/pipeline.go | 40 + pkg/agent/pipeline_execute.go | 700 ++++++ pkg/agent/pipeline_finalize.go | 77 + pkg/agent/pipeline_llm.go | 525 +++++ pkg/agent/pipeline_setup.go | 116 + pkg/agent/subturn.go | 3 +- pkg/agent/turn_coord.go | 624 ++++++ pkg/agent/turn_coord_test.go | 551 +++++ pkg/agent/{turn.go => turn_state.go} | 143 +- 35 files changed, 3307 insertions(+), 1976 deletions(-) create mode 100644 docs/architecture/agent-refactor/agent-rename-plan.md create mode 100644 docs/architecture/agent-refactor/agent-rename-plan.zh.md create mode 100644 docs/architecture/agent-refactor/pipeline-restructuring-plan.md create mode 100644 docs/architecture/agent-refactor/pipeline-restructuring-plan.zh.md create mode 100644 pkg/agent/adapters/channelmanager.go create mode 100644 pkg/agent/adapters/messagebus.go rename pkg/agent/{loop.go => agent.go} (99%) rename pkg/agent/{loop_command.go => agent_command.go} (100%) rename pkg/agent/{loop_event.go => agent_event.go} (93%) rename pkg/agent/{loop_init.go => agent_init.go} (99%) rename pkg/agent/{loop_inject.go => agent_inject.go} (100%) rename pkg/agent/{loop_mcp.go => agent_mcp.go} (100%) rename pkg/agent/{loop_mcp_test.go => agent_mcp_test.go} (100%) rename pkg/agent/{loop_media.go => agent_media.go} (100%) rename pkg/agent/{loop_message.go => agent_message.go} (100%) rename pkg/agent/{loop_outbound.go => agent_outbound.go} (100%) rename pkg/agent/{loop_steering.go => agent_steering.go} (100%) rename pkg/agent/{loop_test.go => agent_test.go} (100%) rename pkg/agent/{loop_transcribe.go => agent_transcribe.go} (100%) rename pkg/agent/{loop_utils.go => agent_utils.go} (100%) create mode 100644 pkg/agent/interfaces/interfaces.go delete mode 100644 pkg/agent/loop_turn.go create mode 100644 pkg/agent/pipeline.go create mode 100644 pkg/agent/pipeline_execute.go create mode 100644 pkg/agent/pipeline_finalize.go create mode 100644 pkg/agent/pipeline_llm.go create mode 100644 pkg/agent/pipeline_setup.go create mode 100644 pkg/agent/turn_coord.go create mode 100644 pkg/agent/turn_coord_test.go rename pkg/agent/{turn.go => turn_state.go} (72%) diff --git a/docs/architecture/agent-refactor/agent-rename-plan.md b/docs/architecture/agent-refactor/agent-rename-plan.md new file mode 100644 index 000000000..f4ab408fe --- /dev/null +++ b/docs/architecture/agent-refactor/agent-rename-plan.md @@ -0,0 +1,100 @@ +# Agent File Rename Plan + +## Goal + +Unify `pkg/agent/` package file naming to resolve the `loop_*` prefix naming confusion and unclear responsibility boundaries. + +## Change Overview + +### File Renames (12 files) + +| Original | New | Description | +|----------|-----|-------------| +| `loop.go` | `agent.go` | AgentLoop main body + lifecycle methods | +| `loop_message.go` | `agent_message.go` | Message handling and routing | +| `loop_outbound.go` | `agent_outbound.go` | Response publishing | +| `loop_event.go` | `agent_event.go` | Event system | +| `loop_command.go` | `agent_command.go` | Command processing | +| `loop_steering.go` | `agent_steering.go` | Steering message handling | +| `loop_transcribe.go` | `agent_transcribe.go` | Audio transcription | +| `loop_media.go` | `agent_media.go` | Media processing | +| `loop_mcp.go` | `agent_mcp.go` | MCP initialization | +| `loop_utils.go` | `agent_utils.go` | Utility functions | +| `loop_inject.go` | `agent_inject.go` | Dependency injection | +| `loop_turn.go` | `turn_coord.go` | Turn coordinator | + +### File Merges (2 → 1) + +| Original | New | Description | +|----------|-----|-------------| +| `turn.go` + `turn_exec.go` | `turn_state.go` | Turn-related type definitions | + +## Final File Structure + +``` +pkg/agent/ +├── agent.go # AgentLoop + Run/Stop/Close lifecycle +├── agent_message.go # Message processing +├── agent_outbound.go # Response publishing +├── agent_event.go # Event system +├── agent_command.go # Command processing +├── agent_steering.go # Steering +├── agent_transcribe.go # Transcription +├── agent_media.go # Media processing +├── agent_mcp.go # MCP +├── agent_utils.go # Utility functions +├── agent_inject.go # Dependency injection +├── turn_coord.go # runTurn + coordinator +├── turn_state.go # turnState + turnExecution + Control + ToolControl + LLMPhase +├── pipeline.go # Pipeline struct + NewPipeline +├── pipeline_setup.go +├── pipeline_llm.go +├── pipeline_execute.go +└── pipeline_finalize.go +``` + +## Naming Convention + +| Prefix | Content | Example | +|--------|---------|---------| +| `agent_*` | AgentLoop method files | `agent_message.go`, `agent_event.go` | +| `turn_*` | Turn lifecycle related | `turn_coord.go`, `turn_state.go` | +| `pipeline_*` | Pipeline methods | `pipeline_setup.go`, `pipeline_llm.go` | +| `context_*` | Context management | `context_manager.go`, `context_legacy.go` | +| `hook_*` | Hook system | `hook_process.go`, `hook_mount.go` | + +## Architecture Layers + +``` +┌─────────────────────────────────────────────────────────┐ +│ AgentLoop (agent.go) │ +│ - Message loop Run/Stop/Close │ +│ - Dependency injection (agent_inject.go) │ +│ - Message routing (agent_message.go) │ +│ - Response publishing (agent_outbound.go) │ +└─────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────┐ +│ Turn Coordinator (turn_coord.go) │ +│ - runTurn(): main coordinator │ +│ - abortTurn(): abort │ +│ - askSideQuestion(): side question │ +│ - selectCandidates(): model selection │ +└─────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────┐ +│ Pipeline (pipeline_*.go) │ +│ - SetupTurn(): initialization │ +│ - CallLLM(): LLM call │ +│ - ExecuteTools(): tool execution │ +│ - Finalize(): finalization │ +└─────────────────────────────────────────────────────────┘ +``` + +## Verification Results + +- ✅ `go build ./pkg/agent/...` - Pass +- ✅ `go vet ./pkg/agent/...` - No warnings +- ✅ `go test ./pkg/agent/... -skip "TestSeahorse|TestGlobalSkillFileContentChange"` - Pass diff --git a/docs/architecture/agent-refactor/agent-rename-plan.zh.md b/docs/architecture/agent-refactor/agent-rename-plan.zh.md new file mode 100644 index 000000000..938817e10 --- /dev/null +++ b/docs/architecture/agent-refactor/agent-rename-plan.zh.md @@ -0,0 +1,100 @@ +# Agent 文件重命名计划 + +## 目标 + +统一 `pkg/agent/` 包的文件命名,解决 `loop_*` 前缀命名混乱、职责边界不清晰的问题。 + +## 变更概览 + +### 文件重命名(12 个) + +| 原文件 | 新文件 | 说明 | +|--------|--------|------| +| `loop.go` | `agent.go` | AgentLoop 主体 + 生命周期方法 | +| `loop_message.go` | `agent_message.go` | 消息处理和路由 | +| `loop_outbound.go` | `agent_outbound.go` | 响应发布 | +| `loop_event.go` | `agent_event.go` | 事件系统 | +| `loop_command.go` | `agent_command.go` | 命令处理 | +| `loop_steering.go` | `agent_steering.go` | Steering 消息处理 | +| `loop_transcribe.go` | `agent_transcribe.go` | 音频转录 | +| `loop_media.go` | `agent_media.go` | 媒体处理 | +| `loop_mcp.go` | `agent_mcp.go` | MCP 初始化 | +| `loop_utils.go` | `agent_utils.go` | 工具函数 | +| `loop_inject.go` | `agent_inject.go` | 依赖注入 | +| `loop_turn.go` | `turn_coord.go` | Turn 协调器 | + +### 文件合并(2 → 1) + +| 原文件 | 新文件 | 说明 | +|--------|--------|------| +| `turn.go` + `turn_exec.go` | `turn_state.go` | Turn 相关类型定义 | + +## 最终文件结构 + +``` +pkg/agent/ +├── agent.go # AgentLoop + Run/Stop/Close 生命周期 +├── agent_message.go # 消息处理 +├── agent_outbound.go # 响应发布 +├── agent_event.go # 事件系统 +├── agent_command.go # 命令处理 +├── agent_steering.go # Steering +├── agent_transcribe.go # 转录 +├── agent_media.go # 媒体处理 +├── agent_mcp.go # MCP +├── agent_utils.go # 工具函数 +├── agent_inject.go # 依赖注入 +├── turn_coord.go # runTurn + 协调器 +├── turn_state.go # turnState + turnExecution + Control + ToolControl + LLMPhase +├── pipeline.go # Pipeline struct + NewPipeline +├── pipeline_setup.go +├── pipeline_llm.go +├── pipeline_execute.go +└── pipeline_finalize.go +``` + +## 命名约定 + +| 前缀 | 内容 | 示例 | +|------|------|------| +| `agent_*` | AgentLoop 的方法文件 | `agent_message.go`, `agent_event.go` | +| `turn_*` | Turn 生命周期相关 | `turn_coord.go`, `turn_state.go` | +| `pipeline_*` | Pipeline 方法 | `pipeline_setup.go`, `pipeline_llm.go` | +| `context_*` | 上下文管理 | `context_manager.go`, `context_legacy.go` | +| `hook_*` | Hook 系统 | `hook_process.go`, `hook_mount.go` | + +## 架构层次 + +``` +┌─────────────────────────────────────────────────────────┐ +│ AgentLoop (agent.go) │ +│ - 消息循环 Run/Stop/Close │ +│ - 依赖注入 (agent_inject.go) │ +│ - 消息路由 (agent_message.go) │ +│ - 响应发布 (agent_outbound.go) │ +└─────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────┐ +│ Turn Coordinator (turn_coord.go) │ +│ - runTurn(): 主协调器 │ +│ - abortTurn(): 中止 │ +│ - askSideQuestion(): 侧问 │ +│ - selectCandidates(): 模型选择 │ +└─────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────┐ +│ Pipeline (pipeline_*.go) │ +│ - SetupTurn(): 初始化 │ +│ - CallLLM(): LLM 调用 │ +│ - ExecuteTools(): 工具执行 │ +│ - Finalize(): 终结 │ +└─────────────────────────────────────────────────────────┘ +``` + +## 验证结果 + +- ✅ `go build ./pkg/agent/...` - 通过 +- ✅ `go vet ./pkg/agent/...` - 无警告 +- ✅ `go test ./pkg/agent/... -skip "TestSeahorse|TestGlobalSkillFileContentChange"` - 通过 diff --git a/docs/architecture/agent-refactor/loop-split.md b/docs/architecture/agent-refactor/loop-split.md index 0c759e63d..5395baeeb 100644 --- a/docs/architecture/agent-refactor/loop-split.md +++ b/docs/architecture/agent-refactor/loop-split.md @@ -1,5 +1,7 @@ # AgentLoop File Split +> **Note:** This document describes the file split that was completed in a previous phase. The `loop_*` naming has since been renamed to `agent_*` and `turn_*`. See [agent-rename-plan.md](./agent-rename-plan.md) for the current file structure. + ## Overview The `pkg/agent/loop.go` file (originally 4384 lines) has been split into 12 focused source files. This is a pure refactoring with no behavioral changes. @@ -11,76 +13,65 @@ The `pkg/agent/loop.go` file (originally 4384 lines) has been split into 12 focu - Maintain all existing functionality and tests - Keep imports minimal per file -## File Map +## Original File Map (Renamed in Phase 2) -| File | Lines | Responsibility | -|------|-------|----------------| -| `loop.go` | ~650 | Core `AgentLoop` struct, `Run`, `Stop`, `Close`, `ReloadProviderAndConfig`, `runAgentLoop` | -| `loop_turn.go` | ~1880 | Turn execution: `runTurn`, `abortTurn`, `selectCandidates`, `askSideQuestion`, `isolatedSideQuestionProvider`, side question model config | -| `loop_utils.go` | ~480 | Standalone utility functions: formatters, cloners, helpers (no receiver) | -| `loop_init.go` | ~355 | `NewAgentLoop` constructor and `registerSharedTools` | -| `loop_message.go` | ~300 | Message handling: `processMessage`, `processSystemMessage`, routing helpers, `ProcessDirect`, `ProcessHeartbeat` | -| `loop_command.go` | ~265 | Command processing: `handleCommand`, `applyExplicitSkillCommand`, pending skills management | -| `loop_mcp.go` | ~235 | MCP runtime: `ensureMCPInitialized`, server discovery, deferred server handling | -| `loop_event.go` | ~205 | Event system helpers: `emitEvent`, `logEvent`, `hookAbortError`, `newTurnEventScope`, `MountHook`, `SubscribeEvents` | -| `loop_media.go` | ~198 | Media resolution: `resolveMediaRefs`, artifact building, MIME detection | -| `loop_outbound.go` | ~165 | Response publishing: `PublishResponseIfNeeded`, `publishPicoReasoning`, `handleReasoning` | -| `loop_transcribe.go` | ~110 | Audio transcription: `transcribeAudioInMessage`, `sendTranscriptionFeedback` | -| `loop_steering.go` | ~97 | Steering queue: `runTurnWithSteering`, `processMessageSync`, `resolveSteeringTarget` | -| `loop_inject.go` | ~104 | Setter injection: `SetChannelManager`, `SetMediaStore`, `SetTranscriber`, `GetRegistry`, `GetConfig`, `RecordLastChannel` | +| Old File | New File | Responsibility | +|----------|----------|----------------| +| `loop.go` | `agent.go` | Core `AgentLoop` struct, `Run`, `Stop`, `Close` | +| `loop_turn.go` | `turn_coord.go` + `pipeline_*.go` | Turn execution: coordinator + Pipeline methods | +| `loop_utils.go` | `agent_utils.go` | Standalone utility functions | +| `loop_init.go` | `agent_init.go` | `NewAgentLoop` constructor and tool registration | +| `loop_message.go` | `agent_message.go` | Message handling and routing | +| `loop_command.go` | `agent_command.go` | Command processing | +| `loop_mcp.go` | `agent_mcp.go` | MCP runtime | +| `loop_event.go` | `agent_event.go` | Event system helpers | +| `loop_media.go` | `agent_media.go` | Media resolution | +| `loop_outbound.go` | `agent_outbound.go` | Response publishing | +| `loop_transcribe.go` | `agent_transcribe.go` | Audio transcription | +| `loop_steering.go` | `agent_steering.go` | Steering queue | +| `loop_inject.go` | `agent_inject.go` | Setter injection | + +## Current File Structure + +See [agent-rename-plan.md](./agent-rename-plan.md) for the complete current file structure. + +## Phase 2: Rename and Pipeline Restructuring + +Phase 2 completed the following: + +1. **File renaming**: All `loop_*` files renamed to `agent_*` or `turn_*` +2. **Turn state merging**: `turn.go` + `turn_exec.go` → `turn_state.go` +3. **Pipeline extraction**: Split large `runTurn` into Pipeline methods + +### Pipeline Architecture + +The Pipeline methods provide structured turn execution: + +| Method | File | Responsibility | +|--------|------|----------------| +| `SetupTurn()` | `pipeline_setup.go` | History assembly, message building, candidate selection | +| `CallLLM()` | `pipeline_llm.go` | PreLLM hooks, fallback, retry, AfterLLM hooks | +| `ExecuteTools()` | `pipeline_execute.go` | Tool execution with hooks | +| `Finalize()` | `pipeline_finalize.go` | Session persistence, compression | ## Core Principles Applied ### 1. Same Package, Independent Files -All files belong to the `agent` package and compile together. This preserves the original visibility rules — no interface abstraction was introduced in this phase. +All files belong to the `agent` package and compile together. This preserves the original visibility rules. ### 2. No Logic Changes -All functions were moved verbatim (except updating import statements). The extraction script used the original `loop.go.backup` as source of truth to ensure no drift. +All functions were moved verbatim. The extraction preserved behavioral equivalence. -### 3. Shared Types Remain in loop.go -The `AgentLoop` struct, `processOptions`, `continuationTarget`, and all hook/event types stay in `loop.go` since they are referenced across files. - -### 4. Turn State Is Central -`loop_turn.go` is the largest file because the turn lifecycle (`runTurn`) is inherently large. It contains the core LLM interaction loop, tool execution, subturn spawning, and steering injection. - -## What's Left in loop.go - -```go -// Core struct -type AgentLoop struct { ... } - -// Main lifecycle -func (al *AgentLoop) Run(ctx context.Context) error -func (al *AgentLoop) Stop() -func (al *AgentLoop) Close() -func (al *AgentLoop) ReloadProviderAndConfig(ctx, provider, cfg) - -// Turn orchestration (calls into loop_turn.go) -func (al *AgentLoop) runAgentLoop(ctx, agent, opts) (string, error) -``` - -## Extraction Method - -The split was done programmatically using Node.js to: -1. Identify function boundaries using brace counting -2. Extract each function to its target file -3. Add necessary imports to each file -4. Remove the extracted function from loop.go -5. Run `go fmt` and `go vet` to verify +### 3. Shared Types in turn_state.go +The `turnState`, `turnExecution`, `Control`, `ToolControl`, and `LLMPhase` types are centralized in `turn_state.go`. ## Testing -All existing tests pass. The 5 failing tests (`TestGlobalSkillFileContentChange` and 4 Seahorse tests) are pre-existing failures unrelated to this refactor (database file locking issues on Windows). +All existing tests pass. The 5 failing tests (`TestGlobalSkillFileContentChange` and 4 Seahorse tests) are pre-existing failures unrelated to this refactor. Build status: `go build ./pkg/agent/...` passes with no errors. -## Phase 2: Dependency Inversion (Planned) - -A future phase will introduce interface types to decouple `AgentLoop` from its dependencies, enabling: -- Easier testing with mock dependencies -- Alternative runtime configurations -- Cleaner boundaries for MCP and other extensions - ## See Also +- [agent-rename-plan.md](./agent-rename-plan.md) — Current file naming convention - [context.md](context.md) — context management and session handling diff --git a/docs/architecture/agent-refactor/pipeline-restructuring-plan.md b/docs/architecture/agent-refactor/pipeline-restructuring-plan.md new file mode 100644 index 000000000..b77987af1 --- /dev/null +++ b/docs/architecture/agent-refactor/pipeline-restructuring-plan.md @@ -0,0 +1,68 @@ +# Pipeline Restructuring Plan + +## Goal + +Split `agent/pipeline.go` (~1400 lines) into multiple logical files, organizing code by responsibility. + +## Final File Structure + +``` +pkg/agent/ +├── pipeline.go # Pipeline struct + NewPipeline (~39 lines) +├── pipeline_setup.go # SetupTurn method (~115 lines) +├── pipeline_llm.go # CallLLM method (~519 lines) +├── pipeline_execute.go # ExecuteTools method (~693 lines) +└── pipeline_finalize.go # Finalize method (~78 lines) +``` + +## Actual Line Counts + +| File | Lines | +|------|-------| +| `pipeline.go` | 39 | +| `pipeline_setup.go` | 115 | +| `pipeline_llm.go` | 519 | +| `pipeline_execute.go` | 693 | +| `pipeline_finalize.go` | 78 | +| **Total** | **1444** | + +## Responsibility Matrix + +| File | Method | Responsibility | +|------|--------|----------------| +| `pipeline.go` | `Pipeline` struct, `NewPipeline()` | Pipeline dependency container | +| `pipeline_setup.go` | `SetupTurn()` | Turn initialization: history assembly, message building, candidate selection | +| `pipeline_llm.go` | `CallLLM()` | LLM call: PreLLM hooks, fallback, retry, AfterLLM hooks | +| `pipeline_execute.go` | `ExecuteTools()` | Tool execution: BeforeTool/ApproveTool/AfterTool hooks, media sending, steering handling | +| `pipeline_finalize.go` | `Finalize()` | Turn finalization: session save, compression, status setting | + +## Relationship Between Pipeline and Turn Coordinator + +``` +AgentLoop (agent.go) + │ + ├── runAgentLoop() ──────────────────┐ + │ │ + │ ┌───────────────────────────────▼───────────────────────────────┐ + │ │ Turn Coordinator (turn_coord.go) │ + │ │ │ + │ │ runTurn() { │ + │ │ exec = pipeline.SetupTurn() │ + │ │ loop { │ + │ │ ctrl = pipeline.CallLLM() ──► Pipeline (pipeline_*.go) │ + │ │ if ctrl == ToolLoop { │ + │ │ toolCtrl = pipeline.ExecuteTools() │ + │ │ } │ + │ │ } │ + │ │ return pipeline.Finalize() │ + │ │ } │ + │ └─────────────────────────────────────────────────────────────┘ + │ + └── Publish response (agent_outbound.go) +``` + +## Verification Results + +- ✅ `go build ./pkg/agent/...` - Pass +- ✅ `go vet ./pkg/agent/...` - No warnings +- ✅ `go test ./pkg/agent/... -skip "TestSeahorse|TestGlobalSkillFileContentChange"` - Pass diff --git a/docs/architecture/agent-refactor/pipeline-restructuring-plan.zh.md b/docs/architecture/agent-refactor/pipeline-restructuring-plan.zh.md new file mode 100644 index 000000000..2de1396ad --- /dev/null +++ b/docs/architecture/agent-refactor/pipeline-restructuring-plan.zh.md @@ -0,0 +1,68 @@ +# Pipeline 重构文档 + +## 目标 + +将 `agent/pipeline.go` (1400行) 拆分为多个逻辑文件,代码按职责组织。 + +## 最终文件结构 + +``` +pkg/agent/ +├── pipeline.go # Pipeline struct + NewPipeline (~39行) +├── pipeline_setup.go # SetupTurn 方法 (~115行) +├── pipeline_llm.go # CallLLM 方法 (~519行) +├── pipeline_execute.go # ExecuteTools 方法 (~693行) +└── pipeline_finalize.go # Finalize 方法 (~78行) +``` + +## 实际行数 + +| 文件 | 行数 | +|------|------| +| `pipeline.go` | 39 | +| `pipeline_setup.go` | 115 | +| `pipeline_llm.go` | 519 | +| `pipeline_execute.go` | 693 | +| `pipeline_finalize.go` | 78 | +| **总计** | **1444** | + +## 职责说明 + +| 文件 | 方法 | 职责 | +|------|------|------| +| `pipeline.go` | `Pipeline` struct, `NewPipeline()` | Pipeline 依赖容器 | +| `pipeline_setup.go` | `SetupTurn()` | Turn 初始化:历史组装、消息构建、候选人选择 | +| `pipeline_llm.go` | `CallLLM()` | LLM 调用:PreLLM hook、fallback、重试、AfterLLM hook | +| `pipeline_execute.go` | `ExecuteTools()` | 工具执行:BeforeTool/ApproveTool/AfterTool hook、媒体发送、steering 处理 | +| `pipeline_finalize.go` | `Finalize()` | Turn 终结:会话保存、压缩、状态设置 | + +## Pipeline 与 Turn Coordinator 的关系 + +``` +AgentLoop (agent.go) + │ + ├── runAgentLoop() ──────────────────┐ + │ │ + │ ┌───────────────────────────────▼───────────────────────────────┐ + │ │ Turn Coordinator (turn_coord.go) │ + │ │ │ + │ │ runTurn() { │ + │ │ exec = pipeline.SetupTurn() │ + │ │ loop { │ + │ │ ctrl = pipeline.CallLLM() ──► Pipeline (pipeline_*.go) │ + │ │ if ctrl == ToolLoop { │ + │ │ toolCtrl = pipeline.ExecuteTools() │ + │ │ } │ + │ │ } │ + │ │ return pipeline.Finalize() │ + │ │ } │ + │ └─────────────────────────────────────────────────────────────┘ + │ + └── 发布响应 (agent_outbound.go) +``` + +## 验证结果 + +- ✅ `go build ./pkg/agent/...` - 通过 +- ✅ `go vet ./pkg/agent/...` - 无警告 +- ✅ `go test ./pkg/agent/... -skip "TestSeahorse|TestGlobalSkillFileContentChange"` - 通过 diff --git a/docs/architecture/routing-system.md b/docs/architecture/routing-system.md index 3b4663ee8..ad6c3abfc 100644 --- a/docs/architecture/routing-system.md +++ b/docs/architecture/routing-system.md @@ -19,7 +19,7 @@ It does not describe the launcher's HTTP `ServeMux` routes or the frontend's Tan | Agent dispatch | `pkg/routing/route.go`, `pkg/routing/agent_id.go` | Choose the target agent for the inbound message. | | Session policy selection | `pkg/routing/route.go` | Decide which dimensions should define session isolation for that routed turn. | | Model routing | `pkg/routing/router.go`, `pkg/routing/features.go`, `pkg/routing/classifier.go` | Choose between the primary model and a configured light model based on message complexity. | -| Runtime integration | `pkg/agent/registry.go`, `pkg/agent/loop_message.go`, `pkg/agent/loop_turn.go` | Apply the route result, allocate session scope, and select model candidates before provider execution. | +| Runtime integration | `pkg/agent/registry.go`, `pkg/agent/agent_message.go`, `pkg/agent/turn_coord.go` | Apply the route result, allocate session scope, and select model candidates before provider execution. | ## End-To-End Flow @@ -242,8 +242,8 @@ That makes the following behavior intentional: Agent dispatch and model routing happen in different places: - `pkg/agent/registry.go` owns `RouteResolver` -- `pkg/agent/loop_message.go` resolves the route and allocates session scope -- `pkg/agent/loop_turn.go:selectCandidates` calls `agent.Router.SelectModel(...)` +- `pkg/agent/agent_message.go` resolves the route and allocates session scope +- `pkg/agent/turn_coord.go:selectCandidates` calls `agent.Router.SelectModel(...)` When the light model is selected, the agent loop swaps to `agent.LightCandidates`. When it is not selected, execution stays on the agent's primary provider candidate set. @@ -252,7 +252,7 @@ When it is not selected, execution stays on the agent's primary provider candida One nuance sits just outside `pkg/routing` but matters for the full routing story. -After a route is allocated, `pkg/agent/loop_utils.go:resolveScopeKey` preserves an explicit incoming session key when the caller already supplied: +After a route is allocated, `pkg/agent/agent_utils.go:resolveScopeKey` preserves an explicit incoming session key when the caller already supplied: - an opaque canonical key - a legacy `agent:...` key @@ -278,5 +278,5 @@ They are separate from the runtime routing system described here. - `pkg/routing/agent_id.go` - `pkg/session/allocator.go` - `pkg/agent/registry.go` -- `pkg/agent/loop_message.go` -- `pkg/agent/loop_turn.go` +- `pkg/agent/agent_message.go` +- `pkg/agent/turn_coord.go` diff --git a/docs/architecture/session-system.md b/docs/architecture/session-system.md index 7f896d367..b87f9c38e 100644 --- a/docs/architecture/session-system.md +++ b/docs/architecture/session-system.md @@ -29,7 +29,7 @@ The session system has four jobs: | Session adapter | `pkg/session/jsonl_backend.go` | Adapts `pkg/memory.Store` to `SessionStore`, including alias and scope metadata support. | | Durable storage | `pkg/memory/jsonl.go` | Append-only JSONL storage plus `.meta.json` sidecar metadata. | | Scope and key building | `pkg/session/scope.go`, `pkg/session/key.go`, `pkg/session/allocator.go` | Builds structured scopes, opaque canonical keys, and legacy aliases from routing results. | -| Runtime integration | `pkg/agent/instance.go`, `pkg/agent/loop.go`, `pkg/agent/loop_message.go` | Initializes the store, allocates session scope, and persists metadata before turns run. | +| Runtime integration | `pkg/agent/instance.go`, `pkg/agent/agent.go`, `pkg/agent/agent_message.go` | Initializes the store, allocates session scope, and persists metadata before turns run. | ## Session Data Model @@ -90,7 +90,7 @@ The agent loop also preserves explicit incoming session keys when the caller alr - opaque canonical key - legacy `agent:...` key -That behavior lives in `pkg/agent/loop_utils.go:resolveScopeKey`. +That behavior lives in `pkg/agent/agent_utils.go:resolveScopeKey`. ## Allocation Flow @@ -108,7 +108,7 @@ InboundMessage More concretely: -1. `pkg/agent/loop_message.go` resolves the agent route from normalized inbound context. +1. `pkg/agent/agent_message.go` resolves the agent route from normalized inbound context. 2. `session.AllocateRouteSession` converts the route's `SessionPolicy` plus inbound context into a structured `SessionScope`. 3. The allocator builds: - `SessionKey`: canonical routed session key @@ -251,5 +251,5 @@ The session system is consumed by more than the agent loop: - `pkg/session/allocator.go` - `pkg/memory/jsonl.go` - `pkg/agent/instance.go` -- `pkg/agent/loop.go` -- `pkg/agent/loop_message.go` +- `pkg/agent/agent.go` +- `pkg/agent/agent_message.go` diff --git a/pkg/agent/adapters/channelmanager.go b/pkg/agent/adapters/channelmanager.go new file mode 100644 index 000000000..8265ef99d --- /dev/null +++ b/pkg/agent/adapters/channelmanager.go @@ -0,0 +1,45 @@ +// PicoClaw - Ultra-lightweight personal AI agent + +package adapters + +import ( + "context" + + "github.com/sipeed/picoclaw/pkg/agent/interfaces" + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/channels" +) + +// channelManagerAdapter wraps *channels.Manager to implement interfaces.ChannelManager. +type channelManagerAdapter struct { + inner *channels.Manager +} + +// NewChannelManager creates an adapter for *channels.Manager. +func NewChannelManager(inner *channels.Manager) interfaces.ChannelManager { + return &channelManagerAdapter{inner: inner} +} + +func (a *channelManagerAdapter) GetChannel(name string) (channels.Channel, bool) { + return a.inner.GetChannel(name) +} + +func (a *channelManagerAdapter) GetEnabledChannels() []string { + return a.inner.GetEnabledChannels() +} + +func (a *channelManagerAdapter) InvokeTypingStop(channel, chatID string) { + a.inner.InvokeTypingStop(channel, chatID) +} + +func (a *channelManagerAdapter) SendMessage(ctx context.Context, msg bus.OutboundMessage) error { + return a.inner.SendMessage(ctx, msg) +} + +func (a *channelManagerAdapter) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error { + return a.inner.SendMedia(ctx, msg) +} + +func (a *channelManagerAdapter) SendPlaceholder(ctx context.Context, channel, chatID string) bool { + return a.inner.SendPlaceholder(ctx, channel, chatID) +} diff --git a/pkg/agent/adapters/messagebus.go b/pkg/agent/adapters/messagebus.go new file mode 100644 index 000000000..ccae7e8bc --- /dev/null +++ b/pkg/agent/adapters/messagebus.go @@ -0,0 +1,36 @@ +// PicoClaw - Ultra-lightweight personal AI agent + +package adapters + +import ( + "context" + + "github.com/sipeed/picoclaw/pkg/agent/interfaces" + "github.com/sipeed/picoclaw/pkg/bus" +) + +// messageBusAdapter wraps *bus.MessageBus to implement interfaces.MessageBus. +type messageBusAdapter struct { + inner *bus.MessageBus +} + +// NewMessageBus creates an adapter for *bus.MessageBus. +func NewMessageBus(inner *bus.MessageBus) interfaces.MessageBus { + return &messageBusAdapter{inner: inner} +} + +func (a *messageBusAdapter) PublishInbound(ctx context.Context, msg bus.InboundMessage) error { + return a.inner.PublishInbound(ctx, msg) +} + +func (a *messageBusAdapter) PublishOutbound(ctx context.Context, msg bus.OutboundMessage) error { + return a.inner.PublishOutbound(ctx, msg) +} + +func (a *messageBusAdapter) PublishOutboundMedia(ctx context.Context, msg bus.OutboundMediaMessage) error { + return a.inner.PublishOutboundMedia(ctx, msg) +} + +func (a *messageBusAdapter) InboundChan() <-chan bus.InboundMessage { + return a.inner.InboundChan() +} diff --git a/pkg/agent/loop.go b/pkg/agent/agent.go similarity index 99% rename from pkg/agent/loop.go rename to pkg/agent/agent.go index fb6f95edf..0bbfde7ff 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/agent.go @@ -15,9 +15,9 @@ import ( "sync/atomic" "time" + "github.com/sipeed/picoclaw/pkg/agent/interfaces" "github.com/sipeed/picoclaw/pkg/audio/asr" "github.com/sipeed/picoclaw/pkg/bus" - "github.com/sipeed/picoclaw/pkg/channels" "github.com/sipeed/picoclaw/pkg/commands" "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/constants" @@ -32,7 +32,7 @@ import ( type AgentLoop struct { // Core dependencies - bus *bus.MessageBus + bus interfaces.MessageBus cfg *config.Config registry *AgentRegistry state *state.Manager @@ -45,7 +45,7 @@ type AgentLoop struct { running atomic.Bool contextManager ContextManager fallback *providers.FallbackChain - channelManager *channels.Manager + channelManager interfaces.ChannelManager mediaStore media.MediaStore transcriber asr.Transcriber cmdRegistry *commands.Registry @@ -495,7 +495,8 @@ func (al *AgentLoop) runAgentLoop( newTurnContext(opts.Dispatch.InboundContext, opts.Dispatch.RouteResult, opts.Dispatch.SessionScope), ) ts := newTurnState(agent, opts, turnScope) - result, err := al.runTurn(ctx, ts) + pipeline := NewPipeline(al) + result, err := al.runTurn(ctx, ts, pipeline) if err != nil { return "", err } diff --git a/pkg/agent/loop_command.go b/pkg/agent/agent_command.go similarity index 100% rename from pkg/agent/loop_command.go rename to pkg/agent/agent_command.go diff --git a/pkg/agent/loop_event.go b/pkg/agent/agent_event.go similarity index 93% rename from pkg/agent/loop_event.go rename to pkg/agent/agent_event.go index 510c339c1..9b8625df1 100644 --- a/pkg/agent/loop_event.go +++ b/pkg/agent/agent_event.go @@ -48,24 +48,6 @@ func (al *AgentLoop) emitEvent(kind EventKind, meta EventMeta, payload any) { al.eventBus.Emit(evt) } -func (al *AgentLoop) hookAbortError(ts *turnState, stage string, decision HookDecision) error { - reason := decision.Reason - if reason == "" { - reason = "hook requested turn abort" - } - - err := fmt.Errorf("hook aborted turn during %s: %s", stage, reason) - al.emitEvent( - EventKindError, - ts.eventMeta("hooks", "turn.error"), - ErrorPayload{ - Stage: "hook." + stage, - Message: err.Error(), - }, - ) - return err -} - func (al *AgentLoop) logEvent(evt Event) { fields := map[string]any{ "event_kind": evt.Kind.String(), diff --git a/pkg/agent/loop_init.go b/pkg/agent/agent_init.go similarity index 99% rename from pkg/agent/loop_init.go rename to pkg/agent/agent_init.go index 359dc8060..d7bfc22c7 100644 --- a/pkg/agent/loop_init.go +++ b/pkg/agent/agent_init.go @@ -7,6 +7,7 @@ import ( "fmt" "time" + "github.com/sipeed/picoclaw/pkg/agent/interfaces" "github.com/sipeed/picoclaw/pkg/audio/tts" "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/channels" @@ -79,7 +80,7 @@ func NewAgentLoop( func registerSharedTools( al *AgentLoop, cfg *config.Config, - msgBus *bus.MessageBus, + msgBus interfaces.MessageBus, registry *AgentRegistry, provider providers.LLMProvider, ) { diff --git a/pkg/agent/loop_inject.go b/pkg/agent/agent_inject.go similarity index 100% rename from pkg/agent/loop_inject.go rename to pkg/agent/agent_inject.go diff --git a/pkg/agent/loop_mcp.go b/pkg/agent/agent_mcp.go similarity index 100% rename from pkg/agent/loop_mcp.go rename to pkg/agent/agent_mcp.go diff --git a/pkg/agent/loop_mcp_test.go b/pkg/agent/agent_mcp_test.go similarity index 100% rename from pkg/agent/loop_mcp_test.go rename to pkg/agent/agent_mcp_test.go diff --git a/pkg/agent/loop_media.go b/pkg/agent/agent_media.go similarity index 100% rename from pkg/agent/loop_media.go rename to pkg/agent/agent_media.go diff --git a/pkg/agent/loop_message.go b/pkg/agent/agent_message.go similarity index 100% rename from pkg/agent/loop_message.go rename to pkg/agent/agent_message.go diff --git a/pkg/agent/loop_outbound.go b/pkg/agent/agent_outbound.go similarity index 100% rename from pkg/agent/loop_outbound.go rename to pkg/agent/agent_outbound.go diff --git a/pkg/agent/loop_steering.go b/pkg/agent/agent_steering.go similarity index 100% rename from pkg/agent/loop_steering.go rename to pkg/agent/agent_steering.go diff --git a/pkg/agent/loop_test.go b/pkg/agent/agent_test.go similarity index 100% rename from pkg/agent/loop_test.go rename to pkg/agent/agent_test.go diff --git a/pkg/agent/loop_transcribe.go b/pkg/agent/agent_transcribe.go similarity index 100% rename from pkg/agent/loop_transcribe.go rename to pkg/agent/agent_transcribe.go diff --git a/pkg/agent/loop_utils.go b/pkg/agent/agent_utils.go similarity index 100% rename from pkg/agent/loop_utils.go rename to pkg/agent/agent_utils.go diff --git a/pkg/agent/hooks_test.go b/pkg/agent/hooks_test.go index eb76c4da8..cd1586e75 100644 --- a/pkg/agent/hooks_test.go +++ b/pkg/agent/hooks_test.go @@ -709,9 +709,10 @@ func TestAgentLoop_HookRespond_MediaError(t *testing.T) { t.Fatalf("MountHook failed: %v", err) } - al.channelManager = newStartedTestChannelManager(t, al.bus, al.mediaStore, "discord", &errorMediaChannel{ - sendErr: errors.New("channel unavailable"), - }) + al.channelManager = newStartedTestChannelManager(t, + al.bus.(*bus.MessageBus), al.mediaStore, "discord", &errorMediaChannel{ + sendErr: errors.New("channel unavailable"), + }) sub := al.SubscribeEvents(16) defer al.UnsubscribeEvents(sub.ID) diff --git a/pkg/agent/interfaces/interfaces.go b/pkg/agent/interfaces/interfaces.go new file mode 100644 index 000000000..bdf483e20 --- /dev/null +++ b/pkg/agent/interfaces/interfaces.go @@ -0,0 +1,47 @@ +// PicoClaw - Ultra-lightweight personal AI agent + +package interfaces + +import ( + "context" + + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/channels" +) + +// MessageBus publishes inbound and outbound messages. +// It is the primary communication channel for the agent loop. +type MessageBus interface { + // PublishInbound sends an inbound message to be processed. + PublishInbound(ctx context.Context, msg bus.InboundMessage) error + + // PublishOutbound sends an outbound message to the appropriate channel. + PublishOutbound(ctx context.Context, msg bus.OutboundMessage) error + + // PublishOutboundMedia sends an outbound media message. + PublishOutboundMedia(ctx context.Context, msg bus.OutboundMediaMessage) error + + // InboundChan returns the channel for receiving inbound messages. + InboundChan() <-chan bus.InboundMessage +} + +// ChannelManager manages channel lifecycle and provides channel access. +type ChannelManager interface { + // GetChannel returns the channel with the given name. + GetChannel(name string) (channels.Channel, bool) + + // GetEnabledChannels returns the list of enabled channel names. + GetEnabledChannels() []string + + // InvokeTypingStop signals that typing has stopped. + InvokeTypingStop(channel, chatID string) + + // SendMessage sends a text message to the specified channel and chat. + SendMessage(ctx context.Context, msg bus.OutboundMessage) error + + // SendMedia sends a media message to the specified channel and chat. + SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error + + // SendPlaceholder sends a placeholder message (e.g., for audio transcription). + SendPlaceholder(ctx context.Context, channel, chatID string) bool +} diff --git a/pkg/agent/loop_turn.go b/pkg/agent/loop_turn.go deleted file mode 100644 index 1085ddeae..000000000 --- a/pkg/agent/loop_turn.go +++ /dev/null @@ -1,1878 +0,0 @@ -// PicoClaw - Ultra-lightweight personal AI agent - -package agent - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "strings" - "time" - - "github.com/sipeed/picoclaw/pkg/bus" - "github.com/sipeed/picoclaw/pkg/config" - "github.com/sipeed/picoclaw/pkg/constants" - "github.com/sipeed/picoclaw/pkg/logger" - "github.com/sipeed/picoclaw/pkg/providers" - "github.com/sipeed/picoclaw/pkg/tools" - "github.com/sipeed/picoclaw/pkg/utils" -) - -func (al *AgentLoop) runTurn(ctx context.Context, ts *turnState) (turnResult, error) { - turnCtx, turnCancel := context.WithCancel(ctx) - defer turnCancel() - ts.setTurnCancel(turnCancel) - - // Inject turnState and AgentLoop into context so tools (e.g. spawn) can retrieve them. - turnCtx = withTurnState(turnCtx, ts) - turnCtx = WithAgentLoop(turnCtx, al) - - al.registerActiveTurn(ts) - defer al.clearActiveTurn(ts) - - turnStatus := TurnEndStatusCompleted - defer func() { - al.emitEvent( - EventKindTurnEnd, - ts.eventMeta("runTurn", "turn.end"), - TurnEndPayload{ - Status: turnStatus, - Iterations: ts.currentIteration(), - Duration: time.Since(ts.startedAt), - FinalContentLen: ts.finalContentLen(), - }, - ) - }() - - al.emitEvent( - EventKindTurnStart, - ts.eventMeta("runTurn", "turn.start"), - TurnStartPayload{ - UserMessage: ts.userMessage, - MediaCount: len(ts.media), - }, - ) - - var history []providers.Message - var summary string - if !ts.opts.NoHistory { - // ContextManager assembles budget-aware history and summary. - if resp, err := al.contextManager.Assemble(turnCtx, &AssembleRequest{ - SessionKey: ts.sessionKey, - Budget: ts.agent.ContextWindow, - MaxTokens: ts.agent.MaxTokens, - }); err == nil && resp != nil { - history = resp.History - summary = resp.Summary - } - } - ts.captureRestorePoint(history, summary) - - messages := ts.agent.ContextBuilder.BuildMessages( - history, - summary, - ts.userMessage, - ts.media, - ts.channel, - ts.chatID, - ts.opts.Dispatch.SenderID(), - ts.opts.SenderDisplayName, - activeSkillNames(ts.agent, ts.opts)..., - ) - - cfg := al.GetConfig() - maxMediaSize := cfg.Agents.Defaults.GetMaxMediaSize() - messages = resolveMediaRefs(messages, al.mediaStore, maxMediaSize) - - if !ts.opts.NoHistory { - toolDefs := ts.agent.Tools.ToProviderDefs() - if isOverContextBudget(ts.agent.ContextWindow, messages, toolDefs, ts.agent.MaxTokens) { - logger.WarnCF("agent", "Proactive compression: context budget exceeded before LLM call", - map[string]any{"session_key": ts.sessionKey}) - if err := al.contextManager.Compact(turnCtx, &CompactRequest{ - SessionKey: ts.sessionKey, - Reason: ContextCompressReasonProactive, - Budget: ts.agent.ContextWindow, - }); err != nil { - logger.WarnCF("agent", "Proactive compact failed", map[string]any{ - "session_key": ts.sessionKey, - "error": err.Error(), - }) - } - ts.refreshRestorePointFromSession(ts.agent) - // Re-assemble from CM after compact. - if resp, err := al.contextManager.Assemble(turnCtx, &AssembleRequest{ - SessionKey: ts.sessionKey, - Budget: ts.agent.ContextWindow, - MaxTokens: ts.agent.MaxTokens, - }); err == nil && resp != nil { - history = resp.History - summary = resp.Summary - } - messages = ts.agent.ContextBuilder.BuildMessages( - history, summary, ts.userMessage, - ts.media, ts.channel, ts.chatID, - ts.opts.Dispatch.SenderID(), ts.opts.SenderDisplayName, - activeSkillNames(ts.agent, ts.opts)..., - ) - messages = resolveMediaRefs(messages, al.mediaStore, maxMediaSize) - } - } - - // Save user message to session (from Incoming) - if !ts.opts.NoHistory && (strings.TrimSpace(ts.userMessage) != "" || len(ts.media) > 0) { - rootMsg := providers.Message{ - Role: "user", - Content: ts.userMessage, - Media: append([]string(nil), ts.media...), - } - if len(rootMsg.Media) > 0 { - ts.agent.Sessions.AddFullMessage(ts.sessionKey, rootMsg) - } else { - ts.agent.Sessions.AddMessage(ts.sessionKey, rootMsg.Role, rootMsg.Content) - } - ts.recordPersistedMessage(rootMsg) - ts.ingestMessage(turnCtx, al, rootMsg) - } - - activeCandidates, activeModel, usedLight := al.selectCandidates(ts.agent, ts.userMessage, messages) - activeProvider := ts.agent.Provider - if usedLight && ts.agent.LightProvider != nil { - activeProvider = ts.agent.LightProvider - } - pendingMessages := append([]providers.Message(nil), ts.opts.InitialSteeringMessages...) - var finalContent string - -turnLoop: - for ts.currentIteration() < ts.agent.MaxIterations || len(pendingMessages) > 0 || func() bool { - graceful, _ := ts.gracefulInterruptRequested() - return graceful - }() { - if ts.hardAbortRequested() { - turnStatus = TurnEndStatusAborted - return al.abortTurn(ts) - } - - iteration := ts.currentIteration() + 1 - ts.setIteration(iteration) - ts.setPhase(TurnPhaseRunning) - - if iteration > 1 { - if steerMsgs := al.dequeueSteeringMessagesForScope(ts.sessionKey); len(steerMsgs) > 0 { - pendingMessages = append(pendingMessages, steerMsgs...) - } - } else if !ts.opts.SkipInitialSteeringPoll { - if steerMsgs := al.dequeueSteeringMessagesForScopeWithFallback(ts.sessionKey); len(steerMsgs) > 0 { - pendingMessages = append(pendingMessages, steerMsgs...) - } - } - - // Check if parent turn has ended (SubTurn support from HEAD) - if ts.parentTurnState != nil && ts.IsParentEnded() { - if !ts.critical { - logger.InfoCF("agent", "Parent turn ended, non-critical SubTurn exiting gracefully", map[string]any{ - "agent_id": ts.agentID, - "iteration": iteration, - "turn_id": ts.turnID, - }) - break - } - logger.InfoCF("agent", "Parent turn ended, critical SubTurn continues running", map[string]any{ - "agent_id": ts.agentID, - "iteration": iteration, - "turn_id": ts.turnID, - }) - } - - // Poll for pending SubTurn results (from HEAD) - if ts.pendingResults != nil { - select { - case result, ok := <-ts.pendingResults: - if ok && result != nil && result.ForLLM != "" { - content := al.cfg.FilterSensitiveData(result.ForLLM) - msg := providers.Message{Role: "user", Content: fmt.Sprintf("[SubTurn Result] %s", content)} - pendingMessages = append(pendingMessages, msg) - } - default: - // No results available - } - } - - // Inject pending steering messages - if len(pendingMessages) > 0 { - resolvedPending := resolveMediaRefs(pendingMessages, al.mediaStore, maxMediaSize) - totalContentLen := 0 - for i, pm := range pendingMessages { - messages = append(messages, resolvedPending[i]) - totalContentLen += len(pm.Content) - if !ts.opts.NoHistory { - ts.agent.Sessions.AddFullMessage(ts.sessionKey, pm) - ts.recordPersistedMessage(pm) - ts.ingestMessage(turnCtx, al, pm) - } - logger.InfoCF("agent", "Injected steering message into context", - map[string]any{ - "agent_id": ts.agent.ID, - "iteration": iteration, - "content_len": len(pm.Content), - "media_count": len(pm.Media), - }) - } - al.emitEvent( - EventKindSteeringInjected, - ts.eventMeta("runTurn", "turn.steering.injected"), - SteeringInjectedPayload{ - Count: len(pendingMessages), - TotalContentLen: totalContentLen, - }, - ) - pendingMessages = nil - } - - logger.DebugCF("agent", "LLM iteration", - map[string]any{ - "agent_id": ts.agent.ID, - "iteration": iteration, - "max": ts.agent.MaxIterations, - }) - - gracefulTerminal, _ := ts.gracefulInterruptRequested() - providerToolDefs := ts.agent.Tools.ToProviderDefs() - - // Native web search support (from HEAD) - _, hasWebSearch := ts.agent.Tools.Get("web_search") - useNativeSearch := al.cfg.Tools.Web.PreferNative && - hasWebSearch && - func() bool { - // Check if provider supports native search - if ns, ok := ts.agent.Provider.(interface{ SupportsNativeSearch() bool }); ok { - return ns.SupportsNativeSearch() - } - return false - }() - - if useNativeSearch { - // Filter out client-side web_search tool - filtered := make([]providers.ToolDefinition, 0, len(providerToolDefs)) - for _, td := range providerToolDefs { - if td.Function.Name != "web_search" { - filtered = append(filtered, td) - } - } - providerToolDefs = filtered - } - - // Resolve media:// refs produced by tool results (e.g. load_image). - // Skipped on iteration 1 because inbound user media is already resolved - // before entering the loop; only subsequent iterations can contain new - // tool-generated media refs that need base64 encoding. - if iteration > 1 { - messages = resolveMediaRefs(messages, al.mediaStore, maxMediaSize) - } - - callMessages := messages - if gracefulTerminal { - callMessages = append(append([]providers.Message(nil), messages...), ts.interruptHintMessage()) - providerToolDefs = nil - ts.markGracefulTerminalUsed() - } - - llmOpts := map[string]any{ - "max_tokens": ts.agent.MaxTokens, - "temperature": ts.agent.Temperature, - "prompt_cache_key": ts.agent.ID, - } - if useNativeSearch { - llmOpts["native_search"] = true - } - if ts.agent.ThinkingLevel != ThinkingOff { - if tc, ok := ts.agent.Provider.(providers.ThinkingCapable); ok && tc.SupportsThinking() { - llmOpts["thinking_level"] = string(ts.agent.ThinkingLevel) - } else { - logger.WarnCF("agent", "thinking_level is set but current provider does not support it, ignoring", - map[string]any{"agent_id": ts.agent.ID, "thinking_level": string(ts.agent.ThinkingLevel)}) - } - } - - llmModel := activeModel - if al.hooks != nil { - llmReq, decision := al.hooks.BeforeLLM(turnCtx, &LLMHookRequest{ - Meta: ts.eventMeta("runTurn", "turn.llm.request"), - Context: cloneTurnContext(ts.turnCtx), - Model: llmModel, - Messages: callMessages, - Tools: providerToolDefs, - Options: llmOpts, - GracefulTerminal: gracefulTerminal, - }) - switch decision.normalizedAction() { - case HookActionContinue, HookActionModify: - if llmReq != nil { - llmModel = llmReq.Model - callMessages = llmReq.Messages - providerToolDefs = llmReq.Tools - llmOpts = llmReq.Options - } - case HookActionAbortTurn: - turnStatus = TurnEndStatusError - return turnResult{}, al.hookAbortError(ts, "before_llm", decision) - case HookActionHardAbort: - _ = ts.requestHardAbort() - turnStatus = TurnEndStatusAborted - return al.abortTurn(ts) - } - } - - al.emitEvent( - EventKindLLMRequest, - ts.eventMeta("runTurn", "turn.llm.request"), - LLMRequestPayload{ - Model: llmModel, - MessagesCount: len(callMessages), - ToolsCount: len(providerToolDefs), - MaxTokens: ts.agent.MaxTokens, - Temperature: ts.agent.Temperature, - }, - ) - - logger.DebugCF("agent", "LLM request", - map[string]any{ - "agent_id": ts.agent.ID, - "iteration": iteration, - "model": llmModel, - "messages_count": len(callMessages), - "tools_count": len(providerToolDefs), - "max_tokens": ts.agent.MaxTokens, - "temperature": ts.agent.Temperature, - "system_prompt_len": len(callMessages[0].Content), - }) - logger.DebugCF("agent", "Full LLM request", - map[string]any{ - "iteration": iteration, - "messages_json": formatMessagesForLog(callMessages), - "tools_json": formatToolsForLog(providerToolDefs), - }) - - callLLM := func(messagesForCall []providers.Message, toolDefsForCall []providers.ToolDefinition) (*providers.LLMResponse, error) { - providerCtx, providerCancel := context.WithCancel(turnCtx) - ts.setProviderCancel(providerCancel) - defer func() { - providerCancel() - ts.clearProviderCancel(providerCancel) - }() - - al.activeRequests.Add(1) - defer al.activeRequests.Done() - - if len(activeCandidates) > 1 && al.fallback != nil { - fbResult, fbErr := al.fallback.Execute( - providerCtx, - activeCandidates, - func(ctx context.Context, provider, model string) (*providers.LLMResponse, error) { - candidateProvider := activeProvider - if cp, ok := ts.agent.CandidateProviders[providers.ModelKey(provider, model)]; ok { - candidateProvider = cp - } - return candidateProvider.Chat(ctx, messagesForCall, toolDefsForCall, model, llmOpts) - }, - ) - if fbErr != nil { - return nil, fbErr - } - if fbResult.Provider != "" && len(fbResult.Attempts) > 0 { - logger.InfoCF( - "agent", - fmt.Sprintf("Fallback: succeeded with %s/%s after %d attempts", - fbResult.Provider, fbResult.Model, len(fbResult.Attempts)+1), - map[string]any{"agent_id": ts.agent.ID, "iteration": iteration}, - ) - } - return fbResult.Response, nil - } - return activeProvider.Chat(providerCtx, messagesForCall, toolDefsForCall, llmModel, llmOpts) - } - - var response *providers.LLMResponse - var err error - maxRetries := 2 - for retry := 0; retry <= maxRetries; retry++ { - response, err = callLLM(callMessages, providerToolDefs) - if err == nil { - break - } - if ts.hardAbortRequested() && errors.Is(err, context.Canceled) { - turnStatus = TurnEndStatusAborted - return al.abortTurn(ts) - } - - // Retry without media if vision is unsupported - if hasMediaRefs(callMessages) && isVisionUnsupportedError(err) && retry < maxRetries { - al.emitEvent( - EventKindLLMRetry, - ts.eventMeta("runTurn", "turn.llm.retry"), - LLMRetryPayload{ - Attempt: retry + 1, - MaxRetries: maxRetries, - Reason: "vision_unsupported", - Error: err.Error(), - Backoff: 0, - }, - ) - logger.WarnCF("agent", "Vision unsupported, retrying without media", map[string]any{ - "error": err.Error(), - "retry": retry, - }) - callMessages = stripMessageMedia(callMessages) - // Also strip media from session history to prevent future errors - if !ts.opts.NoHistory { - history = stripMessageMedia(history) - ts.agent.Sessions.SetHistory(ts.sessionKey, history) - for i := range ts.persistedMessages { - ts.persistedMessages[i].Media = nil - } - ts.refreshRestorePointFromSession(ts.agent) - } - continue - } - - errMsg := strings.ToLower(err.Error()) - isTimeoutError := errors.Is(err, context.DeadlineExceeded) || - strings.Contains(errMsg, "deadline exceeded") || - strings.Contains(errMsg, "client.timeout") || - strings.Contains(errMsg, "timed out") || - strings.Contains(errMsg, "timeout exceeded") - - isContextError := !isTimeoutError && (strings.Contains(errMsg, "context_length_exceeded") || - strings.Contains(errMsg, "context window") || - strings.Contains(errMsg, "context_window") || - strings.Contains(errMsg, "maximum context length") || - strings.Contains(errMsg, "token limit") || - strings.Contains(errMsg, "too many tokens") || - strings.Contains(errMsg, "max_tokens") || - strings.Contains(errMsg, "invalidparameter") || - strings.Contains(errMsg, "prompt is too long") || - strings.Contains(errMsg, "request too large")) - - if isTimeoutError && retry < maxRetries { - backoff := time.Duration(retry+1) * 5 * time.Second - al.emitEvent( - EventKindLLMRetry, - ts.eventMeta("runTurn", "turn.llm.retry"), - LLMRetryPayload{ - Attempt: retry + 1, - MaxRetries: maxRetries, - Reason: "timeout", - Error: err.Error(), - Backoff: backoff, - }, - ) - logger.WarnCF("agent", "Timeout error, retrying after backoff", map[string]any{ - "error": err.Error(), - "retry": retry, - "backoff": backoff.String(), - }) - if sleepErr := sleepWithContext(turnCtx, backoff); sleepErr != nil { - if ts.hardAbortRequested() { - turnStatus = TurnEndStatusAborted - return al.abortTurn(ts) - } - err = sleepErr - break - } - continue - } - - if isContextError && retry < maxRetries && !ts.opts.NoHistory { - al.emitEvent( - EventKindLLMRetry, - ts.eventMeta("runTurn", "turn.llm.retry"), - LLMRetryPayload{ - Attempt: retry + 1, - MaxRetries: maxRetries, - Reason: "context_limit", - Error: err.Error(), - }, - ) - logger.WarnCF( - "agent", - "Context window error detected, attempting compression", - map[string]any{ - "error": err.Error(), - "retry": retry, - }, - ) - - if retry == 0 && !constants.IsInternalChannel(ts.channel) { - al.bus.PublishOutbound(ctx, outboundMessageForTurn( - ts, - "Context window exceeded. Compressing history and retrying...", - )) - } - - if compactErr := al.contextManager.Compact(turnCtx, &CompactRequest{ - SessionKey: ts.sessionKey, - Reason: ContextCompressReasonRetry, - Budget: ts.agent.ContextWindow, - }); compactErr != nil { - logger.WarnCF("agent", "Context overflow compact failed", map[string]any{ - "session_key": ts.sessionKey, - "error": compactErr.Error(), - }) - } - ts.refreshRestorePointFromSession(ts.agent) - // Re-assemble from CM after compact. - if asmResp, asmErr := al.contextManager.Assemble(turnCtx, &AssembleRequest{ - SessionKey: ts.sessionKey, - Budget: ts.agent.ContextWindow, - MaxTokens: ts.agent.MaxTokens, - }); asmErr == nil && asmResp != nil { - history = asmResp.History - summary = asmResp.Summary - } - messages = ts.agent.ContextBuilder.BuildMessages( - history, summary, "", - nil, ts.channel, ts.chatID, ts.opts.Dispatch.SenderID(), ts.opts.SenderDisplayName, - activeSkillNames(ts.agent, ts.opts)..., - ) - callMessages = messages - if gracefulTerminal { - callMessages = append(append([]providers.Message(nil), messages...), ts.interruptHintMessage()) - } - continue - } - break - } - - if err != nil { - turnStatus = TurnEndStatusError - al.emitEvent( - EventKindError, - ts.eventMeta("runTurn", "turn.error"), - ErrorPayload{ - Stage: "llm", - Message: err.Error(), - }, - ) - logger.ErrorCF("agent", "LLM call failed", - map[string]any{ - "agent_id": ts.agent.ID, - "iteration": iteration, - "model": llmModel, - "error": err.Error(), - }) - return turnResult{}, fmt.Errorf("LLM call failed after retries: %w", err) - } - - if al.hooks != nil { - llmResp, decision := al.hooks.AfterLLM(turnCtx, &LLMHookResponse{ - Meta: ts.eventMeta("runTurn", "turn.llm.response"), - Context: cloneTurnContext(ts.turnCtx), - Model: llmModel, - Response: response, - }) - switch decision.normalizedAction() { - case HookActionContinue, HookActionModify: - if llmResp != nil && llmResp.Response != nil { - response = llmResp.Response - } - case HookActionAbortTurn: - turnStatus = TurnEndStatusError - return turnResult{}, al.hookAbortError(ts, "after_llm", decision) - case HookActionHardAbort: - _ = ts.requestHardAbort() - turnStatus = TurnEndStatusAborted - return al.abortTurn(ts) - } - } - - // Save finishReason to turnState for SubTurn truncation detection - if innerTS := turnStateFromContext(ctx); innerTS != nil { - innerTS.SetLastFinishReason(response.FinishReason) - // Save usage for token budget tracking - if response.Usage != nil { - innerTS.SetLastUsage(response.Usage) - } - } - - reasoningContent := response.Reasoning - if reasoningContent == "" { - reasoningContent = response.ReasoningContent - } - if ts.channel == "pico" { - go al.publishPicoReasoning(turnCtx, reasoningContent, ts.chatID) - } else { - go al.handleReasoning( - turnCtx, - reasoningContent, - ts.channel, - al.targetReasoningChannelID(ts.channel), - ) - } - al.emitEvent( - EventKindLLMResponse, - ts.eventMeta("runTurn", "turn.llm.response"), - LLMResponsePayload{ - ContentLen: len(response.Content), - ToolCalls: len(response.ToolCalls), - HasReasoning: response.Reasoning != "" || response.ReasoningContent != "", - }, - ) - - llmResponseFields := map[string]any{ - "agent_id": ts.agent.ID, - "iteration": iteration, - "content_chars": len(response.Content), - "tool_calls": len(response.ToolCalls), - "reasoning": response.Reasoning, - "target_channel": al.targetReasoningChannelID(ts.channel), - "channel": ts.channel, - } - if response.Usage != nil { - llmResponseFields["prompt_tokens"] = response.Usage.PromptTokens - llmResponseFields["completion_tokens"] = response.Usage.CompletionTokens - llmResponseFields["total_tokens"] = response.Usage.TotalTokens - } - logger.DebugCF("agent", "LLM response", llmResponseFields) - - if al.bus != nil && ts.channel == "pico" && len(response.ToolCalls) > 0 && ts.opts.AllowInterimPicoPublish { - if strings.TrimSpace(response.Content) != "" { - outCtx, outCancel := context.WithTimeout(turnCtx, 3*time.Second) - err := al.bus.PublishOutbound(outCtx, bus.OutboundMessage{ - Channel: ts.channel, - ChatID: ts.chatID, - Content: response.Content, - }) - outCancel() - if err != nil { - logger.WarnCF("agent", "Failed to publish pico interim tool-call content", map[string]any{ - "error": err.Error(), - "channel": ts.channel, - "chat_id": ts.chatID, - "iteration": iteration, - }) - } - } - } - - if len(response.ToolCalls) == 0 || gracefulTerminal { - responseContent := response.Content - if responseContent == "" && response.ReasoningContent != "" && ts.channel != "pico" { - responseContent = response.ReasoningContent - } - if steerMsgs := al.dequeueSteeringMessagesForScope(ts.sessionKey); len(steerMsgs) > 0 { - logger.InfoCF("agent", "Steering arrived after direct LLM response; continuing turn", - map[string]any{ - "agent_id": ts.agent.ID, - "iteration": iteration, - "steering_count": len(steerMsgs), - }) - pendingMessages = append(pendingMessages, steerMsgs...) - continue - } - finalContent = responseContent - logger.InfoCF("agent", "LLM response without tool calls (direct answer)", - map[string]any{ - "agent_id": ts.agent.ID, - "iteration": iteration, - "content_chars": len(finalContent), - }) - break - } - - normalizedToolCalls := make([]providers.ToolCall, 0, len(response.ToolCalls)) - for _, tc := range response.ToolCalls { - normalizedToolCalls = append(normalizedToolCalls, providers.NormalizeToolCall(tc)) - } - - toolNames := make([]string, 0, len(normalizedToolCalls)) - for _, tc := range normalizedToolCalls { - toolNames = append(toolNames, tc.Name) - } - logger.InfoCF("agent", "LLM requested tool calls", - map[string]any{ - "agent_id": ts.agent.ID, - "tools": toolNames, - "count": len(normalizedToolCalls), - "iteration": iteration, - }) - - allResponsesHandled := len(normalizedToolCalls) > 0 - assistantMsg := providers.Message{ - Role: "assistant", - Content: response.Content, - ReasoningContent: response.ReasoningContent, - } - for _, tc := range normalizedToolCalls { - argumentsJSON, _ := json.Marshal(tc.Arguments) - extraContent := tc.ExtraContent - thoughtSignature := "" - if tc.Function != nil { - thoughtSignature = tc.Function.ThoughtSignature - } - assistantMsg.ToolCalls = append(assistantMsg.ToolCalls, providers.ToolCall{ - ID: tc.ID, - Type: "function", - Name: tc.Name, - Function: &providers.FunctionCall{ - Name: tc.Name, - Arguments: string(argumentsJSON), - ThoughtSignature: thoughtSignature, - }, - ExtraContent: extraContent, - ThoughtSignature: thoughtSignature, - }) - } - messages = append(messages, assistantMsg) - if !ts.opts.NoHistory { - ts.agent.Sessions.AddFullMessage(ts.sessionKey, assistantMsg) - ts.recordPersistedMessage(assistantMsg) - ts.ingestMessage(turnCtx, al, assistantMsg) - } - - ts.setPhase(TurnPhaseTools) - for i, tc := range normalizedToolCalls { - if ts.hardAbortRequested() { - turnStatus = TurnEndStatusAborted - return al.abortTurn(ts) - } - - toolName := tc.Name - toolArgs := cloneStringAnyMap(tc.Arguments) - - if al.hooks != nil { - toolReq, decision := al.hooks.BeforeTool(turnCtx, &ToolCallHookRequest{ - Meta: ts.eventMeta("runTurn", "turn.tool.before"), - Context: cloneTurnContext(ts.turnCtx), - Tool: toolName, - Arguments: toolArgs, - }) - switch decision.normalizedAction() { - case HookActionContinue, HookActionModify: - if toolReq != nil { - toolName = toolReq.Tool - toolArgs = toolReq.Arguments - } - case HookActionRespond: - // Hook returns result directly, skip tool execution. - // SECURITY: This bypasses ApproveTool, allowing hooks to respond - // for any tool name without approval. This is intentional for - // plugin tools but means a before_tool hook can override even - // sensitive tools like bash. Hook configuration should be - // carefully reviewed to prevent unauthorized tool execution. - if toolReq != nil && toolReq.HookResult != nil { - hookResult := toolReq.HookResult - - argsJSON, _ := json.Marshal(toolArgs) - argsPreview := utils.Truncate(string(argsJSON), 200) - logger.InfoCF("agent", fmt.Sprintf("Tool call (hook respond): %s(%s)", toolName, argsPreview), - map[string]any{ - "agent_id": ts.agent.ID, - "tool": toolName, - "iteration": iteration, - }) - - // Emit ToolExecStart event (same as normal tool execution) - al.emitEvent( - EventKindToolExecStart, - ts.eventMeta("runTurn", "turn.tool.start"), - ToolExecStartPayload{ - Tool: toolName, - Arguments: cloneEventArguments(toolArgs), - }, - ) - - // Send tool feedback to chat channel if enabled (same as normal tool execution) - if al.cfg.Agents.Defaults.IsToolFeedbackEnabled() && - ts.channel != "" && - !ts.opts.SuppressToolFeedback { - argsJSON, _ := json.Marshal(toolArgs) - feedbackPreview := utils.Truncate( - string(argsJSON), - al.cfg.Agents.Defaults.GetToolFeedbackMaxArgsLength(), - ) - feedbackMsg := utils.FormatToolFeedbackMessage(toolName, feedbackPreview) - fbCtx, fbCancel := context.WithTimeout(turnCtx, 3*time.Second) - _ = al.bus.PublishOutbound(fbCtx, bus.OutboundMessage{ - Channel: ts.channel, - ChatID: ts.chatID, - Content: feedbackMsg, - }) - fbCancel() - } - - toolDuration := time.Duration(0) // Hook execution time unknown - - // Send ForUser content to user - // For ResponseHandled results, send regardless of SendResponse setting, - // same as normal tool execution path. - shouldSendForUser := !hookResult.Silent && hookResult.ForUser != "" && - (ts.opts.SendResponse || hookResult.ResponseHandled) - if shouldSendForUser { - al.bus.PublishOutbound(ctx, bus.OutboundMessage{ - Context: bus.InboundContext{ - Channel: ts.channel, - ChatID: ts.chatID, - Raw: map[string]string{ - "is_tool_call": "true", - }, - }, - Content: hookResult.ForUser, - }) - } - - // Handle media from hook result (same as normal tool execution) - if len(hookResult.Media) > 0 && hookResult.ResponseHandled { - parts := make([]bus.MediaPart, 0, len(hookResult.Media)) - for _, ref := range hookResult.Media { - part := bus.MediaPart{Ref: ref} - if al.mediaStore != nil { - if _, meta, err := al.mediaStore.ResolveWithMeta(ref); err == nil { - part.Filename = meta.Filename - part.ContentType = meta.ContentType - part.Type = inferMediaType(meta.Filename, meta.ContentType) - } - } - parts = append(parts, part) - } - outboundMedia := bus.OutboundMediaMessage{ - Channel: ts.channel, - ChatID: ts.chatID, - Parts: parts, - } - if al.channelManager != nil && ts.channel != "" && !constants.IsInternalChannel(ts.channel) { - if err := al.channelManager.SendMedia(ctx, outboundMedia); err != nil { - logger.WarnCF("agent", "Failed to deliver hook media", - map[string]any{ - "agent_id": ts.agent.ID, - "tool": toolName, - "channel": ts.channel, - "chat_id": ts.chatID, - "error": err.Error(), - }) - // Same as normal tool execution: notify LLM about delivery failure - hookResult.IsError = true - hookResult.ForLLM = fmt.Sprintf("failed to deliver attachment: %v", err) - } - } else if al.bus != nil { - al.bus.PublishOutboundMedia(ctx, outboundMedia) - // Same as normal tool execution: bus only queues, media not yet delivered - hookResult.ResponseHandled = false - } - } - - // Track response handling status (same as normal tool execution) - if !hookResult.ResponseHandled { - allResponsesHandled = false - } - - // Build tool message - contentForLLM := hookResult.ContentForLLM() - if al.cfg.Tools.IsFilterSensitiveDataEnabled() { - contentForLLM = al.cfg.FilterSensitiveData(contentForLLM) - } - - toolResultMsg := providers.Message{ - Role: "tool", - Content: contentForLLM, - ToolCallID: tc.ID, - } - - // Handle media for LLM vision (same as normal tool execution) - if len(hookResult.Media) > 0 && !hookResult.ResponseHandled { - hookResult.ArtifactTags = buildArtifactTags(al.mediaStore, hookResult.Media) - // Recalculate contentForLLM after adding ArtifactTags - contentForLLM = hookResult.ContentForLLM() - if al.cfg.Tools.IsFilterSensitiveDataEnabled() { - contentForLLM = al.cfg.FilterSensitiveData(contentForLLM) - } - toolResultMsg.Content = contentForLLM - toolResultMsg.Media = append(toolResultMsg.Media, hookResult.Media...) - } - - // Emit ToolExecEnd event (after filtering, same as normal tool execution) - al.emitEvent( - EventKindToolExecEnd, - ts.eventMeta("runTurn", "turn.tool.end"), - ToolExecEndPayload{ - Tool: toolName, - Duration: toolDuration, - ForLLMLen: len(contentForLLM), - ForUserLen: len(hookResult.ForUser), - IsError: hookResult.IsError, - Async: hookResult.Async, - }, - ) - - messages = append(messages, toolResultMsg) - if !ts.opts.NoHistory { - ts.agent.Sessions.AddFullMessage(ts.sessionKey, toolResultMsg) - ts.recordPersistedMessage(toolResultMsg) - ts.ingestMessage(turnCtx, al, toolResultMsg) - } - - // Same as normal tool execution: check for steering/interrupt/SubTurn after each tool - if steerMsgs := al.dequeueSteeringMessagesForScope(ts.sessionKey); len(steerMsgs) > 0 { - pendingMessages = append(pendingMessages, steerMsgs...) - } - - skipReason := "" - skipMessage := "" - if len(pendingMessages) > 0 { - skipReason = "queued user steering message" - skipMessage = "Skipped due to queued user message." - } else if gracefulPending, _ := ts.gracefulInterruptRequested(); gracefulPending { - skipReason = "graceful interrupt requested" - skipMessage = "Skipped due to graceful interrupt." - } - - if skipReason != "" { - remaining := len(normalizedToolCalls) - i - 1 - if remaining > 0 { - logger.InfoCF("agent", "Turn checkpoint: skipping remaining tools after hook respond", - map[string]any{ - "agent_id": ts.agent.ID, - "completed": i + 1, - "skipped": remaining, - "reason": skipReason, - }) - for j := i + 1; j < len(normalizedToolCalls); j++ { - skippedTC := normalizedToolCalls[j] - al.emitEvent( - EventKindToolExecSkipped, - ts.eventMeta("runTurn", "turn.tool.skipped"), - ToolExecSkippedPayload{ - Tool: skippedTC.Name, - Reason: skipReason, - }, - ) - skippedMsg := providers.Message{ - Role: "tool", - Content: skipMessage, - ToolCallID: skippedTC.ID, - } - messages = append(messages, skippedMsg) - if !ts.opts.NoHistory { - ts.agent.Sessions.AddFullMessage(ts.sessionKey, skippedMsg) - ts.recordPersistedMessage(skippedMsg) - } - } - } - break - } - - // Also poll for any SubTurn results that arrived during tool execution. - if ts.pendingResults != nil { - select { - case result, ok := <-ts.pendingResults: - if ok && result != nil && result.ForLLM != "" { - content := al.cfg.FilterSensitiveData(result.ForLLM) - msg := providers.Message{Role: "user", Content: fmt.Sprintf("[SubTurn Result] %s", content)} - messages = append(messages, msg) - ts.agent.Sessions.AddFullMessage(ts.sessionKey, msg) - } - default: - // No results available - } - } - - continue - } - // If no HookResult, fall back to continue with warning - logger.WarnCF("agent", "Hook returned respond action but no HookResult provided", - map[string]any{ - "agent_id": ts.agent.ID, - "tool": toolName, - "action": "respond", - }) - case HookActionDenyTool: - allResponsesHandled = false - denyContent := hookDeniedToolContent("Tool execution denied by hook", decision.Reason) - al.emitEvent( - EventKindToolExecSkipped, - ts.eventMeta("runTurn", "turn.tool.skipped"), - ToolExecSkippedPayload{ - Tool: toolName, - Reason: denyContent, - }, - ) - deniedMsg := providers.Message{ - Role: "tool", - Content: denyContent, - ToolCallID: tc.ID, - } - messages = append(messages, deniedMsg) - if !ts.opts.NoHistory { - ts.agent.Sessions.AddFullMessage(ts.sessionKey, deniedMsg) - ts.recordPersistedMessage(deniedMsg) - } - continue - case HookActionAbortTurn: - turnStatus = TurnEndStatusError - return turnResult{}, al.hookAbortError(ts, "before_tool", decision) - case HookActionHardAbort: - _ = ts.requestHardAbort() - turnStatus = TurnEndStatusAborted - return al.abortTurn(ts) - } - } - - if al.hooks != nil { - approval := al.hooks.ApproveTool(turnCtx, &ToolApprovalRequest{ - Meta: ts.eventMeta("runTurn", "turn.tool.approve"), - Context: cloneTurnContext(ts.turnCtx), - Tool: toolName, - Arguments: toolArgs, - }) - if !approval.Approved { - allResponsesHandled = false - denyContent := hookDeniedToolContent("Tool execution denied by approval hook", approval.Reason) - al.emitEvent( - EventKindToolExecSkipped, - ts.eventMeta("runTurn", "turn.tool.skipped"), - ToolExecSkippedPayload{ - Tool: toolName, - Reason: denyContent, - }, - ) - deniedMsg := providers.Message{ - Role: "tool", - Content: denyContent, - ToolCallID: tc.ID, - } - messages = append(messages, deniedMsg) - if !ts.opts.NoHistory { - ts.agent.Sessions.AddFullMessage(ts.sessionKey, deniedMsg) - ts.recordPersistedMessage(deniedMsg) - } - continue - } - } - - argsJSON, _ := json.Marshal(toolArgs) - argsPreview := utils.Truncate(string(argsJSON), 200) - logger.InfoCF("agent", fmt.Sprintf("Tool call: %s(%s)", toolName, argsPreview), - map[string]any{ - "agent_id": ts.agent.ID, - "tool": toolName, - "iteration": iteration, - }) - al.emitEvent( - EventKindToolExecStart, - ts.eventMeta("runTurn", "turn.tool.start"), - ToolExecStartPayload{ - Tool: toolName, - Arguments: cloneEventArguments(toolArgs), - }, - ) - - // Send tool feedback to chat channel if enabled (from HEAD) - if al.cfg.Agents.Defaults.IsToolFeedbackEnabled() && - ts.channel != "" && - !ts.opts.SuppressToolFeedback { - feedbackPreview := utils.Truncate( - string(argsJSON), - al.cfg.Agents.Defaults.GetToolFeedbackMaxArgsLength(), - ) - feedbackMsg := utils.FormatToolFeedbackMessage(tc.Name, feedbackPreview) - fbCtx, fbCancel := context.WithTimeout(turnCtx, 3*time.Second) - _ = al.bus.PublishOutbound(fbCtx, outboundMessageForTurn(ts, feedbackMsg)) - fbCancel() - } - - toolCallID := tc.ID - toolIteration := iteration - asyncToolName := toolName - asyncCallback := func(_ context.Context, result *tools.ToolResult) { - // Send ForUser content directly to the user (immediate feedback), - // mirroring the synchronous tool execution path. - if !result.Silent && result.ForUser != "" { - outCtx, outCancel := context.WithTimeout(context.Background(), 5*time.Second) - defer outCancel() - _ = al.bus.PublishOutbound(outCtx, outboundMessageForTurn(ts, result.ForUser)) - } - - // Determine content for the agent loop (ForLLM or error). - content := result.ContentForLLM() - if content == "" { - return - } - - // Filter sensitive data before publishing - content = al.cfg.FilterSensitiveData(content) - - logger.InfoCF("agent", "Async tool completed, publishing result", - map[string]any{ - "tool": asyncToolName, - "content_len": len(content), - "channel": ts.channel, - }) - al.emitEvent( - EventKindFollowUpQueued, - ts.scope.meta(toolIteration, "runTurn", "turn.follow_up.queued"), - FollowUpQueuedPayload{ - SourceTool: asyncToolName, - ContentLen: len(content), - }, - ) - - pubCtx, pubCancel := context.WithTimeout(context.Background(), 5*time.Second) - defer pubCancel() - _ = al.bus.PublishInbound(pubCtx, bus.InboundMessage{ - Context: bus.InboundContext{ - Channel: "system", - ChatID: fmt.Sprintf("%s:%s", ts.channel, ts.chatID), - ChatType: "direct", - SenderID: fmt.Sprintf("async:%s", asyncToolName), - }, - Content: content, - }) - } - - toolStart := time.Now() - execCtx := tools.WithToolInboundContext( - turnCtx, - ts.channel, - ts.chatID, - ts.opts.Dispatch.MessageID(), - ts.opts.Dispatch.ReplyToMessageID(), - ) - execCtx = tools.WithToolSessionContext( - execCtx, - ts.agent.ID, - ts.sessionKey, - ts.opts.Dispatch.SessionScope, - ) - toolResult := ts.agent.Tools.ExecuteWithContext( - execCtx, - toolName, - toolArgs, - ts.channel, - ts.chatID, - asyncCallback, - ) - toolDuration := time.Since(toolStart) - - if ts.hardAbortRequested() { - turnStatus = TurnEndStatusAborted - return al.abortTurn(ts) - } - - if al.hooks != nil { - toolResp, decision := al.hooks.AfterTool(turnCtx, &ToolResultHookResponse{ - Meta: ts.eventMeta("runTurn", "turn.tool.after"), - Context: cloneTurnContext(ts.turnCtx), - Tool: toolName, - Arguments: toolArgs, - Result: toolResult, - Duration: toolDuration, - }) - switch decision.normalizedAction() { - case HookActionContinue, HookActionModify: - if toolResp != nil { - if toolResp.Tool != "" { - toolName = toolResp.Tool - } - if toolResp.Result != nil { - toolResult = toolResp.Result - } - } - case HookActionAbortTurn: - turnStatus = TurnEndStatusError - return turnResult{}, al.hookAbortError(ts, "after_tool", decision) - case HookActionHardAbort: - _ = ts.requestHardAbort() - turnStatus = TurnEndStatusAborted - return al.abortTurn(ts) - } - } - - if toolResult == nil { - toolResult = tools.ErrorResult("hook returned nil tool result") - } - - if len(toolResult.Media) > 0 && toolResult.ResponseHandled { - parts := make([]bus.MediaPart, 0, len(toolResult.Media)) - for _, ref := range toolResult.Media { - part := bus.MediaPart{Ref: ref} - if al.mediaStore != nil { - if _, meta, err := al.mediaStore.ResolveWithMeta(ref); err == nil { - part.Filename = meta.Filename - part.ContentType = meta.ContentType - part.Type = inferMediaType(meta.Filename, meta.ContentType) - } - } - parts = append(parts, part) - } - outboundMedia := bus.OutboundMediaMessage{ - Channel: ts.channel, - ChatID: ts.chatID, - Context: outboundContextFromInbound( - ts.opts.Dispatch.InboundContext, - ts.channel, - ts.chatID, - ts.opts.Dispatch.ReplyToMessageID(), - ), - AgentID: ts.agent.ID, - SessionKey: ts.sessionKey, - Scope: outboundScopeFromSessionScope(ts.opts.Dispatch.SessionScope), - Parts: parts, - } - if al.channelManager != nil && ts.channel != "" && !constants.IsInternalChannel(ts.channel) { - if err := al.channelManager.SendMedia(ctx, outboundMedia); err != nil { - logger.WarnCF("agent", "Failed to deliver handled tool media", - map[string]any{ - "agent_id": ts.agent.ID, - "tool": toolName, - "channel": ts.channel, - "chat_id": ts.chatID, - "error": err.Error(), - }) - toolResult = tools.ErrorResult(fmt.Sprintf("failed to deliver attachment: %v", err)).WithError(err) - } - } else if al.bus != nil { - al.bus.PublishOutboundMedia(ctx, outboundMedia) - // Queuing media is only best-effort; it has not been delivered yet. - toolResult.ResponseHandled = false - } - } - - if len(toolResult.Media) > 0 && !toolResult.ResponseHandled { - // For tools like load_image that produce media refs without sending them - // to the user channel (ResponseHandled == false), both Media and ArtifactTags - // coexist on the result: - // - Media: carries media:// refs that resolveMediaRefs will base64-encode - // into image_url parts in the next LLM iteration (enabling vision). - // - ArtifactTags: exposes the local file path as a structured [file:…] tag - // in the tool result text, so the LLM knows an artifact was produced. - toolResult.ArtifactTags = buildArtifactTags(al.mediaStore, toolResult.Media) - } - - if !toolResult.ResponseHandled { - allResponsesHandled = false - } - - shouldSendForUser := !toolResult.Silent && - toolResult.ForUser != "" && - (ts.opts.SendResponse || toolResult.ResponseHandled) - if shouldSendForUser { - al.bus.PublishOutbound(ctx, outboundMessageForTurn(ts, toolResult.ForUser)) - logger.DebugCF("agent", "Sent tool result to user", - map[string]any{ - "tool": toolName, - "content_len": len(toolResult.ForUser), - }) - } - contentForLLM := toolResult.ContentForLLM() - - // Filter sensitive data (API keys, tokens, secrets) before sending to LLM - if al.cfg.Tools.IsFilterSensitiveDataEnabled() { - contentForLLM = al.cfg.FilterSensitiveData(contentForLLM) - } - - toolResultMsg := providers.Message{ - Role: "tool", - Content: contentForLLM, - ToolCallID: toolCallID, - } - if len(toolResult.Media) > 0 && !toolResult.ResponseHandled { - toolResultMsg.Media = append(toolResultMsg.Media, toolResult.Media...) - } - al.emitEvent( - EventKindToolExecEnd, - ts.eventMeta("runTurn", "turn.tool.end"), - ToolExecEndPayload{ - Tool: toolName, - Duration: toolDuration, - ForLLMLen: len(contentForLLM), - ForUserLen: len(toolResult.ForUser), - IsError: toolResult.IsError, - Async: toolResult.Async, - }, - ) - messages = append(messages, toolResultMsg) - if !ts.opts.NoHistory { - ts.agent.Sessions.AddFullMessage(ts.sessionKey, toolResultMsg) - ts.recordPersistedMessage(toolResultMsg) - ts.ingestMessage(turnCtx, al, toolResultMsg) - } - - if steerMsgs := al.dequeueSteeringMessagesForScope(ts.sessionKey); len(steerMsgs) > 0 { - pendingMessages = append(pendingMessages, steerMsgs...) - } - - skipReason := "" - skipMessage := "" - if len(pendingMessages) > 0 { - skipReason = "queued user steering message" - skipMessage = "Skipped due to queued user message." - } else if gracefulPending, _ := ts.gracefulInterruptRequested(); gracefulPending { - skipReason = "graceful interrupt requested" - skipMessage = "Skipped due to graceful interrupt." - } - - if skipReason != "" { - remaining := len(normalizedToolCalls) - i - 1 - if remaining > 0 { - logger.InfoCF("agent", "Turn checkpoint: skipping remaining tools", - map[string]any{ - "agent_id": ts.agent.ID, - "completed": i + 1, - "skipped": remaining, - "reason": skipReason, - }) - for j := i + 1; j < len(normalizedToolCalls); j++ { - skippedTC := normalizedToolCalls[j] - al.emitEvent( - EventKindToolExecSkipped, - ts.eventMeta("runTurn", "turn.tool.skipped"), - ToolExecSkippedPayload{ - Tool: skippedTC.Name, - Reason: skipReason, - }, - ) - skippedMsg := providers.Message{ - Role: "tool", - Content: skipMessage, - ToolCallID: skippedTC.ID, - } - messages = append(messages, skippedMsg) - if !ts.opts.NoHistory { - ts.agent.Sessions.AddFullMessage(ts.sessionKey, skippedMsg) - ts.recordPersistedMessage(skippedMsg) - } - } - } - break - } - - // Also poll for any SubTurn results that arrived during tool execution. - if ts.pendingResults != nil { - select { - case result, ok := <-ts.pendingResults: - if ok && result != nil && result.ForLLM != "" { - content := al.cfg.FilterSensitiveData(result.ForLLM) - msg := providers.Message{Role: "user", Content: fmt.Sprintf("[SubTurn Result] %s", content)} - messages = append(messages, msg) - ts.agent.Sessions.AddFullMessage(ts.sessionKey, msg) - } - default: - // No results available - } - } - } - - if allResponsesHandled { - if len(pendingMessages) > 0 { - logger.InfoCF("agent", "Pending steering exists after handled tool delivery; continuing turn before finalizing", - map[string]any{ - "agent_id": ts.agent.ID, - "steering_count": len(pendingMessages), - "session_key": ts.sessionKey, - }) - finalContent = "" - goto turnLoop - } - - if steerMsgs := al.dequeueSteeringMessagesForScope(ts.sessionKey); len(steerMsgs) > 0 { - logger.InfoCF("agent", "Steering arrived after handled tool delivery; continuing turn before finalizing", - map[string]any{ - "agent_id": ts.agent.ID, - "steering_count": len(steerMsgs), - "session_key": ts.sessionKey, - }) - pendingMessages = append(pendingMessages, steerMsgs...) - finalContent = "" - goto turnLoop - } - - summaryMsg := providers.Message{ - Role: "assistant", - Content: handledToolResponseSummary, - } - - if !ts.opts.NoHistory { - ts.agent.Sessions.AddMessage(ts.sessionKey, summaryMsg.Role, summaryMsg.Content) - ts.recordPersistedMessage(summaryMsg) - ts.ingestMessage(turnCtx, al, summaryMsg) - if err := ts.agent.Sessions.Save(ts.sessionKey); err != nil { - turnStatus = TurnEndStatusError - al.emitEvent( - EventKindError, - ts.eventMeta("runTurn", "turn.error"), - ErrorPayload{ - Stage: "session_save", - Message: err.Error(), - }, - ) - return turnResult{}, err - } - } - if ts.opts.EnableSummary { - al.contextManager.Compact(turnCtx, &CompactRequest{SessionKey: ts.sessionKey, Reason: ContextCompressReasonSummarize, Budget: ts.agent.ContextWindow}) - } - - ts.setPhase(TurnPhaseCompleted) - ts.setFinalContent("") - logger.InfoCF("agent", "Tool output satisfied delivery; ending turn without follow-up LLM", - map[string]any{ - "agent_id": ts.agent.ID, - "iteration": iteration, - "tool_count": len(normalizedToolCalls), - }) - return turnResult{ - finalContent: "", - status: turnStatus, - followUps: append([]bus.InboundMessage(nil), ts.followUps...), - }, nil - } - - ts.agent.Tools.TickTTL() - logger.DebugCF("agent", "TTL tick after tool execution", map[string]any{ - "agent_id": ts.agent.ID, "iteration": iteration, - }) - } - - if steerMsgs := al.dequeueSteeringMessagesForScope(ts.sessionKey); len(steerMsgs) > 0 { - logger.InfoCF("agent", "Steering arrived after turn completion; continuing turn before finalizing", - map[string]any{ - "agent_id": ts.agent.ID, - "steering_count": len(steerMsgs), - "session_key": ts.sessionKey, - }) - pendingMessages = append(pendingMessages, steerMsgs...) - finalContent = "" - goto turnLoop - } - - if ts.hardAbortRequested() { - turnStatus = TurnEndStatusAborted - return al.abortTurn(ts) - } - - if finalContent == "" { - if ts.currentIteration() >= ts.agent.MaxIterations && ts.agent.MaxIterations > 0 { - finalContent = toolLimitResponse - } else { - finalContent = ts.opts.DefaultResponse - } - } - - ts.setPhase(TurnPhaseFinalizing) - ts.setFinalContent(finalContent) - if !ts.opts.NoHistory { - finalMsg := providers.Message{Role: "assistant", Content: finalContent} - ts.agent.Sessions.AddMessage(ts.sessionKey, finalMsg.Role, finalMsg.Content) - ts.recordPersistedMessage(finalMsg) - ts.ingestMessage(turnCtx, al, finalMsg) - if err := ts.agent.Sessions.Save(ts.sessionKey); err != nil { - turnStatus = TurnEndStatusError - al.emitEvent( - EventKindError, - ts.eventMeta("runTurn", "turn.error"), - ErrorPayload{ - Stage: "session_save", - Message: err.Error(), - }, - ) - return turnResult{}, err - } - } - - if ts.opts.EnableSummary { - al.contextManager.Compact( - turnCtx, - &CompactRequest{ - SessionKey: ts.sessionKey, - Reason: ContextCompressReasonSummarize, - Budget: ts.agent.ContextWindow, - }, - ) - } - - ts.setPhase(TurnPhaseCompleted) - return turnResult{ - finalContent: finalContent, - status: turnStatus, - followUps: append([]bus.InboundMessage(nil), ts.followUps...), - }, nil -} - -func (al *AgentLoop) abortTurn(ts *turnState) (turnResult, error) { - ts.setPhase(TurnPhaseAborted) - if !ts.opts.NoHistory { - if err := ts.restoreSession(ts.agent); err != nil { - al.emitEvent( - EventKindError, - ts.eventMeta("abortTurn", "turn.error"), - ErrorPayload{ - Stage: "session_restore", - Message: err.Error(), - }, - ) - return turnResult{}, err - } - } - return turnResult{status: TurnEndStatusAborted}, nil -} - -func (al *AgentLoop) selectCandidates( - agent *AgentInstance, - userMsg string, - history []providers.Message, -) (candidates []providers.FallbackCandidate, model string, usedLight bool) { - if agent.Router == nil || len(agent.LightCandidates) == 0 { - return agent.Candidates, resolvedCandidateModel(agent.Candidates, agent.Model), false - } - - _, usedLight, score := agent.Router.SelectModel(userMsg, history, agent.Model) - if !usedLight { - logger.DebugCF("agent", "Model routing: primary model selected", - map[string]any{ - "agent_id": agent.ID, - "score": score, - "threshold": agent.Router.Threshold(), - }) - return agent.Candidates, resolvedCandidateModel(agent.Candidates, agent.Model), false - } - - logger.InfoCF("agent", "Model routing: light model selected", - map[string]any{ - "agent_id": agent.ID, - "light_model": agent.Router.LightModel(), - "score": score, - "threshold": agent.Router.Threshold(), - }) - return agent.LightCandidates, resolvedCandidateModel(agent.LightCandidates, agent.Router.LightModel()), true -} - -func (al *AgentLoop) resolveContextManager() ContextManager { - name := al.cfg.Agents.Defaults.ContextManager - if name == "" || name == "legacy" { - return &legacyContextManager{al: al} - } - factory, ok := lookupContextManager(name) - if !ok { - logger.WarnCF("agent", "Unknown context manager, falling back to legacy", map[string]any{ - "name": name, - }) - return &legacyContextManager{al: al} - } - cm, err := factory(al.cfg.Agents.Defaults.ContextManagerConfig, al) - if err != nil { - logger.WarnCF("agent", "Failed to create context manager, falling back to legacy", map[string]any{ - "name": name, - "error": err.Error(), - }) - return &legacyContextManager{al: al} - } - return cm -} - -func (al *AgentLoop) askSideQuestion( - ctx context.Context, - agent *AgentInstance, - opts *processOptions, - question string, -) (string, error) { - if agent == nil { - return "", fmt.Errorf("askSideQuestion: no agent available for /btw") - } - - question = strings.TrimSpace(question) - if question == "" { - return "", fmt.Errorf("askSideQuestion: %w", fmt.Errorf("Usage: /btw ")) - } - - if opts != nil { - normalizeProcessOptionsInPlace(opts) - } - - var media []string - var channel, chatID, senderID, senderDisplayName string - if opts != nil { - media = opts.Media - channel = opts.Channel - chatID = opts.ChatID - senderID = opts.SenderID - senderDisplayName = opts.SenderDisplayName - } - - // Build messages with context but WITHOUT adding to session history - var history []providers.Message - var summary string - if opts != nil && !opts.NoHistory { - if resp, err := al.contextManager.Assemble(ctx, &AssembleRequest{ - SessionKey: opts.SessionKey, - Budget: agent.ContextWindow, - MaxTokens: agent.MaxTokens, - }); err == nil && resp != nil { - history = resp.History - summary = resp.Summary - } - } - - messages := agent.ContextBuilder.BuildMessages( - history, - summary, - question, - media, - channel, - chatID, - senderID, - senderDisplayName, - ) - - maxMediaSize := al.GetConfig().Agents.Defaults.GetMaxMediaSize() - messages = resolveMediaRefs(messages, al.mediaStore, maxMediaSize) - - activeCandidates, activeModel, usedLight := al.selectCandidates(agent, question, messages) - selectedModelName := sideQuestionModelName(agent, usedLight) - - llmOpts := map[string]any{ - "max_tokens": agent.MaxTokens, - "temperature": agent.Temperature, - "prompt_cache_key": agent.ID + ":btw", - } - - hookModelChanged := false - callProvider := func( - ctx context.Context, - candidate providers.FallbackCandidate, - model string, - forceModel bool, - callMessages []providers.Message, - ) (*providers.LLMResponse, error) { - provider, providerModel, cleanup, err := al.isolatedSideQuestionProvider(agent, selectedModelName, candidate) - if err != nil { - return nil, err - } - defer cleanup() - if !forceModel || strings.TrimSpace(model) == "" { - model = providerModel - } - callOpts := llmOpts - if _, exists := callOpts["thinking_level"]; !exists && agent.ThinkingLevel != ThinkingOff { - if tc, ok := provider.(providers.ThinkingCapable); ok && tc.SupportsThinking() { - callOpts = shallowCloneLLMOptions(llmOpts) - callOpts["thinking_level"] = string(agent.ThinkingLevel) - } - } - return provider.Chat(ctx, callMessages, nil, model, callOpts) - } - - turnCtx := newTurnContext(nil, nil, nil) - if opts != nil { - turnCtx = newTurnContext(opts.Dispatch.InboundContext, opts.Dispatch.RouteResult, opts.Dispatch.SessionScope) - } - llmModel := activeModel - if al.hooks != nil { - llmReq, decision := al.hooks.BeforeLLM(ctx, &LLMHookRequest{ - Meta: EventMeta{ - Source: "askSideQuestion", - TracePath: "turn.llm.request", - turnContext: cloneTurnContext(turnCtx), - }, - Context: cloneTurnContext(turnCtx), - Model: llmModel, - Messages: messages, - Tools: nil, - Options: llmOpts, - GracefulTerminal: false, - }) - switch decision.normalizedAction() { - case HookActionContinue, HookActionModify: - if llmReq != nil { - if strings.TrimSpace(llmReq.Model) != "" && llmReq.Model != llmModel { - hookModelChanged = true - } - llmModel = llmReq.Model - messages = llmReq.Messages - llmOpts = llmReq.Options - } - case HookActionAbortTurn: - reason := decision.Reason - if reason == "" { - reason = "hook requested turn abort" - } - return "", fmt.Errorf("hook aborted turn during before_llm: %s", reason) - case HookActionHardAbort: - reason := decision.Reason - if reason == "" { - reason = "hook requested turn abort" - } - return "", fmt.Errorf("hook aborted turn during before_llm: %s", reason) - } - } - if hookModelChanged { - // Hook-selected models must not continue through the pre-hook fallback - // candidate list, otherwise fallback execution would call the original - // candidate model and silently ignore the hook decision. - activeCandidates = nil - } - - callSideLLM := func(callMessages []providers.Message) (*providers.LLMResponse, error) { - if len(activeCandidates) > 1 && al.fallback != nil { - fbResult, err := al.fallback.Execute( - ctx, - activeCandidates, - func(ctx context.Context, providerName, model string) (*providers.LLMResponse, error) { - candidate := providers.FallbackCandidate{Provider: providerName, Model: model} - for _, activeCandidate := range activeCandidates { - if activeCandidate.Provider == providerName && activeCandidate.Model == model { - candidate = activeCandidate - break - } - } - return callProvider(ctx, candidate, model, false, callMessages) - }, - ) - if err != nil { - return nil, err - } - return fbResult.Response, nil - } - - var candidate providers.FallbackCandidate - if len(activeCandidates) > 0 { - candidate = activeCandidates[0] - } - return callProvider(ctx, candidate, llmModel, hookModelChanged, callMessages) - } - - // Retry without media if vision is unsupported - // Note: Vision retry is only applied to the initial call. If fallback chain - // is used, vision errors from fallback providers will not trigger retry. - var resp *providers.LLMResponse - var err error - resp, err = callSideLLM(messages) - if err != nil && hasMediaRefs(messages) && isVisionUnsupportedError(err) { - al.emitEvent( - EventKindLLMRetry, - EventMeta{ - Source: "askSideQuestion", - TracePath: "turn.llm.retry", - turnContext: cloneTurnContext(turnCtx), - }, - LLMRetryPayload{ - Attempt: 1, - MaxRetries: 1, - Reason: "vision_unsupported", - Error: err.Error(), - Backoff: 0, - }, - ) - messagesWithoutMedia := stripMessageMedia(messages) - resp, err = callSideLLM(messagesWithoutMedia) - } - if err != nil { - return "", err - } - if resp == nil { - return "", nil - } - - // Apply after_llm hooks - if al.hooks != nil { - llmResp, decision := al.hooks.AfterLLM(ctx, &LLMHookResponse{ - Meta: EventMeta{ - Source: "askSideQuestion", - TracePath: "turn.llm.response", - turnContext: cloneTurnContext(turnCtx), - }, - Context: cloneTurnContext(turnCtx), - Model: llmModel, - Response: resp, - }) - switch decision.normalizedAction() { - case HookActionContinue, HookActionModify: - if llmResp != nil && llmResp.Response != nil { - resp = llmResp.Response - } - case HookActionAbortTurn, HookActionHardAbort: - reason := decision.Reason - if reason == "" { - reason = "hook requested turn abort" - } - return "", fmt.Errorf("hook aborted turn during after_llm: %s", reason) - } - } - - return sideQuestionResponseContent(resp), nil -} - -func (al *AgentLoop) isolatedSideQuestionProvider( - agent *AgentInstance, - baseModelName string, - candidate providers.FallbackCandidate, -) (providers.LLMProvider, string, func(), error) { - if agent == nil { - return nil, "", func() {}, fmt.Errorf("isolatedSideQuestionProvider: no agent available for /btw") - } - - modelCfg, err := al.sideQuestionModelConfig(agent, baseModelName, candidate) - if err != nil { - return nil, "", func() {}, fmt.Errorf("isolatedSideQuestionProvider: %w", err) - } - - factory := al.providerFactory - if factory == nil { - factory = providers.CreateProviderFromConfig - } - provider, modelID, err := factory(modelCfg) - if err != nil { - return nil, "", func() {}, fmt.Errorf("isolatedSideQuestionProvider: %w", err) - } - - cleanup := func() { - closeProviderIfStateful(provider) - } - return provider, modelID, cleanup, nil -} - -func (al *AgentLoop) sideQuestionModelConfig( - agent *AgentInstance, - baseModelName string, - candidate providers.FallbackCandidate, -) (*config.ModelConfig, error) { - if agent == nil { - return nil, fmt.Errorf("sideQuestionModelConfig: no agent available for /btw") - } - - // If candidate has an identity key, use that - if name := modelNameFromIdentityKey(candidate.IdentityKey); name != "" { - modelCfg, err := resolvedModelConfig(al.GetConfig(), name, agent.Workspace) - if err == nil { - return modelCfg, nil - } - // Fallback: create a minimal config if lookup fails - } - - // Otherwise, clean up the base model name and use it - baseModelName = strings.TrimSpace(baseModelName) - modelCfg, err := resolvedModelConfig(al.GetConfig(), baseModelName, agent.Workspace) - if err != nil { - // Fallback: create a minimal config for test scenarios - model := strings.TrimSpace(baseModelName) - if candidate.Model != "" { - model = candidate.Model - } - if candidate.Provider != "" && candidate.Model != "" { - model = providers.NormalizeProvider(candidate.Provider) + "/" + candidate.Model - } else { - model = ensureProtocolModel(model) - } - return &config.ModelConfig{ - ModelName: baseModelName, - Model: model, - Workspace: agent.Workspace, - }, nil - } - - // If candidate specifies a different provider/model, override - clone := *modelCfg - if candidate.Provider != "" && candidate.Model != "" { - clone.Model = providers.NormalizeProvider(candidate.Provider) + "/" + candidate.Model - } - return &clone, nil -} diff --git a/pkg/agent/pipeline.go b/pkg/agent/pipeline.go new file mode 100644 index 000000000..c4b9ec3af --- /dev/null +++ b/pkg/agent/pipeline.go @@ -0,0 +1,40 @@ +// PicoClaw - Ultra-lightweight personal AI agent + +package agent + +import ( + "github.com/sipeed/picoclaw/pkg/agent/interfaces" + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/media" + "github.com/sipeed/picoclaw/pkg/providers" +) + +// Pipeline holds the runtime dependencies used by Pipeline methods. +// It is constructed by runTurn via NewPipeline and passed to sub-methods +// so that the coordinator can delegate phase execution. +type Pipeline struct { + Bus interfaces.MessageBus + Cfg *config.Config + ContextManager ContextManager + Hooks *HookManager + Fallback *providers.FallbackChain + ChannelManager interfaces.ChannelManager + MediaStore media.MediaStore + Steering any // TODO: *Steering + al *AgentLoop +} + +// NewPipeline creates a Pipeline from an AgentLoop instance. +func NewPipeline(al *AgentLoop) *Pipeline { + return &Pipeline{ + Bus: al.bus, + Cfg: al.GetConfig(), + ContextManager: al.contextManager, + Hooks: al.hooks, + Fallback: al.fallback, + ChannelManager: al.channelManager, + MediaStore: al.mediaStore, + Steering: al.steering, + al: al, + } +} diff --git a/pkg/agent/pipeline_execute.go b/pkg/agent/pipeline_execute.go new file mode 100644 index 000000000..76ada0e64 --- /dev/null +++ b/pkg/agent/pipeline_execute.go @@ -0,0 +1,700 @@ +// PicoClaw - Ultra-lightweight personal AI agent + +package agent + +import ( + "context" + "encoding/json" + "fmt" + "time" + + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/constants" + "github.com/sipeed/picoclaw/pkg/logger" + "github.com/sipeed/picoclaw/pkg/providers" + "github.com/sipeed/picoclaw/pkg/tools" + "github.com/sipeed/picoclaw/pkg/utils" +) + +// ExecuteTools executes the tool loop, handling BeforeTool/ApproveTool/AfterTool hooks, +// tool execution with async callbacks, media delivery, and steering injection. +// Returns ToolControl indicating what the coordinator should do next: +// - ToolControlContinue: all tool results handled, pendingMessages or steering exists, continue turn +// - ToolControlBreak: tool loop exited, proceed to coordinator's hardAbort/finalContent/finalize +func (p *Pipeline) ExecuteTools( + ctx context.Context, + turnCtx context.Context, + ts *turnState, + exec *turnExecution, + iteration int, +) ToolControl { + al := p.al + normalizedToolCalls := exec.normalizedToolCalls + + ts.setPhase(TurnPhaseTools) + messages := exec.messages + +toolLoop: + for i, tc := range normalizedToolCalls { + if ts.hardAbortRequested() { + exec.abortedByHardAbort = true + return ToolControlBreak + } + + toolName := tc.Name + toolArgs := cloneStringAnyMap(tc.Arguments) + + if al.hooks != nil { + toolReq, decision := al.hooks.BeforeTool(turnCtx, &ToolCallHookRequest{ + Meta: ts.eventMeta("runTurn", "turn.tool.before"), + Context: cloneTurnContext(ts.turnCtx), + Tool: toolName, + Arguments: toolArgs, + }) + switch decision.normalizedAction() { + case HookActionContinue, HookActionModify: + if toolReq != nil { + toolName = toolReq.Tool + toolArgs = toolReq.Arguments + } + case HookActionRespond: + if toolReq != nil && toolReq.HookResult != nil { + hookResult := toolReq.HookResult + + argsJSON, _ := json.Marshal(toolArgs) + argsPreview := utils.Truncate(string(argsJSON), 200) + logger.InfoCF("agent", fmt.Sprintf("Tool call (hook respond): %s(%s)", toolName, argsPreview), + map[string]any{ + "agent_id": ts.agent.ID, + "tool": toolName, + "iteration": iteration, + }) + + al.emitEvent( + EventKindToolExecStart, + ts.eventMeta("runTurn", "turn.tool.start"), + ToolExecStartPayload{ + Tool: toolName, + Arguments: cloneEventArguments(toolArgs), + }, + ) + + if al.cfg.Agents.Defaults.IsToolFeedbackEnabled() && + ts.channel != "" && + !ts.opts.SuppressToolFeedback { + argsJSON, _ := json.Marshal(toolArgs) + feedbackPreview := utils.Truncate( + string(argsJSON), + al.cfg.Agents.Defaults.GetToolFeedbackMaxArgsLength(), + ) + feedbackMsg := utils.FormatToolFeedbackMessage(toolName, feedbackPreview) + fbCtx, fbCancel := context.WithTimeout(turnCtx, 3*time.Second) + _ = al.bus.PublishOutbound(fbCtx, bus.OutboundMessage{ + Channel: ts.channel, + ChatID: ts.chatID, + Content: feedbackMsg, + }) + fbCancel() + } + + toolDuration := time.Duration(0) + + shouldSendForUser := !hookResult.Silent && hookResult.ForUser != "" && + (ts.opts.SendResponse || hookResult.ResponseHandled) + if shouldSendForUser { + al.bus.PublishOutbound(ctx, bus.OutboundMessage{ + Context: bus.InboundContext{ + Channel: ts.channel, + ChatID: ts.chatID, + Raw: map[string]string{ + "is_tool_call": "true", + }, + }, + Content: hookResult.ForUser, + }) + } + + if len(hookResult.Media) > 0 && hookResult.ResponseHandled { + parts := make([]bus.MediaPart, 0, len(hookResult.Media)) + for _, ref := range hookResult.Media { + part := bus.MediaPart{Ref: ref} + if al.mediaStore != nil { + if _, meta, err := al.mediaStore.ResolveWithMeta(ref); err == nil { + part.Filename = meta.Filename + part.ContentType = meta.ContentType + part.Type = inferMediaType(meta.Filename, meta.ContentType) + } + } + parts = append(parts, part) + } + outboundMedia := bus.OutboundMediaMessage{ + Channel: ts.channel, + ChatID: ts.chatID, + Parts: parts, + } + if al.channelManager != nil && ts.channel != "" && !constants.IsInternalChannel(ts.channel) { + if err := al.channelManager.SendMedia(ctx, outboundMedia); err != nil { + logger.WarnCF("agent", "Failed to deliver hook media", + map[string]any{ + "agent_id": ts.agent.ID, + "tool": toolName, + "channel": ts.channel, + "chat_id": ts.chatID, + "error": err.Error(), + }) + hookResult.IsError = true + hookResult.ForLLM = fmt.Sprintf("failed to deliver attachment: %v", err) + } + } else if al.bus != nil { + al.bus.PublishOutboundMedia(ctx, outboundMedia) + hookResult.ResponseHandled = false + } + } + + if !hookResult.ResponseHandled { + exec.allResponsesHandled = false + } + + contentForLLM := hookResult.ContentForLLM() + if al.cfg.Tools.IsFilterSensitiveDataEnabled() { + contentForLLM = al.cfg.FilterSensitiveData(contentForLLM) + } + + toolResultMsg := providers.Message{ + Role: "tool", + Content: contentForLLM, + ToolCallID: tc.ID, + } + + if len(hookResult.Media) > 0 && !hookResult.ResponseHandled { + hookResult.ArtifactTags = buildArtifactTags(al.mediaStore, hookResult.Media) + contentForLLM = hookResult.ContentForLLM() + if al.cfg.Tools.IsFilterSensitiveDataEnabled() { + contentForLLM = al.cfg.FilterSensitiveData(contentForLLM) + } + toolResultMsg.Content = contentForLLM + toolResultMsg.Media = append(toolResultMsg.Media, hookResult.Media...) + } + + al.emitEvent( + EventKindToolExecEnd, + ts.eventMeta("runTurn", "turn.tool.end"), + ToolExecEndPayload{ + Tool: toolName, + Duration: toolDuration, + ForLLMLen: len(contentForLLM), + ForUserLen: len(hookResult.ForUser), + IsError: hookResult.IsError, + Async: hookResult.Async, + }, + ) + + messages = append(messages, toolResultMsg) + if !ts.opts.NoHistory { + ts.agent.Sessions.AddFullMessage(ts.sessionKey, toolResultMsg) + ts.recordPersistedMessage(toolResultMsg) + ts.ingestMessage(turnCtx, al, toolResultMsg) + } + + if steerMsgs := al.dequeueSteeringMessagesForScope(ts.sessionKey); len(steerMsgs) > 0 { + exec.pendingMessages = append(exec.pendingMessages, steerMsgs...) + } + + skipReason := "" + skipMessage := "" + if len(exec.pendingMessages) > 0 { + skipReason = "queued user steering message" + skipMessage = "Skipped due to queued user message." + } else if gracefulPending, _ := ts.gracefulInterruptRequested(); gracefulPending { + skipReason = "graceful interrupt requested" + skipMessage = "Skipped due to graceful interrupt." + } + + if skipReason != "" { + remaining := len(normalizedToolCalls) - i - 1 + if remaining > 0 { + logger.InfoCF("agent", "Turn checkpoint: skipping remaining tools after hook respond", + map[string]any{ + "agent_id": ts.agent.ID, + "completed": i + 1, + "skipped": remaining, + "reason": skipReason, + }) + for j := i + 1; j < len(normalizedToolCalls); j++ { + skippedTC := normalizedToolCalls[j] + al.emitEvent( + EventKindToolExecSkipped, + ts.eventMeta("runTurn", "turn.tool.skipped"), + ToolExecSkippedPayload{ + Tool: skippedTC.Name, + Reason: skipReason, + }, + ) + skippedMsg := providers.Message{ + Role: "tool", + Content: skipMessage, + ToolCallID: skippedTC.ID, + } + messages = append(messages, skippedMsg) + if !ts.opts.NoHistory { + ts.agent.Sessions.AddFullMessage(ts.sessionKey, skippedMsg) + ts.recordPersistedMessage(skippedMsg) + } + } + } + break toolLoop + } + + if ts.pendingResults != nil { + select { + case result, ok := <-ts.pendingResults: + if ok && result != nil && result.ForLLM != "" { + content := al.cfg.FilterSensitiveData(result.ForLLM) + msg := providers.Message{Role: "user", Content: fmt.Sprintf("[SubTurn Result] %s", content)} + messages = append(messages, msg) + ts.agent.Sessions.AddFullMessage(ts.sessionKey, msg) + } + default: + } + } + + continue + } + logger.WarnCF("agent", "Hook returned respond action but no HookResult provided", + map[string]any{ + "agent_id": ts.agent.ID, + "tool": toolName, + "action": "respond", + }) + case HookActionDenyTool: + exec.allResponsesHandled = false + denyContent := hookDeniedToolContent("Tool execution denied by hook", decision.Reason) + al.emitEvent( + EventKindToolExecSkipped, + ts.eventMeta("runTurn", "turn.tool.skipped"), + ToolExecSkippedPayload{ + Tool: toolName, + Reason: denyContent, + }, + ) + deniedMsg := providers.Message{ + Role: "tool", + Content: denyContent, + ToolCallID: tc.ID, + } + messages = append(messages, deniedMsg) + if !ts.opts.NoHistory { + ts.agent.Sessions.AddFullMessage(ts.sessionKey, deniedMsg) + ts.recordPersistedMessage(deniedMsg) + } + continue + case HookActionAbortTurn: + exec.abortedByHook = true + return ToolControlBreak + case HookActionHardAbort: + _ = ts.requestHardAbort() + exec.abortedByHardAbort = true + return ToolControlBreak + } + } + + if al.hooks != nil { + approval := al.hooks.ApproveTool(turnCtx, &ToolApprovalRequest{ + Meta: ts.eventMeta("runTurn", "turn.tool.approve"), + Context: cloneTurnContext(ts.turnCtx), + Tool: toolName, + Arguments: toolArgs, + }) + if !approval.Approved { + exec.allResponsesHandled = false + denyContent := hookDeniedToolContent("Tool execution denied by approval hook", approval.Reason) + al.emitEvent( + EventKindToolExecSkipped, + ts.eventMeta("runTurn", "turn.tool.skipped"), + ToolExecSkippedPayload{ + Tool: toolName, + Reason: denyContent, + }, + ) + deniedMsg := providers.Message{ + Role: "tool", + Content: denyContent, + ToolCallID: tc.ID, + } + messages = append(messages, deniedMsg) + if !ts.opts.NoHistory { + ts.agent.Sessions.AddFullMessage(ts.sessionKey, deniedMsg) + ts.recordPersistedMessage(deniedMsg) + } + continue + } + } + + argsJSON, _ := json.Marshal(toolArgs) + argsPreview := utils.Truncate(string(argsJSON), 200) + logger.InfoCF("agent", fmt.Sprintf("Tool call: %s(%s)", toolName, argsPreview), + map[string]any{ + "agent_id": ts.agent.ID, + "tool": toolName, + "iteration": iteration, + }) + al.emitEvent( + EventKindToolExecStart, + ts.eventMeta("runTurn", "turn.tool.start"), + ToolExecStartPayload{ + Tool: toolName, + Arguments: cloneEventArguments(toolArgs), + }, + ) + + if al.cfg.Agents.Defaults.IsToolFeedbackEnabled() && + ts.channel != "" && + !ts.opts.SuppressToolFeedback { + feedbackPreview := utils.Truncate( + string(argsJSON), + al.cfg.Agents.Defaults.GetToolFeedbackMaxArgsLength(), + ) + feedbackMsg := utils.FormatToolFeedbackMessage(tc.Name, feedbackPreview) + fbCtx, fbCancel := context.WithTimeout(turnCtx, 3*time.Second) + _ = al.bus.PublishOutbound(fbCtx, outboundMessageForTurn(ts, feedbackMsg)) + fbCancel() + } + + toolCallID := tc.ID + asyncToolName := toolName + asyncCallback := func(_ context.Context, result *tools.ToolResult) { + if !result.Silent && result.ForUser != "" { + outCtx, outCancel := context.WithTimeout(context.Background(), 5*time.Second) + defer outCancel() + _ = al.bus.PublishOutbound(outCtx, outboundMessageForTurn(ts, result.ForUser)) + } + + content := result.ContentForLLM() + if content == "" { + return + } + + content = al.cfg.FilterSensitiveData(content) + + logger.InfoCF("agent", "Async tool completed, publishing result", + map[string]any{ + "tool": asyncToolName, + "content_len": len(content), + "channel": ts.channel, + }) + al.emitEvent( + EventKindFollowUpQueued, + ts.scope.meta(iteration, "runTurn", "turn.follow_up.queued"), + FollowUpQueuedPayload{ + SourceTool: asyncToolName, + ContentLen: len(content), + }, + ) + pubCtx, pubCancel := context.WithTimeout(context.Background(), 5*time.Second) + defer pubCancel() + _ = al.bus.PublishInbound(pubCtx, bus.InboundMessage{ + Context: bus.InboundContext{ + Channel: "system", + ChatID: fmt.Sprintf("%s:%s", ts.channel, ts.chatID), + ChatType: "direct", + SenderID: fmt.Sprintf("async:%s", asyncToolName), + }, + Content: content, + }) + } + + toolStart := time.Now() + execCtx := tools.WithToolInboundContext( + turnCtx, + ts.channel, + ts.chatID, + ts.opts.Dispatch.MessageID(), + ts.opts.Dispatch.ReplyToMessageID(), + ) + execCtx = tools.WithToolSessionContext( + execCtx, + ts.agent.ID, + ts.sessionKey, + ts.opts.Dispatch.SessionScope, + ) + toolResult := ts.agent.Tools.ExecuteWithContext( + execCtx, + toolName, + toolArgs, + ts.channel, + ts.chatID, + asyncCallback, + ) + toolDuration := time.Since(toolStart) + + if ts.hardAbortRequested() { + exec.abortedByHardAbort = true + return ToolControlBreak + } + + if al.hooks != nil { + toolResp, decision := al.hooks.AfterTool(turnCtx, &ToolResultHookResponse{ + Meta: ts.eventMeta("runTurn", "turn.tool.after"), + Context: cloneTurnContext(ts.turnCtx), + Tool: toolName, + Arguments: toolArgs, + Result: toolResult, + Duration: toolDuration, + }) + switch decision.normalizedAction() { + case HookActionContinue, HookActionModify: + if toolResp != nil { + if toolResp.Tool != "" { + toolName = toolResp.Tool + } + if toolResp.Result != nil { + toolResult = toolResp.Result + } + } + case HookActionAbortTurn: + exec.abortedByHook = true + return ToolControlBreak + case HookActionHardAbort: + _ = ts.requestHardAbort() + exec.abortedByHardAbort = true + return ToolControlBreak + } + } + + if toolResult == nil { + toolResult = tools.ErrorResult("hook returned nil tool result") + } + + if len(toolResult.Media) > 0 && toolResult.ResponseHandled { + parts := make([]bus.MediaPart, 0, len(toolResult.Media)) + for _, ref := range toolResult.Media { + part := bus.MediaPart{Ref: ref} + if al.mediaStore != nil { + if _, meta, err := al.mediaStore.ResolveWithMeta(ref); err == nil { + part.Filename = meta.Filename + part.ContentType = meta.ContentType + part.Type = inferMediaType(meta.Filename, meta.ContentType) + } + } + parts = append(parts, part) + } + outboundMedia := bus.OutboundMediaMessage{ + Channel: ts.channel, + ChatID: ts.chatID, + Context: outboundContextFromInbound( + ts.opts.Dispatch.InboundContext, + ts.channel, + ts.chatID, + ts.opts.Dispatch.ReplyToMessageID(), + ), + AgentID: ts.agent.ID, + SessionKey: ts.sessionKey, + Scope: outboundScopeFromSessionScope(ts.opts.Dispatch.SessionScope), + Parts: parts, + } + if al.channelManager != nil && ts.channel != "" && !constants.IsInternalChannel(ts.channel) { + if err := al.channelManager.SendMedia(ctx, outboundMedia); err != nil { + logger.WarnCF("agent", "Failed to deliver handled tool media", + map[string]any{ + "agent_id": ts.agent.ID, + "tool": toolName, + "channel": ts.channel, + "chat_id": ts.chatID, + "error": err.Error(), + }) + toolResult = tools.ErrorResult(fmt.Sprintf("failed to deliver attachment: %v", err)).WithError(err) + } + } else if al.bus != nil { + al.bus.PublishOutboundMedia(ctx, outboundMedia) + toolResult.ResponseHandled = false + } + } + + if len(toolResult.Media) > 0 && !toolResult.ResponseHandled { + toolResult.ArtifactTags = buildArtifactTags(al.mediaStore, toolResult.Media) + } + + if !toolResult.ResponseHandled { + exec.allResponsesHandled = false + } + + shouldSendForUser := !toolResult.Silent && + toolResult.ForUser != "" && + (ts.opts.SendResponse || toolResult.ResponseHandled) + if shouldSendForUser { + al.bus.PublishOutbound(ctx, outboundMessageForTurn(ts, toolResult.ForUser)) + logger.DebugCF("agent", "Sent tool result to user", + map[string]any{ + "tool": toolName, + "content_len": len(toolResult.ForUser), + }) + } + contentForLLM := toolResult.ContentForLLM() + + if al.cfg.Tools.IsFilterSensitiveDataEnabled() { + contentForLLM = al.cfg.FilterSensitiveData(contentForLLM) + } + + toolResultMsg := providers.Message{ + Role: "tool", + Content: contentForLLM, + ToolCallID: toolCallID, + } + if len(toolResult.Media) > 0 && !toolResult.ResponseHandled { + toolResultMsg.Media = append(toolResultMsg.Media, toolResult.Media...) + } + al.emitEvent( + EventKindToolExecEnd, + ts.eventMeta("runTurn", "turn.tool.end"), + ToolExecEndPayload{ + Tool: toolName, + Duration: toolDuration, + ForLLMLen: len(contentForLLM), + ForUserLen: len(toolResult.ForUser), + IsError: toolResult.IsError, + Async: toolResult.Async, + }, + ) + messages = append(messages, toolResultMsg) + if !ts.opts.NoHistory { + ts.agent.Sessions.AddFullMessage(ts.sessionKey, toolResultMsg) + ts.recordPersistedMessage(toolResultMsg) + ts.ingestMessage(turnCtx, al, toolResultMsg) + } + + if steerMsgs := al.dequeueSteeringMessagesForScope(ts.sessionKey); len(steerMsgs) > 0 { + exec.pendingMessages = append(exec.pendingMessages, steerMsgs...) + } + + skipReason := "" + skipMessage := "" + if len(exec.pendingMessages) > 0 { + skipReason = "queued user steering message" + skipMessage = "Skipped due to queued user message." + } else if gracefulPending, _ := ts.gracefulInterruptRequested(); gracefulPending { + skipReason = "graceful interrupt requested" + skipMessage = "Skipped due to graceful interrupt." + } + + if skipReason != "" { + remaining := len(normalizedToolCalls) - i - 1 + if remaining > 0 { + logger.InfoCF("agent", "Turn checkpoint: skipping remaining tools", + map[string]any{ + "agent_id": ts.agent.ID, + "completed": i + 1, + "skipped": remaining, + "reason": skipReason, + }) + for j := i + 1; j < len(normalizedToolCalls); j++ { + skippedTC := normalizedToolCalls[j] + al.emitEvent( + EventKindToolExecSkipped, + ts.eventMeta("runTurn", "turn.tool.skipped"), + ToolExecSkippedPayload{ + Tool: skippedTC.Name, + Reason: skipReason, + }, + ) + skippedMsg := providers.Message{ + Role: "tool", + Content: skipMessage, + ToolCallID: skippedTC.ID, + } + messages = append(messages, skippedMsg) + if !ts.opts.NoHistory { + ts.agent.Sessions.AddFullMessage(ts.sessionKey, skippedMsg) + ts.recordPersistedMessage(skippedMsg) + } + } + } + break toolLoop + } + + if ts.pendingResults != nil { + select { + case result, ok := <-ts.pendingResults: + if ok && result != nil && result.ForLLM != "" { + content := al.cfg.FilterSensitiveData(result.ForLLM) + msg := providers.Message{Role: "user", Content: fmt.Sprintf("[SubTurn Result] %s", content)} + messages = append(messages, msg) + ts.agent.Sessions.AddFullMessage(ts.sessionKey, msg) + } + default: + } + } + } + + exec.messages = messages + + // Continue if pending steering exists (regardless of allResponsesHandled). + // This covers the case where tools were partially executed and skipped due to steering, + // but one tool had ResponseHandled=false (so allResponsesHandled=false). + if len(exec.pendingMessages) > 0 { + logger.InfoCF("agent", "Pending steering after partial tool execution; continuing turn", + map[string]any{ + "agent_id": ts.agent.ID, + "pending_count": len(exec.pendingMessages), + "allResponsesHandled": exec.allResponsesHandled, + }) + exec.allResponsesHandled = false + return ToolControlContinue + } + + // Poll for newly arrived steering + if steerMsgs := al.dequeueSteeringMessagesForScope(ts.sessionKey); len(steerMsgs) > 0 { + logger.InfoCF("agent", "Steering arrived after tool delivery; continuing turn", + map[string]any{ + "agent_id": ts.agent.ID, + "steering_count": len(steerMsgs), + }) + exec.pendingMessages = append(exec.pendingMessages, steerMsgs...) + exec.allResponsesHandled = false + return ToolControlContinue + } + + // No pending steering: finalize or break depending on allResponsesHandled + if exec.allResponsesHandled { + summaryMsg := providers.Message{ + Role: "assistant", + Content: handledToolResponseSummary, + } + if !ts.opts.NoHistory { + ts.agent.Sessions.AddMessage(ts.sessionKey, summaryMsg.Role, summaryMsg.Content) + ts.recordPersistedMessage(summaryMsg) + ts.ingestMessage(turnCtx, al, summaryMsg) + if err := ts.agent.Sessions.Save(ts.sessionKey); err != nil { + logger.WarnCF("agent", "Failed to save session after tool delivery", + map[string]any{ + "agent_id": ts.agent.ID, + "error": err.Error(), + }) + } + } + if ts.opts.EnableSummary { + al.contextManager.Compact(turnCtx, &CompactRequest{ + SessionKey: ts.sessionKey, + Reason: ContextCompressReasonSummarize, + Budget: ts.agent.ContextWindow, + }) + } + ts.setPhase(TurnPhaseCompleted) + ts.setFinalContent("") + logger.InfoCF("agent", "Tool output satisfied delivery; ending turn without follow-up LLM", + map[string]any{ + "agent_id": ts.agent.ID, + "iteration": iteration, + "tool_count": len(normalizedToolCalls), + }) + return ToolControlBreak + } + + // allResponsesHandled=false and no pending steering: continue so coordinator + // makes another LLM call. The tool result is in messages and the LLM will + // return it as finalContent in the next iteration. + ts.agent.Tools.TickTTL() + logger.DebugCF("agent", "TTL tick after tool execution", map[string]any{ + "agent_id": ts.agent.ID, "iteration": iteration, + }) + return ToolControlContinue +} diff --git a/pkg/agent/pipeline_finalize.go b/pkg/agent/pipeline_finalize.go new file mode 100644 index 000000000..43d44099a --- /dev/null +++ b/pkg/agent/pipeline_finalize.go @@ -0,0 +1,77 @@ +// PicoClaw - Ultra-lightweight personal AI agent + +package agent + +import ( + "context" + + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/providers" +) + +// Finalize handles turn finalization, either: +// - Early return when allResponsesHandled=true (ExecuteTools already finalized) +// - Normal finalization for allResponsesHandled=false (sets finalContent, saves session, compact) +func (p *Pipeline) Finalize( + ctx context.Context, + turnCtx context.Context, + ts *turnState, + exec *turnExecution, + turnStatus TurnEndStatus, + finalContent string, +) (turnResult, error) { + al := p.al + + // When allResponsesHandled=true, ExecuteTools already finalized + // (added handledToolResponseSummary, saved session, set phase to Completed). + // But still check for hard abort - if requested, abort the turn. + if exec.allResponsesHandled { + if ts.hardAbortRequested() { + return al.abortTurn(ts) + } + ts.setPhase(TurnPhaseCompleted) + return turnResult{ + finalContent: finalContent, + status: turnStatus, + followUps: append([]bus.InboundMessage(nil), ts.followUps...), + }, nil + } + + ts.setPhase(TurnPhaseFinalizing) + ts.setFinalContent(finalContent) + if !ts.opts.NoHistory { + finalMsg := providers.Message{Role: "assistant", Content: finalContent} + ts.agent.Sessions.AddMessage(ts.sessionKey, finalMsg.Role, finalMsg.Content) + ts.recordPersistedMessage(finalMsg) + ts.ingestMessage(turnCtx, al, finalMsg) + if err := ts.agent.Sessions.Save(ts.sessionKey); err != nil { + al.emitEvent( + EventKindError, + ts.eventMeta("runTurn", "turn.error"), + ErrorPayload{ + Stage: "session_save", + Message: err.Error(), + }, + ) + return turnResult{status: TurnEndStatusError}, err + } + } + + if ts.opts.EnableSummary { + al.contextManager.Compact( + turnCtx, + &CompactRequest{ + SessionKey: ts.sessionKey, + Reason: ContextCompressReasonSummarize, + Budget: ts.agent.ContextWindow, + }, + ) + } + + ts.setPhase(TurnPhaseCompleted) + return turnResult{ + finalContent: finalContent, + status: turnStatus, + followUps: append([]bus.InboundMessage(nil), ts.followUps...), + }, nil +} diff --git a/pkg/agent/pipeline_llm.go b/pkg/agent/pipeline_llm.go new file mode 100644 index 000000000..c426c25c9 --- /dev/null +++ b/pkg/agent/pipeline_llm.go @@ -0,0 +1,525 @@ +// PicoClaw - Ultra-lightweight personal AI agent + +package agent + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" + "time" + + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/constants" + "github.com/sipeed/picoclaw/pkg/logger" + "github.com/sipeed/picoclaw/pkg/providers" +) + +// CallLLM performs an LLM call with fallback support, hook invocation, and retry logic. +// It handles PreLLM setup, the actual LLM invocation with retry, and AfterLLM processing. +// Returns Control indicating what the coordinator should do next. +func (p *Pipeline) CallLLM( + ctx context.Context, + turnCtx context.Context, + ts *turnState, + exec *turnExecution, + iteration int, +) (Control, error) { + al := p.al + maxMediaSize := p.Cfg.Agents.Defaults.GetMaxMediaSize() + + // PreLLM: resolve media refs (except on iteration 1 where user media is already resolved) + if iteration > 1 { + exec.messages = resolveMediaRefs(exec.messages, p.MediaStore, maxMediaSize) + } + + // PreLLM: graceful terminal handling + exec.gracefulTerminal, _ = ts.gracefulInterruptRequested() + exec.providerToolDefs = ts.agent.Tools.ToProviderDefs() + + // Native web search support + _, hasWebSearch := ts.agent.Tools.Get("web_search") + exec.useNativeSearch = al.cfg.Tools.Web.PreferNative && hasWebSearch && + func() bool { + if ns, ok := ts.agent.Provider.(interface{ SupportsNativeSearch() bool }); ok { + return ns.SupportsNativeSearch() + } + return false + }() + + if exec.useNativeSearch { + filtered := make([]providers.ToolDefinition, 0, len(exec.providerToolDefs)) + for _, td := range exec.providerToolDefs { + if td.Function.Name != "web_search" { + filtered = append(filtered, td) + } + } + exec.providerToolDefs = filtered + } + + exec.callMessages = exec.messages + if exec.gracefulTerminal { + exec.callMessages = append(append([]providers.Message(nil), exec.messages...), ts.interruptHintMessage()) + exec.providerToolDefs = nil + ts.markGracefulTerminalUsed() + } + + exec.llmOpts = map[string]any{ + "max_tokens": ts.agent.MaxTokens, + "temperature": ts.agent.Temperature, + "prompt_cache_key": ts.agent.ID, + } + if exec.useNativeSearch { + exec.llmOpts["native_search"] = true + } + if ts.agent.ThinkingLevel != ThinkingOff { + if tc, ok := ts.agent.Provider.(providers.ThinkingCapable); ok && tc.SupportsThinking() { + exec.llmOpts["thinking_level"] = string(ts.agent.ThinkingLevel) + } else { + logger.WarnCF("agent", "thinking_level is set but current provider does not support it, ignoring", + map[string]any{"agent_id": ts.agent.ID, "thinking_level": string(ts.agent.ThinkingLevel)}) + } + } + + exec.llmModel = exec.activeModel + + // BeforeLLM hook + if p.Hooks != nil { + llmReq, decision := p.Hooks.BeforeLLM(turnCtx, &LLMHookRequest{ + Meta: ts.eventMeta("runTurn", "turn.llm.request"), + Context: cloneTurnContext(ts.turnCtx), + Model: exec.llmModel, + Messages: exec.callMessages, + Tools: exec.providerToolDefs, + Options: exec.llmOpts, + GracefulTerminal: exec.gracefulTerminal, + }) + switch decision.normalizedAction() { + case HookActionContinue, HookActionModify: + if llmReq != nil { + exec.llmModel = llmReq.Model + exec.callMessages = llmReq.Messages + exec.providerToolDefs = llmReq.Tools + exec.llmOpts = llmReq.Options + } + case HookActionAbortTurn: + exec.abortedByHook = true + return ControlBreak, nil + case HookActionHardAbort: + _ = ts.requestHardAbort() + exec.abortedByHardAbort = true + return ControlBreak, nil + } + } + + al.emitEvent( + EventKindLLMRequest, + ts.eventMeta("runTurn", "turn.llm.request"), + LLMRequestPayload{ + Model: exec.llmModel, + MessagesCount: len(exec.callMessages), + ToolsCount: len(exec.providerToolDefs), + MaxTokens: ts.agent.MaxTokens, + Temperature: ts.agent.Temperature, + }, + ) + + logger.DebugCF("agent", "LLM request", + map[string]any{ + "agent_id": ts.agent.ID, + "iteration": iteration, + "model": exec.llmModel, + "messages_count": len(exec.callMessages), + "tools_count": len(exec.providerToolDefs), + "max_tokens": ts.agent.MaxTokens, + "temperature": ts.agent.Temperature, + "system_prompt_len": len(exec.callMessages[0].Content), + }) + logger.DebugCF("agent", "Full LLM request", + map[string]any{ + "iteration": iteration, + "messages_json": formatMessagesForLog(exec.callMessages), + "tools_json": formatToolsForLog(exec.providerToolDefs), + }) + + // LLM call closure with fallback support + callLLM := func(messagesForCall []providers.Message, toolDefsForCall []providers.ToolDefinition) (*providers.LLMResponse, error) { + providerCtx, providerCancel := context.WithCancel(turnCtx) + ts.setProviderCancel(providerCancel) + defer func() { + providerCancel() + ts.clearProviderCancel(providerCancel) + }() + + al.activeRequests.Add(1) + defer al.activeRequests.Done() + + if len(exec.activeCandidates) > 1 && p.Fallback != nil { + fbResult, fbErr := p.Fallback.Execute( + providerCtx, + exec.activeCandidates, + func(ctx context.Context, provider, model string) (*providers.LLMResponse, error) { + candidateProvider := exec.activeProvider + if cp, ok := ts.agent.CandidateProviders[providers.ModelKey(provider, model)]; ok { + candidateProvider = cp + } + return candidateProvider.Chat(ctx, messagesForCall, toolDefsForCall, model, exec.llmOpts) + }, + ) + if fbErr != nil { + return nil, fbErr + } + if fbResult.Provider != "" && len(fbResult.Attempts) > 0 { + logger.InfoCF( + "agent", + fmt.Sprintf("Fallback: succeeded with %s/%s after %d attempts", + fbResult.Provider, fbResult.Model, len(fbResult.Attempts)+1), + map[string]any{"agent_id": ts.agent.ID, "iteration": iteration}, + ) + } + return fbResult.Response, nil + } + return exec.activeProvider.Chat(providerCtx, messagesForCall, toolDefsForCall, exec.llmModel, exec.llmOpts) + } + + // Retry loop + var err error + maxRetries := 2 + for retry := 0; retry <= maxRetries; retry++ { + exec.response, err = callLLM(exec.callMessages, exec.providerToolDefs) + if err == nil { + break + } + if ts.hardAbortRequested() && errors.Is(err, context.Canceled) { + _ = ts.requestHardAbort() + exec.abortedByHardAbort = true + return ControlBreak, nil + } + + // Retry without media if vision is unsupported + if hasMediaRefs(exec.callMessages) && isVisionUnsupportedError(err) && retry < maxRetries { + al.emitEvent( + EventKindLLMRetry, + ts.eventMeta("runTurn", "turn.llm.retry"), + LLMRetryPayload{ + Attempt: retry + 1, + MaxRetries: maxRetries, + Reason: "vision_unsupported", + Error: err.Error(), + Backoff: 0, + }, + ) + logger.WarnCF("agent", "Vision unsupported, retrying without media", map[string]any{ + "error": err.Error(), + "retry": retry, + }) + exec.callMessages = stripMessageMedia(exec.callMessages) + if !ts.opts.NoHistory { + exec.history = stripMessageMedia(exec.history) + ts.agent.Sessions.SetHistory(ts.sessionKey, exec.history) + for i := range ts.persistedMessages { + ts.persistedMessages[i].Media = nil + } + ts.refreshRestorePointFromSession(ts.agent) + } + continue + } + + errMsg := strings.ToLower(err.Error()) + isTimeoutError := errors.Is(err, context.DeadlineExceeded) || + strings.Contains(errMsg, "deadline exceeded") || + strings.Contains(errMsg, "client.timeout") || + strings.Contains(errMsg, "timed out") || + strings.Contains(errMsg, "timeout exceeded") + + isContextError := !isTimeoutError && (strings.Contains(errMsg, "context_length_exceeded") || + strings.Contains(errMsg, "context window") || + strings.Contains(errMsg, "context_window") || + strings.Contains(errMsg, "maximum context length") || + strings.Contains(errMsg, "token limit") || + strings.Contains(errMsg, "too many tokens") || + strings.Contains(errMsg, "max_tokens") || + strings.Contains(errMsg, "invalidparameter") || + strings.Contains(errMsg, "prompt is too long") || + strings.Contains(errMsg, "request too large")) + + if isTimeoutError && retry < maxRetries { + backoff := time.Duration(retry+1) * 5 * time.Second + al.emitEvent( + EventKindLLMRetry, + ts.eventMeta("runTurn", "turn.llm.retry"), + LLMRetryPayload{ + Attempt: retry + 1, + MaxRetries: maxRetries, + Reason: "timeout", + Error: err.Error(), + Backoff: backoff, + }, + ) + logger.WarnCF("agent", "Timeout error, retrying after backoff", map[string]any{ + "error": err.Error(), + "retry": retry, + "backoff": backoff.String(), + }) + if sleepErr := sleepWithContext(turnCtx, backoff); sleepErr != nil { + if ts.hardAbortRequested() { + _ = ts.requestHardAbort() + return ControlBreak, nil + } + err = sleepErr + break + } + continue + } + + if isContextError && retry < maxRetries && !ts.opts.NoHistory { + al.emitEvent( + EventKindLLMRetry, + ts.eventMeta("runTurn", "turn.llm.retry"), + LLMRetryPayload{ + Attempt: retry + 1, + MaxRetries: maxRetries, + Reason: "context_limit", + Error: err.Error(), + }, + ) + logger.WarnCF( + "agent", + "Context window error detected, attempting compression", + map[string]any{ + "error": err.Error(), + "retry": retry, + }, + ) + + if retry == 0 && !constants.IsInternalChannel(ts.channel) { + al.bus.PublishOutbound(ctx, outboundMessageForTurn( + ts, + "Context window exceeded. Compressing history and retrying...", + )) + } + + if compactErr := p.ContextManager.Compact(ctx, &CompactRequest{ + SessionKey: ts.sessionKey, + Reason: ContextCompressReasonRetry, + Budget: ts.agent.ContextWindow, + }); compactErr != nil { + logger.WarnCF("agent", "Context overflow compact failed", map[string]any{ + "session_key": ts.sessionKey, + "error": compactErr.Error(), + }) + } + ts.refreshRestorePointFromSession(ts.agent) + if asmResp, asmErr := p.ContextManager.Assemble(ctx, &AssembleRequest{ + SessionKey: ts.sessionKey, + Budget: ts.agent.ContextWindow, + MaxTokens: ts.agent.MaxTokens, + }); asmErr == nil && asmResp != nil { + exec.history = asmResp.History + exec.summary = asmResp.Summary + } + exec.messages = ts.agent.ContextBuilder.BuildMessages( + exec.history, exec.summary, "", + nil, ts.channel, ts.chatID, ts.opts.Dispatch.SenderID(), ts.opts.SenderDisplayName, + activeSkillNames(ts.agent, ts.opts)..., + ) + exec.callMessages = exec.messages + if exec.gracefulTerminal { + msgs := append([]providers.Message(nil), exec.messages...) + exec.callMessages = append(msgs, ts.interruptHintMessage()) + } + continue + } + break + } + + if err != nil { + al.emitEvent( + EventKindError, + ts.eventMeta("runTurn", "turn.error"), + ErrorPayload{ + Stage: "llm", + Message: err.Error(), + }, + ) + logger.ErrorCF("agent", "LLM call failed", + map[string]any{ + "agent_id": ts.agent.ID, + "iteration": iteration, + "model": exec.llmModel, + "error": err.Error(), + }) + return ControlBreak, fmt.Errorf("LLM call failed after retries: %w", err) + } + + // AfterLLM hook + if p.Hooks != nil { + llmResp, decision := p.Hooks.AfterLLM(turnCtx, &LLMHookResponse{ + Meta: ts.eventMeta("runTurn", "turn.llm.response"), + Context: cloneTurnContext(ts.turnCtx), + Model: exec.llmModel, + Response: exec.response, + }) + switch decision.normalizedAction() { + case HookActionContinue, HookActionModify: + if llmResp != nil && llmResp.Response != nil { + exec.response = llmResp.Response + } + case HookActionAbortTurn: + exec.abortedByHook = true + return ControlBreak, nil + case HookActionHardAbort: + _ = ts.requestHardAbort() + exec.abortedByHardAbort = true + return ControlBreak, nil + } + } + + // Save finishReason to turnState for SubTurn truncation detection + if innerTS := turnStateFromContext(ctx); innerTS != nil { + innerTS.SetLastFinishReason(exec.response.FinishReason) + if exec.response.Usage != nil { + innerTS.SetLastUsage(exec.response.Usage) + } + } + + reasoningContent := exec.response.Reasoning + if reasoningContent == "" { + reasoningContent = exec.response.ReasoningContent + } + if ts.channel == "pico" { + go al.publishPicoReasoning(turnCtx, reasoningContent, ts.chatID) + } else { + go al.handleReasoning( + turnCtx, + reasoningContent, + ts.channel, + al.targetReasoningChannelID(ts.channel), + ) + } + al.emitEvent( + EventKindLLMResponse, + ts.eventMeta("runTurn", "turn.llm.response"), + LLMResponsePayload{ + ContentLen: len(exec.response.Content), + ToolCalls: len(exec.response.ToolCalls), + HasReasoning: exec.response.Reasoning != "" || exec.response.ReasoningContent != "", + }, + ) + + llmResponseFields := map[string]any{ + "agent_id": ts.agent.ID, + "iteration": iteration, + "content_chars": len(exec.response.Content), + "tool_calls": len(exec.response.ToolCalls), + "reasoning": exec.response.Reasoning, + "target_channel": al.targetReasoningChannelID(ts.channel), + "channel": ts.channel, + } + if exec.response.Usage != nil { + llmResponseFields["prompt_tokens"] = exec.response.Usage.PromptTokens + llmResponseFields["completion_tokens"] = exec.response.Usage.CompletionTokens + llmResponseFields["total_tokens"] = exec.response.Usage.TotalTokens + } + logger.DebugCF("agent", "LLM response", llmResponseFields) + + if al.bus != nil && ts.channel == "pico" && len(exec.response.ToolCalls) > 0 && ts.opts.AllowInterimPicoPublish { + if strings.TrimSpace(exec.response.Content) != "" { + outCtx, outCancel := context.WithTimeout(turnCtx, 3*time.Second) + publishErr := al.bus.PublishOutbound(outCtx, bus.OutboundMessage{ + Channel: ts.channel, + ChatID: ts.chatID, + Content: exec.response.Content, + }) + outCancel() + if publishErr != nil { + logger.WarnCF("agent", "Failed to publish pico interim tool-call content", map[string]any{ + "error": publishErr.Error(), + "channel": ts.channel, + "chat_id": ts.chatID, + "iteration": iteration, + }) + } + } + } + + // No-tool-call path: steering check and direct response + if len(exec.response.ToolCalls) == 0 || exec.gracefulTerminal { + responseContent := exec.response.Content + if responseContent == "" && exec.response.ReasoningContent != "" && ts.channel != "pico" { + responseContent = exec.response.ReasoningContent + } + if steerMsgs := al.dequeueSteeringMessagesForScope(ts.sessionKey); len(steerMsgs) > 0 { + logger.InfoCF("agent", "Steering arrived after direct LLM response; continuing turn", + map[string]any{ + "agent_id": ts.agent.ID, + "iteration": iteration, + "steering_count": len(steerMsgs), + }) + exec.pendingMessages = append(exec.pendingMessages, steerMsgs...) + return ControlContinue, nil + } + exec.finalContent = responseContent + logger.InfoCF("agent", "LLM response without tool calls (direct answer)", + map[string]any{ + "agent_id": ts.agent.ID, + "iteration": iteration, + "content_chars": len(exec.finalContent), + }) + return ControlBreak, nil + } + + // Tool-call path: normalize and prepare for tool execution + exec.normalizedToolCalls = make([]providers.ToolCall, 0, len(exec.response.ToolCalls)) + for _, tc := range exec.response.ToolCalls { + exec.normalizedToolCalls = append(exec.normalizedToolCalls, providers.NormalizeToolCall(tc)) + } + + toolNames := make([]string, 0, len(exec.normalizedToolCalls)) + for _, tc := range exec.normalizedToolCalls { + toolNames = append(toolNames, tc.Name) + } + logger.InfoCF("agent", "LLM requested tool calls", + map[string]any{ + "agent_id": ts.agent.ID, + "tools": toolNames, + "count": len(exec.normalizedToolCalls), + "iteration": iteration, + }) + + exec.allResponsesHandled = len(exec.normalizedToolCalls) > 0 + assistantMsg := providers.Message{ + Role: "assistant", + Content: exec.response.Content, + ReasoningContent: exec.response.ReasoningContent, + } + for _, tc := range exec.normalizedToolCalls { + argumentsJSON, _ := json.Marshal(tc.Arguments) + extraContent := tc.ExtraContent + thoughtSignature := "" + if tc.Function != nil { + thoughtSignature = tc.Function.ThoughtSignature + } + assistantMsg.ToolCalls = append(assistantMsg.ToolCalls, providers.ToolCall{ + ID: tc.ID, + Type: "function", + Name: tc.Name, + Function: &providers.FunctionCall{ + Name: tc.Name, + Arguments: string(argumentsJSON), + ThoughtSignature: thoughtSignature, + }, + ExtraContent: extraContent, + ThoughtSignature: thoughtSignature, + }) + } + exec.messages = append(exec.messages, assistantMsg) + if !ts.opts.NoHistory { + ts.agent.Sessions.AddFullMessage(ts.sessionKey, assistantMsg) + ts.recordPersistedMessage(assistantMsg) + ts.ingestMessage(turnCtx, al, assistantMsg) + } + + return ControlToolLoop, nil +} diff --git a/pkg/agent/pipeline_setup.go b/pkg/agent/pipeline_setup.go new file mode 100644 index 000000000..e6ead1012 --- /dev/null +++ b/pkg/agent/pipeline_setup.go @@ -0,0 +1,116 @@ +// PicoClaw - Ultra-lightweight personal AI agent + +package agent + +import ( + "context" + "strings" + + "github.com/sipeed/picoclaw/pkg/logger" + "github.com/sipeed/picoclaw/pkg/providers" +) + +// SetupTurn extracts the one-time initialization phase, returning a +// turnExecution populated with history, messages, and candidate selection. +// It replaces lines 56-145 of the original runTurn. +func (p *Pipeline) SetupTurn(ctx context.Context, ts *turnState) (*turnExecution, error) { + cfg := p.Cfg + maxMediaSize := cfg.Agents.Defaults.GetMaxMediaSize() + + var history []providers.Message + var summary string + if !ts.opts.NoHistory { + if resp, err := p.ContextManager.Assemble(ctx, &AssembleRequest{ + SessionKey: ts.sessionKey, + Budget: ts.agent.ContextWindow, + MaxTokens: ts.agent.MaxTokens, + }); err == nil && resp != nil { + history = resp.History + summary = resp.Summary + } + } + ts.captureRestorePoint(history, summary) + + messages := ts.agent.ContextBuilder.BuildMessages( + history, + summary, + ts.userMessage, + ts.media, + ts.channel, + ts.chatID, + ts.opts.Dispatch.SenderID(), + ts.opts.SenderDisplayName, + activeSkillNames(ts.agent, ts.opts)..., + ) + + messages = resolveMediaRefs(messages, p.MediaStore, maxMediaSize) + + if !ts.opts.NoHistory { + toolDefs := ts.agent.Tools.ToProviderDefs() + if isOverContextBudget(ts.agent.ContextWindow, messages, toolDefs, ts.agent.MaxTokens) { + logger.WarnCF("agent", "Proactive compression: context budget exceeded before LLM call", + map[string]any{"session_key": ts.sessionKey}) + if err := p.ContextManager.Compact(ctx, &CompactRequest{ + SessionKey: ts.sessionKey, + Reason: ContextCompressReasonProactive, + Budget: ts.agent.ContextWindow, + }); err != nil { + logger.WarnCF("agent", "Proactive compact failed", map[string]any{ + "session_key": ts.sessionKey, + "error": err.Error(), + }) + } + ts.refreshRestorePointFromSession(ts.agent) + if resp, err := p.ContextManager.Assemble(ctx, &AssembleRequest{ + SessionKey: ts.sessionKey, + Budget: ts.agent.ContextWindow, + MaxTokens: ts.agent.MaxTokens, + }); err == nil && resp != nil { + history = resp.History + summary = resp.Summary + } + messages = ts.agent.ContextBuilder.BuildMessages( + history, summary, ts.userMessage, + ts.media, ts.channel, ts.chatID, + ts.opts.Dispatch.SenderID(), ts.opts.SenderDisplayName, + activeSkillNames(ts.agent, ts.opts)..., + ) + messages = resolveMediaRefs(messages, p.MediaStore, maxMediaSize) + } + } + + if !ts.opts.NoHistory && (strings.TrimSpace(ts.userMessage) != "" || len(ts.media) > 0) { + rootMsg := providers.Message{ + Role: "user", + Content: ts.userMessage, + Media: append([]string(nil), ts.media...), + } + if len(rootMsg.Media) > 0 { + ts.agent.Sessions.AddFullMessage(ts.sessionKey, rootMsg) + } else { + ts.agent.Sessions.AddMessage(ts.sessionKey, rootMsg.Role, rootMsg.Content) + } + ts.recordPersistedMessage(rootMsg) + ts.ingestMessage(ctx, p.al, rootMsg) + } + + activeCandidates, activeModel, usedLight := p.al.selectCandidates(ts.agent, ts.userMessage, messages) + activeProvider := ts.agent.Provider + if usedLight && ts.agent.LightProvider != nil { + activeProvider = ts.agent.LightProvider + } + + exec := newTurnExecution( + ts.agent, + ts.opts, + history, + summary, + messages, + ) + exec.activeCandidates = activeCandidates + exec.activeModel = activeModel + exec.activeProvider = activeProvider + exec.usedLight = usedLight + + return exec, nil +} diff --git a/pkg/agent/subturn.go b/pkg/agent/subturn.go index cd193017b..a65467dbb 100644 --- a/pkg/agent/subturn.go +++ b/pkg/agent/subturn.go @@ -462,7 +462,8 @@ func spawnSubTurn( }() // 8. Execute sub-turn via the real agent loop. - turnRes, turnErr := al.runTurn(childCtx, childTS) + pipeline := NewPipeline(al) + turnRes, turnErr := al.runTurn(childCtx, childTS, pipeline) // Release the concurrency semaphore immediately after runTurn completes, // before the cleanup defer runs. This prevents a deadlock where: diff --git a/pkg/agent/turn_coord.go b/pkg/agent/turn_coord.go new file mode 100644 index 000000000..4c8335933 --- /dev/null +++ b/pkg/agent/turn_coord.go @@ -0,0 +1,624 @@ +// PicoClaw - Ultra-lightweight personal AI agent + +package agent + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/logger" + "github.com/sipeed/picoclaw/pkg/providers" +) + +func (al *AgentLoop) runTurn(ctx context.Context, ts *turnState, pipeline *Pipeline) (turnResult, error) { + turnCtx, turnCancel := context.WithCancel(ctx) + defer turnCancel() + ts.setTurnCancel(turnCancel) + + // Inject turnState and AgentLoop into context so tools (e.g. spawn) can retrieve them. + turnCtx = withTurnState(turnCtx, ts) + turnCtx = WithAgentLoop(turnCtx, al) + + al.registerActiveTurn(ts) + defer al.clearActiveTurn(ts) + + turnStatus := TurnEndStatusCompleted + defer func() { + al.emitEvent( + EventKindTurnEnd, + ts.eventMeta("runTurn", "turn.end"), + TurnEndPayload{ + Status: turnStatus, + Iterations: ts.currentIteration(), + Duration: time.Since(ts.startedAt), + FinalContentLen: ts.finalContentLen(), + }, + ) + }() + + al.emitEvent( + EventKindTurnStart, + ts.eventMeta("runTurn", "turn.start"), + TurnStartPayload{ + UserMessage: ts.userMessage, + MediaCount: len(ts.media), + }, + ) + + // SetupTurn extracts the one-time initialization phase. + exec, err := pipeline.SetupTurn(turnCtx, ts) + if err != nil { + return turnResult{}, err + } + + // Convenience references to exec fields used throughout the turn loop. + messages := exec.messages + pendingMessages := exec.pendingMessages + maxMediaSize := pipeline.Cfg.Agents.Defaults.GetMaxMediaSize() + finalContent := exec.finalContent + + for ts.currentIteration() < ts.agent.MaxIterations || len(exec.pendingMessages) > 0 || func() bool { + graceful, _ := ts.gracefulInterruptRequested() + return graceful + }() { + if ts.hardAbortRequested() { + turnStatus = TurnEndStatusAborted + return al.abortTurn(ts) + } + + iteration := ts.currentIteration() + 1 + ts.setIteration(iteration) + ts.setPhase(TurnPhaseRunning) + + if iteration > 1 { + // For subsequent iterations, read from exec.pendingMessages which + // is where ExecuteTools (or initial poll) deposits steering. + // We do NOT call dequeueSteeringMessagesForScope here because + // steering was already consumed from al.steering by ExecuteTools. + if len(exec.pendingMessages) > 0 { + pendingMessages = append(pendingMessages, exec.pendingMessages...) + exec.pendingMessages = nil + } + } else if !ts.opts.SkipInitialSteeringPoll { + if steerMsgs := al.dequeueSteeringMessagesForScopeWithFallback(ts.sessionKey); len(steerMsgs) > 0 { + pendingMessages = append(pendingMessages, steerMsgs...) + } + } + + // Check if parent turn has ended (SubTurn support from HEAD) + if ts.parentTurnState != nil && ts.IsParentEnded() { + if !ts.critical { + logger.InfoCF("agent", "Parent turn ended, non-critical SubTurn exiting gracefully", map[string]any{ + "agent_id": ts.agentID, + "iteration": iteration, + "turn_id": ts.turnID, + }) + break + } + logger.InfoCF("agent", "Parent turn ended, critical SubTurn continues running", map[string]any{ + "agent_id": ts.agentID, + "iteration": iteration, + "turn_id": ts.turnID, + }) + } + + // Poll for pending SubTurn results (from HEAD) + if ts.pendingResults != nil { + select { + case result, ok := <-ts.pendingResults: + if ok && result != nil && result.ForLLM != "" { + content := al.cfg.FilterSensitiveData(result.ForLLM) + msg := providers.Message{Role: "user", Content: fmt.Sprintf("[SubTurn Result] %s", content)} + pendingMessages = append(pendingMessages, msg) + } + default: + // No results available + } + } + + // Inject pending steering messages + if len(pendingMessages) > 0 { + resolvedPending := resolveMediaRefs(pendingMessages, al.mediaStore, maxMediaSize) + totalContentLen := 0 + for i, pm := range pendingMessages { + messages = append(messages, resolvedPending[i]) + totalContentLen += len(pm.Content) + if !ts.opts.NoHistory { + ts.agent.Sessions.AddFullMessage(ts.sessionKey, pm) + ts.recordPersistedMessage(pm) + ts.ingestMessage(turnCtx, al, pm) + } + logger.InfoCF("agent", "Injected steering message into context", + map[string]any{ + "agent_id": ts.agent.ID, + "iteration": iteration, + "content_len": len(pm.Content), + "media_count": len(pm.Media), + }) + } + al.emitEvent( + EventKindSteeringInjected, + ts.eventMeta("runTurn", "turn.steering.injected"), + SteeringInjectedPayload{ + Count: len(pendingMessages), + TotalContentLen: totalContentLen, + }, + ) + // Clear exec.pendingMessages after injection so InitialSteeringMessages + // are not re-injected on subsequent iterations (Issue 2 fix). + exec.pendingMessages = nil + } + // Always sync messages into exec.messages so CallLLM sees the updated state + exec.messages = messages + + logger.DebugCF("agent", "LLM iteration", + map[string]any{ + "agent_id": ts.agent.ID, + "iteration": iteration, + "max": ts.agent.MaxIterations, + }) + + // Execute LLM call via Pipeline + ts.setPhase(TurnPhaseRunning) + ctrl, callErr := pipeline.CallLLM(ctx, turnCtx, ts, exec, iteration) + if callErr != nil { + turnStatus = TurnEndStatusError + return turnResult{}, callErr + } + messages = exec.messages + pendingMessages = exec.pendingMessages + finalContent = exec.finalContent + + switch ctrl { + case ControlContinue: + continue + case ControlBreak: + // Hard abort: delegate to abortTurn (sets TurnEndStatusAborted) + if exec.abortedByHardAbort { + turnStatus = TurnEndStatusAborted + return al.abortTurn(ts) + } + // Hook abort (HookActionAbortTurn): sets TurnEndStatusError, returns error + if exec.abortedByHook { + turnStatus = TurnEndStatusError + return turnResult{}, fmt.Errorf("hook requested turn abort") + } + // Ensure empty response falls back to DefaultResponse + if finalContent == "" { + finalContent = ts.opts.DefaultResponse + } + return pipeline.Finalize(ctx, turnCtx, ts, exec, turnStatus, finalContent) + case ControlToolLoop: + // Execute tools via Pipeline + toolCtrl := pipeline.ExecuteTools(ctx, turnCtx, ts, exec, iteration) + switch toolCtrl { + case ToolControlContinue: + // Re-read exec.messages since ExecuteTools may have updated it + // (added tool results/skipped messages) before returning ControlContinue + messages = exec.messages + continue + case ToolControlBreak: + // Hard abort: delegate to abortTurn (sets TurnEndStatusAborted) + if exec.abortedByHardAbort { + turnStatus = TurnEndStatusAborted + return al.abortTurn(ts) + } + // Hook abort (HookActionAbortTurn): sets TurnEndStatusError, returns error + if exec.abortedByHook { + turnStatus = TurnEndStatusError + return turnResult{}, fmt.Errorf("hook requested turn abort") + } + // ExecuteTools returned ControlBreak: + // - allResponsesHandled=true: finalize without DefaultResponse (exec.finalContent empty) + // - allResponsesHandled=false: coordinator applies DefaultResponse before finalize + if exec.allResponsesHandled { + finalContent = "" + } + return pipeline.Finalize(ctx, turnCtx, ts, exec, turnStatus, finalContent) + } + } + } + + if ts.hardAbortRequested() { + turnStatus = TurnEndStatusAborted + return al.abortTurn(ts) + } + + if finalContent == "" { + if ts.currentIteration() >= ts.agent.MaxIterations && ts.agent.MaxIterations > 0 { + finalContent = toolLimitResponse + } else { + finalContent = ts.opts.DefaultResponse + } + } + + // Check hard abort before finalizing (may have been set during tool execution) + if ts.hardAbortRequested() { + turnStatus = TurnEndStatusAborted + return al.abortTurn(ts) + } + + return pipeline.Finalize(ctx, turnCtx, ts, exec, turnStatus, finalContent) +} + +func (al *AgentLoop) abortTurn(ts *turnState) (turnResult, error) { + ts.setPhase(TurnPhaseAborted) + if !ts.opts.NoHistory { + if err := ts.restoreSession(ts.agent); err != nil { + al.emitEvent( + EventKindError, + ts.eventMeta("abortTurn", "turn.error"), + ErrorPayload{ + Stage: "session_restore", + Message: err.Error(), + }, + ) + return turnResult{}, err + } + } + return turnResult{status: TurnEndStatusAborted}, nil +} + +func (al *AgentLoop) selectCandidates( + agent *AgentInstance, + userMsg string, + history []providers.Message, +) (candidates []providers.FallbackCandidate, model string, usedLight bool) { + if agent.Router == nil || len(agent.LightCandidates) == 0 { + return agent.Candidates, resolvedCandidateModel(agent.Candidates, agent.Model), false + } + + _, usedLight, score := agent.Router.SelectModel(userMsg, history, agent.Model) + if !usedLight { + logger.DebugCF("agent", "Model routing: primary model selected", + map[string]any{ + "agent_id": agent.ID, + "score": score, + "threshold": agent.Router.Threshold(), + }) + return agent.Candidates, resolvedCandidateModel(agent.Candidates, agent.Model), false + } + + logger.InfoCF("agent", "Model routing: light model selected", + map[string]any{ + "agent_id": agent.ID, + "light_model": agent.Router.LightModel(), + "score": score, + "threshold": agent.Router.Threshold(), + }) + return agent.LightCandidates, resolvedCandidateModel(agent.LightCandidates, agent.Router.LightModel()), true +} + +func (al *AgentLoop) resolveContextManager() ContextManager { + name := al.cfg.Agents.Defaults.ContextManager + if name == "" || name == "legacy" { + return &legacyContextManager{al: al} + } + factory, ok := lookupContextManager(name) + if !ok { + logger.WarnCF("agent", "Unknown context manager, falling back to legacy", map[string]any{ + "name": name, + }) + return &legacyContextManager{al: al} + } + cm, err := factory(al.cfg.Agents.Defaults.ContextManagerConfig, al) + if err != nil { + logger.WarnCF("agent", "Failed to create context manager, falling back to legacy", map[string]any{ + "name": name, + "error": err.Error(), + }) + return &legacyContextManager{al: al} + } + return cm +} + +func (al *AgentLoop) askSideQuestion( + ctx context.Context, + agent *AgentInstance, + opts *processOptions, + question string, +) (string, error) { + if agent == nil { + return "", fmt.Errorf("askSideQuestion: no agent available for /btw") + } + + question = strings.TrimSpace(question) + if question == "" { + return "", fmt.Errorf("askSideQuestion: %w", fmt.Errorf("Usage: /btw ")) + } + + if opts != nil { + normalizeProcessOptionsInPlace(opts) + } + + var media []string + var channel, chatID, senderID, senderDisplayName string + if opts != nil { + media = opts.Media + channel = opts.Channel + chatID = opts.ChatID + senderID = opts.SenderID + senderDisplayName = opts.SenderDisplayName + } + + // Build messages with context but WITHOUT adding to session history + var history []providers.Message + var summary string + if opts != nil && !opts.NoHistory { + if resp, err := al.contextManager.Assemble(ctx, &AssembleRequest{ + SessionKey: opts.SessionKey, + Budget: agent.ContextWindow, + MaxTokens: agent.MaxTokens, + }); err == nil && resp != nil { + history = resp.History + summary = resp.Summary + } + } + + messages := agent.ContextBuilder.BuildMessages( + history, + summary, + question, + media, + channel, + chatID, + senderID, + senderDisplayName, + ) + + maxMediaSize := al.GetConfig().Agents.Defaults.GetMaxMediaSize() + messages = resolveMediaRefs(messages, al.mediaStore, maxMediaSize) + + activeCandidates, activeModel, usedLight := al.selectCandidates(agent, question, messages) + selectedModelName := sideQuestionModelName(agent, usedLight) + + llmOpts := map[string]any{ + "max_tokens": agent.MaxTokens, + "temperature": agent.Temperature, + "prompt_cache_key": agent.ID + ":btw", + } + + hookModelChanged := false + callProvider := func( + ctx context.Context, + candidate providers.FallbackCandidate, + model string, + forceModel bool, + callMessages []providers.Message, + ) (*providers.LLMResponse, error) { + provider, providerModel, cleanup, err := al.isolatedSideQuestionProvider(agent, selectedModelName, candidate) + if err != nil { + return nil, err + } + defer cleanup() + if !forceModel || strings.TrimSpace(model) == "" { + model = providerModel + } + callOpts := llmOpts + if _, exists := callOpts["thinking_level"]; !exists && agent.ThinkingLevel != ThinkingOff { + if tc, ok := provider.(providers.ThinkingCapable); ok && tc.SupportsThinking() { + callOpts = shallowCloneLLMOptions(llmOpts) + callOpts["thinking_level"] = string(agent.ThinkingLevel) + } + } + return provider.Chat(ctx, callMessages, nil, model, callOpts) + } + + turnCtx := newTurnContext(nil, nil, nil) + if opts != nil { + turnCtx = newTurnContext(opts.Dispatch.InboundContext, opts.Dispatch.RouteResult, opts.Dispatch.SessionScope) + } + llmModel := activeModel + if al.hooks != nil { + llmReq, decision := al.hooks.BeforeLLM(ctx, &LLMHookRequest{ + Meta: EventMeta{ + Source: "askSideQuestion", + TracePath: "turn.llm.request", + turnContext: cloneTurnContext(turnCtx), + }, + Context: cloneTurnContext(turnCtx), + Model: llmModel, + Messages: messages, + Tools: nil, + Options: llmOpts, + GracefulTerminal: false, + }) + switch decision.normalizedAction() { + case HookActionContinue, HookActionModify: + if llmReq != nil { + if strings.TrimSpace(llmReq.Model) != "" && llmReq.Model != llmModel { + hookModelChanged = true + } + llmModel = llmReq.Model + messages = llmReq.Messages + llmOpts = llmReq.Options + } + case HookActionAbortTurn: + reason := decision.Reason + if reason == "" { + reason = "hook requested turn abort" + } + return "", fmt.Errorf("hook aborted turn during before_llm: %s", reason) + case HookActionHardAbort: + reason := decision.Reason + if reason == "" { + reason = "hook requested turn abort" + } + return "", fmt.Errorf("hook aborted turn during before_llm: %s", reason) + } + } + if hookModelChanged { + // Hook-selected models must not continue through the pre-hook fallback + // candidate list, otherwise fallback execution would call the original + // candidate model and silently ignore the hook decision. + activeCandidates = nil + } + + callSideLLM := func(callMessages []providers.Message) (*providers.LLMResponse, error) { + if len(activeCandidates) > 1 && al.fallback != nil { + fbResult, err := al.fallback.Execute( + ctx, + activeCandidates, + func(ctx context.Context, providerName, model string) (*providers.LLMResponse, error) { + candidate := providers.FallbackCandidate{Provider: providerName, Model: model} + for _, activeCandidate := range activeCandidates { + if activeCandidate.Provider == providerName && activeCandidate.Model == model { + candidate = activeCandidate + break + } + } + return callProvider(ctx, candidate, model, false, callMessages) + }, + ) + if err != nil { + return nil, err + } + return fbResult.Response, nil + } + + var candidate providers.FallbackCandidate + if len(activeCandidates) > 0 { + candidate = activeCandidates[0] + } + return callProvider(ctx, candidate, llmModel, hookModelChanged, callMessages) + } + + // Retry without media if vision is unsupported + // Note: Vision retry is only applied to the initial call. If fallback chain + // is used, vision errors from fallback providers will not trigger retry. + var resp *providers.LLMResponse + var err error + resp, err = callSideLLM(messages) + if err != nil && hasMediaRefs(messages) && isVisionUnsupportedError(err) { + al.emitEvent( + EventKindLLMRetry, + EventMeta{ + Source: "askSideQuestion", + TracePath: "turn.llm.retry", + turnContext: cloneTurnContext(turnCtx), + }, + LLMRetryPayload{ + Attempt: 1, + MaxRetries: 1, + Reason: "vision_unsupported", + Error: err.Error(), + Backoff: 0, + }, + ) + messagesWithoutMedia := stripMessageMedia(messages) + resp, err = callSideLLM(messagesWithoutMedia) + } + if err != nil { + return "", err + } + if resp == nil { + return "", nil + } + + // Apply after_llm hooks + if al.hooks != nil { + llmResp, decision := al.hooks.AfterLLM(ctx, &LLMHookResponse{ + Meta: EventMeta{ + Source: "askSideQuestion", + TracePath: "turn.llm.response", + turnContext: cloneTurnContext(turnCtx), + }, + Context: cloneTurnContext(turnCtx), + Model: llmModel, + Response: resp, + }) + switch decision.normalizedAction() { + case HookActionContinue, HookActionModify: + if llmResp != nil && llmResp.Response != nil { + resp = llmResp.Response + } + case HookActionAbortTurn, HookActionHardAbort: + reason := decision.Reason + if reason == "" { + reason = "hook requested turn abort" + } + return "", fmt.Errorf("hook aborted turn during after_llm: %s", reason) + } + } + + return sideQuestionResponseContent(resp), nil +} + +func (al *AgentLoop) isolatedSideQuestionProvider( + agent *AgentInstance, + baseModelName string, + candidate providers.FallbackCandidate, +) (providers.LLMProvider, string, func(), error) { + if agent == nil { + return nil, "", func() {}, fmt.Errorf("isolatedSideQuestionProvider: no agent available for /btw") + } + + modelCfg, err := al.sideQuestionModelConfig(agent, baseModelName, candidate) + if err != nil { + return nil, "", func() {}, fmt.Errorf("isolatedSideQuestionProvider: %w", err) + } + + factory := al.providerFactory + if factory == nil { + factory = providers.CreateProviderFromConfig + } + provider, modelID, err := factory(modelCfg) + if err != nil { + return nil, "", func() {}, fmt.Errorf("isolatedSideQuestionProvider: %w", err) + } + + cleanup := func() { + closeProviderIfStateful(provider) + } + return provider, modelID, cleanup, nil +} + +func (al *AgentLoop) sideQuestionModelConfig( + agent *AgentInstance, + baseModelName string, + candidate providers.FallbackCandidate, +) (*config.ModelConfig, error) { + if agent == nil { + return nil, fmt.Errorf("sideQuestionModelConfig: no agent available for /btw") + } + + // If candidate has an identity key, use that + if name := modelNameFromIdentityKey(candidate.IdentityKey); name != "" { + modelCfg, err := resolvedModelConfig(al.GetConfig(), name, agent.Workspace) + if err == nil { + return modelCfg, nil + } + // Fallback: create a minimal config if lookup fails + } + + // Otherwise, clean up the base model name and use it + baseModelName = strings.TrimSpace(baseModelName) + modelCfg, err := resolvedModelConfig(al.GetConfig(), baseModelName, agent.Workspace) + if err != nil { + // Fallback: create a minimal config for test scenarios + model := strings.TrimSpace(baseModelName) + if candidate.Model != "" { + model = candidate.Model + } + if candidate.Provider != "" && candidate.Model != "" { + model = providers.NormalizeProvider(candidate.Provider) + "/" + candidate.Model + } else { + model = ensureProtocolModel(model) + } + return &config.ModelConfig{ + ModelName: baseModelName, + Model: model, + Workspace: agent.Workspace, + }, nil + } + + // If candidate specifies a different provider/model, override + clone := *modelCfg + if candidate.Provider != "" && candidate.Model != "" { + clone.Model = providers.NormalizeProvider(candidate.Provider) + "/" + candidate.Model + } + return &clone, nil +} diff --git a/pkg/agent/turn_coord_test.go b/pkg/agent/turn_coord_test.go new file mode 100644 index 000000000..7a362a662 --- /dev/null +++ b/pkg/agent/turn_coord_test.go @@ -0,0 +1,551 @@ +package agent + +import ( + "context" + "errors" + "sync" + "testing" + "time" + + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/providers" +) + +// ============================================================================= +// Mock Providers for turn_coord Tests +// ============================================================================= + +// simpleConvProvider returns a simple text response without tools +type simpleConvProvider struct{} + +func (p *simpleConvProvider) Chat( + ctx context.Context, + messages []providers.Message, + tools []providers.ToolDefinition, + model string, + opts map[string]any, +) (*providers.LLMResponse, error) { + return &providers.LLMResponse{ + Content: "Hello! How can I help you today?", + FinishReason: "stop", + }, nil +} + +func (p *simpleConvProvider) GetDefaultModel() string { + return "simple-model" +} + +// toolCallRespProvider returns a tool call response +type toolCallRespProvider struct { + toolName string + toolArgs map[string]any + response string + callCount int + mu sync.Mutex +} + +func (p *toolCallRespProvider) Chat( + ctx context.Context, + messages []providers.Message, + tools []providers.ToolDefinition, + model string, + opts map[string]any, +) (*providers.LLMResponse, error) { + p.mu.Lock() + p.callCount++ + count := p.callCount + p.mu.Unlock() + + // First call returns a tool call, subsequent calls return final response + if count == 1 { + return &providers.LLMResponse{ + Content: "Let me search for that information.", + ToolCalls: []providers.ToolCall{ + { + ID: "call_1", + Name: p.toolName, + Arguments: p.toolArgs, + }, + }, + FinishReason: "tool_calls", + }, nil + } + return &providers.LLMResponse{ + Content: p.response, + FinishReason: "stop", + }, nil +} + +func (p *toolCallRespProvider) GetDefaultModel() string { + return "tool-model" +} + +// errorProvider simulates various error conditions +type errorProvider struct { + errType string + callCount int + mu sync.Mutex +} + +func (p *errorProvider) Chat( + ctx context.Context, + messages []providers.Message, + tools []providers.ToolDefinition, + model string, + opts map[string]any, +) (*providers.LLMResponse, error) { + p.mu.Lock() + p.callCount++ + p.mu.Unlock() + + switch p.errType { + case "timeout": + return nil, context.DeadlineExceeded + case "context_length": + return nil, errors.New("context_length_exceeded") + case "vision": + return nil, errors.New("vision_unsupported") + default: + return nil, errors.New("unknown error") + } +} + +func (p *errorProvider) GetDefaultModel() string { + return "error-model" +} + +// ============================================================================= +// Test Helper Functions +// ============================================================================= + +func newTurnCoordTestLoop(t *testing.T, provider providers.LLMProvider) (*AgentLoop, *AgentInstance, func()) { + t.Helper() + tmpDir := t.TempDir() + + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + ModelName: "test-model", + MaxTokens: 4096, + MaxToolIterations: 10, + }, + }, + } + + msgBus := bus.NewMessageBus() + al := NewAgentLoop(cfg, msgBus, provider) + agent := al.registry.GetDefaultAgent() + if agent == nil { + t.Fatal("expected default agent") + } + + return al, agent, func() { + al.Close() + } +} + +func makeTestProcessOpts(sessionKey string) processOptions { + return processOptions{ + SessionKey: sessionKey, + Channel: "cli", + ChatID: "test-chat", + UserMessage: "test message", + DefaultResponse: "I couldn't process your request.", + EnableSummary: false, + SendResponse: false, + NoHistory: false, + } +} + +// ============================================================================= +// Pipeline Method Tests: SetupTurn +// ============================================================================= + +func TestPipeline_SetupTurn_BasicInitialization(t *testing.T) { + al, agent, cleanup := newTurnCoordTestLoop(t, &simpleConvProvider{}) + defer cleanup() + + pipeline := NewPipeline(al) + ts := newTurnState(agent, makeTestProcessOpts("test-session"), turnEventScope{ + turnID: "turn-1", + context: newTurnContext(nil, nil, nil), + }) + + exec, err := pipeline.SetupTurn(context.Background(), ts) + if err != nil { + t.Fatalf("SetupTurn failed: %v", err) + } + if exec == nil { + t.Fatal("expected non-nil turnExecution") + } + if len(exec.messages) == 0 { + t.Error("expected messages to be populated") + } + if exec.iteration != 0 { + t.Errorf("expected iteration 0, got %d", exec.iteration) + } +} + +// ============================================================================= +// Pipeline Method Tests: CallLLM +// ============================================================================= + +func TestPipeline_CallLLM_SimpleResponse(t *testing.T) { + al, agent, cleanup := newTurnCoordTestLoop(t, &simpleConvProvider{}) + defer cleanup() + + pipeline := NewPipeline(al) + ts := newTurnState(agent, makeTestProcessOpts("test-session"), turnEventScope{ + turnID: "turn-1", + context: newTurnContext(nil, nil, nil), + }) + + exec, err := pipeline.SetupTurn(context.Background(), ts) + if err != nil { + t.Fatalf("SetupTurn failed: %v", err) + } + + ctrl, err := pipeline.CallLLM(context.Background(), context.Background(), ts, exec, 1) + if err != nil { + t.Fatalf("CallLLM failed: %v", err) + } + if ctrl != ControlBreak { + t.Errorf("expected ControlBreak, got %v", ctrl) + } + if exec.response == nil { + t.Fatal("expected non-nil response") + } + if exec.response.Content == "" { + t.Error("expected non-empty content") + } +} + +func TestPipeline_CallLLM_WithToolCall(t *testing.T) { + provider := &toolCallRespProvider{ + toolName: "web_search", + toolArgs: map[string]any{"query": "test"}, + response: "Found information about test.", + } + al, agent, cleanup := newTurnCoordTestLoop(t, provider) + defer cleanup() + + pipeline := NewPipeline(al) + ts := newTurnState(agent, makeTestProcessOpts("test-session"), turnEventScope{ + turnID: "turn-1", + context: newTurnContext(nil, nil, nil), + }) + + exec, err := pipeline.SetupTurn(context.Background(), ts) + if err != nil { + t.Fatalf("SetupTurn failed: %v", err) + } + + ctrl, err := pipeline.CallLLM(context.Background(), context.Background(), ts, exec, 1) + if err != nil { + t.Fatalf("CallLLM failed: %v", err) + } + if ctrl != ControlToolLoop { + t.Errorf("expected ControlToolLoop, got %v", ctrl) + } + if len(exec.normalizedToolCalls) == 0 { + t.Fatal("expected tool calls") + } + if exec.normalizedToolCalls[0].Name != "web_search" { + t.Errorf("expected tool name 'web_search', got %q", exec.normalizedToolCalls[0].Name) + } +} + +func TestPipeline_CallLLM_TimeoutRetry(t *testing.T) { + errorPrv := &errorProvider{errType: "timeout"} + al, agent, cleanup := newTurnCoordTestLoop(t, errorPrv) + defer cleanup() + + pipeline := NewPipeline(al) + ts := newTurnState(agent, makeTestProcessOpts("test-session"), turnEventScope{ + turnID: "turn-1", + context: newTurnContext(nil, nil, nil), + }) + + exec, err := pipeline.SetupTurn(context.Background(), ts) + if err != nil { + t.Fatalf("SetupTurn failed: %v", err) + } + + // Should retry and eventually fail after max retries + _, err = pipeline.CallLLM(context.Background(), context.Background(), ts, exec, 1) + if err == nil { + t.Error("expected error after retries") + } +} + +func TestPipeline_CallLLM_ContextLengthError(t *testing.T) { + errorPrv := &errorProvider{errType: "context_length"} + al, agent, cleanup := newTurnCoordTestLoop(t, errorPrv) + defer cleanup() + + pipeline := NewPipeline(al) + ts := newTurnState(agent, makeTestProcessOpts("test-session"), turnEventScope{ + turnID: "turn-1", + context: newTurnContext(nil, nil, nil), + }) + + exec, err := pipeline.SetupTurn(context.Background(), ts) + if err != nil { + t.Fatalf("SetupTurn failed: %v", err) + } + + // Should trigger context compression and retry + _, err = pipeline.CallLLM(context.Background(), context.Background(), ts, exec, 1) + // May succeed after compression or fail - either is acceptable + t.Logf("CallLLM result after context error: err=%v", err) +} + +// ============================================================================= +// Pipeline Method Tests: ExecuteTools +// ============================================================================= + +func TestPipeline_ExecuteTools_NoTools(t *testing.T) { + // Provider returns no tool calls, so ExecuteTools should not be called + // This test verifies the ControlBreak path from CallLLM + provider := &simpleConvProvider{} + al, agent, cleanup := newTurnCoordTestLoop(t, provider) + defer cleanup() + + pipeline := NewPipeline(al) + ts := newTurnState(agent, makeTestProcessOpts("test-session"), turnEventScope{ + turnID: "turn-1", + context: newTurnContext(nil, nil, nil), + }) + + exec, err := pipeline.SetupTurn(context.Background(), ts) + if err != nil { + t.Fatalf("SetupTurn failed: %v", err) + } + + // First CallLLM returns ControlBreak (no tools) + ctrl, err := pipeline.CallLLM(context.Background(), context.Background(), ts, exec, 1) + if err != nil { + t.Fatalf("CallLLM failed: %v", err) + } + + if ctrl != ControlBreak { + t.Fatalf("expected ControlBreak, got %v", ctrl) + } + // No tools to execute, Finalize should be called directly +} + +// ============================================================================= +// runTurn Integration Tests +// ============================================================================= + +func TestRunTurn_SimpleConversation(t *testing.T) { + provider := &simpleConvProvider{} + al, agent, cleanup := newTurnCoordTestLoop(t, provider) + defer cleanup() + + pipeline := NewPipeline(al) + opts := makeTestProcessOpts("test-session-simple") + + ts := newTurnState(agent, opts, turnEventScope{ + turnID: "turn-simple", + context: newTurnContext(nil, nil, nil), + }) + + result, err := al.runTurn(context.Background(), ts, pipeline) + if err != nil { + t.Fatalf("runTurn failed: %v", err) + } + if result.status != TurnEndStatusCompleted { + t.Errorf("expected status Completed, got %v", result.status) + } + if result.finalContent == "" { + t.Error("expected non-empty finalContent") + } +} + +func TestRunTurn_MaxIterations(t *testing.T) { + // Provider always returns tool calls, should hit max iterations + provider := &toolCallRespProvider{ + toolName: "search", + toolArgs: map[string]any{"q": "x"}, + response: "done", + } + al, agent, cleanup := newTurnCoordTestLoop(t, provider) + defer cleanup() + + // Override max iterations to 2 + agent.MaxIterations = 2 + + pipeline := NewPipeline(al) + opts := makeTestProcessOpts("test-session-maxiter") + + ts := newTurnState(agent, opts, turnEventScope{ + turnID: "turn-maxiter", + context: newTurnContext(nil, nil, nil), + }) + + result, err := al.runTurn(context.Background(), ts, pipeline) + if err != nil { + t.Fatalf("runTurn failed: %v", err) + } + // Should complete due to max iterations + if result.status != TurnEndStatusCompleted { + t.Errorf("expected status Completed, got %v", result.status) + } +} + +func TestRunTurn_HardAbort(t *testing.T) { + // Provider simulates a slow response, but we'll abort mid-turn + slowProvider := &slowMockProvider{delay: 10 * time.Second} + al, agent, cleanup := newTurnCoordTestLoop(t, slowProvider) + defer cleanup() + + pipeline := NewPipeline(al) + opts := makeTestProcessOpts("test-session-abort") + + ts := newTurnState(agent, opts, turnEventScope{ + turnID: "turn-abort", + context: newTurnContext(nil, nil, nil), + }) + + // Run in goroutine with abort after short delay + done := make(chan struct{}) + + go func() { + al.runTurn(context.Background(), ts, pipeline) + close(done) + }() + + // Give it a moment to start + time.Sleep(50 * time.Millisecond) + + // Request hard abort + ts.requestHardAbort() + + // Wait for runTurn to complete + select { + case <-done: + case <-time.After(3 * time.Second): + t.Fatal("runTurn did not complete after abort") + } +} + +func TestRunTurn_SteeringMessageInjection(t *testing.T) { + provider := &simpleConvProvider{} + al, agent, cleanup := newTurnCoordTestLoop(t, provider) + defer cleanup() + + pipeline := NewPipeline(al) + opts := makeTestProcessOpts("test-session-steering") + + ts := newTurnState(agent, opts, turnEventScope{ + turnID: "turn-steering", + context: newTurnContext(nil, nil, nil), + }) + + // Enqueue steering message before runTurn + steeringMsg := providers.Message{ + Role: "user", + Content: "Steering message", + } + al.Steer(steeringMsg) + + result, err := al.runTurn(context.Background(), ts, pipeline) + if err != nil { + t.Fatalf("runTurn failed: %v", err) + } + if result.status != TurnEndStatusCompleted { + t.Errorf("expected status Completed, got %v", result.status) + } + // Steering message should have been injected +} + +func TestRunTurn_GracefulInterrupt(t *testing.T) { + provider := &toolCallRespProvider{ + toolName: "search", + toolArgs: map[string]any{"q": "test"}, + response: "Final response after interrupt", + } + al, agent, cleanup := newTurnCoordTestLoop(t, provider) + defer cleanup() + + pipeline := NewPipeline(al) + opts := makeTestProcessOpts("test-session-graceful") + + ts := newTurnState(agent, opts, turnEventScope{ + turnID: "turn-graceful", + context: newTurnContext(nil, nil, nil), + }) + + // Run in goroutine with graceful interrupt after first iteration + done := make(chan struct{}) + var result turnResult + + go func() { + result, _ = al.runTurn(context.Background(), ts, pipeline) + close(done) + }() + + // Give it a moment to start first iteration + time.Sleep(50 * time.Millisecond) + + // Request graceful interrupt + ts.requestGracefulInterrupt("Please stop") + + select { + case <-done: + case <-time.After(5 * time.Second): + t.Fatal("runTurn did not complete after graceful interrupt") + } + + // Should complete gracefully + if result.status != TurnEndStatusCompleted { + t.Errorf("expected status Completed, got %v", result.status) + } +} + +// ============================================================================= +// turnState Tests +// ============================================================================= + +func TestTurnState_GracefulInterruptRequested(t *testing.T) { + ts := &turnState{ + gracefulInterrupt: false, + gracefulInterruptHint: "", + } + + // Initially should not be requested + requested, _ := ts.gracefulInterruptRequested() + if requested { + t.Error("expected no interrupt initially") + } + + // Request interrupt + ts.requestGracefulInterrupt("test hint") + + requested, hint := ts.gracefulInterruptRequested() + if !requested { + t.Error("expected interrupt to be requested") + } + if hint != "test hint" { + t.Errorf("expected hint 'test hint', got %q", hint) + } +} + +func TestTurnState_HardAbortRequested(t *testing.T) { + ts := &turnState{ + hardAbort: false, + } + + if ts.hardAbortRequested() { + t.Error("expected no hard abort initially") + } + + ts.requestHardAbort() + + if !ts.hardAbortRequested() { + t.Error("expected hard abort to be requested") + } +} diff --git a/pkg/agent/turn.go b/pkg/agent/turn_state.go similarity index 72% rename from pkg/agent/turn.go rename to pkg/agent/turn_state.go index cc67ec926..edf8654b5 100644 --- a/pkg/agent/turn.go +++ b/pkg/agent/turn_state.go @@ -1,3 +1,5 @@ +// PicoClaw - Ultra-lightweight personal AI agent + package agent import ( @@ -14,6 +16,10 @@ import ( "github.com/sipeed/picoclaw/pkg/tools" ) +// ============================================================================= +// TurnPhase - represents the current phase of a turn +// ============================================================================= + type TurnPhase string const ( @@ -25,6 +31,65 @@ const ( TurnPhaseAborted TurnPhase = "aborted" ) +// ============================================================================= +// Control signals - returned from Pipeline methods to drive runTurn's coordinator loop +// ============================================================================= + +type Control int + +const ( + // ControlContinue tells the coordinator to jump back to the top of the turn loop + // (equivalent to the original "goto turnLoop"). + ControlContinue Control = iota + // ControlBreak tells the coordinator to exit the turn loop and proceed to Finalize. + ControlBreak + // ControlToolLoop tells the coordinator to execute the tool loop. + ControlToolLoop +) + +// ToolControl signals returned from ExecuteTools to drive tool loop iteration. +type ToolControl int + +const ( + // ToolControlContinue tells the tool loop to jump to the next iteration + // (pendingMessages arrived, SubTurn results, etc.). + ToolControlContinue ToolControl = iota + // ToolControlBreak tells the tool loop to exit and return to the coordinator. + ToolControlBreak + // ToolControlFinalize tells the coordinator that all tool responses were + // handled and the turn should finalize without another LLM call. + ToolControlFinalize +) + +// LLMPhase indicates which phase the turn is executing in. +type LLMPhase int + +const ( + LLMPhaseSetup LLMPhase = iota + LLMPhasePreLLM + LLMPhaseLLMCall + LLMPhaseProcessing + LLMPhaseToolLoop + LLMPhaseTools + LLMPhaseFinalizing + LLMPhaseCompleted + LLMPhaseAborted +) + +// ============================================================================= +// turnResult - returned from runTurn +// ============================================================================= + +type turnResult struct { + finalContent string + status TurnEndStatus + followUps []bus.InboundMessage +} + +// ============================================================================= +// ActiveTurnInfo - public info about an active turn +// ============================================================================= + type ActiveTurnInfo struct { TurnID string AgentID string @@ -40,12 +105,70 @@ type ActiveTurnInfo struct { ChildTurnIDs []string } -type turnResult struct { +// ============================================================================= +// turnExecution - mutable state that persists across turn loop iterations +// ============================================================================= + +type turnExecution struct { + // Core message state (accumulates throughout the turn) + messages []providers.Message // built from ContextBuilder, grows per-iteration + pendingMessages []providers.Message // steering/SubTurn messages awaiting injection + history []providers.Message // from ContextManager.Assemble + summary string + + // Turn output finalContent string - status TurnEndStatus - followUps []bus.InboundMessage + + // Iteration tracking + iteration int + + // Per-iteration state set by Pipeline.PreLLM + activeCandidates []providers.FallbackCandidate + activeModel string + activeProvider providers.LLMProvider + usedLight bool + + // LLM call per-iteration state + response *providers.LLMResponse + normalizedToolCalls []providers.ToolCall + allResponsesHandled bool + callMessages []providers.Message + providerToolDefs []providers.ToolDefinition + llmModel string + llmOpts map[string]any + gracefulTerminal bool + useNativeSearch bool + + // Phase tracking + phase LLMPhase + + // Abort signaling for coordinator (set by Pipeline methods) + abortedByHardAbort bool // true when hard abort triggered during LLM/tools + abortedByHook bool // true when HookActionAbortTurn triggered } +// newTurnExecution creates a turnExecution initialized from turnState and options. +func newTurnExecution( + agent *AgentInstance, + opts processOptions, + history []providers.Message, + summary string, + messages []providers.Message, +) *turnExecution { + return &turnExecution{ + history: history, + summary: summary, + messages: messages, + pendingMessages: append([]providers.Message(nil), opts.InitialSteeringMessages...), + iteration: 0, + phase: LLMPhaseSetup, + } +} + +// ============================================================================= +// turnState - the full state for a turn, constructed once per turn +// ============================================================================= + type turnState struct { mu sync.RWMutex @@ -109,6 +232,10 @@ type turnState struct { al *AgentLoop } +// ============================================================================= +// turnState constructors and active turn management +// ============================================================================= + func newTurnState(agent *AgentInstance, opts processOptions, scope turnEventScope) *turnState { ts := &turnState{ agent: agent, @@ -194,6 +321,10 @@ func (al *AgentLoop) GetActiveTurnBySession(sessionKey string) *ActiveTurnInfo { return &info } +// ============================================================================= +// turnState - getters and setters +// ============================================================================= + func (ts *turnState) snapshot() ActiveTurnInfo { ts.mu.RLock() defer ts.mu.RUnlock() @@ -402,7 +533,9 @@ func (ts *turnState) interruptHintMessage() providers.Message { } } +// ============================================================================= // SubTurn-related methods +// ============================================================================= // Finish marks the turn as finished and closes the pendingResults channel func (ts *turnState) Finish(isHardAbort bool) { @@ -493,7 +626,9 @@ func (ts *turnState) SetLastUsage(usage *providers.UsageInfo) { ts.lastUsage = usage } -// Context helper functions for SubTurn +// ============================================================================= +// Context helper functions for turnState +// ============================================================================= type turnStateKeyType struct{} From dcb4b67e00f1422fa4a808d92a016f0f2726a228 Mon Sep 17 00:00:00 2001 From: wenjie Date: Tue, 21 Apr 2026 11:52:58 +0800 Subject: [PATCH 09/31] fix(web): clean up restored chat transcripts and optimize chat UI (#2605) Filter raw tool messages from session history and avoid duplicate summaries for visible message-tool output. Preserve final assistant replies after tool delivery and add coverage for visible transcript counts. Also refine the chat UI with collapsible reasoning blocks, send shortcut hints, command-style user messages, stable scroll gutters, and updated i18n strings. --- web/backend/api/session.go | 99 +++++++++------ web/backend/api/session_test.go | 45 ++++--- .../src/components/chat/assistant-message.tsx | 120 ++++++++++-------- .../src/components/chat/chat-composer.tsx | 47 ++++--- .../src/components/chat/chat-page.tsx | 4 +- .../src/components/chat/typing-indicator.tsx | 5 +- .../src/components/chat/user-message.tsx | 22 +++- web/frontend/src/components/ui/tooltip.tsx | 12 +- web/frontend/src/i18n/locales/en.json | 5 +- web/frontend/src/i18n/locales/zh.json | 5 +- web/frontend/src/store/chat.ts | 2 + 11 files changed, 233 insertions(+), 133 deletions(-) diff --git a/web/backend/api/session.go b/web/backend/api/session.go index 054b78b73..0143f5737 100644 --- a/web/backend/api/session.go +++ b/web/backend/api/session.go @@ -460,6 +460,9 @@ func visibleSessionMessages(messages []providers.Message, toolFeedbackMaxArgsLen for _, msg := range messages { switch msg.Role { + case "tool": + continue + case "user": if sessionMessageVisible(msg) { transcript = append(transcript, sessionChatMessage{ @@ -501,7 +504,18 @@ func visibleSessionMessages(messages []providers.Message, toolFeedbackMaxArgsLen } } - return transcript + return filterSessionChatMessages(transcript) +} + +func filterSessionChatMessages(messages []sessionChatMessage) []sessionChatMessage { + filtered := messages[:0] + for _, msg := range messages { + if msg.Role != "user" && msg.Role != "assistant" { + continue + } + filtered = append(filtered, msg) + } + return filtered } func assistantMessageTransientThought(msg providers.Message) bool { @@ -528,22 +542,16 @@ func visibleAssistantToolSummaryMessages( messages := make([]sessionChatMessage, 0, len(toolCalls)) for _, tc := range toolCalls { - name := tc.Name - argsJSON := "" - if tc.Function != nil { - if name == "" { - name = tc.Function.Name - } - argsJSON = tc.Function.Arguments - } - + name, argsJSON := toolCallNameAndArguments(tc) if strings.TrimSpace(name) == "" { continue } - - if strings.TrimSpace(argsJSON) == "" && len(tc.Arguments) > 0 { - if encodedArgs, err := json.Marshal(tc.Arguments); err == nil { - argsJSON = string(encodedArgs) + if name == "web_search" || name == "web_fetch" { + continue + } + if name == "message" { + if _, ok := parseMessageToolContent(argsJSON); ok { + continue } } @@ -568,36 +576,53 @@ func visibleAssistantToolMessages(toolCalls []providers.ToolCall) []sessionChatM messages := make([]sessionChatMessage, 0, len(toolCalls)) for _, tc := range toolCalls { - name := tc.Name - argsJSON := "" - if tc.Function != nil { - if name == "" { - name = tc.Function.Name - } - argsJSON = tc.Function.Arguments + name, argsJSON := toolCallNameAndArguments(tc) + if name != "message" { + continue } - - switch name { - case "message": - var args struct { - Content string `json:"content"` - } - if err := json.Unmarshal([]byte(argsJSON), &args); err != nil { - continue - } - if strings.TrimSpace(args.Content) == "" { - continue - } - messages = append(messages, sessionChatMessage{ - Role: "assistant", - Content: args.Content, - }) + content, ok := parseMessageToolContent(argsJSON) + if !ok { + continue } + messages = append(messages, sessionChatMessage{ + Role: "assistant", + Content: content, + }) } return messages } +func toolCallNameAndArguments(tc providers.ToolCall) (string, string) { + name := tc.Name + argsJSON := "" + if tc.Function != nil { + if name == "" { + name = tc.Function.Name + } + argsJSON = tc.Function.Arguments + } + if strings.TrimSpace(argsJSON) == "" && len(tc.Arguments) > 0 { + if encodedArgs, err := json.Marshal(tc.Arguments); err == nil { + argsJSON = string(encodedArgs) + } + } + return name, argsJSON +} + +func parseMessageToolContent(argsJSON string) (string, bool) { + var args struct { + Content string `json:"content"` + } + if err := json.Unmarshal([]byte(argsJSON), &args); err != nil { + return "", false + } + if strings.TrimSpace(args.Content) == "" { + return "", false + } + return args.Content, true +} + // sessionsDir resolves the path to the gateway's session storage directory. // It reads the workspace from config, falling back to ~/.picoclaw/workspace. func (h *Handler) sessionsDir() (string, error) { diff --git a/web/backend/api/session_test.go b/web/backend/api/session_test.go index e40a8c77c..f6c643bde 100644 --- a/web/backend/api/session_test.go +++ b/web/backend/api/session_test.go @@ -346,7 +346,7 @@ func TestHandleGetSession_OmitsTransientThoughtMessages(t *testing.T) { } } -func TestHandleGetSession_ReconstructsVisibleMessageToolOutput(t *testing.T) { +func TestHandleGetSession_ReconstructsVisibleMessageToolOutputWithoutDuplicateSummary(t *testing.T) { configPath, cleanup := setupOAuthTestEnv(t) defer cleanup() @@ -402,14 +402,19 @@ func TestHandleGetSession_ReconstructsVisibleMessageToolOutput(t *testing.T) { if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { t.Fatalf("Unmarshal() error = %v", err) } - if len(resp.Messages) != 3 { - t.Fatalf("len(resp.Messages) = %d, want 3", len(resp.Messages)) + if len(resp.Messages) != 2 { + t.Fatalf("len(resp.Messages) = %d, want 2", len(resp.Messages)) } - if !strings.Contains(resp.Messages[1].Content, "`message`") { - t.Fatalf("tool summary message = %#v, want message tool summary", resp.Messages[1]) + if resp.Messages[0].Role != "user" || resp.Messages[0].Content != "test" { + t.Fatalf("first message = %#v, want user/test", resp.Messages[0]) } - if resp.Messages[2].Role != "assistant" || resp.Messages[2].Content != "visible tool output" { - t.Fatalf("assistant message = %#v, want visible tool output", resp.Messages[2]) + if resp.Messages[1].Role != "assistant" || resp.Messages[1].Content != "visible tool output" { + t.Fatalf("assistant message = %#v, want visible tool output", resp.Messages[1]) + } + for _, msg := range resp.Messages { + if msg.Role == "tool" || strings.Contains(msg.Content, "`message`") { + t.Fatalf("unexpected raw tool or duplicate message-tool summary: %#v", msg) + } } } @@ -468,17 +473,17 @@ func TestHandleGetSession_PreservesFinalAssistantReplyAfterMessageToolOutput(t * if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { t.Fatalf("Unmarshal() error = %v", err) } - if len(resp.Messages) != 4 { - t.Fatalf("len(resp.Messages) = %d, want 4", len(resp.Messages)) + if len(resp.Messages) != 3 { + t.Fatalf("len(resp.Messages) = %d, want 3", len(resp.Messages)) } - if !strings.Contains(resp.Messages[1].Content, "`message`") { - t.Fatalf("tool summary message = %#v, want message tool summary", resp.Messages[1]) + if resp.Messages[0].Role != "user" || resp.Messages[0].Content != "test" { + t.Fatalf("first message = %#v, want user/test", resp.Messages[0]) } - if resp.Messages[2].Role != "assistant" || resp.Messages[2].Content != "visible tool output" { - t.Fatalf("interim assistant message = %#v, want visible tool output", resp.Messages[2]) + if resp.Messages[1].Role != "assistant" || resp.Messages[1].Content != "visible tool output" { + t.Fatalf("interim assistant message = %#v, want visible tool output", resp.Messages[1]) } - if resp.Messages[3].Role != "assistant" || resp.Messages[3].Content != "final assistant reply" { - t.Fatalf("final assistant message = %#v, want final assistant reply", resp.Messages[3]) + if resp.Messages[2].Role != "assistant" || resp.Messages[2].Content != "final assistant reply" { + t.Fatalf("final assistant message = %#v, want final assistant reply", resp.Messages[2]) } } @@ -535,8 +540,8 @@ func TestHandleListSessions_MessageCountUsesVisibleTranscript(t *testing.T) { if len(items) != 1 { t.Fatalf("len(items) = %d, want 1", len(items)) } - if items[0].MessageCount != 3 { - t.Fatalf("items[0].MessageCount = %d, want 3", items[0].MessageCount) + if items[0].MessageCount != 2 { + t.Fatalf("items[0].MessageCount = %d, want 2", items[0].MessageCount) } } @@ -567,6 +572,7 @@ func TestHandleGetSession_PreservesToolSummaryAndAssistantContent(t *testing.T) }, }, }, + {Role: "tool", Content: "raw read_file result", ToolCallID: "call_1"}, } { if err := store.AddFullMessage(nil, sessionKey, msg); err != nil { t.Fatalf("AddFullMessage() error = %v", err) @@ -606,6 +612,11 @@ func TestHandleGetSession_PreservesToolSummaryAndAssistantContent(t *testing.T) if resp.Messages[2].Role != "assistant" || resp.Messages[2].Content != "model final reply" { t.Fatalf("assistant message = %#v, want model final reply", resp.Messages[2]) } + for _, msg := range resp.Messages { + if msg.Role == "tool" || strings.Contains(msg.Content, "raw read_file result") { + t.Fatalf("unexpected raw tool result in history: %#v", msg) + } + } } func TestHandleGetSession_UsesConfiguredToolFeedbackMaxArgsLength(t *testing.T) { diff --git a/web/frontend/src/components/chat/assistant-message.tsx b/web/frontend/src/components/chat/assistant-message.tsx index 55b7b9bf6..5c2235982 100644 --- a/web/frontend/src/components/chat/assistant-message.tsx +++ b/web/frontend/src/components/chat/assistant-message.tsx @@ -1,4 +1,10 @@ -import { IconBrain, IconCheck, IconCopy } from "@tabler/icons-react" +import { + IconBrain, + IconCheck, + IconChevronDown, + IconCopy, +} from "@tabler/icons-react" +import { useAtom } from "jotai" import { useState } from "react" import { useTranslation } from "react-i18next" import ReactMarkdown from "react-markdown" @@ -10,6 +16,7 @@ import remarkGfm from "remark-gfm" import { Button } from "@/components/ui/button" import { formatMessageTime } from "@/hooks/use-pico-chat" import { cn } from "@/lib/utils" +import { showThoughtsAtom } from "@/store/chat" interface AssistantMessageProps { content: string @@ -24,6 +31,7 @@ export function AssistantMessage({ }: AssistantMessageProps) { const { t } = useTranslation() const [isCopied, setIsCopied] = useState(false) + const [isExpanded, setIsExpanded] = useAtom(showThoughtsAtom) const formattedTimestamp = timestamp !== "" ? formatMessageTime(timestamp) : "" @@ -36,64 +44,76 @@ export function AssistantMessage({ return (

) diff --git a/web/frontend/src/components/chat/chat-composer.tsx b/web/frontend/src/components/chat/chat-composer.tsx index 58612d846..cb3016842 100644 --- a/web/frontend/src/components/chat/chat-composer.tsx +++ b/web/frontend/src/components/chat/chat-composer.tsx @@ -4,6 +4,11 @@ import { useTranslation } from "react-i18next" import TextareaAutosize from "react-textarea-autosize" import { Button } from "@/components/ui/button" +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip" import { cn } from "@/lib/utils" import type { ChatAttachment } from "@/store/chat" @@ -57,8 +62,8 @@ export function ChatComposer({ } return ( -
-
+
+
{attachments.length > 0 && (
{attachments.map((attachment, index) => ( @@ -93,17 +98,12 @@ export function ChatComposer({ disabled={!canInput} title={disabledMessage || undefined} className={cn( - "placeholder:text-muted-foreground/50 max-h-[200px] min-h-[60px] resize-none border-0 bg-transparent px-2 py-1 text-[15px] shadow-none transition-colors focus-visible:ring-0 focus-visible:outline-none dark:bg-transparent", + "placeholder:text-muted-foreground/50 max-h-[200px] min-h-[64px] resize-none border-0 bg-transparent px-2 py-1 text-[15px] shadow-none transition-colors focus-visible:ring-0 focus-visible:outline-none dark:bg-transparent", !canInput && "cursor-not-allowed", )} minRows={1} maxRows={8} /> - {!canInput && disabledMessage && ( -
- {disabledMessage} -
- )}
@@ -122,15 +122,28 @@ export function ChatComposer({
{canInput ? ( - + + + + + + + + {t("chat.sendHint")} + + ) : null}
diff --git a/web/frontend/src/components/chat/chat-page.tsx b/web/frontend/src/components/chat/chat-page.tsx index 4129d812a..c117be0b7 100644 --- a/web/frontend/src/components/chat/chat-page.tsx +++ b/web/frontend/src/components/chat/chat-page.tsx @@ -153,7 +153,7 @@ export function ChatPage() { }) const syncScrollState = (element: HTMLDivElement) => { - const { scrollTop, scrollHeight, clientHeight } = element + const { clientHeight, scrollHeight, scrollTop } = element setHasScrolled(scrollTop > 0) setIsAtBottom(scrollHeight - scrollTop <= clientHeight + 10) } @@ -294,7 +294,7 @@ export function ChatPage() {
{messages.length === 0 && !isTyping && ( diff --git a/web/frontend/src/components/chat/typing-indicator.tsx b/web/frontend/src/components/chat/typing-indicator.tsx index 98580963d..df138553c 100644 --- a/web/frontend/src/components/chat/typing-indicator.tsx +++ b/web/frontend/src/components/chat/typing-indicator.tsx @@ -21,10 +21,7 @@ export function TypingIndicator() { return (
-
- PicoClaw -
-
+
diff --git a/web/frontend/src/components/chat/user-message.tsx b/web/frontend/src/components/chat/user-message.tsx index 96119a534..8bfdf24c9 100644 --- a/web/frontend/src/components/chat/user-message.tsx +++ b/web/frontend/src/components/chat/user-message.tsx @@ -1,3 +1,4 @@ +import { cn } from "@/lib/utils" import type { ChatAttachment } from "@/store/chat" interface UserMessageProps { @@ -7,6 +8,7 @@ interface UserMessageProps { export function UserMessage({ content, attachments = [] }: UserMessageProps) { const hasText = content.trim().length > 0 + const isCommand = content.trim().startsWith("/") const imageAttachments = attachments.filter( (attachment) => attachment.type === "image", ) @@ -27,8 +29,24 @@ export function UserMessage({ content, attachments = [] }: UserMessageProps) { )} {hasText && ( -
- {content} +
+ {isCommand ? ( +
+ + ❯ + + {content} +
+ ) : ( + content + )}
)}
diff --git a/web/frontend/src/components/ui/tooltip.tsx b/web/frontend/src/components/ui/tooltip.tsx index 757f05b03..6e71ad55f 100644 --- a/web/frontend/src/components/ui/tooltip.tsx +++ b/web/frontend/src/components/ui/tooltip.tsx @@ -30,10 +30,13 @@ function TooltipTrigger({ function TooltipContent({ className, + arrowClassName, sideOffset = 0, children, ...props -}: React.ComponentProps) { +}: React.ComponentProps & { + arrowClassName?: string +}) { return ( {children} - + ) diff --git a/web/frontend/src/i18n/locales/en.json b/web/frontend/src/i18n/locales/en.json index c96d4b71b..2c51cc6d7 100644 --- a/web/frontend/src/i18n/locales/en.json +++ b/web/frontend/src/i18n/locales/en.json @@ -38,7 +38,7 @@ "chat": { "welcome": "How can I help you today?", "welcomeDesc": "Ask me about weather, settings, or any other tasks. I'm here to assist you.", - "placeholder": "Start a new message...\nPress Enter to send, Shift + Enter for a new line", + "placeholder": "Start a new message...", "disabledPlaceholder": { "gatewayUnknown": "Unable to chat: Gateway status is still being checked. Please wait, then refresh the page or restart Launcher if needed.", "gatewayStarting": "Unable to chat: Gateway is starting. Wait for startup to complete, then try again.", @@ -60,6 +60,7 @@ "step4": "Almost there..." }, "reasoningLabel": "Reasoning", + "toolLabel": "Tool", "history": "History", "noHistory": "No chat history yet", "historyLoadFailed": "Failed to load chat history", @@ -72,6 +73,8 @@ "notConnected": "Gateway is not running. Start it to chat.", "noModel": "No default model configured. Go to Models page to set one." }, + "sendMessage": "Send message", + "sendHint": "Press Enter to send\nShift + Enter for a new line", "attachImage": "Add images", "removeImage": "Remove image", "uploadedImage": "Uploaded image", diff --git a/web/frontend/src/i18n/locales/zh.json b/web/frontend/src/i18n/locales/zh.json index 4a9e59cf4..11827fc8a 100644 --- a/web/frontend/src/i18n/locales/zh.json +++ b/web/frontend/src/i18n/locales/zh.json @@ -38,7 +38,7 @@ "chat": { "welcome": "今天我能为您做些什么?", "welcomeDesc": "您可以询问我天气、设置或其他任何任务,我随时为您效劳。", - "placeholder": "输入新消息...\n按 Enter 发送,Shift + Enter 换行", + "placeholder": "输入新消息...", "disabledPlaceholder": { "gatewayUnknown": "无法对话:网关状态仍在检测中。请稍候重试,如仍无效请刷新页面或重启 Launcher。", "gatewayStarting": "无法对话:网关正在启动。请等待启动完成后重试。", @@ -60,6 +60,7 @@ "step4": "马上就好..." }, "reasoningLabel": "思考", + "toolLabel": "工具", "history": "历史记录", "noHistory": "暂无对话历史", "historyLoadFailed": "加载历史记录失败", @@ -72,6 +73,8 @@ "notConnected": "服务未运行,请先启动以进行对话。", "noModel": "未设置默认模型,请前往模型页面进行配置。" }, + "sendMessage": "发送消息", + "sendHint": "按 Enter 发送\nShift + Enter 换行", "attachImage": "添加图片", "removeImage": "移除图片", "uploadedImage": "已上传图片", diff --git a/web/frontend/src/store/chat.ts b/web/frontend/src/store/chat.ts index 2c6f70610..c3b44f348 100644 --- a/web/frontend/src/store/chat.ts +++ b/web/frontend/src/store/chat.ts @@ -48,6 +48,8 @@ const DEFAULT_CHAT_STATE: ChatStoreState = { export const chatAtom = atom(DEFAULT_CHAT_STATE) +export const showThoughtsAtom = atom(true) + const store = getDefaultStore() export function getChatState() { From ba6992234faaef77e55abb5c3ab3cf5fe5193656 Mon Sep 17 00:00:00 2001 From: wenjie Date: Tue, 21 Apr 2026 16:04:28 +0800 Subject: [PATCH 10/31] feat(web): support list editing for channel array fields (#2595) Add reusable channel array list controls and parsing utilities for channel forms. Normalize channel string-array payloads in the backend, including pasted values, numeric IDs, hidden characters, duplicates, and empty clears. Also allow FlexibleStringSlice to unmarshal null values and cover the new behavior with backend and config tests. --- pkg/config/config_struct.go | 5 + pkg/config/config_test.go | 11 + web/backend/api/config.go | 199 ++++++++++++- web/backend/api/config_test.go | 279 ++++++++++++++++++ .../channels/channel-array-list-field.tsx | 180 +++++++++++ .../channels/channel-array-utils.ts | 72 +++++ .../channels/channel-config-page.tsx | 112 ++++++- .../channels/channel-forms/discord-form.tsx | 45 +-- .../channels/channel-forms/feishu-form.tsx | 45 +-- .../channels/channel-forms/generic-form.tsx | 122 ++++---- .../channels/channel-forms/slack-form.tsx | 46 +-- .../channels/channel-forms/telegram-form.tsx | 45 +-- .../channels/channel-forms/weixin-form.tsx | 45 +-- web/frontend/src/i18n/locales/en.json | 7 +- web/frontend/src/i18n/locales/zh.json | 7 +- 15 files changed, 1025 insertions(+), 195 deletions(-) create mode 100644 web/frontend/src/components/channels/channel-array-list-field.tsx create mode 100644 web/frontend/src/components/channels/channel-array-utils.ts diff --git a/pkg/config/config_struct.go b/pkg/config/config_struct.go index 6eaf32bc1..65cfeb107 100644 --- a/pkg/config/config_struct.go +++ b/pkg/config/config_struct.go @@ -22,6 +22,11 @@ import ( type FlexibleStringSlice []string func (f *FlexibleStringSlice) UnmarshalJSON(data []byte) error { + if string(data) == "null" { + *f = nil + return nil + } + // Accept a single JSON string for convenience, e.g.: // "text": "Thinking..." var singleString string diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index d9ca0cb9d..24c86d452 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -1258,6 +1258,11 @@ func TestFlexibleStringSlice_UnmarshalJSON(t *testing.T) { input string expected []string }{ + { + name: "null", + input: `null`, + expected: nil, + }, { name: "single string", input: `"Thinking..."`, @@ -1286,6 +1291,12 @@ func TestFlexibleStringSlice_UnmarshalJSON(t *testing.T) { if err := json.Unmarshal([]byte(tt.input), &f); err != nil { t.Fatalf("json.Unmarshal(%s) error = %v", tt.input, err) } + if tt.expected == nil { + if f != nil { + t.Fatalf("json.Unmarshal(%s) = %#v, want nil slice", tt.input, f) + } + return + } if len(f) != len(tt.expected) { t.Fatalf("json.Unmarshal(%s) len = %d, want %d", tt.input, len(f), len(tt.expected)) } diff --git a/web/backend/api/config.go b/web/backend/api/config.go index c7bd21197..afcd3f74e 100644 --- a/web/backend/api/config.go +++ b/web/backend/api/config.go @@ -56,13 +56,22 @@ func (h *Handler) handleUpdateConfig(w http.ResponseWriter, r *http.Request) { } defer r.Body.Close() - var cfg config.Config - if err = json.Unmarshal(body, &cfg); err != nil { + var raw map[string]any + if err = json.Unmarshal(body, &raw); err != nil { http.Error(w, fmt.Sprintf("Invalid JSON: %v", err), http.StatusBadRequest) return } - var raw map[string]any - if err = json.Unmarshal(body, &raw); err != nil { + if err = normalizeChannelArrayFields(raw); err != nil { + http.Error(w, fmt.Sprintf("Invalid channel array field: %v", err), http.StatusBadRequest) + return + } + normalizedBody, err := json.Marshal(raw) + if err != nil { + http.Error(w, "Failed to normalize config payload", http.StatusBadRequest) + return + } + var cfg config.Config + if err = json.Unmarshal(normalizedBody, &cfg); err != nil { http.Error(w, fmt.Sprintf("Invalid JSON: %v", err), http.StatusBadRequest) return } @@ -154,6 +163,10 @@ func (h *Handler) handlePatchConfig(w http.ResponseWriter, r *http.Request) { // Recursively merge patch into base mergeMap(base, patch) + if err = normalizeChannelArrayFields(base); err != nil { + http.Error(w, fmt.Sprintf("Invalid channel array field: %v", err), http.StatusBadRequest) + return + } // Convert merged map back to Config struct merged, err := json.Marshal(base) @@ -382,6 +395,184 @@ func asMapField(value map[string]any, key string) (map[string]any, bool) { return m, isMap } +var ( + allowFromHiddenCharsRe = regexp.MustCompile("[\u200B\u200C\u200D\u200E\u200F\u202A-\u202E\u2060-\u2069\uFEFF]") + allowFromSplitRe = regexp.MustCompile("[,\uFF0C、;;\r\n\t]+") + conservativeSplitRe = regexp.MustCompile("[,\uFF0C\r\n\t]+") +) + +type stringArrayParserOptions struct { + stripHiddenChars bool +} + +func normalizeChannelArrayFields(raw map[string]any) error { + channelsMap, hasChannels := asMapField(raw, "channel_list") + if !hasChannels { + return nil + } + + defaultCfg := config.DefaultConfig() + for channelName, rawChannel := range channelsMap { + chMap, ok := rawChannel.(map[string]any) + if !ok { + continue + } + + if rawAllowFrom, exists := chMap["allow_from"]; exists { + normalized, err := normalizeStringArrayValue(rawAllowFrom, stringArrayParserOptions{ + stripHiddenChars: true, + }) + if err != nil { + return fmt.Errorf("channel_list.%s.allow_from: %w", channelName, err) + } + chMap["allow_from"] = normalized + } + + if groupTrigger, ok := asMapField(chMap, "group_trigger"); ok { + if rawPrefixes, exists := groupTrigger["prefixes"]; exists { + normalized, err := normalizeStringArrayValue(rawPrefixes, stringArrayParserOptions{}) + if err != nil { + return fmt.Errorf("channel_list.%s.group_trigger.prefixes: %w", channelName, err) + } + groupTrigger["prefixes"] = normalized + } + } + + settingsMap, hasSettings := asMapField(chMap, "settings") + if !hasSettings { + continue + } + + settingsType := channelSettingsType(defaultCfg, channelName, chMap) + if settingsType == nil { + continue + } + + for i := range settingsType.NumField() { + field := settingsType.Field(i) + if !field.IsExported() || !isStringSliceType(field.Type) { + continue + } + jsonKey := strings.Split(field.Tag.Get("json"), ",")[0] + if jsonKey == "" || jsonKey == "-" { + continue + } + rawValue, exists := settingsMap[jsonKey] + if !exists { + continue + } + + options := stringArrayParserOptions{} + if jsonKey == "allow_from" { + options.stripHiddenChars = true + } + normalized, err := normalizeStringArrayValue(rawValue, options) + if err != nil { + return fmt.Errorf("channel_list.%s.settings.%s: %w", channelName, jsonKey, err) + } + settingsMap[jsonKey] = normalized + } + } + return nil +} + +func channelSettingsType( + defaultCfg *config.Config, + channelName string, + channelMap map[string]any, +) reflect.Type { + if channelType, _ := channelMap["type"].(string); channelType != "" { + if bc := defaultCfg.Channels.GetByType(channelType); bc != nil { + if decoded, err := bc.GetDecoded(); err == nil && decoded != nil { + return derefType(reflect.TypeOf(decoded)) + } + } + } + + if bc := defaultCfg.Channels.Get(channelName); bc != nil { + if decoded, err := bc.GetDecoded(); err == nil && decoded != nil { + return derefType(reflect.TypeOf(decoded)) + } + } + + return nil +} + +func derefType(typ reflect.Type) reflect.Type { + for typ != nil && typ.Kind() == reflect.Ptr { + typ = typ.Elem() + } + return typ +} + +func isStringSliceType(typ reflect.Type) bool { + typ = derefType(typ) + return typ != nil && typ.Kind() == reflect.Slice && typ.Elem().Kind() == reflect.String +} + +func normalizeStringArrayValue(value any, options stringArrayParserOptions) ([]string, error) { + switch typed := value.(type) { + case nil: + return nil, nil + case string: + return parseStringArrayValue(typed, options), nil + case float64: + return normalizeStringArrayItems([]string{fmt.Sprintf("%.0f", typed)}, options), nil + case []string: + return normalizeStringArrayItems(typed, options), nil + case []any: + items := make([]string, 0, len(typed)) + for _, item := range typed { + switch raw := item.(type) { + case string: + items = append(items, raw) + case float64: + items = append(items, fmt.Sprintf("%.0f", raw)) + default: + return nil, fmt.Errorf("unsupported list item type %T", item) + } + } + return normalizeStringArrayItems(items, options), nil + default: + return nil, fmt.Errorf("unsupported list field type %T", value) + } +} + +func parseStringArrayValue(raw string, options stringArrayParserOptions) []string { + if strings.TrimSpace(raw) == "" { + return []string{} + } + splitRe := conservativeSplitRe + if options.stripHiddenChars { + splitRe = allowFromSplitRe + } + return normalizeStringArrayItems(splitRe.Split(raw, -1), options) +} + +func normalizeStringArrayItems(items []string, options stringArrayParserOptions) []string { + result := make([]string, 0, len(items)) + seen := make(map[string]struct{}, len(items)) + for _, item := range items { + normalized := item + if options.stripHiddenChars { + normalized = allowFromHiddenCharsRe.ReplaceAllString(normalized, "") + } + normalized = strings.TrimSpace(normalized) + if normalized == "" { + continue + } + if _, exists := seen[normalized]; exists { + continue + } + seen[normalized] = struct{}{} + result = append(result, normalized) + } + if len(result) == 0 { + return []string{} + } + return result +} + func getSecretString(m map[string]any, key string) (string, bool) { if raw, exists := m[key]; exists { s, isString := raw.(string) diff --git a/web/backend/api/config_test.go b/web/backend/api/config_test.go index 0e0fa5229..8377c2eca 100644 --- a/web/backend/api/config_test.go +++ b/web/backend/api/config_test.go @@ -230,6 +230,285 @@ func TestHandlePatchConfig_SavesChannelListSettingsPatch(t *testing.T) { } } +func TestHandlePatchConfig_NormalizesStringChannelArrayFields(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + req := httptest.NewRequest(http.MethodPatch, "/api/config", bytes.NewBufferString(`{ + "channel_list": { + "pico": { + "type": "pico", + "allow_from": " ou_a\u200b,\u2060ou_b\tou_c\u202e,ou_a ", + "group_trigger": { + "prefixes": "/,!;\n?,/" + }, + "settings": { + "allow_origins": "https://a.example.com,http://localhost:5173,https://a.example.com" + } + }, + "irc": { + "type": "irc", + "settings": { + "channels": "#ops,\n#dev,\n#ops", + "request_caps": "multi-prefix,echo-message\tbatch,multi-prefix" + } + } + } + }`)) + req.Header.Set("Content-Type", "application/json") + + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("PATCH /api/config status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + + picoChannel := cfg.Channels[config.ChannelPico] + if len(picoChannel.AllowFrom) != 3 || + picoChannel.AllowFrom[0] != "ou_a" || + picoChannel.AllowFrom[1] != "ou_b" || + picoChannel.AllowFrom[2] != "ou_c" { + t.Fatalf("pico allow_from = %#v, want [\"ou_a\", \"ou_b\", \"ou_c\"]", picoChannel.AllowFrom) + } + if len(picoChannel.GroupTrigger.Prefixes) != 3 || + picoChannel.GroupTrigger.Prefixes[0] != "/" || + picoChannel.GroupTrigger.Prefixes[1] != "!;" || + picoChannel.GroupTrigger.Prefixes[2] != "?" { + t.Fatalf( + "pico group_trigger.prefixes = %#v, want [\"/\", \"!;\", \"?\"]", + picoChannel.GroupTrigger.Prefixes, + ) + } + + decoded, err := picoChannel.GetDecoded() + if err != nil { + t.Fatalf("GetDecoded() pico error = %v", err) + } + picoCfg := decoded.(*config.PicoSettings) + if len(picoCfg.AllowOrigins) != 2 || + picoCfg.AllowOrigins[0] != "https://a.example.com" || + picoCfg.AllowOrigins[1] != "http://localhost:5173" { + t.Fatalf( + "pico allow_origins = %#v, want [\"https://a.example.com\", \"http://localhost:5173\"]", + picoCfg.AllowOrigins, + ) + } + + ircChannel := cfg.Channels[config.ChannelIRC] + decoded, err = ircChannel.GetDecoded() + if err != nil { + t.Fatalf("GetDecoded() irc error = %v", err) + } + ircCfg := decoded.(*config.IRCSettings) + if len(ircCfg.Channels) != 2 || + ircCfg.Channels[0] != "#ops" || + ircCfg.Channels[1] != "#dev" { + t.Fatalf("irc channels = %#v, want [\"#ops\", \"#dev\"]", ircCfg.Channels) + } + if len(ircCfg.RequestCaps) != 3 || + ircCfg.RequestCaps[0] != "multi-prefix" || + ircCfg.RequestCaps[1] != "echo-message" || + ircCfg.RequestCaps[2] != "batch" { + t.Fatalf( + "irc request_caps = %#v, want [\"multi-prefix\", \"echo-message\", \"batch\"]", + ircCfg.RequestCaps, + ) + } +} + +func TestHandlePatchConfig_NormalizesSingleNumericAllowFrom(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + req := httptest.NewRequest(http.MethodPatch, "/api/config", bytes.NewBufferString(`{ + "channel_list": { + "telegram": { + "type": "telegram", + "allow_from": 123456 + } + } + }`)) + req.Header.Set("Content-Type", "application/json") + + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("PATCH /api/config status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + telegramChannel := cfg.Channels[config.ChannelTelegram] + if len(telegramChannel.AllowFrom) != 1 || telegramChannel.AllowFrom[0] != "123456" { + t.Fatalf("telegram allow_from = %#v, want [\"123456\"]", telegramChannel.AllowFrom) + } +} + +func TestHandlePatchConfig_RejectsInvalidChannelArrayFields(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + telegramChannel := cfg.Channels[config.ChannelTelegram] + telegramChannel.AllowFrom = config.FlexibleStringSlice{"existing-user"} + if err := config.SaveConfig(configPath, cfg); err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + tests := []struct { + name string + body string + }{ + { + name: "object allow_from", + body: `{ + "channel_list": { + "telegram": { + "type": "telegram", + "allow_from": {"id": "bad"} + } + } + }`, + }, + { + name: "boolean allow_from", + body: `{ + "channel_list": { + "telegram": { + "type": "telegram", + "allow_from": true + } + } + }`, + }, + { + name: "object settings array", + body: `{ + "channel_list": { + "irc": { + "type": "irc", + "settings": { + "channels": {"name": "#ops"} + } + } + } + }`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + req := httptest.NewRequest(http.MethodPatch, "/api/config", bytes.NewBufferString(tt.body)) + req.Header.Set("Content-Type", "application/json") + + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + if rec.Code != http.StatusBadRequest { + t.Fatalf( + "PATCH /api/config status = %d, want %d, body=%s", + rec.Code, + http.StatusBadRequest, + rec.Body.String(), + ) + } + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + telegramChannel := cfg.Channels[config.ChannelTelegram] + if len(telegramChannel.AllowFrom) != 1 || telegramChannel.AllowFrom[0] != "existing-user" { + t.Fatalf("telegram allow_from = %#v, want unchanged [\"existing-user\"]", telegramChannel.AllowFrom) + } + }) + } +} + +func TestHandlePatchConfig_ClearingAllowFromDoesNotLeaveEmptyStringItem(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + feishuChannel := cfg.Channels[config.ChannelFeishu] + feishuChannel.Enabled = true + feishuChannel.AllowFrom = config.FlexibleStringSlice{"ou_existing_user"} + decoded, err := feishuChannel.GetDecoded() + if err != nil { + t.Fatalf("GetDecoded() error = %v", err) + } + feishuCfg := decoded.(*config.FeishuSettings) + feishuCfg.AppID = "cli_existing_app" + feishuCfg.AppSecret = *config.NewSecureString("existing-secret") + if err = config.SaveConfig(configPath, cfg); err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + req := httptest.NewRequest(http.MethodPatch, "/api/config", bytes.NewBufferString(`{ + "channel_list": { + "feishu": { + "enabled": true, + "allow_from": "", + "settings": { + "app_id": "cli_existing_app" + } + } + } + }`)) + req.Header.Set("Content-Type", "application/json") + + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("PATCH /api/config status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + cfg, err = config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + feishuChannel = cfg.Channels[config.ChannelFeishu] + if len(feishuChannel.AllowFrom) != 0 { + t.Fatalf("feishu allow_from = %#v, want empty slice", feishuChannel.AllowFrom) + } + + configData, err := os.ReadFile(configPath) + if err != nil { + t.Fatalf("ReadFile(configPath) error = %v", err) + } + if strings.Contains(string(configData), `"allow_from": [""]`) { + t.Fatalf("config file should not contain empty-string allow_from item: %s", string(configData)) + } +} + func TestHandlePatchConfig_CreatesMissingChannelWithTypeAndSecret(t *testing.T) { configPath, cleanup := setupOAuthTestEnv(t) defer cleanup() diff --git a/web/frontend/src/components/channels/channel-array-list-field.tsx b/web/frontend/src/components/channels/channel-array-list-field.tsx new file mode 100644 index 000000000..ff601c07b --- /dev/null +++ b/web/frontend/src/components/channels/channel-array-list-field.tsx @@ -0,0 +1,180 @@ +import { IconX } from "@tabler/icons-react" +import { + type KeyboardEvent, + useCallback, + useEffect, + useRef, + useState, +} from "react" +import { useTranslation } from "react-i18next" + +import { + mergeUniqueStringItems, + parseConservativeStringListInput, +} from "@/components/channels/channel-array-utils" +import { Field } from "@/components/shared-form" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" + +type StringListParser = (raw: string) => string[] +export type ArrayFieldFlusher = () => string[] | null + +type RegisterArrayFieldFlusher = ( + fieldPath: string, + flusher: ArrayFieldFlusher | null, +) => void + +function areStringArraysEqual(left: string[], right: string[]): boolean { + if (left.length !== right.length) { + return false + } + return left.every((item, index) => item === right[index]) +} + +interface ChannelArrayListFieldProps { + label: string + hint?: string + error?: string + required?: boolean + value: string[] + onChange: (value: string[]) => void + placeholder?: string + parser?: StringListParser + fieldPath?: string + registerFlusher?: RegisterArrayFieldFlusher + resetVersion?: number +} + +export function ChannelArrayListField({ + label, + hint, + error, + required, + value, + onChange, + placeholder, + parser = parseConservativeStringListInput, + fieldPath, + registerFlusher, + resetVersion, +}: ChannelArrayListFieldProps) { + const { t } = useTranslation() + const [draft, setDraft] = useState("") + const draftRef = useRef("") + const valueRef = useRef(value) + const localValueRef = useRef(value) + const parserRef = useRef(parser) + const onChangeRef = useRef(onChange) + + useEffect(() => { + valueRef.current = value + localValueRef.current = value + }, [value]) + + useEffect(() => { + draftRef.current = "" + setDraft("") + }, [resetVersion]) + + useEffect(() => { + parserRef.current = parser + }, [parser]) + + useEffect(() => { + onChangeRef.current = onChange + }, [onChange]) + + const commitDraft = useCallback(() => { + const rawDraft = draftRef.current + if (rawDraft.trim() === "") { + if (!areStringArraysEqual(localValueRef.current, valueRef.current)) { + return localValueRef.current + } + draftRef.current = "" + setDraft("") + return null + } + draftRef.current = "" + setDraft("") + const nextItems = parserRef.current(rawDraft) + if (nextItems.length === 0) { + return null + } + const mergedItems = mergeUniqueStringItems(localValueRef.current, nextItems) + localValueRef.current = mergedItems + onChangeRef.current(mergedItems) + return mergedItems + }, []) + + useEffect(() => { + if (!fieldPath || !registerFlusher) { + return + } + registerFlusher(fieldPath, commitDraft) + return () => registerFlusher(fieldPath, null) + }, [commitDraft, fieldPath, registerFlusher]) + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key !== "Enter") { + return + } + event.preventDefault() + commitDraft() + } + + const handleRemove = (index: number) => { + const nextValue = value.filter((_, itemIndex) => itemIndex !== index) + localValueRef.current = nextValue + onChangeRef.current(nextValue) + } + + return ( + +
+ {value.length > 0 && ( +
+ {value.map((item, index) => ( + + {item} + + + ))} +
+ )} + +
+ { + const nextDraft = event.target.value + draftRef.current = nextDraft + setDraft(nextDraft) + }} + onKeyDown={handleKeyDown} + placeholder={placeholder} + /> + +
+
+
+ ) +} diff --git a/web/frontend/src/components/channels/channel-array-utils.ts b/web/frontend/src/components/channels/channel-array-utils.ts new file mode 100644 index 000000000..0f6268be8 --- /dev/null +++ b/web/frontend/src/components/channels/channel-array-utils.ts @@ -0,0 +1,72 @@ +const ALLOW_FROM_HIDDEN_CHARS_RE = + /\u200b|\u200c|\u200d|\u200e|\u200f|\u202a|\u202b|\u202c|\u202d|\u202e|\u2060|\u2061|\u2062|\u2063|\u2064|\u2066|\u2067|\u2068|\u2069|\ufeff/g + +function normalizeStringListItems( + items: string[], + options: { stripHiddenChars?: boolean } = {}, +): string[] { + const result: string[] = [] + const seen = new Set() + + for (const item of items) { + const normalized = options.stripHiddenChars + ? item.replace(ALLOW_FROM_HIDDEN_CHARS_RE, "") + : item + const trimmed = normalized.trim() + if (trimmed.length === 0 || seen.has(trimmed)) { + continue + } + seen.add(trimmed) + result.push(trimmed) + } + + return result +} + +function splitStringList( + raw: string, + separators: RegExp, + options: { stripHiddenChars?: boolean } = {}, +): string[] { + if (raw.trim() === "") { + return [] + } + return normalizeStringListItems(raw.split(separators), options) +} + +export function asStringArray(value: unknown): string[] { + if (!Array.isArray(value)) { + return [] + } + return value.filter((item): item is string => typeof item === "string") +} + +export function parseAllowFromInput(raw: string): string[] { + return splitStringList(raw, /[,\uFF0C、;;\n\r\t]+/, { + stripHiddenChars: true, + }) +} + +export function parseConservativeStringListInput(raw: string): string[] { + return splitStringList(raw, /[,\uFF0C\n\r\t]+/) +} + +export function normalizeAllowFromValues(value: unknown): string[] { + return normalizeStringListItems(asStringArray(value), { + stripHiddenChars: true, + }) +} + +export function mergeUniqueStringItems( + currentItems: string[], + nextItems: string[], +): string[] { + return normalizeStringListItems([...currentItems, ...nextItems]) +} + +export function serializeStringArrayForSubmit(value: unknown): unknown { + if (!Array.isArray(value)) { + return value + } + return normalizeStringListItems(asStringArray(value)).join("\n") +} diff --git a/web/frontend/src/components/channels/channel-config-page.tsx b/web/frontend/src/components/channels/channel-config-page.tsx index a235daf8d..f6609e3ba 100644 --- a/web/frontend/src/components/channels/channel-config-page.tsx +++ b/web/frontend/src/components/channels/channel-config-page.tsx @@ -9,6 +9,11 @@ import { getChannelsCatalog, patchAppConfig, } from "@/api/channels" +import { type ArrayFieldFlusher } from "@/components/channels/channel-array-list-field" +import { + normalizeAllowFromValues, + serializeStringArrayForSubmit, +} from "@/components/channels/channel-array-utils" import { SECRET_FIELD_MAP, buildEditConfig, @@ -48,6 +53,43 @@ function asBool(value: unknown): boolean { return value === true } +function setRecordValueByPath( + source: Record, + pathSegments: string[], + value: unknown, +): Record { + const [segment, ...rest] = pathSegments + if (!segment) { + return source + } + if (rest.length === 0) { + return { ...source, [segment]: value } + } + return { + ...source, + [segment]: setRecordValueByPath(asRecord(source[segment]), rest, value), + } +} + +function setConfigValueByPath( + source: ChannelConfig, + fieldPath: string, + value: unknown, +): ChannelConfig { + return setRecordValueByPath(source, fieldPath.split("."), value) +} + +function serializeGroupTriggerForSubmit(value: unknown): unknown { + const groupTrigger = asRecord(value) + if (Object.keys(groupTrigger).length === 0) { + return value + } + return { + ...groupTrigger, + prefixes: serializeStringArrayForSubmit(groupTrigger.prefixes), + } +} + const CHANNEL_COMMON_CONFIG_KEYS = new Set([ "allow_from", "group_trigger", @@ -82,12 +124,20 @@ function buildSavePayload( if (key.startsWith("_")) continue if (key === "enabled") continue if (CHANNEL_COMMON_CONFIG_KEYS.has(key)) { - payload[key] = value + if (key === "allow_from") { + payload[key] = serializeStringArrayForSubmit( + normalizeAllowFromValues(value), + ) + } else if (key === "group_trigger") { + payload[key] = serializeGroupTriggerForSubmit(value) + } else { + payload[key] = value + } continue } if (isSecretField(key)) continue - settings[key] = value + settings[key] = serializeStringArrayForSubmit(value) } for (const [secretKey, editKey] of Object.entries(SECRET_FIELD_MAP)) { @@ -244,6 +294,8 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) { const [editConfig, setEditConfig] = useState({}) const [configuredSecrets, setConfiguredSecrets] = useState([]) const [enabled, setEnabled] = useState(false) + const [arrayFieldResetVersion, setArrayFieldResetVersion] = useState(0) + const arrayFieldFlushersRef = useRef(new Map()) const loadData = useCallback( async (silent = false) => { @@ -302,11 +354,6 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) { previousGatewayStatusRef.current = gatewayState }, [gatewayState, loadData]) - const savePayload = useMemo(() => { - if (!channel) return null - return buildSavePayload(channel, editConfig, enabled) - }, [channel, editConfig, enabled]) - const configured = useMemo(() => { if (!channel) return false return isConfigured(channel, editConfig, configuredSecrets) @@ -362,20 +409,52 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) { }) }, []) + const registerArrayFieldFlusher = useCallback( + (fieldPath: string, flusher: ArrayFieldFlusher | null) => { + if (flusher) { + arrayFieldFlushersRef.current.set(fieldPath, flusher) + return + } + arrayFieldFlushersRef.current.delete(fieldPath) + }, + [], + ) + + const flushPendingArrayFieldDrafts = useCallback( + (sourceConfig: ChannelConfig): ChannelConfig => { + let nextConfig = sourceConfig + for (const [fieldPath, flusher] of arrayFieldFlushersRef.current) { + const flushedValue = flusher() + if (flushedValue === null) { + continue + } + nextConfig = setConfigValueByPath(nextConfig, fieldPath, flushedValue) + } + return nextConfig + }, + [], + ) + const handleReset = () => { if (!channel) return setEditConfig(buildEditConfig(channel.name, baseConfig)) setEnabled(asBool(baseConfig.enabled)) setServerError("") setFieldErrors({}) + setArrayFieldResetVersion((version) => version + 1) } const handleSave = async () => { - if (!channel || !savePayload) return + if (!channel) return + + const preparedEditConfig = flushPendingArrayFieldDrafts(editConfig) + if (preparedEditConfig !== editConfig) { + setEditConfig(preparedEditConfig) + } const missingRequiredFields = requiredKeys.filter((key) => isMissingRequiredValue( - getFieldValueForValidation(editConfig, configuredSecrets, key), + getFieldValueForValidation(preparedEditConfig, configuredSecrets, key), ), ) if (missingRequiredFields.length > 0) { @@ -393,6 +472,7 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) { setServerError("") setFieldErrors({}) try { + const savePayload = buildSavePayload(channel, preparedEditConfig, enabled) await patchAppConfig({ channel_list: { [channel.config_key]: savePayload, @@ -462,6 +542,8 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) { onChange={handleChange} configuredSecrets={configuredSecrets} fieldErrors={fieldErrors} + registerArrayFieldFlusher={registerArrayFieldFlusher} + arrayFieldResetVersion={arrayFieldResetVersion} /> ) case "discord": @@ -471,6 +553,8 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) { onChange={handleChange} configuredSecrets={configuredSecrets} fieldErrors={fieldErrors} + registerArrayFieldFlusher={registerArrayFieldFlusher} + arrayFieldResetVersion={arrayFieldResetVersion} /> ) case "slack": @@ -480,6 +564,8 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) { onChange={handleChange} configuredSecrets={configuredSecrets} fieldErrors={fieldErrors} + registerArrayFieldFlusher={registerArrayFieldFlusher} + arrayFieldResetVersion={arrayFieldResetVersion} /> ) case "feishu": @@ -489,6 +575,8 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) { onChange={handleChange} configuredSecrets={configuredSecrets} fieldErrors={fieldErrors} + registerArrayFieldFlusher={registerArrayFieldFlusher} + arrayFieldResetVersion={arrayFieldResetVersion} /> ) case "weixin": @@ -498,6 +586,8 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) { onChange={handleChange} isEdit={isEdit} onBindSuccess={() => void handleWeixinBindSuccess()} + registerArrayFieldFlusher={registerArrayFieldFlusher} + arrayFieldResetVersion={arrayFieldResetVersion} /> ) case "wecom": @@ -518,6 +608,8 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) { hiddenKeys={[...hiddenKeys, "bot_id"]} requiredKeys={requiredKeys} fieldErrors={fieldErrors} + registerArrayFieldFlusher={registerArrayFieldFlusher} + arrayFieldResetVersion={arrayFieldResetVersion} /> ) @@ -530,6 +622,8 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) { hiddenKeys={hiddenKeys} requiredKeys={requiredKeys} fieldErrors={fieldErrors} + registerArrayFieldFlusher={registerArrayFieldFlusher} + arrayFieldResetVersion={arrayFieldResetVersion} /> ) } diff --git a/web/frontend/src/components/channels/channel-forms/discord-form.tsx b/web/frontend/src/components/channels/channel-forms/discord-form.tsx index f72e1c5c7..d2a98d325 100644 --- a/web/frontend/src/components/channels/channel-forms/discord-form.tsx +++ b/web/frontend/src/components/channels/channel-forms/discord-form.tsx @@ -1,6 +1,14 @@ import { useTranslation } from "react-i18next" import type { ChannelConfig } from "@/api/channels" +import { + type ArrayFieldFlusher, + ChannelArrayListField, +} from "@/components/channels/channel-array-list-field" +import { + asStringArray, + parseAllowFromInput, +} from "@/components/channels/channel-array-utils" import { getSecretInputPlaceholder } from "@/components/channels/channel-config-fields" import { Field, KeyInput, SwitchCardField } from "@/components/shared-form" import { Card, CardContent } from "@/components/ui/card" @@ -11,17 +19,17 @@ interface DiscordFormProps { onChange: (key: string, value: unknown) => void configuredSecrets: string[] fieldErrors?: Record + registerArrayFieldFlusher?: ( + fieldPath: string, + flusher: ArrayFieldFlusher | null, + ) => void + arrayFieldResetVersion?: number } function asString(value: unknown): string { return typeof value === "string" ? value : "" } -function asStringArray(value: unknown): string[] { - if (!Array.isArray(value)) return [] - return value.filter((item): item is string => typeof item === "string") -} - function asBool(value: unknown): boolean { return value === true } @@ -38,6 +46,8 @@ export function DiscordForm({ onChange, configuredSecrets, fieldErrors = {}, + registerArrayFieldFlusher, + arrayFieldResetVersion, }: DiscordFormProps) { const { t } = useTranslation() const groupTriggerConfig = asRecord(config.group_trigger) @@ -78,24 +88,17 @@ export function DiscordForm({ placeholder="http://127.0.0.1:7890" /> - - - onChange( - "allow_from", - e.target.value - .split(",") - .map((s: string) => s.trim()) - .filter(Boolean), - ) - } - placeholder={t("channels.field.allowFromPlaceholder")} - /> - + value={asStringArray(config.allow_from)} + onChange={(value) => onChange("allow_from", value)} + placeholder={t("channels.field.allowFromPlaceholder")} + parser={parseAllowFromInput} + fieldPath="allow_from" + registerFlusher={registerArrayFieldFlusher} + resetVersion={arrayFieldResetVersion} + />
void configuredSecrets: string[] fieldErrors?: Record + registerArrayFieldFlusher?: ( + fieldPath: string, + flusher: ArrayFieldFlusher | null, + ) => void + arrayFieldResetVersion?: number } function asString(value: unknown): string { @@ -21,16 +34,13 @@ function asBool(value: unknown): boolean { return typeof value === "boolean" ? value : false } -function asStringArray(value: unknown): string[] { - if (!Array.isArray(value)) return [] - return value.filter((item): item is string => typeof item === "string") -} - export function FeishuForm({ config, onChange, configuredSecrets, fieldErrors = {}, + registerArrayFieldFlusher, + arrayFieldResetVersion, }: FeishuFormProps) { const { t } = useTranslation() @@ -104,24 +114,17 @@ export function FeishuForm({ /> - - - onChange( - "allow_from", - e.target.value - .split(",") - .map((s: string) => s.trim()) - .filter(Boolean), - ) - } - placeholder={t("channels.field.allowFromPlaceholder")} - /> - + value={asStringArray(config.allow_from)} + onChange={(value) => onChange("allow_from", value)} + placeholder={t("channels.field.allowFromPlaceholder")} + parser={parseAllowFromInput} + fieldPath="allow_from" + registerFlusher={registerArrayFieldFlusher} + resetVersion={arrayFieldResetVersion} + />
+ registerArrayFieldFlusher?: ( + fieldPath: string, + flusher: ArrayFieldFlusher | null, + ) => void + arrayFieldResetVersion?: number } // Fields to skip in the generic form (handled by enabled toggle or internal). @@ -48,11 +61,6 @@ function asString(value: unknown): string { return typeof value === "string" ? value : "" } -function asStringArray(value: unknown): string[] { - if (!Array.isArray(value)) return [] - return value.filter((item): item is string => typeof item === "string") -} - function asRecord(value: unknown): Record { if (value && typeof value === "object" && !Array.isArray(value)) { return value as Record @@ -71,6 +79,8 @@ export function GenericForm({ hiddenKeys = [], requiredKeys = [], fieldErrors = {}, + registerArrayFieldFlusher, + arrayFieldResetVersion, }: GenericFormProps) { const { t } = useTranslation() const hiddenFieldSet = new Set(hiddenKeys) @@ -187,26 +197,18 @@ export function GenericForm({ if (Array.isArray(value)) { return ( - - - onChange( - key, - e.target.value - .split(",") - .map((s: string) => s.trim()) - .filter(Boolean), - ) - } - /> - + value={asStringArray(value)} + onChange={(nextValue) => onChange(key, nextValue)} + fieldPath={key} + registerFlusher={registerArrayFieldFlusher} + resetVersion={arrayFieldResetVersion} + /> ) } @@ -281,46 +283,31 @@ export function GenericForm({ {config.allow_from !== undefined && !hiddenFieldSet.has("allow_from") && ( - - - onChange( - "allow_from", - e.target.value - .split(",") - .map((s: string) => s.trim()) - .filter(Boolean), - ) - } - placeholder={t("channels.field.allowFromPlaceholder")} - /> - + value={asStringArray(config.allow_from)} + onChange={(value) => onChange("allow_from", value)} + placeholder={t("channels.field.allowFromPlaceholder")} + parser={parseAllowFromInput} + fieldPath="allow_from" + registerFlusher={registerArrayFieldFlusher} + resetVersion={arrayFieldResetVersion} + /> )} {config.allow_origins !== undefined && !hiddenFieldSet.has("allow_origins") && ( - - - onChange( - "allow_origins", - e.target.value - .split(",") - .map((s: string) => s.trim()) - .filter(Boolean), - ) - } - placeholder={t("channels.field.allowOriginsPlaceholder")} - /> - + value={asStringArray(config.allow_origins)} + onChange={(value) => onChange("allow_origins", value)} + placeholder={t("channels.field.allowOriginsPlaceholder")} + fieldPath="allow_origins" + registerFlusher={registerArrayFieldFlusher} + resetVersion={arrayFieldResetVersion} + /> )} {config.allow_token_query !== undefined && @@ -356,26 +343,21 @@ export function GenericForm({ />
- - - onChange("group_trigger", { - ...groupTriggerConfig, - prefixes: e.target.value - .split(",") - .map((s: string) => s.trim()) - .filter(Boolean), - }) - } - placeholder={t("channels.field.groupTriggerPrefixes")} - /> - + value={asStringArray(groupTriggerConfig.prefixes)} + onChange={(value) => + onChange("group_trigger", { + ...groupTriggerConfig, + prefixes: value, + }) + } + placeholder={t("channels.field.groupTriggerPrefixes")} + fieldPath="group_trigger.prefixes" + registerFlusher={registerArrayFieldFlusher} + resetVersion={arrayFieldResetVersion} + /> )} diff --git a/web/frontend/src/components/channels/channel-forms/slack-form.tsx b/web/frontend/src/components/channels/channel-forms/slack-form.tsx index 14ffa0913..b8184e8bc 100644 --- a/web/frontend/src/components/channels/channel-forms/slack-form.tsx +++ b/web/frontend/src/components/channels/channel-forms/slack-form.tsx @@ -1,32 +1,41 @@ import { useTranslation } from "react-i18next" import type { ChannelConfig } from "@/api/channels" +import { + type ArrayFieldFlusher, + ChannelArrayListField, +} from "@/components/channels/channel-array-list-field" +import { + asStringArray, + parseAllowFromInput, +} from "@/components/channels/channel-array-utils" import { getSecretInputPlaceholder } from "@/components/channels/channel-config-fields" import { Field, KeyInput } from "@/components/shared-form" import { Card, CardContent } from "@/components/ui/card" -import { Input } from "@/components/ui/input" interface SlackFormProps { config: ChannelConfig onChange: (key: string, value: unknown) => void configuredSecrets: string[] fieldErrors?: Record + registerArrayFieldFlusher?: ( + fieldPath: string, + flusher: ArrayFieldFlusher | null, + ) => void + arrayFieldResetVersion?: number } function asString(value: unknown): string { return typeof value === "string" ? value : "" } -function asStringArray(value: unknown): string[] { - if (!Array.isArray(value)) return [] - return value.filter((item): item is string => typeof item === "string") -} - export function SlackForm({ config, onChange, configuredSecrets, fieldErrors = {}, + registerArrayFieldFlusher, + arrayFieldResetVersion, }: SlackFormProps) { const { t } = useTranslation() @@ -72,24 +81,17 @@ export function SlackForm({ - - - onChange( - "allow_from", - e.target.value - .split(",") - .map((s: string) => s.trim()) - .filter(Boolean), - ) - } - placeholder={t("channels.field.allowFromPlaceholder")} - /> - + value={asStringArray(config.allow_from)} + onChange={(value) => onChange("allow_from", value)} + placeholder={t("channels.field.allowFromPlaceholder")} + parser={parseAllowFromInput} + fieldPath="allow_from" + registerFlusher={registerArrayFieldFlusher} + resetVersion={arrayFieldResetVersion} + />
diff --git a/web/frontend/src/components/channels/channel-forms/telegram-form.tsx b/web/frontend/src/components/channels/channel-forms/telegram-form.tsx index 696da245d..f9c7c778a 100644 --- a/web/frontend/src/components/channels/channel-forms/telegram-form.tsx +++ b/web/frontend/src/components/channels/channel-forms/telegram-form.tsx @@ -1,6 +1,14 @@ import { useTranslation } from "react-i18next" import type { ChannelConfig } from "@/api/channels" +import { + type ArrayFieldFlusher, + ChannelArrayListField, +} from "@/components/channels/channel-array-list-field" +import { + asStringArray, + parseAllowFromInput, +} from "@/components/channels/channel-array-utils" import { getSecretInputPlaceholder } from "@/components/channels/channel-config-fields" import { Field, KeyInput, SwitchCardField } from "@/components/shared-form" import { Card, CardContent } from "@/components/ui/card" @@ -11,17 +19,17 @@ interface TelegramFormProps { onChange: (key: string, value: unknown) => void configuredSecrets: string[] fieldErrors?: Record + registerArrayFieldFlusher?: ( + fieldPath: string, + flusher: ArrayFieldFlusher | null, + ) => void + arrayFieldResetVersion?: number } function asString(value: unknown): string { return typeof value === "string" ? value : "" } -function asStringArray(value: unknown): string[] { - if (!Array.isArray(value)) return [] - return value.filter((item): item is string => typeof item === "string") -} - function asRecord(value: unknown): Record { if (value && typeof value === "object" && !Array.isArray(value)) { return value as Record @@ -38,6 +46,8 @@ export function TelegramForm({ onChange, configuredSecrets, fieldErrors = {}, + registerArrayFieldFlusher, + arrayFieldResetVersion, }: TelegramFormProps) { const { t } = useTranslation() const typingConfig = asRecord(config.typing) @@ -91,24 +101,17 @@ export function TelegramForm({ placeholder="http://127.0.0.1:7890" /> - - - onChange( - "allow_from", - e.target.value - .split(",") - .map((s: string) => s.trim()) - .filter(Boolean), - ) - } - placeholder={t("channels.field.allowFromPlaceholder")} - /> - + value={asStringArray(config.allow_from)} + onChange={(value) => onChange("allow_from", value)} + placeholder={t("channels.field.allowFromPlaceholder")} + parser={parseAllowFromInput} + fieldPath="allow_from" + registerFlusher={registerArrayFieldFlusher} + resetVersion={arrayFieldResetVersion} + />
void isEdit: boolean onBindSuccess?: () => void + registerArrayFieldFlusher?: ( + fieldPath: string, + flusher: ArrayFieldFlusher | null, + ) => void + arrayFieldResetVersion?: number } function asString(value: unknown): string { return typeof value === "string" ? value : "" } -function asStringArray(value: unknown): string[] { - if (!Array.isArray(value)) return [] - return value.filter((item): item is string => typeof item === "string") -} - export function WeixinForm({ config, onChange, isEdit, onBindSuccess, + registerArrayFieldFlusher, + arrayFieldResetVersion, }: WeixinFormProps) { const { t } = useTranslation() @@ -321,24 +331,17 @@ export function WeixinForm({ - - - onChange( - "allow_from", - e.target.value - .split(",") - .map((s: string) => s.trim()) - .filter(Boolean), - ) - } - placeholder={t("channels.field.allowFromPlaceholder")} - /> - + value={asStringArray(config.allow_from)} + onChange={(value) => onChange("allow_from", value)} + placeholder={t("channels.field.allowFromPlaceholder")} + parser={parseAllowFromInput} + fieldPath="allow_from" + registerFlusher={registerArrayFieldFlusher} + resetVersion={arrayFieldResetVersion} + /> Date: Tue, 21 Apr 2026 16:15:09 +0800 Subject: [PATCH 11/31] docs: update documentation for Gemini native protocol (#2601) * docs: update documentation for Gemini native protocol * docs: fix capitalization and grammar of Gemini --- docs/design/provider-refactoring.md | 2 +- docs/guides/configuration.fr.md | 5 ++++- docs/guides/configuration.ja.md | 5 ++++- docs/guides/configuration.md | 3 ++- docs/guides/configuration.pt-br.md | 5 ++++- docs/guides/configuration.vi.md | 5 ++++- docs/guides/configuration.zh.md | 5 +++-- docs/guides/providers.fr.md | 7 ++++--- docs/guides/providers.ja.md | 5 +++-- docs/guides/providers.md | 7 ++++--- docs/guides/providers.pt-br.md | 5 +++-- docs/guides/providers.vi.md | 5 +++-- docs/guides/providers.zh.md | 7 ++++--- 13 files changed, 43 insertions(+), 23 deletions(-) diff --git a/docs/design/provider-refactoring.md b/docs/design/provider-refactoring.md index 38f379c50..3c31f610f 100644 --- a/docs/design/provider-refactoring.md +++ b/docs/design/provider-refactoring.md @@ -154,7 +154,7 @@ Identify protocol via prefix in `model` field: | `openai/` | OpenAI-compatible | Most common, includes DeepSeek, Qwen, Groq, etc. | | `anthropic/` | Anthropic | Claude series specific | | `antigravity/` | Antigravity | Google Cloud Code Assist | -| `gemini/` | Gemini | Google Gemini native API (if needed) | +| `gemini/` | Gemini | Google Gemini native API | --- diff --git a/docs/guides/configuration.fr.md b/docs/guides/configuration.fr.md index f147fea95..786a0c28f 100644 --- a/docs/guides/configuration.fr.md +++ b/docs/guides/configuration.fr.md @@ -339,7 +339,7 @@ Répond HEARTBEAT_OK Utilisateur reçoit le résultat | **Anthropic** | `anthropic/` | `https://api.anthropic.com/v1` | Anthropic | [Obtenir](https://console.anthropic.com) | | **智谱 AI (GLM)** | `zhipu/` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [Obtenir](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) | | **DeepSeek** | `deepseek/` | `https://api.deepseek.com/v1` | OpenAI | [Obtenir](https://platform.deepseek.com) | -| **Google Gemini** | `gemini/` | `https://generativelanguage.googleapis.com/v1beta` | OpenAI | [Obtenir](https://aistudio.google.com/api-keys) | +| **Google Gemini** | `gemini/` | `https://generativelanguage.googleapis.com/v1beta` | Gemini | [Obtenir](https://aistudio.google.com/api-keys) | | **Groq** | `groq/` | `https://api.groq.com/openai/v1` | OpenAI | [Obtenir](https://console.groq.com) | | **通义千问 (Qwen)** | `qwen/` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI | [Obtenir](https://dashscope.console.aliyun.com) | | **Ollama** | `ollama/` | `http://localhost:11434/v1` | OpenAI | Local (pas de clé) | @@ -369,9 +369,12 @@ L'ancienne configuration `providers` est **dépréciée** et a été supprimée PicoClaw route les providers par famille de protocole : - **Compatible OpenAI** : OpenRouter, Groq, Zhipu, endpoints vLLM et la plupart des autres. +- **Gemini natif** : Google Gemini via les endpoints natifs `models/*:generateContent` et `models/*:streamGenerateContent`. - **Anthropic** : Comportement natif de l'API Claude. - **Codex/OAuth** : Route d'authentification OAuth/token OpenAI. +Cela maintient le runtime léger tout en faisant des nouveaux backends compatibles OpenAI principalement une opération de configuration (`api_base` + `api_keys`). + ### Tâches Planifiées / Rappels PicoClaw supporte les tâches planifiées via l'outil `cron`. L'agent peut définir, lister et annuler des rappels ou tâches récurrentes. diff --git a/docs/guides/configuration.ja.md b/docs/guides/configuration.ja.md index 1940eacda..0234edbd7 100644 --- a/docs/guides/configuration.ja.md +++ b/docs/guides/configuration.ja.md @@ -340,7 +340,7 @@ HEARTBEAT_OK を返信 ユーザーが直接結果を受信 | **Anthropic** | `anthropic/` | `https://api.anthropic.com/v1` | Anthropic | [取得](https://console.anthropic.com) | | **智谱 AI (GLM)** | `zhipu/` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [取得](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) | | **DeepSeek** | `deepseek/` | `https://api.deepseek.com/v1` | OpenAI | [取得](https://platform.deepseek.com) | -| **Google Gemini** | `gemini/` | `https://generativelanguage.googleapis.com/v1beta` | OpenAI | [取得](https://aistudio.google.com/api-keys) | +| **Google Gemini** | `gemini/` | `https://generativelanguage.googleapis.com/v1beta` | Gemini | [取得](https://aistudio.google.com/api-keys) | | **Groq** | `groq/` | `https://api.groq.com/openai/v1` | OpenAI | [取得](https://console.groq.com) | | **通義千問 (Qwen)** | `qwen/` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI | [取得](https://dashscope.console.aliyun.com) | | **Ollama** | `ollama/` | `http://localhost:11434/v1` | OpenAI | ローカル(キー不要) | @@ -370,9 +370,12 @@ HEARTBEAT_OK を返信 ユーザーが直接結果を受信 PicoClaw はプロトコルファミリーで Provider をルーティングします: - **OpenAI 互換**:OpenRouter、Groq、Zhipu、vLLM スタイルのエンドポイントなど。 +- **Gemini ネイティブ**:Google Gemini のネイティブ `models/*:generateContent` / `models/*:streamGenerateContent` エンドポイント。 - **Anthropic**:Claude ネイティブ API の動作。 - **Codex/OAuth**:OpenAI OAuth/トークン認証ルート。 +これによりランタイムを軽量に保ちつつ、新しい OpenAI 互換バックエンドの追加をほぼ設定操作(`api_base` + `api_keys`)のみで実現します。 + ### スケジュールタスク / リマインダー PicoClaw は `cron` ツールを通じて cron スタイルのスケジュールタスクをサポートします。 diff --git a/docs/guides/configuration.md b/docs/guides/configuration.md index bb58d5081..ef3b14b24 100644 --- a/docs/guides/configuration.md +++ b/docs/guides/configuration.md @@ -576,7 +576,7 @@ For complete documentation, see [`../security/security_configuration.md`](../sec | **Anthropic** | `anthropic/` | `https://api.anthropic.com/v1` | Anthropic | [Get Key](https://console.anthropic.com) | | **智谱 AI (GLM)** | `zhipu/` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [Get Key](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) | | **DeepSeek** | `deepseek/` | `https://api.deepseek.com/v1` | OpenAI | [Get Key](https://platform.deepseek.com) | -| **Google Gemini** | `gemini/` | `https://generativelanguage.googleapis.com/v1beta` | OpenAI | [Get Key](https://aistudio.google.com/api-keys) | +| **Google Gemini** | `gemini/` | `https://generativelanguage.googleapis.com/v1beta` | Gemini | [Get Key](https://aistudio.google.com/api-keys) | | **Groq** | `groq/` | `https://api.groq.com/openai/v1` | OpenAI | [Get Key](https://console.groq.com) | | **Moonshot** | `moonshot/` | `https://api.moonshot.cn/v1` | OpenAI | [Get Key](https://platform.moonshot.cn) | | **通义千问 (Qwen)** | `qwen/` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI | [Get Key](https://dashscope.console.aliyun.com) | @@ -820,6 +820,7 @@ The old `providers` configuration is **deprecated** and has been removed in V2. PicoClaw routes providers by protocol family: - **OpenAI-compatible**: OpenRouter, Groq, Zhipu, vLLM-style endpoints, and most others. +- **Gemini native**: Google Gemini via the native `models/*:generateContent` and `models/*:streamGenerateContent` endpoints. - **Anthropic**: Claude-native API behavior. - **Codex/OAuth**: OpenAI OAuth/token authentication route. diff --git a/docs/guides/configuration.pt-br.md b/docs/guides/configuration.pt-br.md index c47278484..e5d904e29 100644 --- a/docs/guides/configuration.pt-br.md +++ b/docs/guides/configuration.pt-br.md @@ -340,7 +340,7 @@ Responde HEARTBEAT_OK Usuário recebe resultado diretamente | **Anthropic** | `anthropic/` | `https://api.anthropic.com/v1` | Anthropic | [Obter](https://console.anthropic.com) | | **智谱 AI (GLM)** | `zhipu/` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [Obter](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) | | **DeepSeek** | `deepseek/` | `https://api.deepseek.com/v1` | OpenAI | [Obter](https://platform.deepseek.com) | -| **Google Gemini** | `gemini/` | `https://generativelanguage.googleapis.com/v1beta` | OpenAI | [Obter](https://aistudio.google.com/api-keys) | +| **Google Gemini** | `gemini/` | `https://generativelanguage.googleapis.com/v1beta` | Gemini | [Obter](https://aistudio.google.com/api-keys) | | **Groq** | `groq/` | `https://api.groq.com/openai/v1` | OpenAI | [Obter](https://console.groq.com) | | **通义千问 (Qwen)** | `qwen/` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI | [Obter](https://dashscope.console.aliyun.com) | | **Ollama** | `ollama/` | `http://localhost:11434/v1` | OpenAI | Local (sem chave) | @@ -370,9 +370,12 @@ A configuração antiga `providers` está **depreciada** e foi removida no V2. C PicoClaw roteia providers por família de protocolo: - **Compatível com OpenAI**: OpenRouter, Groq, Zhipu, endpoints vLLM e a maioria dos outros. +- **Gemini nativo**: Google Gemini via endpoints nativos `models/*:generateContent` e `models/*:streamGenerateContent`. - **Anthropic**: Comportamento nativo da API Claude. - **Codex/OAuth**: Rota de autenticação OAuth/token OpenAI. +Isso mantém o runtime leve enquanto torna novos backends compatíveis com OpenAI basicamente uma operação de configuração (`api_base` + `api_keys`). + ### Tarefas Agendadas / Lembretes PicoClaw suporta tarefas agendadas via ferramenta `cron`. diff --git a/docs/guides/configuration.vi.md b/docs/guides/configuration.vi.md index 9efeaa2b6..d905b6d2b 100644 --- a/docs/guides/configuration.vi.md +++ b/docs/guides/configuration.vi.md @@ -340,7 +340,7 @@ Trả lời HEARTBEAT_OK Người dùng nhận kết quả trực tiếp | **Anthropic** | `anthropic/` | `https://api.anthropic.com/v1` | Anthropic | [Lấy](https://console.anthropic.com) | | **智谱 AI (GLM)** | `zhipu/` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [Lấy](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) | | **DeepSeek** | `deepseek/` | `https://api.deepseek.com/v1` | OpenAI | [Lấy](https://platform.deepseek.com) | -| **Google Gemini** | `gemini/` | `https://generativelanguage.googleapis.com/v1beta` | OpenAI | [Lấy](https://aistudio.google.com/api-keys) | +| **Google Gemini** | `gemini/` | `https://generativelanguage.googleapis.com/v1beta` | Gemini | [Lấy](https://aistudio.google.com/api-keys) | | **Groq** | `groq/` | `https://api.groq.com/openai/v1` | OpenAI | [Lấy](https://console.groq.com) | | **通义千问 (Qwen)** | `qwen/` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI | [Lấy](https://dashscope.console.aliyun.com) | | **Ollama** | `ollama/` | `http://localhost:11434/v1` | OpenAI | Cục bộ (không cần key) | @@ -370,9 +370,12 @@ Cấu hình `providers` cũ đã **bị deprecated** và đã được loại b PicoClaw định tuyến provider theo họ giao thức: - **Tương thích OpenAI**: OpenRouter, Groq, Zhipu, endpoint kiểu vLLM và hầu hết các provider khác. +- **Gemini native**: Google Gemini qua các endpoint native `models/*:generateContent` và `models/*:streamGenerateContent`. - **Anthropic**: Hành vi API Claude gốc. - **Codex/OAuth**: Tuyến xác thực OAuth/token OpenAI. +Điều này giữ runtime nhẹ trong khi khiến backend OpenAI-compatible mới chủ yếu chỉ là thao tác cấu hình (`api_base` + `api_keys`). + ### Tác Vụ Đã Lên Lịch / Nhắc Nhở PicoClaw hỗ trợ tác vụ theo lịch qua công cụ `cron`. diff --git a/docs/guides/configuration.zh.md b/docs/guides/configuration.zh.md index ecaef6eb7..adbe77db0 100644 --- a/docs/guides/configuration.zh.md +++ b/docs/guides/configuration.zh.md @@ -441,7 +441,7 @@ Agent 读取 HEARTBEAT.md | **Anthropic** | `anthropic/` | `https://api.anthropic.com/v1` | Anthropic | [获取](https://console.anthropic.com) | | **智谱 AI (GLM)** | `zhipu/` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [获取](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) | | **DeepSeek** | `deepseek/` | `https://api.deepseek.com/v1` | OpenAI | [获取](https://platform.deepseek.com) | -| **Google Gemini** | `gemini/` | `https://generativelanguage.googleapis.com/v1beta` | OpenAI | [获取](https://aistudio.google.com/api-keys) | +| **Google Gemini** | `gemini/` | `https://generativelanguage.googleapis.com/v1beta` | Gemini | [获取](https://aistudio.google.com/api-keys) | | **Groq** | `groq/` | `https://api.groq.com/openai/v1` | OpenAI | [获取](https://console.groq.com) | | **Moonshot** | `moonshot/` | `https://api.moonshot.cn/v1` | OpenAI | [获取](https://platform.moonshot.cn) | | **通义千问 (Qwen)** | `qwen/` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI | [获取](https://dashscope.console.aliyun.com) | @@ -652,10 +652,11 @@ PicoClaw 只剥离最外层的 `litellm/` 前缀再发送请求,因此 `litell PicoClaw 按协议族路由提供商: - **OpenAI 兼容**:OpenRouter、Groq、智谱、vLLM 风格端点及大多数其他提供商。 +- **Gemini 原生**:Google Gemini 通过原生 `models/*:generateContent` 和 `models/*:streamGenerateContent` 端点接入。 - **Anthropic**:Claude 原生 API 行为。 - **Codex/OAuth**:OpenAI OAuth/Token 认证路由。 -这使运行时保持轻量,同时让接入新的 OpenAI 兼容后端基本只需配置 `api_base` + `api_key`。 +这使运行时保持轻量,同时让接入新的 OpenAI 兼容后端基本只需配置 `api_base` + `api_keys`。
智谱(旧版 providers 格式) diff --git a/docs/guides/providers.fr.md b/docs/guides/providers.fr.md index 5e2700a01..aff600351 100644 --- a/docs/guides/providers.fr.md +++ b/docs/guides/providers.fr.md @@ -46,7 +46,7 @@ Cette conception permet également le **support multi-agents** avec une sélecti | **Anthropic** | `anthropic/` | `https://api.anthropic.com/v1` | Anthropic | [Get Key](https://console.anthropic.com) | | **智谱 AI (GLM)** | `zhipu/` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [Get Key](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) | | **DeepSeek** | `deepseek/` | `https://api.deepseek.com/v1` | OpenAI | [Get Key](https://platform.deepseek.com) | -| **Google Gemini** | `gemini/` | `https://generativelanguage.googleapis.com/v1beta` | OpenAI | [Get Key](https://aistudio.google.com/api-keys) | +| **Google Gemini** | `gemini/` | `https://generativelanguage.googleapis.com/v1beta` | Gemini | [Get Key](https://aistudio.google.com/api-keys) | | **Groq** | `groq/` | `https://api.groq.com/openai/v1` | OpenAI | [Get Key](https://console.groq.com) | | **Moonshot** | `moonshot/` | `https://api.moonshot.cn/v1` | OpenAI | [Get Key](https://platform.moonshot.cn) | | **通义千问 (Qwen)** | `qwen/` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI | [Get Key](https://dashscope.console.aliyun.com) | @@ -108,7 +108,7 @@ Cette conception permet également le **support multi-agents** avec une sélecti | `api_keys` | string[] | Oui* | Clé(s) API pour l'authentification. Plusieurs clés permettent la rotation par requête. Non requis pour les fournisseurs locaux (Ollama, LM Studio, VLLM) | | `api_base` | string | Non | Remplace l'URL de base API par défaut | | `proxy` | string | Non | URL du proxy HTTP pour cette entrée de modèle | -| `user_agent` | string | Non | En-tête `User-Agent` personnalisé pour les requêtes API (supporté par les providers OpenAI-compatible, Anthropic et Azure) | +| `user_agent` | string | Non | En-tête `User-Agent` personnalisé pour les requêtes API (supporté par les providers compatibles OpenAI, Gemini, Anthropic et Azure) | | `request_timeout` | int | Non | Délai d'expiration de la requête en secondes (la valeur par défaut varie selon le provider) | | `max_tokens_field` | string | Non | Remplace le nom du champ max tokens dans le corps de la requête (ex : `max_completion_tokens` pour les modèles o1) | | `thinking_level` | string | Non | Niveau de pensée étendue : `off`, `low`, `medium`, `high`, `xhigh` ou `adaptive` | @@ -299,10 +299,11 @@ Pour un guide de migration détaillé, voir [migration/model-list-migration.md]( PicoClaw route les fournisseurs par famille de protocoles : - Protocole compatible OpenAI : OpenRouter, passerelles compatibles OpenAI, Groq, Zhipu et endpoints de type vLLM. +- Protocole Gemini natif : Google Gemini via les endpoints natifs `models/*:generateContent` et `models/*:streamGenerateContent`. - Protocole Anthropic : Comportement natif de l'API Claude. - Chemin Codex/OAuth : Route d'authentification OAuth/token OpenAI. -Cela maintient le runtime léger tout en faisant des nouveaux backends compatibles OpenAI principalement une opération de configuration (`api_base` + `api_key`). +Cela maintient le runtime léger tout en faisant des nouveaux backends compatibles OpenAI principalement une opération de configuration (`api_base` + `api_keys`).
Zhipu diff --git a/docs/guides/providers.ja.md b/docs/guides/providers.ja.md index 77cf18d55..fecc74519 100644 --- a/docs/guides/providers.ja.md +++ b/docs/guides/providers.ja.md @@ -47,7 +47,7 @@ | **Anthropic** | `anthropic/` | `https://api.anthropic.com/v1` | Anthropic | [キーを取得](https://console.anthropic.com) | | **智谱 AI (GLM)** | `zhipu/` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [キーを取得](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) | | **DeepSeek** | `deepseek/` | `https://api.deepseek.com/v1` | OpenAI | [キーを取得](https://platform.deepseek.com) | -| **Google Gemini** | `gemini/` | `https://generativelanguage.googleapis.com/v1beta` | OpenAI | [キーを取得](https://aistudio.google.com/api-keys) | +| **Google Gemini** | `gemini/` | `https://generativelanguage.googleapis.com/v1beta` | Gemini | [キーを取得](https://aistudio.google.com/api-keys) | | **Groq** | `groq/` | `https://api.groq.com/openai/v1` | OpenAI | [キーを取得](https://console.groq.com) | | **Moonshot** | `moonshot/` | `https://api.moonshot.cn/v1` | OpenAI | [キーを取得](https://platform.moonshot.cn) | | **通義千問 (Qwen)** | `qwen/` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI | [キーを取得](https://dashscope.console.aliyun.com) | @@ -109,7 +109,7 @@ | `api_keys` | string[] | はい* | 認証キー。複数キーでリクエストごとのローテーションが可能。ローカル provider(Ollama、LM Studio、VLLM)には不要 | | `api_base` | string | いいえ | デフォルトの API エンドポイント URL を上書き | | `proxy` | string | いいえ | このモデルエントリの HTTP プロキシ URL | -| `user_agent` | string | いいえ | カスタム `User-Agent` リクエストヘッダー(OpenAI 互換、Anthropic、Azure provider で対応) | +| `user_agent` | string | いいえ | カスタム `User-Agent` リクエストヘッダー(OpenAI 互換、Gemini、Anthropic、Azure provider で対応) | | `request_timeout` | int | いいえ | リクエストタイムアウト(秒)。デフォルト値は provider により異なる | | `max_tokens_field` | string | いいえ | リクエストボディの max tokens フィールド名を上書き(例:o1 モデルでは `max_completion_tokens`) | | `thinking_level` | string | いいえ | 拡張思考レベル:`off`、`low`、`medium`、`high`、`xhigh`、`adaptive` | @@ -311,6 +311,7 @@ PicoClaw はリクエスト送信前に外側の `litellm/` プレフィック PicoClaw はプロトコルファミリーごとに Provider をルーティングします: - OpenAI 互換プロトコル:OpenRouter、OpenAI 互換ゲートウェイ、Groq、Zhipu、vLLM スタイルのエンドポイント。 +- Gemini ネイティブプロトコル:Google Gemini のネイティブ `models/*:generateContent` / `models/*:streamGenerateContent` エンドポイント。 - Anthropic プロトコル:Claude ネイティブ API 動作。 - Codex/OAuth パス:OpenAI OAuth/Token 認証ルート。 diff --git a/docs/guides/providers.md b/docs/guides/providers.md index 41f3caae0..81c12cd4a 100644 --- a/docs/guides/providers.md +++ b/docs/guides/providers.md @@ -54,7 +54,7 @@ This design also enables **multi-agent support** with flexible provider selectio | **智谱 AI (GLM)** | `zhipu/` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [Get Key](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) | | **Z.AI Coding Plan** | `openai/` | `https://api.z.ai/api/coding/paas/v4` | OpenAI | [Get Key](https://z.ai/manage-apikey/apikey-list) | | **DeepSeek** | `deepseek/` | `https://api.deepseek.com/v1` | OpenAI | [Get Key](https://platform.deepseek.com) | -| **Google Gemini** | `gemini/` | `https://generativelanguage.googleapis.com/v1beta` | OpenAI | [Get Key](https://aistudio.google.com/api-keys) | +| **Google Gemini** | `gemini/` | `https://generativelanguage.googleapis.com/v1beta` | Gemini | [Get Key](https://aistudio.google.com/api-keys) | | **Groq** | `groq/` | `https://api.groq.com/openai/v1` | OpenAI | [Get Key](https://console.groq.com) | | **Moonshot** | `moonshot/` | `https://api.moonshot.cn/v1` | OpenAI | [Get Key](https://platform.moonshot.cn) | | **通义千问 (Qwen)** | `qwen/` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI | [Get Key](https://dashscope.console.aliyun.com) | @@ -119,7 +119,7 @@ This design also enables **multi-agent support** with flexible provider selectio | `api_keys` | string[] | Yes* | API key(s) for authentication. Multiple keys enable per-request rotation. Not required for local providers (Ollama, LM Studio, VLLM) | | `api_base` | string | No | Override the default API endpoint URL | | `proxy` | string | No | HTTP proxy URL for this model entry | -| `user_agent` | string | No | Custom `User-Agent` header sent with API requests (supported by OpenAI-compatible, Anthropic, and Azure providers) | +| `user_agent` | string | No | Custom `User-Agent` header sent with API requests (supported by OpenAI-compatible, Gemini, Anthropic, and Azure providers) | | `request_timeout` | int | No | Request timeout in seconds (default varies by provider) | | `max_tokens_field` | string | No | Override the max tokens field name in request body (e.g., `max_completion_tokens` for o1 models) | | `thinking_level` | string | No | Extended thinking level: `off`, `low`, `medium`, `high`, `xhigh`, or `adaptive` | @@ -415,10 +415,11 @@ For detailed migration guide, see [migration/model-list-migration.md](../migrati PicoClaw routes providers by protocol family: - OpenAI-compatible protocol: OpenRouter, OpenAI-compatible gateways, Groq, Zhipu, and vLLM-style endpoints. +- Gemini native protocol: Google Gemini via the native `models/*:generateContent` and `models/*:streamGenerateContent` endpoints. - Anthropic protocol: Claude-native API behavior. - Codex/OAuth path: OpenAI OAuth/token authentication route. -This keeps the runtime lightweight while making new OpenAI-compatible backends mostly a config operation (`api_base` + `api_key`). +This keeps the runtime lightweight while making new OpenAI-compatible backends mostly a config operation (`api_base` + `api_keys`).
Zhipu diff --git a/docs/guides/providers.pt-br.md b/docs/guides/providers.pt-br.md index fedeec5c5..0d45dc309 100644 --- a/docs/guides/providers.pt-br.md +++ b/docs/guides/providers.pt-br.md @@ -46,7 +46,7 @@ Este design também permite **suporte multi-agente** com seleção flexível de | **Anthropic** | `anthropic/` | `https://api.anthropic.com/v1` | Anthropic | [Get Key](https://console.anthropic.com) | | **智谱 AI (GLM)** | `zhipu/` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [Get Key](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) | | **DeepSeek** | `deepseek/` | `https://api.deepseek.com/v1` | OpenAI | [Get Key](https://platform.deepseek.com) | -| **Google Gemini** | `gemini/` | `https://generativelanguage.googleapis.com/v1beta` | OpenAI | [Get Key](https://aistudio.google.com/api-keys) | +| **Google Gemini** | `gemini/` | `https://generativelanguage.googleapis.com/v1beta` | Gemini | [Get Key](https://aistudio.google.com/api-keys) | | **Groq** | `groq/` | `https://api.groq.com/openai/v1` | OpenAI | [Get Key](https://console.groq.com) | | **Moonshot** | `moonshot/` | `https://api.moonshot.cn/v1` | OpenAI | [Get Key](https://platform.moonshot.cn) | | **通义千问 (Qwen)** | `qwen/` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI | [Get Key](https://dashscope.console.aliyun.com) | @@ -108,7 +108,7 @@ Este design também permite **suporte multi-agente** com seleção flexível de | `api_keys` | string[] | Sim* | Chave(s) API para autenticação. Múltiplas chaves permitem rotação por requisição. Não necessário para providers locais (Ollama, LM Studio, VLLM) | | `api_base` | string | Não | Substitui a URL base da API padrão | | `proxy` | string | Não | URL do proxy HTTP para esta entrada de modelo | -| `user_agent` | string | Não | Cabeçalho `User-Agent` personalizado enviado com requisições API (suportado por providers OpenAI-compatible, Anthropic e Azure) | +| `user_agent` | string | Não | Cabeçalho `User-Agent` personalizado enviado com requisições API (suportado por providers OpenAI-compatible, Gemini, Anthropic e Azure) | | `request_timeout` | int | Não | Timeout de requisição em segundos (o padrão varia por provider) | | `max_tokens_field` | string | Não | Substitui o nome do campo max tokens no corpo da requisição (ex: `max_completion_tokens` para modelos o1) | | `thinking_level` | string | Não | Nível de pensamento estendido: `off`, `low`, `medium`, `high`, `xhigh` ou `adaptive` | @@ -299,6 +299,7 @@ Para guia de migração detalhado, veja [migration/model-list-migration.md](../m O PicoClaw roteia provedores por família de protocolo: - Protocolo compatível com OpenAI: OpenRouter, gateways compatíveis com OpenAI, Groq, Zhipu e endpoints estilo vLLM. +- Protocolo Gemini nativo: Google Gemini via endpoints nativos `models/*:generateContent` e `models/*:streamGenerateContent`. - Protocolo Anthropic: Comportamento nativo da API Claude. - Caminho Codex/OAuth: Rota de autenticação OAuth/token da OpenAI. diff --git a/docs/guides/providers.vi.md b/docs/guides/providers.vi.md index 1bc76092d..c354461cf 100644 --- a/docs/guides/providers.vi.md +++ b/docs/guides/providers.vi.md @@ -46,7 +46,7 @@ Thiết kế này cũng cho phép **hỗ trợ đa agent** với lựa chọn pr | **Anthropic** | `anthropic/` | `https://api.anthropic.com/v1` | Anthropic | [Get Key](https://console.anthropic.com) | | **智谱 AI (GLM)** | `zhipu/` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [Get Key](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) | | **DeepSeek** | `deepseek/` | `https://api.deepseek.com/v1` | OpenAI | [Get Key](https://platform.deepseek.com) | -| **Google Gemini** | `gemini/` | `https://generativelanguage.googleapis.com/v1beta` | OpenAI | [Get Key](https://aistudio.google.com/api-keys) | +| **Google Gemini** | `gemini/` | `https://generativelanguage.googleapis.com/v1beta` | Gemini | [Get Key](https://aistudio.google.com/api-keys) | | **Groq** | `groq/` | `https://api.groq.com/openai/v1` | OpenAI | [Get Key](https://console.groq.com) | | **Moonshot** | `moonshot/` | `https://api.moonshot.cn/v1` | OpenAI | [Get Key](https://platform.moonshot.cn) | | **通义千问 (Qwen)** | `qwen/` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI | [Get Key](https://dashscope.console.aliyun.com) | @@ -108,7 +108,7 @@ Thiết kế này cũng cho phép **hỗ trợ đa agent** với lựa chọn pr | `api_keys` | string[] | Có* | Khóa API xác thực. Nhiều khóa cho phép xoay vòng theo yêu cầu. Không cần thiết cho provider nội bộ (Ollama, LM Studio, VLLM) | | `api_base` | string | Không | Ghi đè URL endpoint API mặc định | | `proxy` | string | Không | URL proxy HTTP cho entry model này | -| `user_agent` | string | Không | Header `User-Agent` tùy chỉnh gửi với yêu cầu API (được hỗ trợ bởi provider OpenAI-compatible, Anthropic và Azure) | +| `user_agent` | string | Không | Header `User-Agent` tùy chỉnh gửi với yêu cầu API (được hỗ trợ bởi provider OpenAI-compatible, Gemini, Anthropic và Azure) | | `request_timeout` | int | Không | Timeout yêu cầu tính bằng giây (mặc định khác nhau tùy provider) | | `max_tokens_field` | string | Không | Ghi đè tên trường max tokens trong request body (ví dụ: `max_completion_tokens` cho model o1) | | `thinking_level` | string | Không | Mức độ tư duy mở rộng: `off`, `low`, `medium`, `high`, `xhigh` hoặc `adaptive` | @@ -299,6 +299,7 @@ Cấu hình `providers` cũ đã **bị deprecated** và đã được loại b PicoClaw định tuyến provider theo họ giao thức: - Giao thức tương thích OpenAI: OpenRouter, gateway tương thích OpenAI, Groq, Zhipu, và endpoint kiểu vLLM. +- Giao thức Gemini native: Google Gemini qua các endpoint native `models/*:generateContent` và `models/*:streamGenerateContent`. - Giao thức Anthropic: Hành vi API native của Claude. - Đường dẫn Codex/OAuth: Tuyến xác thực OAuth/token của OpenAI. diff --git a/docs/guides/providers.zh.md b/docs/guides/providers.zh.md index 1f1031043..c08d7171b 100644 --- a/docs/guides/providers.zh.md +++ b/docs/guides/providers.zh.md @@ -52,7 +52,7 @@ | **Anthropic** | `anthropic/` | `https://api.anthropic.com/v1` | Anthropic | [获取密钥](https://console.anthropic.com) | | **智谱 AI (GLM)** | `zhipu/` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [获取密钥](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) | | **DeepSeek** | `deepseek/` | `https://api.deepseek.com/v1` | OpenAI | [获取密钥](https://platform.deepseek.com) | -| **Google Gemini** | `gemini/` | `https://generativelanguage.googleapis.com/v1beta` | OpenAI | [获取密钥](https://aistudio.google.com/api-keys) | +| **Google Gemini** | `gemini/` | `https://generativelanguage.googleapis.com/v1beta` | Gemini | [获取密钥](https://aistudio.google.com/api-keys) | | **Groq** | `groq/` | `https://api.groq.com/openai/v1` | OpenAI | [获取密钥](https://console.groq.com) | | **Moonshot** | `moonshot/` | `https://api.moonshot.cn/v1` | OpenAI | [获取密钥](https://platform.moonshot.cn) | | **通义千问 (Qwen)** | `qwen/` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI | [获取密钥](https://dashscope.console.aliyun.com) | @@ -116,7 +116,7 @@ | `api_keys` | string[] | 是* | 认证密钥。多个密钥可按请求轮换。本地 provider(Ollama、LM Studio、VLLM)不需要 | | `api_base` | string | 否 | 覆盖默认的 API 端点 URL | | `proxy` | string | 否 | 此模型条目的 HTTP 代理 URL | -| `user_agent` | string | 否 | 自定义 `User-Agent` 请求头(支持 OpenAI 兼容、Anthropic 和 Azure provider) | +| `user_agent` | string | 否 | 自定义 `User-Agent` 请求头(支持 OpenAI 兼容、Gemini、Anthropic 和 Azure provider) | | `request_timeout` | int | 否 | 请求超时时间(秒),默认值因 provider 而异 | | `max_tokens_field` | string | 否 | 覆盖请求体中 max tokens 的字段名(如 o1 模型使用 `max_completion_tokens`) | | `thinking_level` | string | 否 | 扩展思考级别:`off`、`low`、`medium`、`high`、`xhigh` 或 `adaptive` | @@ -386,10 +386,11 @@ PicoClaw 在发送请求前仅去除外层 `litellm/` 前缀,因此 `litellm/l PicoClaw 按协议族路由 Provider: - OpenAI 兼容协议:OpenRouter、OpenAI 兼容网关、Groq、智谱、vLLM 风格端点。 +- Gemini 原生协议:Google Gemini 通过原生 `models/*:generateContent` 和 `models/*:streamGenerateContent` 端点接入。 - Anthropic 协议:Claude 原生 API 行为。 - Codex/OAuth 路径:OpenAI OAuth/Token 认证路由。 -这使得运行时保持轻量,同时让新的 OpenAI 兼容后端基本只需配置操作(`api_base` + `api_key`)。 +这使得运行时保持轻量,同时让新的 OpenAI 兼容后端基本只需配置操作(`api_base` + `api_keys`)。
智谱 (Zhipu) 配置示例 From 9c3dc0ee3ac47f3053e812814e75b43aeeb8c120 Mon Sep 17 00:00:00 2001 From: LC <64722907+lc6464@users.noreply.github.com> Date: Tue, 21 Apr 2026 16:28:29 +0800 Subject: [PATCH 12/31] fix(auth): canonicalize Google Antigravity provider and enhance credential management (#2599) * fix(auth): canonicalize Google Antigravity provider and enhance credential management * fix(auth): improve error handling in credential storage tests * fix(auth): stabilize canonical provider merge precedence --- cmd/picoclaw/internal/auth/status_test.go | 85 ++++++ pkg/auth/store.go | 145 ++++++++- pkg/auth/store_test.go | 356 ++++++++++++++++++++-- 3 files changed, 560 insertions(+), 26 deletions(-) diff --git a/cmd/picoclaw/internal/auth/status_test.go b/cmd/picoclaw/internal/auth/status_test.go index 7748ba502..2f9a70721 100644 --- a/cmd/picoclaw/internal/auth/status_test.go +++ b/cmd/picoclaw/internal/auth/status_test.go @@ -1,12 +1,53 @@ package auth import ( + "bytes" + "encoding/json" + "io" + "os" + "path/filepath" + "strings" "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + pkgauth "github.com/sipeed/picoclaw/pkg/auth" + "github.com/sipeed/picoclaw/pkg/config" ) +func captureAuthStdout(t *testing.T, fn func()) string { + t.Helper() + + oldStdout := os.Stdout + r, w, err := os.Pipe() + require.NoError(t, err) + os.Stdout = w + t.Cleanup(func() { + os.Stdout = oldStdout + }) + + fn() + + require.NoError(t, w.Close()) + os.Stdout = oldStdout + + var buf bytes.Buffer + _, err = io.Copy(&buf, r) + require.NoError(t, err) + require.NoError(t, r.Close()) + return buf.String() +} + +func setAuthStatusTestHome(t *testing.T) string { + t.Helper() + + tmpDir := t.TempDir() + t.Setenv(config.EnvHome, filepath.Join(tmpDir, ".picoclaw")) + return tmpDir +} + func TestNewStatusSubcommand(t *testing.T) { cmd := newStatusCommand() @@ -16,3 +57,47 @@ func TestNewStatusSubcommand(t *testing.T) { assert.False(t, cmd.HasFlags()) } + +func TestAuthStatusCmdShowsCanonicalGoogleAntigravityAfterLegacyRefresh(t *testing.T) { + tmpDir := setAuthStatusTestHome(t) + + legacyExpiry := time.Date(2026, 4, 16, 10, 0, 0, 0, time.UTC) + legacyStore := map[string]any{ + "credentials": map[string]any{ + "antigravity": map[string]any{ + "access_token": "legacy-token", + "expires_at": legacyExpiry.Format(time.RFC3339), + "provider": "antigravity", + "auth_method": "oauth", + "project_id": "legacy-project", + }, + }, + } + data, err := json.Marshal(legacyStore) + require.NoError(t, err) + + authPath := filepath.Join(tmpDir, ".picoclaw", "auth.json") + require.NoError(t, os.MkdirAll(filepath.Dir(authPath), 0o755)) + require.NoError(t, os.WriteFile(authPath, data, 0o600)) + + refreshedExpiry := time.Date(2026, 4, 16, 12, 30, 0, 0, time.UTC) + err = pkgauth.SetCredential("google-antigravity", &pkgauth.AuthCredential{ + AccessToken: "fresh-token", + ExpiresAt: refreshedExpiry, + Provider: "google-antigravity", + AuthMethod: "oauth", + ProjectID: "fresh-project", + }) + require.NoError(t, err) + + output := captureAuthStdout(t, func() { + require.NoError(t, authStatusCmd()) + }) + + assert.Contains(t, output, "\nAuthenticated Providers:") + assert.Contains(t, output, "\n google-antigravity:\n") + assert.NotContains(t, output, "\n antigravity:\n") + assert.Contains(t, output, " Project: fresh-project") + assert.Contains(t, output, " Expires: 2026-04-16 12:30") + assert.Equal(t, 1, strings.Count(output, ":\n Method: oauth")) +} diff --git a/pkg/auth/store.go b/pkg/auth/store.go index dfea11df4..0e6567a03 100644 --- a/pkg/auth/store.go +++ b/pkg/auth/store.go @@ -4,6 +4,7 @@ import ( "encoding/json" "os" "path/filepath" + "strings" "time" "github.com/sipeed/picoclaw/pkg/config" @@ -25,6 +26,11 @@ type AuthStore struct { Credentials map[string]*AuthCredential `json:"credentials"` } +const ( + providerGoogleAntigravity = "google-antigravity" + providerAntigravityAlias = "antigravity" +) + func (c *AuthCredential) IsExpired() bool { if c.ExpiresAt.IsZero() { return false @@ -43,6 +49,125 @@ func authFilePath() string { return filepath.Join(config.GetHome(), "auth.json") } +func canonicalProvider(provider string) string { + normalized := strings.ToLower(strings.TrimSpace(provider)) + switch normalized { + case providerAntigravityAlias: + return providerGoogleAntigravity + default: + return normalized + } +} + +func cloneCredential(cred *AuthCredential) *AuthCredential { + if cred == nil { + return nil + } + cp := *cred + return &cp +} + +func mergeCredentials(primary, secondary *AuthCredential) *AuthCredential { + if primary == nil { + return cloneCredential(secondary) + } + + merged := *primary + if secondary == nil { + return &merged + } + if merged.AccessToken == "" { + merged.AccessToken = secondary.AccessToken + } + if merged.RefreshToken == "" { + merged.RefreshToken = secondary.RefreshToken + } + if merged.AccountID == "" { + merged.AccountID = secondary.AccountID + } + if merged.ExpiresAt.IsZero() { + merged.ExpiresAt = secondary.ExpiresAt + } + if merged.Provider == "" { + merged.Provider = secondary.Provider + } + if merged.AuthMethod == "" { + merged.AuthMethod = secondary.AuthMethod + } + if merged.Email == "" { + merged.Email = secondary.Email + } + if merged.ProjectID == "" { + merged.ProjectID = secondary.ProjectID + } + + return &merged +} + +func shouldPreferCredential( + candidate *AuthCredential, + candidateCanonical bool, + current *AuthCredential, + currentCanonical bool, +) bool { + if candidate == nil { + return false + } + if current == nil { + return true + } + + switch { + case candidate.ExpiresAt.After(current.ExpiresAt): + return true + case current.ExpiresAt.After(candidate.ExpiresAt): + return false + case candidateCanonical != currentCanonical: + return candidateCanonical + default: + return false + } +} + +func normalizeStore(store *AuthStore) { + if store == nil { + return + } + if store.Credentials == nil { + store.Credentials = make(map[string]*AuthCredential) + return + } + + normalized := make(map[string]*AuthCredential, len(store.Credentials)) + canonicalFlags := make(map[string]bool, len(store.Credentials)) + + for provider, cred := range store.Credentials { + normalizedProvider := strings.ToLower(strings.TrimSpace(provider)) + canonical := canonicalProvider(provider) + normalizedCred := cloneCredential(cred) + if normalizedCred != nil { + normalizedCred.Provider = canonicalProvider(normalizedCred.Provider) + if normalizedCred.Provider == "" { + normalizedCred.Provider = canonical + } + } + + current := normalized[canonical] + currentCanonical := canonicalFlags[canonical] + candidateCanonical := normalizedProvider == canonical + + if shouldPreferCredential(normalizedCred, candidateCanonical, current, currentCanonical) { + normalized[canonical] = mergeCredentials(normalizedCred, current) + canonicalFlags[canonical] = candidateCanonical + continue + } + + normalized[canonical] = mergeCredentials(current, normalizedCred) + } + + store.Credentials = normalized +} + func LoadStore() (*AuthStore, error) { path := authFilePath() data, err := os.ReadFile(path) @@ -57,9 +182,7 @@ func LoadStore() (*AuthStore, error) { if err := json.Unmarshal(data, &store); err != nil { return nil, err } - if store.Credentials == nil { - store.Credentials = make(map[string]*AuthCredential) - } + normalizeStore(&store) return &store, nil } @@ -79,7 +202,7 @@ func GetCredential(provider string) (*AuthCredential, error) { if err != nil { return nil, err } - cred, ok := store.Credentials[provider] + cred, ok := store.Credentials[canonicalProvider(provider)] if !ok { return nil, nil } @@ -91,7 +214,17 @@ func SetCredential(provider string, cred *AuthCredential) error { if err != nil { return err } - store.Credentials[provider] = cred + + canonical := canonicalProvider(provider) + normalized := cloneCredential(cred) + if normalized != nil { + normalized.Provider = canonicalProvider(normalized.Provider) + if normalized.Provider == "" { + normalized.Provider = canonical + } + } + + store.Credentials[canonical] = normalized return SaveStore(store) } @@ -100,7 +233,7 @@ func DeleteCredential(provider string) error { if err != nil { return err } - delete(store.Credentials, provider) + delete(store.Credentials, canonicalProvider(provider)) return SaveStore(store) } diff --git a/pkg/auth/store_test.go b/pkg/auth/store_test.go index f6793cfce..578ed4ead 100644 --- a/pkg/auth/store_test.go +++ b/pkg/auth/store_test.go @@ -1,12 +1,24 @@ package auth import ( + "encoding/json" "os" "path/filepath" + "runtime" "testing" "time" + + "github.com/sipeed/picoclaw/pkg/config" ) +func setTestAuthHome(t *testing.T) string { + t.Helper() + + tmpDir := t.TempDir() + t.Setenv(config.EnvHome, filepath.Join(tmpDir, ".picoclaw")) + return tmpDir +} + func TestAuthCredentialIsExpired(t *testing.T) { tests := []struct { name string @@ -51,10 +63,7 @@ func TestAuthCredentialNeedsRefresh(t *testing.T) { } func TestStoreRoundtrip(t *testing.T) { - tmpDir := t.TempDir() - origHome := os.Getenv("HOME") - t.Setenv("HOME", tmpDir) - defer os.Setenv("HOME", origHome) + setTestAuthHome(t) cred := &AuthCredential{ AccessToken: "test-access-token", @@ -88,10 +97,7 @@ func TestStoreRoundtrip(t *testing.T) { } func TestStoreFilePermissions(t *testing.T) { - tmpDir := t.TempDir() - origHome := os.Getenv("HOME") - t.Setenv("HOME", tmpDir) - defer os.Setenv("HOME", origHome) + tmpDir := setTestAuthHome(t) cred := &AuthCredential{ AccessToken: "secret-token", @@ -108,16 +114,16 @@ func TestStoreFilePermissions(t *testing.T) { t.Fatalf("Stat() error: %v", err) } perm := info.Mode().Perm() + if runtime.GOOS == "windows" { + return + } if perm != 0o600 { t.Errorf("file permissions = %o, want 0600", perm) } } func TestStoreMultiProvider(t *testing.T) { - tmpDir := t.TempDir() - origHome := os.Getenv("HOME") - t.Setenv("HOME", tmpDir) - defer os.Setenv("HOME", origHome) + setTestAuthHome(t) openaiCred := &AuthCredential{AccessToken: "openai-token", Provider: "openai", AuthMethod: "oauth"} anthropicCred := &AuthCredential{AccessToken: "anthropic-token", Provider: "anthropic", AuthMethod: "token"} @@ -147,10 +153,7 @@ func TestStoreMultiProvider(t *testing.T) { } func TestDeleteCredential(t *testing.T) { - tmpDir := t.TempDir() - origHome := os.Getenv("HOME") - t.Setenv("HOME", tmpDir) - defer os.Setenv("HOME", origHome) + setTestAuthHome(t) cred := &AuthCredential{AccessToken: "to-delete", Provider: "openai", AuthMethod: "oauth"} if err := SetCredential("openai", cred); err != nil { @@ -171,10 +174,7 @@ func TestDeleteCredential(t *testing.T) { } func TestLoadStoreEmpty(t *testing.T) { - tmpDir := t.TempDir() - origHome := os.Getenv("HOME") - t.Setenv("HOME", tmpDir) - defer os.Setenv("HOME", origHome) + setTestAuthHome(t) store, err := LoadStore() if err != nil { @@ -187,3 +187,319 @@ func TestLoadStoreEmpty(t *testing.T) { t.Errorf("expected empty credentials, got %d", len(store.Credentials)) } } + +func TestGetCredentialCanonicalizesLegacyAntigravityProvider(t *testing.T) { + tmpDir := setTestAuthHome(t) + + expiresAt := time.Date(2026, 4, 16, 10, 0, 0, 0, time.UTC) + store := map[string]any{ + "credentials": map[string]any{ + "antigravity": map[string]any{ + "access_token": "legacy-token", + "expires_at": expiresAt.Format(time.RFC3339), + "provider": "antigravity", + "auth_method": "oauth", + "project_id": "project-1", + }, + }, + } + data, err := json.Marshal(store) + if err != nil { + t.Fatalf("json.Marshal() error: %v", err) + } + path := filepath.Join(tmpDir, ".picoclaw", "auth.json") + err = os.MkdirAll(filepath.Dir(path), 0o755) + if err != nil { + t.Fatalf("MkdirAll() error: %v", err) + } + err = os.WriteFile(path, data, 0o600) + if err != nil { + t.Fatalf("WriteFile() error: %v", err) + } + + cred, err := GetCredential("google-antigravity") + if err != nil { + t.Fatalf("GetCredential() error: %v", err) + } + if cred == nil { + t.Fatal("GetCredential() returned nil") + } + if cred.Provider != "google-antigravity" { + t.Fatalf("Provider = %q, want %q", cred.Provider, "google-antigravity") + } + if !cred.ExpiresAt.Equal(expiresAt) { + t.Fatalf("ExpiresAt = %v, want %v", cred.ExpiresAt, expiresAt) + } +} + +func TestLoadStoreMergesAntigravityAliasesPreferringNewerExpiry(t *testing.T) { + tmpDir := setTestAuthHome(t) + + legacyExpiry := time.Date(2026, 4, 16, 10, 0, 0, 0, time.UTC) + refreshedExpiry := time.Date(2026, 4, 16, 12, 0, 0, 0, time.UTC) + store := map[string]any{ + "credentials": map[string]any{ + "antigravity": map[string]any{ + "access_token": "legacy-token", + "refresh_token": "legacy-refresh", + "expires_at": legacyExpiry.Format(time.RFC3339), + "provider": "antigravity", + "auth_method": "oauth", + "email": "legacy@example.com", + }, + "google-antigravity": map[string]any{ + "access_token": "fresh-token", + "expires_at": refreshedExpiry.Format(time.RFC3339), + "provider": "google-antigravity", + "auth_method": "oauth", + "project_id": "project-2", + }, + }, + } + data, err := json.Marshal(store) + if err != nil { + t.Fatalf("json.Marshal() error: %v", err) + } + path := filepath.Join(tmpDir, ".picoclaw", "auth.json") + err = os.MkdirAll(filepath.Dir(path), 0o755) + if err != nil { + t.Fatalf("MkdirAll() error: %v", err) + } + err = os.WriteFile(path, data, 0o600) + if err != nil { + t.Fatalf("WriteFile() error: %v", err) + } + + loaded, err := LoadStore() + if err != nil { + t.Fatalf("LoadStore() error: %v", err) + } + if len(loaded.Credentials) != 1 { + t.Fatalf("credential count = %d, want 1", len(loaded.Credentials)) + } + + cred := loaded.Credentials["google-antigravity"] + if cred == nil { + t.Fatal("google-antigravity credential missing") + } + if cred.AccessToken != "fresh-token" { + t.Fatalf("AccessToken = %q, want %q", cred.AccessToken, "fresh-token") + } + if cred.RefreshToken != "legacy-refresh" { + t.Fatalf("RefreshToken = %q, want %q", cred.RefreshToken, "legacy-refresh") + } + if cred.Email != "legacy@example.com" { + t.Fatalf("Email = %q, want %q", cred.Email, "legacy@example.com") + } + if cred.ProjectID != "project-2" { + t.Fatalf("ProjectID = %q, want %q", cred.ProjectID, "project-2") + } + if !cred.ExpiresAt.Equal(refreshedExpiry) { + t.Fatalf("ExpiresAt = %v, want %v", cred.ExpiresAt, refreshedExpiry) + } +} + +func TestLoadStorePrefersCanonicalKeyWhenExpiryMatchesAlias(t *testing.T) { + tmpDir := setTestAuthHome(t) + + expiresAt := time.Date(2026, 4, 16, 12, 0, 0, 0, time.UTC) + store := map[string]any{ + "credentials": map[string]any{ + "antigravity": map[string]any{ + "access_token": "legacy-token", + "refresh_token": "legacy-refresh", + "expires_at": expiresAt.Format(time.RFC3339), + "provider": "antigravity", + "auth_method": "oauth", + "email": "legacy@example.com", + }, + " Google-Antigravity ": map[string]any{ + "access_token": "fresh-token", + "expires_at": expiresAt.Format(time.RFC3339), + "provider": " Google-Antigravity ", + "auth_method": "oauth", + "project_id": "project-2", + }, + }, + } + data, err := json.Marshal(store) + if err != nil { + t.Fatalf("json.Marshal() error: %v", err) + } + path := filepath.Join(tmpDir, ".picoclaw", "auth.json") + err = os.MkdirAll(filepath.Dir(path), 0o755) + if err != nil { + t.Fatalf("MkdirAll() error: %v", err) + } + err = os.WriteFile(path, data, 0o600) + if err != nil { + t.Fatalf("WriteFile() error: %v", err) + } + + loaded, err := LoadStore() + if err != nil { + t.Fatalf("LoadStore() error: %v", err) + } + if len(loaded.Credentials) != 1 { + t.Fatalf("credential count = %d, want 1", len(loaded.Credentials)) + } + + cred := loaded.Credentials["google-antigravity"] + if cred == nil { + t.Fatal("google-antigravity credential missing") + } + if cred.AccessToken != "fresh-token" { + t.Fatalf("AccessToken = %q, want %q", cred.AccessToken, "fresh-token") + } + if cred.RefreshToken != "legacy-refresh" { + t.Fatalf("RefreshToken = %q, want %q", cred.RefreshToken, "legacy-refresh") + } + if cred.Email != "legacy@example.com" { + t.Fatalf("Email = %q, want %q", cred.Email, "legacy@example.com") + } + if cred.ProjectID != "project-2" { + t.Fatalf("ProjectID = %q, want %q", cred.ProjectID, "project-2") + } +} + +func TestSetCredentialReplacesLegacyAntigravityEntry(t *testing.T) { + tmpDir := setTestAuthHome(t) + + legacyStore := map[string]any{ + "credentials": map[string]any{ + "antigravity": map[string]any{ + "access_token": "legacy-token", + "expires_at": time.Date(2026, 4, 16, 10, 0, 0, 0, time.UTC).Format(time.RFC3339), + "provider": "antigravity", + "auth_method": "oauth", + }, + }, + } + data, err := json.Marshal(legacyStore) + if err != nil { + t.Fatalf("json.Marshal() error: %v", err) + } + path := filepath.Join(tmpDir, ".picoclaw", "auth.json") + err = os.MkdirAll(filepath.Dir(path), 0o755) + if err != nil { + t.Fatalf("MkdirAll() error: %v", err) + } + err = os.WriteFile(path, data, 0o600) + if err != nil { + t.Fatalf("WriteFile() error: %v", err) + } + + refreshedExpiry := time.Date(2026, 4, 16, 12, 30, 0, 0, time.UTC) + err = SetCredential("google-antigravity", &AuthCredential{ + AccessToken: "fresh-token", + ExpiresAt: refreshedExpiry, + Provider: "google-antigravity", + AuthMethod: "oauth", + }) + if err != nil { + t.Fatalf("SetCredential() error: %v", err) + } + + loaded, err := LoadStore() + if err != nil { + t.Fatalf("LoadStore() error: %v", err) + } + if len(loaded.Credentials) != 1 { + t.Fatalf("credential count = %d, want 1", len(loaded.Credentials)) + } + + cred := loaded.Credentials["google-antigravity"] + if cred == nil { + t.Fatal("google-antigravity credential missing") + } + if cred.AccessToken != "fresh-token" { + t.Fatalf("AccessToken = %q, want %q", cred.AccessToken, "fresh-token") + } + if !cred.ExpiresAt.Equal(refreshedExpiry) { + t.Fatalf("ExpiresAt = %v, want %v", cred.ExpiresAt, refreshedExpiry) + } +} + +func TestDeleteCredentialRemovesLegacyAntigravityAlias(t *testing.T) { + tmpDir := setTestAuthHome(t) + + legacyStore := map[string]any{ + "credentials": map[string]any{ + "antigravity": map[string]any{ + "access_token": "legacy-token", + "provider": "antigravity", + "auth_method": "oauth", + }, + }, + } + data, err := json.Marshal(legacyStore) + if err != nil { + t.Fatalf("json.Marshal() error: %v", err) + } + path := filepath.Join(tmpDir, ".picoclaw", "auth.json") + err = os.MkdirAll(filepath.Dir(path), 0o755) + if err != nil { + t.Fatalf("MkdirAll() error: %v", err) + } + err = os.WriteFile(path, data, 0o600) + if err != nil { + t.Fatalf("WriteFile() error: %v", err) + } + + err = DeleteCredential(" google-antigravity ") + if err != nil { + t.Fatalf("DeleteCredential() error: %v", err) + } + + loaded, err := LoadStore() + if err != nil { + t.Fatalf("LoadStore() error: %v", err) + } + if len(loaded.Credentials) != 0 { + t.Fatalf("credential count = %d, want 0", len(loaded.Credentials)) + } +} + +func TestSetCredentialCanonicalizesTrimmedMixedCaseProvider(t *testing.T) { + setTestAuthHome(t) + + expiresAt := time.Date(2026, 4, 16, 13, 0, 0, 0, time.UTC) + if err := SetCredential(" AnTiGrAvItY ", &AuthCredential{ + AccessToken: "fresh-token", + ExpiresAt: expiresAt, + Provider: " AnTiGrAvItY ", + AuthMethod: "oauth", + }); err != nil { + t.Fatalf("SetCredential() error: %v", err) + } + + loaded, err := LoadStore() + if err != nil { + t.Fatalf("LoadStore() error: %v", err) + } + if len(loaded.Credentials) != 1 { + t.Fatalf("credential count = %d, want 1", len(loaded.Credentials)) + } + + cred := loaded.Credentials["google-antigravity"] + if cred == nil { + t.Fatal("google-antigravity credential missing") + } + if cred.Provider != "google-antigravity" { + t.Fatalf("Provider = %q, want %q", cred.Provider, "google-antigravity") + } + if !cred.ExpiresAt.Equal(expiresAt) { + t.Fatalf("ExpiresAt = %v, want %v", cred.ExpiresAt, expiresAt) + } + + got, err := GetCredential(" GoOgLe-AnTiGrAvItY ") + if err != nil { + t.Fatalf("GetCredential() error: %v", err) + } + if got == nil { + t.Fatal("GetCredential() returned nil") + } + if got.Provider != "google-antigravity" { + t.Fatalf("GetCredential provider = %q, want %q", got.Provider, "google-antigravity") + } +} From 6ca73112732d680590e097c4d7ef7dc0829511d0 Mon Sep 17 00:00:00 2001 From: Guoguo <16666742+imguoguo@users.noreply.github.com> Date: Tue, 21 Apr 2026 16:30:02 +0800 Subject: [PATCH 13/31] feat(agent): add context usage ring indicator and /context command (#2537) Add a context window usage indicator to the web chat UI and a /context slash command that works across all channels. Backend: - Add computeContextUsage() estimating history + system + tool tokens - Attach ContextUsage to outbound messages via the pico WebSocket protocol - Add /context command showing context stats as formatted text - Add EstimateSystemTokens() on ContextBuilder for system prompt estimation Frontend: - Add ContextUsageRing component (SVG ring + hover/tap popover) - Show usage percentage, token counts, and compression threshold - Hover on desktop (150ms leave delay), tap on mobile - "View Details" sends /context with 1s cooldown - i18n support (en/zh) for popover labels Co-authored-by: Claude Opus 4.6 --- pkg/agent/agent.go | 9 +- pkg/agent/agent_command.go | 18 ++ pkg/agent/agent_outbound.go | 8 +- pkg/agent/context.go | 31 ++++ pkg/agent/context_usage.go | 78 +++++++++ pkg/bus/types.go | 10 ++ pkg/channels/pico/pico.go | 19 ++- pkg/commands/builtin.go | 1 + pkg/commands/cmd_context.go | 42 +++++ pkg/commands/runtime.go | 10 ++ .../src/components/chat/chat-composer.tsx | 60 ++++--- .../src/components/chat/chat-page.tsx | 7 + .../components/chat/context-usage-ring.tsx | 161 ++++++++++++++++++ web/frontend/src/features/chat/controller.ts | 2 + web/frontend/src/features/chat/protocol.ts | 26 ++- web/frontend/src/hooks/use-pico-chat.ts | 3 +- web/frontend/src/i18n/locales/en.json | 2 + web/frontend/src/i18n/locales/zh.json | 2 + web/frontend/src/store/chat.ts | 8 + 19 files changed, 462 insertions(+), 35 deletions(-) create mode 100644 pkg/agent/context_usage.go create mode 100644 pkg/commands/cmd_context.go create mode 100644 web/frontend/src/components/chat/context-usage-ring.tsx diff --git a/pkg/agent/agent.go b/pkg/agent/agent.go index 0bbfde7ff..3c242eecb 100644 --- a/pkg/agent/agent.go +++ b/pkg/agent/agent.go @@ -527,10 +527,11 @@ func (al *AgentLoop) runAgentLoop( opts.Dispatch.ChatID(), opts.Dispatch.ReplyToMessageID(), ), - AgentID: agentID, - SessionKey: sessionKey, - Scope: scope, - Content: result.finalContent, + AgentID: agentID, + SessionKey: sessionKey, + Scope: scope, + Content: result.finalContent, + ContextUsage: computeContextUsage(agent, opts.Dispatch.SessionKey), }) } diff --git a/pkg/agent/agent_command.go b/pkg/agent/agent_command.go index f6b4ab5bc..277ef77cd 100644 --- a/pkg/agent/agent_command.go +++ b/pkg/agent/agent_command.go @@ -214,6 +214,24 @@ func (al *AgentLoop) buildCommandsRuntime( rt.AskSideQuestion = func(ctx context.Context, question string) (string, error) { return al.askSideQuestion(ctx, agent, opts, question) } + + rt.GetContextStats = func() *commands.ContextStats { + if opts == nil || agent.Sessions == nil { + return nil + } + usage := computeContextUsage(agent, opts.SessionKey) + if usage == nil { + return nil + } + history := agent.Sessions.GetHistory(opts.SessionKey) + return &commands.ContextStats{ + UsedTokens: usage.UsedTokens, + TotalTokens: usage.TotalTokens, + CompressAtTokens: usage.CompressAtTokens, + UsedPercent: usage.UsedPercent, + MessageCount: len(history), + } + } } return rt } diff --git a/pkg/agent/agent_outbound.go b/pkg/agent/agent_outbound.go index 906bea5d3..7e36e4ad8 100644 --- a/pkg/agent/agent_outbound.go +++ b/pkg/agent/agent_outbound.go @@ -60,10 +60,14 @@ func (al *AgentLoop) PublishResponseIfNeeded(ctx context.Context, channel, chatI return } - al.bus.PublishOutbound(ctx, bus.OutboundMessage{ + msg := bus.OutboundMessage{ Context: bus.NewOutboundContext(channel, chatID, ""), Content: response, - }) + } + if sessionKey != "" { + msg.ContextUsage = computeContextUsage(al.agentForSession(sessionKey), sessionKey) + } + al.bus.PublishOutbound(ctx, msg) logger.InfoCF("agent", "Published outbound response", map[string]any{ "channel": channel, diff --git a/pkg/agent/context.go b/pkg/agent/context.go index ecf5da3dc..1e5a75d92 100644 --- a/pkg/agent/context.go +++ b/pkg/agent/context.go @@ -11,6 +11,7 @@ import ( "strings" "sync" "time" + "unicode/utf8" "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/logger" @@ -210,6 +211,36 @@ func (cb *ContextBuilder) BuildSystemPromptWithCache() string { return prompt } +// EstimateSystemTokens estimates the token count of the full system message +// that would be sent to the LLM, mirroring the composition logic in BuildMessages. +// It includes: static prompt, dynamic context, active skills, and summary with +// wrapping prefixes and separators. This avoids needing all per-request parameters +// that BuildMessages requires (media, channel, chatID, sender, etc.). +func (cb *ContextBuilder) EstimateSystemTokens(summary string, activeSkills []string) int { + staticPrompt := cb.BuildSystemPromptWithCache() + + // Dynamic context is small and varies per request; use a representative estimate. + // Actual buildDynamicContext produces ~200-400 chars of time/runtime/session info. + const dynamicContextChars = 300 + + totalChars := utf8.RuneCountInString(staticPrompt) + dynamicContextChars + + if skillsText := cb.buildActiveSkillsContext(activeSkills); skillsText != "" { + totalChars += utf8.RuneCountInString(skillsText) + totalChars += 7 // separator \n\n---\n\n + } + + if summary != "" { + // Matches the CONTEXT_SUMMARY: prefix added in BuildMessages + const summaryPrefix = "CONTEXT_SUMMARY: The following is an approximate summary of prior conversation " + + "for reference only. It may be incomplete or outdated — always defer to explicit instructions.\n\n" + totalChars += utf8.RuneCountInString(summaryPrefix) + utf8.RuneCountInString(summary) + totalChars += 7 // separator + } + + return totalChars * 2 / 5 // same heuristic as tokenizer.EstimateMessageTokens +} + // InvalidateCache clears the cached system prompt. // Normally not needed because the cache auto-invalidates via mtime checks, // but this is useful for tests or explicit reload commands. diff --git a/pkg/agent/context_usage.go b/pkg/agent/context_usage.go new file mode 100644 index 000000000..39d4f3dee --- /dev/null +++ b/pkg/agent/context_usage.go @@ -0,0 +1,78 @@ +package agent + +import ( + "github.com/sipeed/picoclaw/pkg/bus" +) + +// computeContextUsage estimates current context window consumption for the +// given agent and session. Includes history, system prompt (with dynamic context, +// summary, and skills — mirroring BuildMessages composition), and tool definitions. +// The output reserve (MaxTokens) is not counted as "used" but reduces the +// effective budget, matching isOverContextBudget's compression trigger: +// +// compress when: history + system + tools + maxTokens > contextWindow +// equivalent to: history + system + tools > contextWindow - maxTokens +// +// Returns nil when the agent or session is unavailable. +func computeContextUsage(agent *AgentInstance, sessionKey string) *bus.ContextUsage { + if agent == nil || agent.Sessions == nil { + return nil + } + contextWindow := agent.ContextWindow + if contextWindow <= 0 { + return nil + } + + // History tokens + history := agent.Sessions.GetHistory(sessionKey) + historyTokens := 0 + for _, m := range history { + historyTokens += EstimateMessageTokens(m) + } + + // System message tokens: uses EstimateSystemTokens which mirrors + // the full system message composition in BuildMessages (static prompt, + // dynamic context, active skills, summary with wrapping prefix). + systemTokens := 0 + if agent.ContextBuilder != nil { + summary := agent.Sessions.GetSummary(sessionKey) + // Pass nil for active skills: skills are only injected when the user + // explicitly activates them via /use, which is rare. Using nil matches + // the common case and avoids over-counting all installed skills. + systemTokens = agent.ContextBuilder.EstimateSystemTokens(summary, nil) + } + + // Tool definition tokens + toolTokens := 0 + if agent.Tools != nil { + toolTokens = EstimateToolDefsTokens(agent.Tools.ToProviderDefs()) + } + + // Used = history + system (includes summary) + tools + usedTokens := historyTokens + systemTokens + toolTokens + + // Effective budget = contextWindow minus output reserve (maxTokens) + effectiveWindow := contextWindow - agent.MaxTokens + if effectiveWindow < 0 { + effectiveWindow = contextWindow + } + + // compressAt = effectiveWindow: aligns with isOverContextBudget's + // proactive trigger (msgTokens + toolTokens + maxTokens > contextWindow). + compressAt := effectiveWindow + + usedPercent := 0 + if compressAt > 0 { + usedPercent = usedTokens * 100 / compressAt + } + if usedPercent > 100 { + usedPercent = 100 + } + + return &bus.ContextUsage{ + UsedTokens: usedTokens, + TotalTokens: contextWindow, + CompressAtTokens: compressAt, + UsedPercent: usedPercent, + } +} diff --git a/pkg/bus/types.go b/pkg/bus/types.go index aa06ca173..953e69d9c 100644 --- a/pkg/bus/types.go +++ b/pkg/bus/types.go @@ -61,6 +61,15 @@ type OutboundScope struct { Values map[string]string `json:"values,omitempty"` } +// ContextUsage describes how much of the model's context window the current +// session consumes, and how far it is from triggering compression. +type ContextUsage struct { + UsedTokens int `json:"used_tokens"` + TotalTokens int `json:"total_tokens"` // model context window + CompressAtTokens int `json:"compress_at_tokens"` // threshold that triggers compression + UsedPercent int `json:"used_percent"` // 0-100 +} + type OutboundMessage struct { Channel string `json:"channel"` ChatID string `json:"chat_id"` @@ -70,6 +79,7 @@ type OutboundMessage struct { Scope *OutboundScope `json:"scope,omitempty"` Content string `json:"content"` ReplyToMessageID string `json:"reply_to_message_id,omitempty"` + ContextUsage *ContextUsage `json:"context_usage,omitempty"` } // MediaPart describes a single media attachment to send. diff --git a/pkg/channels/pico/pico.go b/pkg/channels/pico/pico.go index f998712c8..8b41023f0 100644 --- a/pkg/channels/pico/pico.go +++ b/pkg/channels/pico/pico.go @@ -262,10 +262,12 @@ func (c *PicoChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]stri } isThought := outboundMessageIsThought(msg) - outMsg := newMessage(TypeMessageCreate, map[string]any{ + payload := map[string]any{ PayloadKeyContent: msg.Content, PayloadKeyThought: isThought, - }) + } + setContextUsagePayload(payload, msg.ContextUsage) + outMsg := newMessage(TypeMessageCreate, payload) return nil, c.broadcastToSession(msg.ChatID, outMsg) } @@ -716,3 +718,16 @@ func validateInlineImageDataURL(mediaURL string) error { return nil } + +// setContextUsagePayload adds context window usage stats to a pico payload. +func setContextUsagePayload(payload map[string]any, u *bus.ContextUsage) { + if u == nil { + return + } + payload["context_usage"] = map[string]any{ + "used_tokens": u.UsedTokens, + "total_tokens": u.TotalTokens, + "compress_at_tokens": u.CompressAtTokens, + "used_percent": u.UsedPercent, + } +} diff --git a/pkg/commands/builtin.go b/pkg/commands/builtin.go index 5cf9425cb..a7e401bb8 100644 --- a/pkg/commands/builtin.go +++ b/pkg/commands/builtin.go @@ -15,6 +15,7 @@ func BuiltinDefinitions() []Definition { switchCommand(), checkCommand(), clearCommand(), + contextCommand(), subagentsCommand(), reloadCommand(), } diff --git a/pkg/commands/cmd_context.go b/pkg/commands/cmd_context.go new file mode 100644 index 000000000..55481662c --- /dev/null +++ b/pkg/commands/cmd_context.go @@ -0,0 +1,42 @@ +package commands + +import ( + "context" + "fmt" +) + +func contextCommand() Definition { + return Definition{ + Name: "context", + Description: "Show current session context and token usage", + Usage: "/context", + Handler: func(_ context.Context, req Request, rt *Runtime) error { + if rt == nil || rt.GetContextStats == nil { + return req.Reply(unavailableMsg) + } + stats := rt.GetContextStats() + if stats == nil { + return req.Reply("No active session context.") + } + return req.Reply(formatContextStats(stats)) + }, + } +} + +func formatContextStats(s *ContextStats) string { + remaining := s.CompressAtTokens - s.UsedTokens + if remaining < 0 { + remaining = 0 + } + usedWindowPercent := s.UsedTokens * 100 / max(s.TotalTokens, 1) + return fmt.Sprintf( + "Context usage \nMessages: %d \nUsed: ~%d / %d tokens (%d%%) \nCompress at: %d tokens \nCompression progress: %d%% \nRemaining: ~%d tokens", + s.MessageCount, + s.UsedTokens, + s.TotalTokens, + usedWindowPercent, + s.CompressAtTokens, + s.UsedPercent, + remaining, + ) +} diff --git a/pkg/commands/runtime.go b/pkg/commands/runtime.go index 69373f561..68c286dde 100644 --- a/pkg/commands/runtime.go +++ b/pkg/commands/runtime.go @@ -6,6 +6,15 @@ import ( "github.com/sipeed/picoclaw/pkg/config" ) +// ContextStats describes current session context window usage. +type ContextStats struct { + UsedTokens int + TotalTokens int // model context window + CompressAtTokens int // compression threshold + UsedPercent int // 0-100 + MessageCount int +} + // Runtime provides runtime dependencies to command handlers. It is constructed // per-request by the agent loop so that per-request state (like session scope) // can coexist with long-lived callbacks (like GetModelInfo). @@ -18,6 +27,7 @@ type Runtime struct { ListSkillNames func() []string GetEnabledChannels func() []string GetActiveTurn func() any // Returning any to avoid circular dependency with agent package + GetContextStats func() *ContextStats SwitchModel func(value string) (oldModel string, err error) SwitchChannel func(value string) error ClearHistory func() error diff --git a/web/frontend/src/components/chat/chat-composer.tsx b/web/frontend/src/components/chat/chat-composer.tsx index cb3016842..b3354cc33 100644 --- a/web/frontend/src/components/chat/chat-composer.tsx +++ b/web/frontend/src/components/chat/chat-composer.tsx @@ -3,6 +3,7 @@ import type { KeyboardEvent } from "react" import { useTranslation } from "react-i18next" import TextareaAutosize from "react-textarea-autosize" +import { ContextUsageRing } from "@/components/chat/context-usage-ring" import { Button } from "@/components/ui/button" import { Tooltip, @@ -10,7 +11,7 @@ import { TooltipTrigger, } from "@/components/ui/tooltip" import { cn } from "@/lib/utils" -import type { ChatAttachment } from "@/store/chat" +import type { ChatAttachment, ContextUsage } from "@/store/chat" export type ChatInputDisabledReason = | "gatewayUnknown" @@ -31,8 +32,10 @@ interface ChatComposerProps { onAddImages: () => void onRemoveAttachment: (index: number) => void onSend: () => void + onContextDetail?: () => void inputDisabledReason: ChatInputDisabledReason | null canSend: boolean + contextUsage?: ContextUsage } export function ChatComposer({ @@ -42,8 +45,10 @@ export function ChatComposer({ onAddImages, onRemoveAttachment, onSend, + onContextDetail, inputDisabledReason, canSend, + contextUsage, }: ChatComposerProps) { const { t } = useTranslation() const canInput = inputDisabledReason === null @@ -121,30 +126,35 @@ export function ChatComposer({
- {canInput ? ( - - - - - - - - {t("chat.sendHint")} - - - ) : null} +
+ {contextUsage && ( + + )} + {canInput ? ( + + + + + + + + {t("chat.sendHint")} + + + ) : null} +
diff --git a/web/frontend/src/components/chat/chat-page.tsx b/web/frontend/src/components/chat/chat-page.tsx index c117be0b7..cb109daf5 100644 --- a/web/frontend/src/components/chat/chat-page.tsx +++ b/web/frontend/src/components/chat/chat-page.tsx @@ -115,6 +115,7 @@ export function ChatPage() { connectionState, isTyping, activeSessionId, + contextUsage, sendMessage, switchSession, newChat, @@ -341,8 +342,14 @@ export function ChatPage() { onAddImages={handleAddImages} onRemoveAttachment={handleRemoveAttachment} onSend={handleSend} + onContextDetail={() => { + if (sendMessage({ content: "/context", attachments: [] })) { + setInput("") + } + }} inputDisabledReason={inputDisabledReason} canSend={canSubmit} + contextUsage={contextUsage} />
) diff --git a/web/frontend/src/components/chat/context-usage-ring.tsx b/web/frontend/src/components/chat/context-usage-ring.tsx new file mode 100644 index 000000000..4a32e617b --- /dev/null +++ b/web/frontend/src/components/chat/context-usage-ring.tsx @@ -0,0 +1,161 @@ +import { IconArrowRight } from "@tabler/icons-react" +import { useEffect, useRef, useState } from "react" +import { useTranslation } from "react-i18next" + +import type { ContextUsage } from "@/store/chat" + +interface ContextUsageRingProps { + usage: ContextUsage + onDetailClick?: () => void +} + +function formatTokens(n: number): string { + if (n >= 1000) return `${(n / 1000).toFixed(1)}k` + return String(n) +} + +export function ContextUsageRing({ + usage, + onDetailClick, +}: ContextUsageRingProps) { + const { t } = useTranslation() + const [intent, setIntent] = useState(false) // user wants open + const [visible, setVisible] = useState(false) // DOM mounted + const [animated, setAnimated] = useState(false) // CSS target state + const [cooldown, setCooldown] = useState(false) + const containerRef = useRef(null) + const timerRef = useRef>(null) + const hoverIntent = useRef>(null) + const closeTimer = useRef>(null) + + useEffect(() => { + if (intent) { + // Mount first, animate in on next frame + if (closeTimer.current) clearTimeout(closeTimer.current) + setVisible(true) + requestAnimationFrame(() => { + requestAnimationFrame(() => setAnimated(true)) + }) + } else if (visible) { + // Animate out, then unmount + setAnimated(false) + closeTimer.current = setTimeout(() => setVisible(false), 150) + } + }, [intent, visible]) + + useEffect(() => { + return () => { + if (timerRef.current) clearTimeout(timerRef.current) + if (hoverIntent.current) clearTimeout(hoverIntent.current) + if (closeTimer.current) clearTimeout(closeTimer.current) + } + }, []) + + const percent = Math.min(usage.used_percent, 100) + const radius = 8 + const circumference = 2 * Math.PI * radius + const offset = circumference - (percent / 100) * circumference + const barPercent = Math.min(percent, 100) + + const handleDetail = () => { + if (cooldown || !onDetailClick) return + setCooldown(true) + onDetailClick() + setIntent(false) + timerRef.current = setTimeout(() => setCooldown(false), 1000) + } + + // Desktop: hover to open, mouse leave to close (with small delay) + const handleMouseEnter = () => { + if (hoverIntent.current) clearTimeout(hoverIntent.current) + setIntent(true) + } + + const handleMouseLeave = () => { + hoverIntent.current = setTimeout(() => setIntent(false), 150) + } + + // Mobile: tap to toggle (preventDefault suppresses synthetic mouseenter) + const handleTouchStart = (e: React.TouchEvent) => { + e.preventDefault() + setIntent((v) => !v) + } + + return ( +
+ + + {visible && ( +
+
+ +
+ + {t("chat.contextTitle")} + + + {formatTokens(usage.used_tokens)} /{" "} + {formatTokens(usage.compress_at_tokens)} + +
+
+
+
+ + +
+ )} +
+ ) +} diff --git a/web/frontend/src/features/chat/controller.ts b/web/frontend/src/features/chat/controller.ts index 183b1ba6f..489194421 100644 --- a/web/frontend/src/features/chat/controller.ts +++ b/web/frontend/src/features/chat/controller.ts @@ -392,6 +392,7 @@ export async function switchChatSession(sessionId: string) { messages: historyMessages, isTyping: false, hasHydratedActiveSession: true, + contextUsage: undefined, }) if (store.get(gatewayAtom).status === "running") { @@ -415,6 +416,7 @@ export async function newChatSession() { messages: [], isTyping: false, hasHydratedActiveSession: true, + contextUsage: undefined, }) if (store.get(gatewayAtom).status === "running") { diff --git a/web/frontend/src/features/chat/protocol.ts b/web/frontend/src/features/chat/protocol.ts index 717b42f84..cc2ef45e7 100644 --- a/web/frontend/src/features/chat/protocol.ts +++ b/web/frontend/src/features/chat/protocol.ts @@ -1,7 +1,11 @@ import { toast } from "sonner" import { normalizeUnixTimestamp } from "@/features/chat/state" -import { type AssistantMessageKind, updateChatStore } from "@/store/chat" +import { + type AssistantMessageKind, + type ContextUsage, + updateChatStore, +} from "@/store/chat" export interface PicoMessage { type: string @@ -21,6 +25,24 @@ function hasAssistantKindPayload(payload: Record): boolean { return typeof payload.thought === "boolean" } +function parseContextUsage( + payload: Record, +): ContextUsage | undefined { + const raw = payload.context_usage + if (!raw || typeof raw !== "object") return undefined + const obj = raw as Record + const used = Number(obj.used_tokens) + const total = Number(obj.total_tokens) + if (!Number.isFinite(used) || !Number.isFinite(total) || total <= 0) + return undefined + return { + used_tokens: used, + total_tokens: total, + compress_at_tokens: Number(obj.compress_at_tokens) || 0, + used_percent: Number(obj.used_percent) || 0, + } +} + export function handlePicoMessage( message: PicoMessage, expectedSessionId: string, @@ -36,6 +58,7 @@ export function handlePicoMessage( const content = (payload.content as string) || "" const messageId = (payload.message_id as string) || `pico-${Date.now()}` const kind = parseAssistantMessageKind(payload) + const contextUsage = parseContextUsage(payload) const timestamp = message.timestamp !== undefined && Number.isFinite(Number(message.timestamp)) @@ -54,6 +77,7 @@ export function handlePicoMessage( }, ], isTyping: false, + ...(contextUsage ? { contextUsage } : {}), })) break } diff --git a/web/frontend/src/hooks/use-pico-chat.ts b/web/frontend/src/hooks/use-pico-chat.ts index 3ac2e1613..02467bd60 100644 --- a/web/frontend/src/hooks/use-pico-chat.ts +++ b/web/frontend/src/hooks/use-pico-chat.ts @@ -55,7 +55,7 @@ export function formatMessageTime(dateRaw: number | string | Date): string { } export function usePicoChat() { - const { messages, connectionState, isTyping, activeSessionId } = + const { messages, connectionState, isTyping, activeSessionId, contextUsage } = useAtomValue(chatAtom) return { @@ -63,6 +63,7 @@ export function usePicoChat() { connectionState, isTyping, activeSessionId, + contextUsage, sendMessage: sendChatMessage, switchSession: switchChatSession, newChat: newChatSession, diff --git a/web/frontend/src/i18n/locales/en.json b/web/frontend/src/i18n/locales/en.json index d485000ff..f58aff66a 100644 --- a/web/frontend/src/i18n/locales/en.json +++ b/web/frontend/src/i18n/locales/en.json @@ -75,6 +75,8 @@ }, "sendMessage": "Send message", "sendHint": "Press Enter to send\nShift + Enter for a new line", + "contextTitle": "Context", + "contextDetail": "View Details", "attachImage": "Add images", "removeImage": "Remove image", "uploadedImage": "Uploaded image", diff --git a/web/frontend/src/i18n/locales/zh.json b/web/frontend/src/i18n/locales/zh.json index 81335a852..21d6e57cf 100644 --- a/web/frontend/src/i18n/locales/zh.json +++ b/web/frontend/src/i18n/locales/zh.json @@ -75,6 +75,8 @@ }, "sendMessage": "发送消息", "sendHint": "按 Enter 发送\nShift + Enter 换行", + "contextTitle": "上下文", + "contextDetail": "查看详情", "attachImage": "添加图片", "removeImage": "移除图片", "uploadedImage": "已上传图片", diff --git a/web/frontend/src/store/chat.ts b/web/frontend/src/store/chat.ts index c3b44f348..0a5edb646 100644 --- a/web/frontend/src/store/chat.ts +++ b/web/frontend/src/store/chat.ts @@ -22,6 +22,13 @@ export interface ChatMessage { attachments?: ChatAttachment[] } +export interface ContextUsage { + used_tokens: number + total_tokens: number + compress_at_tokens: number + used_percent: number +} + export type ConnectionState = | "disconnected" | "connecting" @@ -34,6 +41,7 @@ export interface ChatStoreState { isTyping: boolean activeSessionId: string hasHydratedActiveSession: boolean + contextUsage?: ContextUsage } type ChatStorePatch = Partial From 276f5425f0b8ac3e56891708ecda94625b4cd3c4 Mon Sep 17 00:00:00 2001 From: afjcjsbx Date: Wed, 15 Apr 2026 19:38:30 +0200 Subject: [PATCH 14/31] feat(commands): add MCP slash commands and tool details --- docs/channels/telegram/README.md | 4 + docs/guides/chat-apps.md | 4 +- docs/guides/configuration.md | 6 +- pkg/agent/agent_command.go | 208 +++++++++++++++++++++++++++++++ pkg/agent/agent_mcp.go | 6 + pkg/agent/agent_test.go | 69 ++++++++++ pkg/commands/builtin_test.go | 88 ++++++++++++- pkg/commands/cmd_list.go | 5 + pkg/commands/cmd_show.go | 6 + pkg/commands/handler_mcp.go | 106 ++++++++++++++++ pkg/commands/runtime.go | 23 ++++ 11 files changed, 521 insertions(+), 4 deletions(-) create mode 100644 pkg/commands/handler_mcp.go diff --git a/docs/channels/telegram/README.md b/docs/channels/telegram/README.md index 3b114ebef..a4138009e 100644 --- a/docs/channels/telegram/README.md +++ b/docs/channels/telegram/README.md @@ -44,6 +44,8 @@ Telegram auto-registers PicoClaw's top-level bot commands at startup, including Skill-related commands: - `/list skills` lists the installed skills visible to the current agent. +- `/list mcp` lists configured MCP servers and whether they are deferred/connected. +- `/show mcp ` lists the active tools for a connected MCP server. - `/use ` forces a skill for a single request. - `/use ` arms the skill for your next message in the same chat. - `/use clear` clears a pending skill override. @@ -52,6 +54,8 @@ Examples: ```text /list skills +/list mcp +/show mcp github /use git explain how to squash the last 3 commits /use git explain how to squash the last 3 commits diff --git a/docs/guides/chat-apps.md b/docs/guides/chat-apps.md index 140a659d1..62418f91a 100644 --- a/docs/guides/chat-apps.md +++ b/docs/guides/chat-apps.md @@ -67,9 +67,11 @@ Telegram command menu registration remains channel-local discovery UX; generic c If command registration fails (network/API transient errors), the channel still starts and PicoClaw retries registration in the background. -You can also manage installed skills directly from Telegram: +You can also inspect skills and MCP servers directly from Telegram: - `/list skills` +- `/list mcp` +- `/show mcp ` - `/use ` - `/use ` and then send the actual request in the next message - `/use clear` diff --git a/docs/guides/configuration.md b/docs/guides/configuration.md index ef3b14b24..826fa681d 100644 --- a/docs/guides/configuration.md +++ b/docs/guides/configuration.md @@ -97,9 +97,11 @@ export PICOCLAW_BUILTIN_SKILLS=/path/to/skills ### Using Skills From Chat Channels -Once skills are installed, you can inspect and force them directly from a chat channel: +Once skills are installed, and MCP servers are configured, you can inspect and force them directly from a chat channel: - `/list skills` shows the installed skill names available to the current agent. +- `/list mcp` shows configured MCP servers with enabled/deferred/connected status. +- `/show mcp ` shows the active tools exposed by a connected MCP server. - `/use ` forces a specific skill for a single request. - `/use ` arms that skill for your next message in the same chat session. - `/use clear` cancels a pending skill override created by `/use `. @@ -109,6 +111,8 @@ Examples: ```text /list skills +/list mcp +/show mcp github /use git explain how to squash the last 3 commits /btw remind me what we already decided about the deploy plan /use italiapersonalfinance diff --git a/pkg/agent/agent_command.go b/pkg/agent/agent_command.go index 277ef77cd..a2ed068d6 100644 --- a/pkg/agent/agent_command.go +++ b/pkg/agent/agent_command.go @@ -4,11 +4,15 @@ package agent import ( "context" + "encoding/json" "fmt" + "sort" "strings" "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/commands" + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/providers" ) @@ -133,6 +137,120 @@ func (al *AgentLoop) buildCommandsRuntime( Config: cfg, ListAgentIDs: registry.ListAgentIDs, ListDefinitions: al.cmdRegistry.Definitions, + ListMCPServers: func(ctx context.Context) []commands.MCPServerInfo { + if cfg == nil { + return nil + } + + if len(cfg.Tools.MCP.Servers) == 0 { + return nil + } + + if err := al.ensureMCPInitialized(ctx); err != nil { + logger.WarnCF("agent", "Failed to refresh MCP status for command", + map[string]any{ + "error": err.Error(), + }) + } + + connected := make(map[string]int) + if manager := al.mcp.getManager(); manager != nil { + for serverName, conn := range manager.GetServers() { + connected[serverName] = len(conn.Tools) + } + } + + servers := make([]commands.MCPServerInfo, 0, len(cfg.Tools.MCP.Servers)) + for serverName, serverCfg := range cfg.Tools.MCP.Servers { + toolCount, isConnected := connected[serverName] + servers = append(servers, commands.MCPServerInfo{ + Name: serverName, + Enabled: serverCfg.Enabled, + Deferred: serverIsDeferred(cfg.Tools.MCP.Discovery.Enabled, serverCfg), + Connected: isConnected, + ToolCount: toolCount, + }) + } + + sort.Slice(servers, func(i, j int) bool { + return strings.ToLower(servers[i].Name) < strings.ToLower(servers[j].Name) + }) + + return servers + }, + ListMCPTools: func(ctx context.Context, serverName string) ([]commands.MCPToolInfo, error) { + if cfg == nil { + return nil, fmt.Errorf("command unavailable: config not loaded") + } + + serverName = strings.TrimSpace(serverName) + if serverName == "" { + return nil, fmt.Errorf("server name is required") + } + + resolvedName := "" + var serverCfg config.MCPServerConfig + for name, candidate := range cfg.Tools.MCP.Servers { + if strings.EqualFold(name, serverName) { + resolvedName = name + serverCfg = candidate + break + } + } + if resolvedName == "" { + return nil, fmt.Errorf("MCP server '%s' is not configured", serverName) + } + if !serverCfg.Enabled { + return nil, fmt.Errorf("MCP server '%s' is configured but disabled", resolvedName) + } + if !cfg.Tools.IsToolEnabled("mcp") { + return nil, fmt.Errorf("MCP integration is disabled") + } + + if err := al.ensureMCPInitialized(ctx); err != nil { + logger.WarnCF("agent", "Failed to initialize MCP runtime for command", + map[string]any{ + "server": resolvedName, + "error": err.Error(), + }) + } + + manager := al.mcp.getManager() + if manager == nil { + return nil, fmt.Errorf("MCP server '%s' is configured but not connected", resolvedName) + } + + conn, ok := manager.GetServer(resolvedName) + if !ok { + return nil, fmt.Errorf("MCP server '%s' is configured but not connected", resolvedName) + } + + toolInfos := make([]commands.MCPToolInfo, 0, len(conn.Tools)) + for _, tool := range conn.Tools { + if tool == nil { + continue + } + name := strings.TrimSpace(tool.Name) + if name == "" { + continue + } + + description := strings.TrimSpace(tool.Description) + if description == "" { + description = fmt.Sprintf("MCP tool from %s server", resolvedName) + } + + toolInfos = append(toolInfos, commands.MCPToolInfo{ + Name: name, + Description: description, + Parameters: summarizeMCPToolParameters(tool.InputSchema), + }) + } + sort.Slice(toolInfos, func(i, j int) bool { + return toolInfos[i].Name < toolInfos[j].Name + }) + return toolInfos, nil + }, GetEnabledChannels: func() []string { if al.channelManager == nil { return nil @@ -236,6 +354,96 @@ func (al *AgentLoop) buildCommandsRuntime( return rt } +func summarizeMCPToolParameters(schema any) []commands.MCPToolParameterInfo { + schemaMap := normalizeMCPSchema(schema) + properties, ok := schemaMap["properties"].(map[string]any) + if !ok || len(properties) == 0 { + return nil + } + + required := make(map[string]struct{}) + switch raw := schemaMap["required"].(type) { + case []string: + for _, name := range raw { + required[name] = struct{}{} + } + case []any: + for _, value := range raw { + name, ok := value.(string) + if ok { + required[name] = struct{}{} + } + } + } + + names := make([]string, 0, len(properties)) + for name := range properties { + names = append(names, name) + } + sort.Strings(names) + + params := make([]commands.MCPToolParameterInfo, 0, len(names)) + for _, name := range names { + param := commands.MCPToolParameterInfo{Name: name} + if propMap, ok := properties[name].(map[string]any); ok { + if typeName, ok := propMap["type"].(string); ok { + param.Type = strings.TrimSpace(typeName) + } + if desc, ok := propMap["description"].(string); ok { + param.Description = strings.TrimSpace(desc) + } + } + _, param.Required = required[name] + params = append(params, param) + } + return params +} + +func normalizeMCPSchema(schema any) map[string]any { + if schema == nil { + return map[string]any{ + "type": "object", + "properties": map[string]any{}, + "required": []string{}, + } + } + + if schemaMap, ok := schema.(map[string]any); ok { + return schemaMap + } + + var jsonData []byte + switch raw := schema.(type) { + case json.RawMessage: + jsonData = raw + case []byte: + jsonData = raw + } + + if jsonData == nil { + var err error + jsonData, err = json.Marshal(schema) + if err != nil { + return map[string]any{ + "type": "object", + "properties": map[string]any{}, + "required": []string{}, + } + } + } + + var result map[string]any + if err := json.Unmarshal(jsonData, &result); err != nil { + return map[string]any{ + "type": "object", + "properties": map[string]any{}, + "required": []string{}, + } + } + + return result +} + func (al *AgentLoop) setPendingSkills(sessionKey string, skillNames []string) { sessionKey = strings.TrimSpace(sessionKey) if sessionKey == "" || len(skillNames) == 0 { diff --git a/pkg/agent/agent_mcp.go b/pkg/agent/agent_mcp.go index 21b6b9eb2..9a2e3d536 100644 --- a/pkg/agent/agent_mcp.go +++ b/pkg/agent/agent_mcp.go @@ -67,6 +67,12 @@ func (r *mcpRuntime) hasManager() bool { return r.manager != nil } +func (r *mcpRuntime) getManager() *mcp.Manager { + r.mu.Lock() + defer r.mu.Unlock() + return r.manager +} + // ensureMCPInitialized loads MCP servers/tools once so both Run() and direct // agent mode share the same initialization path. func (al *AgentLoop) ensureMCPInitialized(ctx context.Context) error { diff --git a/pkg/agent/agent_test.go b/pkg/agent/agent_test.go index 5cdac186c..2f8808dac 100644 --- a/pkg/agent/agent_test.go +++ b/pkg/agent/agent_test.go @@ -2269,6 +2269,75 @@ func TestProcessMessage_CommandOutcomes(t *testing.T) { } } +func TestProcessMessage_MCPCommandsHandledWithoutLLMCall(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "agent-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + deferred := true + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + ModelName: "test-model", + MaxTokens: 4096, + MaxToolIterations: 10, + }, + }, + Session: config.SessionConfig{ + Dimensions: []string{"chat"}, + }, + Tools: config.ToolsConfig{ + MCP: config.MCPConfig{ + ToolConfig: config.ToolConfig{Enabled: true}, + Discovery: config.ToolDiscoveryConfig{Enabled: true}, + Servers: map[string]config.MCPServerConfig{ + "github": { + Enabled: true, + Deferred: &deferred, + }, + }, + }, + }, + } + + msgBus := bus.NewMessageBus() + provider := &countingMockProvider{response: "LLM reply"} + al := NewAgentLoop(cfg, msgBus, provider) + helper := testHelper{al: al} + + baseContext := bus.InboundContext{ + Channel: "whatsapp", + ChatID: "chat1", + ChatType: "direct", + SenderID: "user1", + } + + listResp := helper.executeAndGetResponse(t, context.Background(), bus.InboundMessage{ + Context: baseContext, + Content: "/list mcp", + }) + if !strings.Contains(listResp, "- `github`") || !strings.Contains(listResp, "Deferred: yes") { + t.Fatalf("unexpected /list mcp reply: %q", listResp) + } + if provider.calls != 0 { + t.Fatalf("LLM should not be called for /list mcp, calls=%d", provider.calls) + } + + showResp := helper.executeAndGetResponse(t, context.Background(), bus.InboundMessage{ + Context: baseContext, + Content: "/show mcp github", + }) + if showResp != "MCP server 'github' is configured but not connected" { + t.Fatalf("unexpected /show mcp reply: %q", showResp) + } + if provider.calls != 0 { + t.Fatalf("LLM should not be called for /show mcp, calls=%d", provider.calls) + } +} + func TestProcessMessage_SwitchModelShowModelConsistency(t *testing.T) { tmpDir, err := os.MkdirTemp("", "agent-test-*") if err != nil { diff --git a/pkg/commands/builtin_test.go b/pkg/commands/builtin_test.go index 79e63d9b7..b42328c96 100644 --- a/pkg/commands/builtin_test.go +++ b/pkg/commands/builtin_test.go @@ -36,10 +36,10 @@ func TestBuiltinHelpHandler_ReturnsFormattedMessage(t *testing.T) { t.Fatalf("/help handler error: %v", err) } // Now uses auto-generated EffectiveUsage which includes agents - if !strings.Contains(reply, "/show [model|channel|agents]") { + if !strings.Contains(reply, "/show [model|channel|agents|mcp ]") { t.Fatalf("/help reply missing /show usage, got %q", reply) } - if !strings.Contains(reply, "/list [models|channels|agents|skills]") { + if !strings.Contains(reply, "/list [models|channels|agents|skills|mcp]") { t.Fatalf("/help reply missing /list usage, got %q", reply) } if !strings.Contains(reply, "/use ") { @@ -174,6 +174,90 @@ func TestBuiltinListSkills_UsesRuntimeSkillNames(t *testing.T) { } } +func TestBuiltinListMCP_UsesRuntimeServerStatus(t *testing.T) { + rt := &Runtime{ + ListMCPServers: func(context.Context) []MCPServerInfo { + return []MCPServerInfo{ + {Name: "filesystem", Enabled: true, Deferred: true, Connected: false}, + {Name: "github", Enabled: true, Deferred: false, Connected: true, ToolCount: 3}, + } + }, + } + defs := BuiltinDefinitions() + ex := NewExecutor(NewRegistry(defs), rt) + + var reply string + res := ex.Execute(context.Background(), Request{ + Text: "/list mcp", + Reply: func(text string) error { + reply = text + return nil + }, + }) + if res.Outcome != OutcomeHandled { + t.Fatalf("/list mcp: outcome=%v, want=%v", res.Outcome, OutcomeHandled) + } + if !strings.Contains(reply, "- `filesystem`\n Enabled: yes\n Deferred: yes\n Connected: no\n Active tools: unavailable") { + t.Fatalf("/list mcp reply=%q, want formatted filesystem block", reply) + } + if !strings.Contains(reply, "- `github`\n Enabled: yes\n Deferred: no\n Connected: yes\n Active tools: 3") { + t.Fatalf("/list mcp reply=%q, want formatted github block", reply) + } +} + +func TestBuiltinShowMCP_UsesRuntimeToolNames(t *testing.T) { + rt := &Runtime{ + ListMCPTools: func(_ context.Context, serverName string) ([]MCPToolInfo, error) { + if serverName != "github" { + t.Fatalf("serverName=%q, want github", serverName) + } + return []MCPToolInfo{ + { + Name: "create_issue", + Description: "Create a GitHub issue", + Parameters: []MCPToolParameterInfo{ + {Name: "body", Type: "string", Description: "Issue body"}, + {Name: "title", Type: "string", Description: "Issue title", Required: true}, + }, + }, + { + Name: "list_prs", + Description: "List open pull requests", + }, + }, nil + }, + } + defs := BuiltinDefinitions() + ex := NewExecutor(NewRegistry(defs), rt) + + var reply string + res := ex.Execute(context.Background(), Request{ + Text: "/show mcp github", + Reply: func(text string) error { + reply = text + return nil + }, + }) + if res.Outcome != OutcomeHandled { + t.Fatalf("/show mcp: outcome=%v, want=%v", res.Outcome, OutcomeHandled) + } + if !strings.Contains(reply, "Active MCP tools for `github`:\n- `create_issue`") { + t.Fatalf("/show mcp reply=%q, want tool header", reply) + } + if !strings.Contains(reply, "Description: Create a GitHub issue") { + t.Fatalf("/show mcp reply=%q, want description", reply) + } + if !strings.Contains(reply, " - `title` (string, required): Issue title") { + t.Fatalf("/show mcp reply=%q, want required parameter", reply) + } + if !strings.Contains(reply, " - `body` (string): Issue body") { + t.Fatalf("/show mcp reply=%q, want optional parameter", reply) + } + if !strings.Contains(reply, "- `list_prs`\n Description: List open pull requests\n Parameters: none") { + t.Fatalf("/show mcp reply=%q, want empty parameter block", reply) + } +} + func TestBuiltinUseCommand_PassthroughsToAgentLogic(t *testing.T) { defs := BuiltinDefinitions() ex := NewExecutor(NewRegistry(defs), nil) diff --git a/pkg/commands/cmd_list.go b/pkg/commands/cmd_list.go index 7186a6c25..c0021e55c 100644 --- a/pkg/commands/cmd_list.go +++ b/pkg/commands/cmd_list.go @@ -64,6 +64,11 @@ func listCommand() Definition { )) }, }, + { + Name: "mcp", + Description: "Configured MCP servers", + Handler: listMCPServersHandler(), + }, }, } } diff --git a/pkg/commands/cmd_show.go b/pkg/commands/cmd_show.go index c655e6880..cda7aaea7 100644 --- a/pkg/commands/cmd_show.go +++ b/pkg/commands/cmd_show.go @@ -33,6 +33,12 @@ func showCommand() Definition { Description: "Registered agents", Handler: agentsHandler(), }, + { + Name: "mcp", + Description: "Active tools for an MCP server", + ArgsUsage: "", + Handler: showMCPToolsHandler(), + }, }, } } diff --git a/pkg/commands/handler_mcp.go b/pkg/commands/handler_mcp.go new file mode 100644 index 000000000..c3dcc1147 --- /dev/null +++ b/pkg/commands/handler_mcp.go @@ -0,0 +1,106 @@ +package commands + +import ( + "context" + "fmt" + "strings" +) + +func listMCPServersHandler() Handler { + return func(ctx context.Context, req Request, rt *Runtime) error { + if rt == nil || rt.ListMCPServers == nil { + return req.Reply(unavailableMsg) + } + + servers := rt.ListMCPServers(ctx) + if len(servers) == 0 { + return req.Reply("No MCP servers configured") + } + + header := "Configured MCP Servers:" + if rt.Config != nil && !rt.Config.Tools.IsToolEnabled("mcp") { + header = "Configured MCP Servers (integration disabled):" + } + + lines := make([]string, 0, len(servers)*5+1) + lines = append(lines, header) + for idx, server := range servers { + if idx > 0 { + lines = append(lines, "") + } + lines = append(lines, fmt.Sprintf("- `%s`", server.Name)) + lines = append(lines, fmt.Sprintf(" Enabled: %s", yesNo(server.Enabled))) + lines = append(lines, fmt.Sprintf(" Deferred: %s", yesNo(server.Deferred))) + lines = append(lines, fmt.Sprintf(" Connected: %s", yesNo(server.Connected))) + if server.Connected { + lines = append(lines, fmt.Sprintf(" Active tools: %d", server.ToolCount)) + continue + } + lines = append(lines, " Active tools: unavailable") + } + + return req.Reply(strings.Join(lines, "\n")) + } +} + +func showMCPToolsHandler() Handler { + return func(ctx context.Context, req Request, rt *Runtime) error { + if rt == nil || rt.ListMCPTools == nil { + return req.Reply(unavailableMsg) + } + + serverName := nthToken(req.Text, 2) + if serverName == "" { + return req.Reply("Usage: /show mcp ") + } + + tools, err := rt.ListMCPTools(ctx, serverName) + if err != nil { + return req.Reply(err.Error()) + } + if len(tools) == 0 { + return req.Reply(fmt.Sprintf("MCP server '%s' has no active tools", serverName)) + } + + lines := make([]string, 0, len(tools)*6+1) + lines = append(lines, fmt.Sprintf("Active MCP tools for `%s`:", serverName)) + for idx, tool := range tools { + if idx > 0 { + lines = append(lines, "") + } + lines = append(lines, fmt.Sprintf("- `%s`", tool.Name)) + lines = append(lines, fmt.Sprintf(" Description: %s", tool.Description)) + if len(tool.Parameters) == 0 { + lines = append(lines, " Parameters: none") + continue + } + + lines = append(lines, " Parameters:") + for _, param := range tool.Parameters { + line := fmt.Sprintf(" - `%s`", param.Name) + if param.Type != "" { + line += fmt.Sprintf(" (%s", param.Type) + if param.Required { + line += ", required" + } + line += ")" + } else if param.Required { + line += " (required)" + } + if param.Description != "" { + line += ": " + param.Description + } + lines = append(lines, line) + } + } + + return req.Reply(strings.Join(lines, "\n")) + } +} + +func yesNo(v bool) string { + if v { + return "yes" + } + return "no" +} diff --git a/pkg/commands/runtime.go b/pkg/commands/runtime.go index 68c286dde..c17b7cf1c 100644 --- a/pkg/commands/runtime.go +++ b/pkg/commands/runtime.go @@ -6,6 +6,27 @@ import ( "github.com/sipeed/picoclaw/pkg/config" ) +type MCPServerInfo struct { + Name string + Enabled bool + Deferred bool + Connected bool + ToolCount int +} + +type MCPToolParameterInfo struct { + Name string + Type string + Description string + Required bool +} + +type MCPToolInfo struct { + Name string + Description string + Parameters []MCPToolParameterInfo +} + // ContextStats describes current session context window usage. type ContextStats struct { UsedTokens int @@ -25,6 +46,8 @@ type Runtime struct { ListAgentIDs func() []string ListDefinitions func() []Definition ListSkillNames func() []string + ListMCPServers func(ctx context.Context) []MCPServerInfo + ListMCPTools func(ctx context.Context, serverName string) ([]MCPToolInfo, error) GetEnabledChannels func() []string GetActiveTurn func() any // Returning any to avoid circular dependency with agent package GetContextStats func() *ContextStats From e5a69600782b5aa9a3bf4d7bfd6e272fce0ba69d Mon Sep 17 00:00:00 2001 From: afjcjsbx Date: Wed, 15 Apr 2026 19:48:44 +0200 Subject: [PATCH 15/31] fix lint --- pkg/commands/builtin_test.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/pkg/commands/builtin_test.go b/pkg/commands/builtin_test.go index b42328c96..efd27fa00 100644 --- a/pkg/commands/builtin_test.go +++ b/pkg/commands/builtin_test.go @@ -197,10 +197,12 @@ func TestBuiltinListMCP_UsesRuntimeServerStatus(t *testing.T) { if res.Outcome != OutcomeHandled { t.Fatalf("/list mcp: outcome=%v, want=%v", res.Outcome, OutcomeHandled) } - if !strings.Contains(reply, "- `filesystem`\n Enabled: yes\n Deferred: yes\n Connected: no\n Active tools: unavailable") { + if !strings.Contains(reply, "- `filesystem`\n Enabled: yes\n Deferred: yes\n "+ + "Connected: no\n Active tools: unavailable") { t.Fatalf("/list mcp reply=%q, want formatted filesystem block", reply) } - if !strings.Contains(reply, "- `github`\n Enabled: yes\n Deferred: no\n Connected: yes\n Active tools: 3") { + if !strings.Contains(reply, "- `github`\n Enabled: yes\n Deferred: no\n "+ + "Connected: yes\n Active tools: 3") { t.Fatalf("/list mcp reply=%q, want formatted github block", reply) } } From 5a13616b6469ec722ecc71b43532225d38a31745 Mon Sep 17 00:00:00 2001 From: afjcjsbx Date: Tue, 21 Apr 2026 09:03:17 +0200 Subject: [PATCH 16/31] fix(mcp): surface MCP init failures to command handlers --- pkg/agent/agent_mcp.go | 1 + pkg/agent/agent_mcp_test.go | 46 +++++++++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+) diff --git a/pkg/agent/agent_mcp.go b/pkg/agent/agent_mcp.go index 9a2e3d536..251d32b58 100644 --- a/pkg/agent/agent_mcp.go +++ b/pkg/agent/agent_mcp.go @@ -106,6 +106,7 @@ func (al *AgentLoop) ensureMCPInitialized(ctx context.Context) error { } if err := mcpManager.LoadFromMCPConfig(ctx, al.cfg.Tools.MCP, workspacePath); err != nil { + al.mcp.setInitErr(fmt.Errorf("failed to load MCP servers: %w", err)) logger.WarnCF("agent", "Failed to load MCP servers, MCP tools will not be available", map[string]any{ "error": err.Error(), diff --git a/pkg/agent/agent_mcp_test.go b/pkg/agent/agent_mcp_test.go index 1c810f003..b68fcc2c1 100644 --- a/pkg/agent/agent_mcp_test.go +++ b/pkg/agent/agent_mcp_test.go @@ -9,6 +9,7 @@ package agent import ( "context" "errors" + "strings" "testing" "github.com/sipeed/picoclaw/pkg/config" @@ -133,3 +134,48 @@ func TestServerIsDeferred(t *testing.T) { }) } } + +func TestEnsureMCPInitialized_LoadFailureSetsInitErr(t *testing.T) { + al, cfg, _, _, cleanup := newTestAgentLoop(t) + defer cleanup() + defer al.Close() + + cfg.Tools = config.ToolsConfig{ + MCP: config.MCPConfig{ + ToolConfig: config.ToolConfig{Enabled: true}, + Servers: map[string]config.MCPServerConfig{ + "broken": { + Enabled: true, + Command: "picoclaw-command-that-does-not-exist-for-mcp-tests", + }, + }, + }, + } + + err := al.ensureMCPInitialized(context.Background()) + if err == nil { + t.Fatal("ensureMCPInitialized() error = nil, want load failure") + } + if !strings.Contains(err.Error(), "failed to load MCP servers") { + t.Fatalf("ensureMCPInitialized() error = %q, want wrapped load failure", err.Error()) + } + + initErr := al.mcp.getInitErr() + if initErr == nil { + t.Fatal("getInitErr() = nil, want cached load failure") + } + if !strings.Contains(initErr.Error(), "failed to load MCP servers") { + t.Fatalf("getInitErr() = %q, want wrapped load failure", initErr.Error()) + } + if al.mcp.getManager() != nil { + t.Fatal("expected MCP manager to remain nil after load failure") + } + + err = al.ensureMCPInitialized(context.Background()) + if err == nil { + t.Fatal("second ensureMCPInitialized() error = nil, want cached load failure") + } + if !strings.Contains(err.Error(), "failed to load MCP servers") { + t.Fatalf("second ensureMCPInitialized() error = %q, want wrapped load failure", err.Error()) + } +} From 175682f152c8639dbf30758cb13eb07a3e0681ca Mon Sep 17 00:00:00 2001 From: afjcjsbx Date: Tue, 21 Apr 2026 10:44:47 +0200 Subject: [PATCH 17/31] chore: refresh PR mergeability From a5379d5fff0065d98829746199c7d35f05bb9315 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=82=86=E6=9C=88?= <2835601846@qq.com> Date: Tue, 21 Apr 2026 18:01:16 +0800 Subject: [PATCH 18/31] feat(feishu): Add group chat trigger and random emoji response frontend configuration (#2607) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 添加 group_trigger.mention_only 开关配置(群聊仅提及时响应) - 添加 random_reaction_emoji 数组配置(自定义表情回应列表) - 更新中英文国际化翻译 --- .../channels/channel-forms/feishu-form.tsx | 40 +++++++++++++++++++ web/frontend/src/i18n/locales/en.json | 4 ++ web/frontend/src/i18n/locales/zh.json | 4 ++ 3 files changed, 48 insertions(+) diff --git a/web/frontend/src/components/channels/channel-forms/feishu-form.tsx b/web/frontend/src/components/channels/channel-forms/feishu-form.tsx index f44ca502d..ed49e29cf 100644 --- a/web/frontend/src/components/channels/channel-forms/feishu-form.tsx +++ b/web/frontend/src/components/channels/channel-forms/feishu-form.tsx @@ -8,6 +8,7 @@ import { import { asStringArray, parseAllowFromInput, + parseConservativeStringListInput, } from "@/components/channels/channel-array-utils" import { getSecretInputPlaceholder } from "@/components/channels/channel-config-fields" import { Field, KeyInput, SwitchCardField } from "@/components/shared-form" @@ -34,6 +35,13 @@ function asBool(value: unknown): boolean { return typeof value === "boolean" ? value : false } +function asRecord(value: unknown): Record { + if (value && typeof value === "object" && !Array.isArray(value)) { + return value as Record + } + return {} +} + export function FeishuForm({ config, onChange, @@ -43,6 +51,7 @@ export function FeishuForm({ arrayFieldResetVersion, }: FeishuFormProps) { const { t } = useTranslation() + const groupTriggerConfig = asRecord(config.group_trigger) return (
@@ -137,6 +146,37 @@ export function FeishuForm({
+ + + +
+ { + onChange("group_trigger", { + ...groupTriggerConfig, + mention_only: checked, + }) + }} + ariaLabel={t("channels.field.groupTriggerMentionOnly")} + /> +
+ + onChange("random_reaction_emoji", value)} + placeholder={t("channels.field.randomReactionEmojiPlaceholder")} + parser={parseConservativeStringListInput} + fieldPath="random_reaction_emoji" + registerFlusher={registerArrayFieldFlusher} + resetVersion={arrayFieldResetVersion} + /> +
+
) } diff --git a/web/frontend/src/i18n/locales/en.json b/web/frontend/src/i18n/locales/en.json index f58aff66a..cbeca2463 100644 --- a/web/frontend/src/i18n/locales/en.json +++ b/web/frontend/src/i18n/locales/en.json @@ -359,6 +359,9 @@ "placeholderText": "Placeholder Text", "groupTriggerMentionOnly": "Group Mention Only", "groupTriggerPrefixes": "Group Trigger Prefixes", + "groupTriggerPrefixesPlaceholder": "e.g. /, !, ?", + "randomReactionEmoji": "Random Reaction Emoji", + "randomReactionEmojiPlaceholder": "e.g. THUMBSUP, HEART, SMILE", "isLark": "Lark (International)", "allowFrom": "Allow From", "allowFromPlaceholder": "e.g. 123456, 789012", @@ -393,6 +396,7 @@ "placeholderEnabled": "Enable temporary placeholder messages before the final reply is sent.", "groupTriggerMentionOnly": "In group chats, respond only when the bot is mentioned.", "groupTriggerPrefixes": "Custom group-chat trigger prefixes. Add items one by one, or paste multiple values at once.", + "randomReactionEmoji": "PicoClaw adds emoji reactions to user messages to confirm receipt. Example: \"THUMBSUP\", \"HEART\", \"SMILE\". Leave empty to use the default \"Pin\" emoji.", "isLark": "Use Lark international domain (open.larksuite.com) instead of Feishu domain (open.feishu.cn).", "allowFrom": "Allowed user or group IDs. Add items one by one, or paste multiple values at once.", "allowOrigins": "Allowed origin domains. Add items one by one, or paste multiple values at once.", diff --git a/web/frontend/src/i18n/locales/zh.json b/web/frontend/src/i18n/locales/zh.json index 21d6e57cf..410604953 100644 --- a/web/frontend/src/i18n/locales/zh.json +++ b/web/frontend/src/i18n/locales/zh.json @@ -359,6 +359,9 @@ "placeholderText": "占位文案", "groupTriggerMentionOnly": "群聊仅提及时响应", "groupTriggerPrefixes": "群聊触发前缀", + "groupTriggerPrefixesPlaceholder": "例如 /, !, ?", + "randomReactionEmoji": "随机表情回应", + "randomReactionEmojiPlaceholder": "例如 THUMBSUP, HEART, SMILE", "isLark": "Lark(国际版)", "allowFrom": "允许来源", "allowFromPlaceholder": "例如 123456, 789012", @@ -393,6 +396,7 @@ "placeholderEnabled": "在最终回复发送前,先发送临时占位消息", "groupTriggerMentionOnly": "在群聊中仅当提及机器人时才响应", "groupTriggerPrefixes": "群聊触发前缀。可逐项添加,也支持一次粘贴多个值。", + "randomReactionEmoji": "PicoClaw 会对用户消息添加表情回复以确认已收到。例如:\"THUMBSUP\", \"HEART\", \"SMILE\"。留空则使用默认的 \"Pin\" 表情。", "isLark": "使用 Lark 国际版域名(open.larksuite.com)替代飞书域名(open.feishu.cn)", "allowFrom": "允许访问的用户或群组 ID。可逐项添加,也支持一次粘贴多个值。", "allowOrigins": "允许访问的来源域名。可逐项添加,也支持一次粘贴多个值。", From 71c877a67fa69c3b8c782144e4d0ff4893159929 Mon Sep 17 00:00:00 2001 From: wenjie Date: Tue, 21 Apr 2026 18:04:15 +0800 Subject: [PATCH 19/31] refactor(web): switch dashboard auth from tokens to passwords (#2608) - replace token-based launcher auth with password-based login and sessions - migrate legacy launcher_token values into bcrypt-backed password storage - add one-shot local auto-login bootstrap - update config UI, i18n strings, docs, and auth-related tests --- docs/guides/configuration.md | 11 +- docs/guides/configuration.zh.md | 11 +- docs/guides/docker.fr.md | 2 +- docs/guides/docker.ja.md | 2 +- docs/guides/docker.md | 2 +- docs/guides/docker.ms.md | 2 +- docs/guides/docker.pt-br.md | 2 +- docs/guides/docker.vi.md | 2 +- docs/guides/docker.zh.md | 2 +- web/README.md | 42 ++-- web/backend/api/auth.go | 92 +++----- web/backend/api/auth_test.go | 186 +++++++++++---- web/backend/api/launcher_config.go | 35 ++- web/backend/api/launcher_config_test.go | 22 +- web/backend/launcherconfig/config.go | 69 +----- web/backend/launcherconfig/config_test.go | 133 ++++++----- web/backend/launcherconfig/migration.go | 62 +++++ web/backend/launcherconfig/migration_test.go | 135 +++++++++++ web/backend/launcherconfig/password_store.go | 92 ++++++++ web/backend/main.go | 127 +++++++---- web/backend/main_test.go | 74 +++--- .../middleware/launcher_dashboard_auth.go | 195 +++++++++++----- .../launcher_dashboard_auth_test.go | 213 ++++++++++++------ web/backend/middleware/referrer_policy.go | 4 +- web/frontend/src/api/launcher-auth.ts | 22 +- web/frontend/src/api/system.ts | 1 - web/frontend/src/components/app-header.tsx | 29 +-- .../src/components/config/config-page.tsx | 98 ++++++-- .../src/components/config/config-sections.tsx | 39 +++- .../src/components/config/form-model.ts | 6 +- web/frontend/src/i18n/locales/en.json | 14 +- web/frontend/src/i18n/locales/zh.json | 14 +- web/frontend/src/routes/__root.tsx | 3 +- web/frontend/src/routes/launcher-login.tsx | 30 ++- 34 files changed, 1188 insertions(+), 585 deletions(-) create mode 100644 web/backend/launcherconfig/migration.go create mode 100644 web/backend/launcherconfig/migration_test.go create mode 100644 web/backend/launcherconfig/password_store.go diff --git a/docs/guides/configuration.md b/docs/guides/configuration.md index ef3b14b24..286b5726b 100644 --- a/docs/guides/configuration.md +++ b/docs/guides/configuration.md @@ -71,15 +71,16 @@ PicoClaw stores data in your configured workspace (default: `~/.picoclaw/workspa ### Web launcher dashboard -**picoclaw-launcher** serves a browser UI that requires sign-in first. By default, the **dashboard token** and **session signing key** are **generated in memory on each start** (a new random token after every restart). Set **`PICOCLAW_LAUNCHER_TOKEN`** to pin a fixed token for that process (startup logs do not print the secret when this env var is used). - -**Where to read the token**: In **console mode** (`-console`), it is printed at startup. In **tray / GUI mode**, use the tray action **Copy dashboard token**, and check **`$PICOCLAW_HOME/logs/launcher.log`** (typically `~/.picoclaw/logs/launcher.log` if `PICOCLAW_HOME` is unset) for the random token logged on startup. The login page shows hints that match how the launcher is running (including the absolute log path); **responses do not include the token itself**. +**picoclaw-launcher** serves a browser UI that requires password sign-in first. On first run, open `/launcher-setup` to create the dashboard password. Later manual sign-ins use `/launcher-login`. - **Config file**: Same directory as `config.json` (or the file pointed to by `PICOCLAW_CONFIG`). The launcher-specific file is `launcher-config.json`. -- **Sign-in and links**: Enter the token on the login page, or open with `?token=` when the browser is launched automatically. All responses include **`Referrer-Policy: no-referrer`** to reduce leakage of `token` via the `Referer` header. +- **Password storage**: On supported platforms, the password is stored as a bcrypt hash in `launcher-auth.db`. On platforms where the SQLite password store is unavailable, the bcrypt hash is stored in `launcher-config.json`. +- **Legacy migration**: Older `launcher_token` values are migrated once into password login and removed from saved launcher config. +- **Local auto-login**: When the launcher auto-opens a local browser after startup, it uses a one-shot loopback-only bootstrap endpoint to set the session cookie automatically. +- **Unsupported auth paths**: URL token login (`?token=...`), `PICOCLAW_LAUNCHER_TOKEN`, and `Authorization: Bearer` dashboard auth are no longer supported. - **Sign-out**: Use **`POST /api/auth/logout`** with **`Content-Type: application/json`** (body may be `{}`). Do not rely on a GET URL for logout (CSRF-safe pattern). - **Brute-force**: **`POST /api/auth/login`** is **rate-limited per client IP per minute** (HTTP 429 when exceeded). -- **Session lifetime**: The HttpOnly session cookie lasts about **7 days** by default; sign in again with the token after it expires. +- **Session lifetime**: The HttpOnly session cookie lasts about **31 days** by default, but sessions are invalidated when the launcher process restarts. ### Skill Sources diff --git a/docs/guides/configuration.zh.md b/docs/guides/configuration.zh.md index adbe77db0..adf8f5ffe 100644 --- a/docs/guides/configuration.zh.md +++ b/docs/guides/configuration.zh.md @@ -69,15 +69,16 @@ PicoClaw 将数据存储在您配置的工作区中(默认:`~/.picoclaw/work ### Web 启动器控制台 -用 **picoclaw-launcher** 打开浏览器控制台前需要先登录。**访问口令**与 **会话签名密钥**默认在**每次启动时在内存中生成**(重启后随机口令会变)。若设置环境变量 **`PICOCLAW_LAUNCHER_TOKEN`**,则该进程使用固定口令(启动日志中不会打印具体口令值)。 - -**到哪里找口令**:**控制台模式**(`-console`)请看启动时的终端输出;**托盘 / GUI 模式**可使用托盘菜单中的「复制控制台口令」,并在 **`$PICOCLAW_HOME/logs/launcher.log`**(未设置 `PICOCLAW_HOME` 时一般为 `~/.picoclaw/logs/launcher.log`)中查看本次启动写入的随机口令。登录页在未登录时会根据当前运行方式展示提示(含日志文件绝对路径等;**接口与页面均不会返回口令本身**)。 +用 **picoclaw-launcher** 打开浏览器控制台前需要先使用密码登录。首次启动时打开 `/launcher-setup` 创建 dashboard 登录密码;后续手动登录使用 `/launcher-login`。 - **配置文件**:与 `config.json` 同一目录(若设置了 `PICOCLAW_CONFIG`,则与它所指的文件同目录)。启动器专用文件名为 `launcher-config.json`。 -- **登录与链接**:在登录页输入口令;自动打开浏览器时可在 URL 上使用 `?token=`。全站响应携带 **`Referrer-Policy: no-referrer`**,减轻 `token` 经 `Referer` 头泄露的风险。 +- **密码存储**:支持的平台会把 bcrypt 后的密码哈希存入 `launcher-auth.db`。如果当前平台不支持 SQLite 密码存储,则把 bcrypt 哈希存入 `launcher-config.json`。 +- **旧配置迁移**:旧版 `launcher_token` 会一次性迁移为密码登录,并从保存后的 launcher 配置中移除。 +- **本地自动登录**:launcher 启动后自动打开本地浏览器时,会使用仅允许 loopback 访问的一次性引导入口自动设置会话 Cookie。 +- **不再支持的鉴权方式**:不再支持 URL token 登录(`?token=...`)、`PICOCLAW_LAUNCHER_TOKEN` 和 `Authorization: Bearer` dashboard 鉴权。 - **退出登录**:应使用 **`POST /api/auth/logout`**,且请求头为 **`Content-Type: application/json`**(请求体可为 `{}`),勿使用可被第三方页面触发的 GET 链接登出。 - **暴力尝试**:`POST /api/auth/login` 对同一远程地址有 **每分钟尝试次数上限**(超限返回 HTTP 429)。 -- **会话时长**:登录后的 HttpOnly 会话 Cookie 默认约 **7 天**有效,到期需重新用口令登录。 +- **会话时长**:登录后的 HttpOnly 会话 Cookie 默认约 **31 天**有效,但 launcher 进程重启后已有会话会失效。 ### 技能来源 (Skill Sources) diff --git a/docs/guides/docker.fr.md b/docs/guides/docker.fr.md index f8c821570..ed0d14cf3 100644 --- a/docs/guides/docker.fr.md +++ b/docs/guides/docker.fr.md @@ -45,7 +45,7 @@ docker compose -f docker/docker-compose.yml --profile launcher up -d Ouvrez http://localhost:18800 dans votre navigateur. Le launcher gère automatiquement le processus gateway. > [!WARNING] -> La console web ne prend pas encore en charge l'authentification. Évitez de l'exposer sur Internet public. +> La console web est protégée par un mot de passe de connexion au dashboard. Ne l'exposez pas à des réseaux non fiables ni à Internet public. ### Mode Agent (One-shot) diff --git a/docs/guides/docker.ja.md b/docs/guides/docker.ja.md index f5885e775..8fa5ae60c 100644 --- a/docs/guides/docker.ja.md +++ b/docs/guides/docker.ja.md @@ -45,7 +45,7 @@ docker compose -f docker/docker-compose.yml --profile launcher up -d ブラウザで http://localhost:18800 を開いてください。Launcher が Gateway プロセスを自動管理します。 > [!WARNING] -> Web コンソールはまだ認証をサポートしていません。公開インターネットに公開しないでください。 +> Web コンソールは dashboard ログインパスワードで保護されます。信頼できないネットワークや公開インターネットには公開しないでください。 ### Agent モード (ワンショット) diff --git a/docs/guides/docker.md b/docs/guides/docker.md index 3ccc7a2a7..270bced2e 100644 --- a/docs/guides/docker.md +++ b/docs/guides/docker.md @@ -48,7 +48,7 @@ docker compose -f docker/docker-compose.yml --profile launcher up -d Open http://localhost:18800 in your browser. The launcher manages the gateway process automatically. > [!WARNING] -> The web console uses a dashboard token (in-memory per run unless `PICOCLAW_LAUNCHER_TOKEN` is set). **Do not** expose the launcher to untrusted networks or the public internet. See [Web launcher dashboard](configuration.md#web-launcher-dashboard) in the Configuration Guide. +> The web console is protected by dashboard password login. **Do not** expose the launcher to untrusted networks or the public internet. See [Web launcher dashboard](configuration.md#web-launcher-dashboard) in the Configuration Guide. ### Agent Mode (One-shot) diff --git a/docs/guides/docker.ms.md b/docs/guides/docker.ms.md index 05725e195..7adab6759 100644 --- a/docs/guides/docker.ms.md +++ b/docs/guides/docker.ms.md @@ -44,7 +44,7 @@ docker compose -f docker/docker-compose.yml --profile launcher up -d Buka http://localhost:18800 dalam pelayar anda. Launcher mengurus proses gateway secara automatik. > [!WARNING] -> Konsol web belum menyokong autentikasi. Elakkan mendedahkannya ke internet awam. +> Konsol web dilindungi oleh kata laluan log masuk dashboard. Jangan dedahkannya kepada rangkaian tidak dipercayai atau internet awam. ### Mod Agent (One-shot) diff --git a/docs/guides/docker.pt-br.md b/docs/guides/docker.pt-br.md index 46d273bee..d7d55e753 100644 --- a/docs/guides/docker.pt-br.md +++ b/docs/guides/docker.pt-br.md @@ -45,7 +45,7 @@ docker compose -f docker/docker-compose.yml --profile launcher up -d Abra http://localhost:18800 no seu navegador. O launcher gerencia o processo do gateway automaticamente. > [!WARNING] -> O console web ainda não suporta autenticação. Evite expô-lo na internet pública. +> O console web é protegido por senha de login do dashboard. Não exponha o launcher a redes não confiáveis nem à internet pública. ### Modo Agent (One-shot) diff --git a/docs/guides/docker.vi.md b/docs/guides/docker.vi.md index 716c81544..05f1b3d68 100644 --- a/docs/guides/docker.vi.md +++ b/docs/guides/docker.vi.md @@ -45,7 +45,7 @@ docker compose -f docker/docker-compose.yml --profile launcher up -d Mở http://localhost:18800 trong trình duyệt. Launcher tự động quản lý tiến trình gateway. > [!WARNING] -> Web console chưa hỗ trợ xác thực. Tránh để lộ ra internet công cộng. +> Web console được bảo vệ bằng mật khẩu đăng nhập dashboard. Không để lộ launcher ra mạng không tin cậy hoặc internet công cộng. ### Chế Độ Agent (One-shot) diff --git a/docs/guides/docker.zh.md b/docs/guides/docker.zh.md index 521747d16..7c428d275 100644 --- a/docs/guides/docker.zh.md +++ b/docs/guides/docker.zh.md @@ -45,7 +45,7 @@ docker compose -f docker/docker-compose.yml --profile launcher up -d 在浏览器中打开 。Launcher 会自动管理 Gateway 进程。 > [!WARNING] -> Web 控制台通过 dashboard 令牌鉴权(默认每次启动在内存中生成;可用 `PICOCLAW_LAUNCHER_TOKEN` 固定)。**不要**将启动器暴露到不可信网络或公网。完整说明见 [配置指南](configuration.md) 中的「Web 启动器控制台」一节。 +> Web 控制台通过 dashboard 登录密码保护。**不要**将启动器暴露到不可信网络或公网。完整说明见 [配置指南](configuration.md) 中的「Web 启动器控制台」一节。 ### Agent 模式 (一次性运行) diff --git a/web/README.md b/web/README.md index 0bda4b421..2a57524e0 100644 --- a/web/README.md +++ b/web/README.md @@ -121,23 +121,18 @@ When a gateway process is started by the launcher, the launcher: ### Launcher Authentication -The dashboard is protected by a launcher access token. +The dashboard is protected by password login. -- If `PICOCLAW_LAUNCHER_TOKEN` is set, that token is used. -- Otherwise a random token is generated for each launcher process. -- The browser auto-open URL includes `?token=...` so local launches can sign in automatically. +- First run uses `/launcher-setup` to create the dashboard password. - Manual login uses `/launcher-login`. -- API clients may also authenticate with `Authorization: Bearer `. - -Where users can retrieve the token depends on launch mode: - -- Console mode: printed to stdout -- GUI mode: available through the tray menu on supported builds -- GUI mode without stdout: - - random per-run tokens are written to the launcher log - - default log path: `~/.picoclaw/logs/launcher.log` - - if `PICOCLAW_HOME` is set, use `$PICOCLAW_HOME/logs/launcher.log` - - env-pinned tokens are not reprinted there; the log only notes that `PICOCLAW_LAUNCHER_TOKEN` is in use +- Successful login sets an HttpOnly session cookie. +- Existing sessions are invalidated when the launcher process restarts; otherwise the browser cookie expires after 31 days. +- When the launcher auto-opens a local browser after startup, it uses a one-shot loopback-only bootstrap endpoint to set the session cookie automatically. +- On supported platforms, the password is stored as a bcrypt hash in `launcher-auth.db`. +- On platforms where the SQLite password store is unavailable, the launcher stores the bcrypt hash in `launcher-config.json`. +- Legacy `launcher_token` values are migrated once into password login and are removed from saved launcher config. +- `PICOCLAW_LAUNCHER_TOKEN` is deprecated and ignored; after upgrading from env-token auth, open `/launcher-setup` to create a password. +- URL token login and `Authorization: Bearer` dashboard auth are not supported. ### Network Exposure @@ -155,7 +150,7 @@ With `-public` or `public: true`, it listens on all interfaces: When public access is enabled: -- the launcher can still protect the dashboard with the access token +- the launcher still protects the dashboard with password login - optional `allowed_cidrs` can restrict which client IP ranges may connect - the gateway host is overridden so remote clients can still use the launcher-managed proxy paths @@ -336,19 +331,8 @@ web/ ### You have to sign in again after the launcher restarts Existing dashboard sessions do not survive launcher restarts. -That is expected: each launcher process generates a new signed session value, so old cookies become invalid. - -To make re-login easier, set a stable token: - -```bash -export PICOCLAW_LAUNCHER_TOKEN="replace-with-a-long-random-token" -``` - -Notes: - -- a stable token does not preserve the old cookie-based session by itself -- when the launcher opens the browser automatically, it appends `?token=...` and signs in again automatically -- if you reopen the dashboard manually, use the same stable token on `/launcher-login` +That is expected: each launcher process generates a new session value, so old cookies become invalid. +Sign in again with the dashboard password on `/launcher-login`. ### "Start Gateway" stays disabled diff --git a/web/backend/api/auth.go b/web/backend/api/auth.go index 3cfc3e20d..da07b76c0 100644 --- a/web/backend/api/auth.go +++ b/web/backend/api/auth.go @@ -12,9 +12,8 @@ import ( "github.com/sipeed/picoclaw/web/backend/middleware" ) -// PasswordStore is the interface for bcrypt-backed dashboard password persistence. -// Implemented by dashboardauth.Store; a nil value falls back to the legacy -// static-token comparison. +// PasswordStore is the interface for dashboard password persistence. +// Implemented by dashboardauth.Store and launcherconfig.PasswordStore. type PasswordStore interface { IsInitialized(ctx context.Context) (bool, error) SetPassword(ctx context.Context, plain string) error @@ -23,18 +22,13 @@ type PasswordStore interface { // LauncherAuthRouteOpts configures dashboard auth handlers. type LauncherAuthRouteOpts struct { - // DashboardToken is the fallback plaintext token used when PasswordStore is - // nil or not yet initialized (env-var / config-file source, and ?token= auto-login). - DashboardToken string - SessionCookie string - SecureCookie func(*http.Request) bool - // PasswordStore enables bcrypt-backed password persistence. When non-nil and - // initialized, web-form login verifies against the stored hash instead of - // the plaintext DashboardToken. + SessionCookie string + SecureCookie func(*http.Request) bool + // PasswordStore enables password login. It must be non-nil for auth to work. PasswordStore PasswordStore // StoreError holds the error returned when opening the password store. When - // non-nil and PasswordStore is nil, the auth endpoints surface a recovery - // message instead of an opaque 501/503. + // non-nil and PasswordStore is nil, auth endpoints fail closed with a + // recovery message. StoreError error } @@ -59,7 +53,6 @@ func RegisterLauncherAuthRoutes(mux *http.ServeMux, opts LauncherAuthRouteOpts) secure = middleware.DefaultLauncherDashboardSecureCookie } h := &launcherAuthHandlers{ - token: opts.DashboardToken, sessionCookie: opts.SessionCookie, secureCookie: secure, store: opts.PasswordStore, @@ -73,7 +66,6 @@ func RegisterLauncherAuthRoutes(mux *http.ServeMux, opts LauncherAuthRouteOpts) } type launcherAuthHandlers struct { - token string sessionCookie string secureCookie func(*http.Request) bool store PasswordStore @@ -81,29 +73,18 @@ type launcherAuthHandlers struct { loginLimit *loginRateLimiter } -func (h *launcherAuthHandlers) usesLegacyTokenAuth() bool { - return h.store == nil && h.storeErr == nil && h.token != "" -} - // isStoreInitialized safely queries the store. -// Returns (true, nil) when legacy token auth is active without a password store. -// Returns (false, nil) when no store/token fallback is configured. // Returns (false, err) on store errors — callers must treat this as a 5xx, not as // "uninitialized", to keep auth fail-closed. -// Exception: handleLogin swallows storeErr and falls back to token auth so -// that a corrupt DB does not lock out all access. func (h *launcherAuthHandlers) isStoreInitialized(ctx context.Context) (bool, error) { if h.store == nil { if h.storeErr != nil { return false, fmt.Errorf( "password store unavailable (%w); "+ - "to recover, stop the application, delete the database file and restart ", + "to recover, stop the application, reset dashboard password storage, and restart", h.storeErr) } - if h.usesLegacyTokenAuth() { - return true, nil - } - return false, nil + return false, fmt.Errorf("password store not configured") } return h.store.IsInitialized(ctx) } @@ -123,35 +104,25 @@ func (h *launcherAuthHandlers) handleLogin(w http.ResponseWriter, r *http.Reques return } in := strings.TrimSpace(body.Password) - var ok bool initialized, initErr := h.isStoreInitialized(r.Context()) if initErr != nil { - if h.storeErr != nil { - // Store failed to open at startup — token login remains available. - initialized = false - } else { - w.WriteHeader(http.StatusInternalServerError) - writeErrorf(w, "%v", initErr) - return - } + w.WriteHeader(http.StatusServiceUnavailable) + writeErrorf(w, "%v", initErr) + return + } + if !initialized { + w.WriteHeader(http.StatusConflict) + _, _ = w.Write([]byte(`{"error":"password has not been set"}`)) + return } - if initialized && h.store != nil { - // Bcrypt path: verify against the stored hash. - var err error - ok, err = h.store.VerifyPassword(r.Context(), in) - if err != nil { - w.WriteHeader(http.StatusInternalServerError) - writeErrorf(w, "password verification failed: %v", err) - return - } - } else { - // Fallback: constant-time compare against the plaintext token. - ok = len(in) == len(h.token) && - subtle.ConstantTimeCompare([]byte(in), []byte(h.token)) == 1 + ok, err := h.store.VerifyPassword(r.Context(), in) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + writeErrorf(w, "password verification failed: %v", err) + return } - if !ok { w.WriteHeader(http.StatusUnauthorized) _, _ = w.Write([]byte(`{"error":"invalid password"}`)) @@ -221,22 +192,19 @@ func (h *launcherAuthHandlers) handleStatus(w http.ResponseWriter, r *http.Reque // handleSetup sets or changes the dashboard password. // // Rules: -// - If the store has no password yet, the endpoint is open (no session required). +// - If the store has no password yet, anyone who can reach the setup endpoint +// may initialize the password. // - If a password is already set, the caller must hold a valid session cookie. func (h *launcherAuthHandlers) handleSetup(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") - if h.usesLegacyTokenAuth() { - w.WriteHeader(http.StatusNotImplemented) - _, _ = w.Write( - []byte(`{"error":"password setup is unavailable on this platform; use the dashboard token instead"}`), - ) - return - } - if h.store == nil { - w.WriteHeader(http.StatusNotImplemented) - _, _ = w.Write([]byte(`{"error":"password store not configured"}`)) + w.WriteHeader(http.StatusServiceUnavailable) + if h.storeErr != nil { + writeErrorf(w, "password store unavailable: %v", h.storeErr) + } else { + _, _ = w.Write([]byte(`{"error":"password store not configured"}`)) + } return } diff --git a/web/backend/api/auth_test.go b/web/backend/api/auth_test.go index 58f819ec6..f7f6037a0 100644 --- a/web/backend/api/auth_test.go +++ b/web/backend/api/auth_test.go @@ -2,7 +2,9 @@ package api import ( "bytes" + "context" "encoding/json" + "errors" "net/http" "net/http/httptest" "strings" @@ -12,17 +14,43 @@ import ( "github.com/sipeed/picoclaw/web/backend/middleware" ) -func TestLauncherAuthLoginAndStatus(t *testing.T) { - key := make([]byte, 32) - for i := range key { - key[i] = 0x55 +type fakePasswordStore struct { + initialized bool + password string + err error +} + +func (s *fakePasswordStore) IsInitialized(context.Context) (bool, error) { + if s.err != nil { + return false, s.err } - const tok = "dashboard-test-token-9" - sess := middleware.SessionCookieValue(key, tok) + return s.initialized, nil +} + +func (s *fakePasswordStore) SetPassword(_ context.Context, plain string) error { + if s.err != nil { + return s.err + } + s.password = plain + s.initialized = true + return nil +} + +func (s *fakePasswordStore) VerifyPassword(_ context.Context, plain string) (bool, error) { + if s.err != nil { + return false, s.err + } + return s.initialized && plain == s.password, nil +} + +func TestLauncherAuthLoginAndStatus(t *testing.T) { + const password = "dashboard-test-password" + const sess = "session-cookie-value" + store := &fakePasswordStore{initialized: true, password: password} mux := http.NewServeMux() RegisterLauncherAuthRoutes(mux, LauncherAuthRouteOpts{ - DashboardToken: tok, - SessionCookie: sess, + SessionCookie: sess, + PasswordStore: store, }) t.Run("status_unauthenticated", func(t *testing.T) { @@ -45,7 +73,7 @@ func TestLauncherAuthLoginAndStatus(t *testing.T) { t.Run("login_ok", func(t *testing.T) { rec := httptest.NewRecorder() - req := httptest.NewRequest(http.MethodPost, "/api/auth/login", strings.NewReader(`{"password":"`+tok+`"}`)) + req := httptest.NewRequest(http.MethodPost, "/api/auth/login", strings.NewReader(`{"password":"`+password+`"}`)) req.Header.Set("Content-Type", "application/json") req.RemoteAddr = "127.0.0.1:12345" mux.ServeHTTP(rec, req) @@ -75,14 +103,13 @@ func TestLauncherAuthLoginAndStatus(t *testing.T) { }) } -func TestLauncherAuthLegacyTokenFallbackReportsInitialized(t *testing.T) { - key := make([]byte, 32) - const tok = "legacy-fallback-token" - sess := middleware.SessionCookieValue(key, tok) +func TestLauncherAuthUninitializedStoreRequiresSetup(t *testing.T) { + const sess = "session-cookie-value" + store := &fakePasswordStore{} mux := http.NewServeMux() RegisterLauncherAuthRoutes(mux, LauncherAuthRouteOpts{ - DashboardToken: tok, - SessionCookie: sess, + SessionCookie: sess, + PasswordStore: store, }) rec := httptest.NewRecorder() @@ -98,29 +125,80 @@ func TestLauncherAuthLegacyTokenFallbackReportsInitialized(t *testing.T) { if err := json.NewDecoder(rec.Body).Decode(&body); err != nil { t.Fatal(err) } - if !body.Initialized { - t.Fatalf("initialized = false, want true in legacy token fallback mode") + if body.Initialized { + t.Fatalf("initialized = true, want false before setup") } if body.Authenticated { t.Fatalf("unexpected authenticated=true: %+v", body) } rec = httptest.NewRecorder() - req := httptest.NewRequest(http.MethodPost, "/api/auth/login", strings.NewReader(`{"password":"`+tok+`"}`)) + req := httptest.NewRequest(http.MethodPost, "/api/auth/login", strings.NewReader(`{"password":"not-set-yet"}`)) + req.Header.Set("Content-Type", "application/json") + mux.ServeHTTP(rec, req) + if rec.Code != http.StatusConflict { + t.Fatalf("login before setup code = %d body=%s", rec.Code, rec.Body.String()) + } + + rec = httptest.NewRecorder() + req = httptest.NewRequest( + http.MethodPost, + "/api/auth/setup", + strings.NewReader(`{"password":"12345678","confirm":"12345678"}`), + ) req.Header.Set("Content-Type", "application/json") mux.ServeHTTP(rec, req) if rec.Code != http.StatusOK { - t.Fatalf("login code = %d body=%s", rec.Code, rec.Body.String()) + t.Fatalf("setup code = %d body=%s", rec.Code, rec.Body.String()) + } + + rec = httptest.NewRecorder() + req = httptest.NewRequest(http.MethodPost, "/api/auth/login", strings.NewReader(`{"password":"12345678"}`)) + req.Header.Set("Content-Type", "application/json") + mux.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("login after setup code = %d body=%s", rec.Code, rec.Body.String()) } } -func TestLauncherAuthSetupRejectedInLegacyTokenFallback(t *testing.T) { - key := make([]byte, 32) - sess := middleware.SessionCookieValue(key, "legacy-token") +func TestLauncherAuthSetupRequiresSessionWhenInitialized(t *testing.T) { + const sess = "session-cookie-value" + store := &fakePasswordStore{initialized: true, password: "old-password"} mux := http.NewServeMux() RegisterLauncherAuthRoutes(mux, LauncherAuthRouteOpts{ - DashboardToken: "legacy-token", - SessionCookie: sess, + SessionCookie: sess, + PasswordStore: store, + }) + + body := strings.NewReader(`{"password":"new-password","confirm":"new-password"}`) + req := httptest.NewRequest(http.MethodPost, "/api/auth/setup", body) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("setup without session code = %d body=%s", rec.Code, rec.Body.String()) + } + + body = strings.NewReader(`{"password":"new-password","confirm":"new-password"}`) + req = httptest.NewRequest(http.MethodPost, "/api/auth/setup", body) + req.Header.Set("Content-Type", "application/json") + req.AddCookie(&http.Cookie{Name: middleware.LauncherDashboardCookieName, Value: sess}) + rec = httptest.NewRecorder() + mux.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("setup with session code = %d body=%s", rec.Code, rec.Body.String()) + } + if store.password != "new-password" { + t.Fatalf("password = %q, want new-password", store.password) + } +} + +func TestLauncherAuthInitialSetupAllowsDirectSetup(t *testing.T) { + store := &fakePasswordStore{} + mux := http.NewServeMux() + RegisterLauncherAuthRoutes(mux, LauncherAuthRouteOpts{ + SessionCookie: "session-cookie-value", + PasswordStore: store, }) rec := httptest.NewRecorder() @@ -131,18 +209,46 @@ func TestLauncherAuthSetupRejectedInLegacyTokenFallback(t *testing.T) { ) req.Header.Set("Content-Type", "application/json") mux.ServeHTTP(rec, req) - if rec.Code != http.StatusNotImplemented { - t.Fatalf("setup code = %d body=%s", rec.Code, rec.Body.String()) + if rec.Code != http.StatusOK { + t.Fatalf("setup without grant code = %d body=%s", rec.Code, rec.Body.String()) + } +} + +func TestLauncherAuthStoreUnavailableFailsClosed(t *testing.T) { + mux := http.NewServeMux() + RegisterLauncherAuthRoutes(mux, LauncherAuthRouteOpts{ + SessionCookie: "session-cookie-value", + StoreError: errors.New("open auth store"), + }) + + for _, tc := range []struct { + name string + method string + path string + body string + }{ + {name: "status", method: http.MethodGet, path: "/api/auth/status"}, + {name: "login", method: http.MethodPost, path: "/api/auth/login", body: `{"password":"password"}`}, + {name: "setup", method: http.MethodPost, path: "/api/auth/setup", body: `{"password":"12345678","confirm":"12345678"}`}, + } { + t.Run(tc.name, func(t *testing.T) { + rec := httptest.NewRecorder() + req := httptest.NewRequest(tc.method, tc.path, strings.NewReader(tc.body)) + if tc.body != "" { + req.Header.Set("Content-Type", "application/json") + } + mux.ServeHTTP(rec, req) + if rec.Code != http.StatusServiceUnavailable { + t.Fatalf("code = %d body=%s", rec.Code, rec.Body.String()) + } + }) } } func TestLauncherAuthLogoutRequiresPostAndJSON(t *testing.T) { - key := make([]byte, 32) - sess := middleware.SessionCookieValue(key, "tok") mux := http.NewServeMux() RegisterLauncherAuthRoutes(mux, LauncherAuthRouteOpts{ - DashboardToken: "tok", - SessionCookie: sess, + SessionCookie: "session-cookie-value", }) rec := httptest.NewRecorder() @@ -169,16 +275,14 @@ func TestLauncherAuthLogoutRequiresPostAndJSON(t *testing.T) { } func TestLauncherAuthLoginRateLimit(t *testing.T) { - key := make([]byte, 32) - const tok = "rate-limit-tok-xxxxxxxx" - sess := middleware.SessionCookieValue(key, tok) + store := &fakePasswordStore{initialized: true, password: "correct-password"} mux := http.NewServeMux() RegisterLauncherAuthRoutes(mux, LauncherAuthRouteOpts{ - DashboardToken: tok, - SessionCookie: sess, + SessionCookie: "session-cookie-value", + PasswordStore: store, }) - // 11 failing logins by wrong token; each consumes allow() slot after valid JSON. + // 11 failing logins by wrong password; each consumes allow() slot after valid JSON. wrongBody := `{"password":"wrong"}` for i := 0; i < loginAttemptsPerIP; i++ { rec := httptest.NewRecorder() @@ -231,12 +335,9 @@ func TestReferrerPolicyMiddleware(t *testing.T) { } func TestLauncherAuthLogoutEmptyBody(t *testing.T) { - key := make([]byte, 32) - sess := middleware.SessionCookieValue(key, "tok") mux := http.NewServeMux() RegisterLauncherAuthRoutes(mux, LauncherAuthRouteOpts{ - DashboardToken: "tok", - SessionCookie: sess, + SessionCookie: "session-cookie-value", }) rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPost, "/api/auth/logout", nil) @@ -249,12 +350,9 @@ func TestLauncherAuthLogoutEmptyBody(t *testing.T) { } func TestLauncherAuthLogoutRejectsTrailingJSON(t *testing.T) { - key := make([]byte, 32) - sess := middleware.SessionCookieValue(key, "tok") mux := http.NewServeMux() RegisterLauncherAuthRoutes(mux, LauncherAuthRouteOpts{ - DashboardToken: "tok", - SessionCookie: sess, + SessionCookie: "session-cookie-value", }) rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPost, "/api/auth/logout", strings.NewReader(`{}{}`)) diff --git a/web/backend/api/launcher_config.go b/web/backend/api/launcher_config.go index d16cd9267..92911157c 100644 --- a/web/backend/api/launcher_config.go +++ b/web/backend/api/launcher_config.go @@ -4,16 +4,14 @@ import ( "encoding/json" "fmt" "net/http" - "strings" "github.com/sipeed/picoclaw/web/backend/launcherconfig" ) type launcherConfigPayload struct { - Port int `json:"port"` - Public bool `json:"public"` - AllowedCIDRs []string `json:"allowed_cidrs"` - LauncherToken string `json:"launcher_token"` + Port int `json:"port"` + Public bool `json:"public"` + AllowedCIDRs []string `json:"allowed_cidrs"` } func (h *Handler) registerLauncherConfigRoutes(mux *http.ServeMux) { @@ -50,10 +48,9 @@ func (h *Handler) handleGetLauncherConfig(w http.ResponseWriter, r *http.Request w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(launcherConfigPayload{ - Port: cfg.Port, - Public: cfg.Public, - AllowedCIDRs: append([]string(nil), cfg.AllowedCIDRs...), - LauncherToken: cfg.LauncherToken, + Port: cfg.Port, + Public: cfg.Public, + AllowedCIDRs: append([]string(nil), cfg.AllowedCIDRs...), }) } @@ -64,12 +61,15 @@ func (h *Handler) handleUpdateLauncherConfig(w http.ResponseWriter, r *http.Requ return } - cfg := launcherconfig.Config{ - Port: payload.Port, - Public: payload.Public, - AllowedCIDRs: append([]string(nil), payload.AllowedCIDRs...), - LauncherToken: strings.TrimSpace(payload.LauncherToken), + cfg, err := h.loadLauncherConfig() + if err != nil { + http.Error(w, fmt.Sprintf("Failed to load launcher config: %v", err), http.StatusInternalServerError) + return } + cfg.Port = payload.Port + cfg.Public = payload.Public + cfg.AllowedCIDRs = append([]string(nil), payload.AllowedCIDRs...) + cfg.LegacyLauncherToken = "" if err := launcherconfig.Validate(cfg); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return @@ -82,9 +82,8 @@ func (h *Handler) handleUpdateLauncherConfig(w http.ResponseWriter, r *http.Requ w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(launcherConfigPayload{ - Port: cfg.Port, - Public: cfg.Public, - AllowedCIDRs: append([]string(nil), cfg.AllowedCIDRs...), - LauncherToken: cfg.LauncherToken, + Port: cfg.Port, + Public: cfg.Public, + AllowedCIDRs: append([]string(nil), cfg.AllowedCIDRs...), }) } diff --git a/web/backend/api/launcher_config_test.go b/web/backend/api/launcher_config_test.go index 4e0acf5d0..68ab1be42 100644 --- a/web/backend/api/launcher_config_test.go +++ b/web/backend/api/launcher_config_test.go @@ -4,6 +4,7 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "os" "path/filepath" "strings" "testing" @@ -34,9 +35,6 @@ func TestGetLauncherConfigUsesRuntimeFallback(t *testing.T) { if got.Port != 19999 || !got.Public { t.Fatalf("response = %+v, want port=19999 public=true", got) } - if got.LauncherToken != "" { - t.Fatalf("response launcher_token = %q, want empty", got.LauncherToken) - } if len(got.AllowedCIDRs) != 1 || got.AllowedCIDRs[0] != "192.168.1.0/24" { t.Fatalf("response allowed_cidrs = %v, want [192.168.1.0/24]", got.AllowedCIDRs) } @@ -44,6 +42,14 @@ func TestGetLauncherConfigUsesRuntimeFallback(t *testing.T) { func TestPutLauncherConfigPersists(t *testing.T) { configPath := filepath.Join(t.TempDir(), "config.json") + path := launcherconfig.PathForAppConfig(configPath) + if err := os.WriteFile( + path, + []byte(`{"port":18800,"public":false,"dashboard_password_hash":"saved-hash","launcher_token":"legacy-token"}`), + 0o600, + ); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } h := NewHandler(configPath) mux := http.NewServeMux() @@ -54,7 +60,7 @@ func TestPutLauncherConfigPersists(t *testing.T) { http.MethodPut, "/api/system/launcher-config", strings.NewReader( - `{"port":18080,"public":true,"allowed_cidrs":["192.168.1.0/24"],"launcher_token":"saved-token"}`, + `{"port":18080,"public":true,"allowed_cidrs":["192.168.1.0/24"]}`, ), ) req.Header.Set("Content-Type", "application/json") @@ -64,7 +70,6 @@ func TestPutLauncherConfigPersists(t *testing.T) { t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) } - path := launcherconfig.PathForAppConfig(configPath) cfg, err := launcherconfig.Load(path, launcherconfig.Default()) if err != nil { t.Fatalf("launcherconfig.Load() error = %v", err) @@ -72,8 +77,11 @@ func TestPutLauncherConfigPersists(t *testing.T) { if cfg.Port != 18080 || !cfg.Public { t.Fatalf("saved config = %+v, want port=18080 public=true", cfg) } - if cfg.LauncherToken != "saved-token" { - t.Fatalf("saved launcher_token = %q, want %q", cfg.LauncherToken, "saved-token") + if cfg.DashboardPasswordHash != "saved-hash" { + t.Fatalf("saved dashboard_password_hash = %q, want saved-hash", cfg.DashboardPasswordHash) + } + if cfg.LegacyLauncherToken != "" { + t.Fatalf("saved legacy launcher_token = %q, want empty", cfg.LegacyLauncherToken) } if len(cfg.AllowedCIDRs) != 1 || cfg.AllowedCIDRs[0] != "192.168.1.0/24" { t.Fatalf("saved config allowed_cidrs = %v, want [192.168.1.0/24]", cfg.AllowedCIDRs) diff --git a/web/backend/launcherconfig/config.go b/web/backend/launcherconfig/config.go index b6faa63fe..e3595738f 100644 --- a/web/backend/launcherconfig/config.go +++ b/web/backend/launcherconfig/config.go @@ -1,8 +1,6 @@ package launcherconfig import ( - "crypto/rand" - "encoding/base64" "encoding/json" "fmt" "net" @@ -16,31 +14,19 @@ const ( FileName = "launcher-config.json" // DefaultPort is the default port for the web launcher. DefaultPort = 18800 - // EnvLauncherToken overrides launcher dashboard token. - EnvLauncherToken = "PICOCLAW_LAUNCHER_TOKEN" // EnvLauncherHost overrides launcher listen host. EnvLauncherHost = "PICOCLAW_LAUNCHER_HOST" - - // dashboardSigningKeyBytes is the HMAC-SHA256 key size (256 bits). - dashboardSigningKeyBytes = 32 - // dashboardTokenEntropyBytes is CSPRNG length before base64 for the per-run dashboard token (256 bits). - dashboardTokenEntropyBytes = 32 -) - -type DashboardTokenSource string - -const ( - DashboardTokenSourceEnv DashboardTokenSource = "env" - DashboardTokenSourceConfig DashboardTokenSource = "config" - DashboardTokenSourceRandom DashboardTokenSource = "random" ) // Config stores launch parameters for the web backend service. type Config struct { - Port int `json:"port"` - Public bool `json:"public"` - AllowedCIDRs []string `json:"allowed_cidrs,omitempty"` - LauncherToken string `json:"launcher_token,omitempty"` + Port int `json:"port"` + Public bool `json:"public"` + AllowedCIDRs []string `json:"allowed_cidrs,omitempty"` + DashboardPasswordHash string `json:"dashboard_password_hash,omitempty"` + // LegacyLauncherToken is read only for one-time migration from the removed + // token login flow. Save always clears it so new configs do not persist it. + LegacyLauncherToken string `json:"launcher_token,omitempty"` } // Default returns default launcher settings. @@ -61,41 +47,6 @@ func Validate(cfg Config) error { return nil } -// EnsureDashboardSecrets returns signing key bytes and the effective dashboard token for this -// process. The signing key is freshly random each call; the token comes from -// EnvLauncherToken when set, otherwise launcher-config.json launcher_token, -// otherwise a new random token. -func EnsureDashboardSecrets( - cfg Config, -) (effectiveToken string, signingKey []byte, source DashboardTokenSource, err error) { - signingKey = make([]byte, dashboardSigningKeyBytes) - if _, err = rand.Read(signingKey); err != nil { - return "", nil, "", err - } - - effectiveToken = strings.TrimSpace(os.Getenv(EnvLauncherToken)) - if effectiveToken != "" { - return effectiveToken, signingKey, DashboardTokenSourceEnv, nil - } - effectiveToken = strings.TrimSpace(cfg.LauncherToken) - if effectiveToken != "" { - return effectiveToken, signingKey, DashboardTokenSourceConfig, nil - } - tok, genErr := randomDashboardToken() - if genErr != nil { - return "", nil, "", genErr - } - return tok, signingKey, DashboardTokenSourceRandom, nil -} - -func randomDashboardToken() (string, error) { - buf := make([]byte, dashboardTokenEntropyBytes) - if _, err := rand.Read(buf); err != nil { - return "", err - } - return base64.RawURLEncoding.EncodeToString(buf), nil -} - // NormalizeCIDRs trims entries, removes empty values, and deduplicates CIDRs. func NormalizeCIDRs(cidrs []string) []string { if len(cidrs) == 0 { @@ -144,7 +95,8 @@ func Load(path string, fallback Config) (Config, error) { return Config{}, err } cfg.AllowedCIDRs = NormalizeCIDRs(cfg.AllowedCIDRs) - cfg.LauncherToken = strings.TrimSpace(cfg.LauncherToken) + cfg.DashboardPasswordHash = strings.TrimSpace(cfg.DashboardPasswordHash) + cfg.LegacyLauncherToken = strings.TrimSpace(cfg.LegacyLauncherToken) if err := Validate(cfg); err != nil { return Config{}, err } @@ -154,7 +106,8 @@ func Load(path string, fallback Config) (Config, error) { // Save writes launcher settings to disk. func Save(path string, cfg Config) error { cfg.AllowedCIDRs = NormalizeCIDRs(cfg.AllowedCIDRs) - cfg.LauncherToken = strings.TrimSpace(cfg.LauncherToken) + cfg.DashboardPasswordHash = strings.TrimSpace(cfg.DashboardPasswordHash) + cfg.LegacyLauncherToken = "" if err := Validate(cfg); err != nil { return err } diff --git a/web/backend/launcherconfig/config_test.go b/web/backend/launcherconfig/config_test.go index 528116417..bb13ea115 100644 --- a/web/backend/launcherconfig/config_test.go +++ b/web/backend/launcherconfig/config_test.go @@ -1,11 +1,10 @@ package launcherconfig import ( + "context" "os" "path/filepath" "testing" - - "github.com/sipeed/picoclaw/web/backend/middleware" ) func TestLoadReturnsFallbackWhenMissing(t *testing.T) { @@ -25,10 +24,11 @@ func TestSaveAndLoadRoundTrip(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "launcher-config.json") want := Config{ - Port: 18080, - Public: true, - AllowedCIDRs: []string{"192.168.1.0/24", "10.0.0.0/8"}, - LauncherToken: "saved-launcher-token", + Port: 18080, + Public: true, + AllowedCIDRs: []string{"192.168.1.0/24", "10.0.0.0/8"}, + DashboardPasswordHash: "$2a$12$saved-dashboard-password-hash", + LegacyLauncherToken: "legacy-token-should-not-persist", } if err := Save(path, want); err != nil { @@ -41,8 +41,11 @@ func TestSaveAndLoadRoundTrip(t *testing.T) { if got.Port != want.Port || got.Public != want.Public { t.Fatalf("Load() = %+v, want %+v", got, want) } - if got.LauncherToken != want.LauncherToken { - t.Fatalf("launcher_token = %q, want %q", got.LauncherToken, want.LauncherToken) + if got.DashboardPasswordHash != want.DashboardPasswordHash { + t.Fatalf("dashboard_password_hash = %q, want %q", got.DashboardPasswordHash, want.DashboardPasswordHash) + } + if got.LegacyLauncherToken != "" { + t.Fatalf("legacy launcher_token = %q, want empty after Save", got.LegacyLauncherToken) } if len(got.AllowedCIDRs) != len(want.AllowedCIDRs) { t.Fatalf("allowed_cidrs len = %d, want %d", len(got.AllowedCIDRs), len(want.AllowedCIDRs)) @@ -62,6 +65,21 @@ func TestSaveAndLoadRoundTrip(t *testing.T) { } } +func TestLoadReadsLegacyLauncherTokenForMigration(t *testing.T) { + path := filepath.Join(t.TempDir(), "launcher-config.json") + if err := os.WriteFile(path, []byte(`{"port":18800,"launcher_token":"legacy-token"}`), 0o600); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + + got, err := Load(path, Default()) + if err != nil { + t.Fatalf("Load() error = %v", err) + } + if got.LegacyLauncherToken != "legacy-token" { + t.Fatalf("legacy launcher_token = %q, want legacy-token", got.LegacyLauncherToken) + } +} + func TestValidateRejectsInvalidPort(t *testing.T) { if err := Validate(Config{Port: 0, Public: false}); err == nil { t.Fatal("Validate() expected error for port 0") @@ -81,66 +99,6 @@ func TestValidateRejectsInvalidCIDR(t *testing.T) { } } -func TestEnsureDashboardSecrets_GeneratesEphemeral(t *testing.T) { - t.Setenv("PICOCLAW_LAUNCHER_TOKEN", "") - - tok, key, source, err := EnsureDashboardSecrets(Default()) - if err != nil { - t.Fatalf("EnsureDashboardSecrets() error = %v", err) - } - if source != DashboardTokenSourceRandom || tok == "" || len(key) != dashboardSigningKeyBytes { - t.Fatalf("unexpected first call: source=%q tok=%q keyLen=%d", source, tok, len(key)) - } - mac := middleware.SessionCookieValue(key, tok) - if mac == "" { - t.Fatal("empty session mac") - } - - tok2, key2, source2, err := EnsureDashboardSecrets(Default()) - if err != nil { - t.Fatalf("EnsureDashboardSecrets() second error = %v", err) - } - if source2 != DashboardTokenSourceRandom { - t.Fatalf("second call source = %q, want %q", source2, DashboardTokenSourceRandom) - } - if tok2 == tok { - t.Fatal("expected a new random dashboard token") - } - if string(key2) == string(key) { - t.Fatal("expected a new signing key") - } -} - -func TestEnsureDashboardSecrets_EnvOverridesGenerated(t *testing.T) { - t.Setenv("PICOCLAW_LAUNCHER_TOKEN", "env-only-token-override") - - tok, _, source, err := EnsureDashboardSecrets(Config{LauncherToken: "config-token"}) - if err != nil { - t.Fatalf("EnsureDashboardSecrets() error = %v", err) - } - if tok != "env-only-token-override" { - t.Fatalf("token = %q, want env value", tok) - } - if source != DashboardTokenSourceEnv { - t.Fatalf("source = %q, want %q", source, DashboardTokenSourceEnv) - } -} - -func TestEnsureDashboardSecrets_ConfigOverridesGenerated(t *testing.T) { - t.Setenv("PICOCLAW_LAUNCHER_TOKEN", "") - - tok, _, source, err := EnsureDashboardSecrets(Config{LauncherToken: "config-token"}) - if err != nil { - t.Fatalf("EnsureDashboardSecrets() error = %v", err) - } - if tok != "config-token" { - t.Fatalf("token = %q, want config value", tok) - } - if source != DashboardTokenSourceConfig { - t.Fatalf("source = %q, want %q", source, DashboardTokenSourceConfig) - } -} - func TestNormalizeCIDRs(t *testing.T) { got := NormalizeCIDRs([]string{" 192.168.1.0/24 ", "", "10.0.0.0/8", "192.168.1.0/24"}) want := []string{"192.168.1.0/24", "10.0.0.0/8"} @@ -153,3 +111,42 @@ func TestNormalizeCIDRs(t *testing.T) { } } } + +func TestPasswordStoreSetAndVerify(t *testing.T) { + path := filepath.Join(t.TempDir(), "launcher-config.json") + store := NewPasswordStore(path, Default()) + ctx := context.Background() + + initialized, err := store.IsInitialized(ctx) + if err != nil { + t.Fatalf("IsInitialized() error = %v", err) + } + if initialized { + t.Fatal("IsInitialized() = true, want false before SetPassword") + } + + if err = store.SetPassword(ctx, "dashboard-password"); err != nil { + t.Fatalf("SetPassword() error = %v", err) + } + initialized, err = store.IsInitialized(ctx) + if err != nil { + t.Fatalf("IsInitialized() after SetPassword error = %v", err) + } + if !initialized { + t.Fatal("IsInitialized() = false, want true after SetPassword") + } + ok, err := store.VerifyPassword(ctx, "dashboard-password") + if err != nil { + t.Fatalf("VerifyPassword() error = %v", err) + } + if !ok { + t.Fatal("VerifyPassword(correct) = false, want true") + } + ok, err = store.VerifyPassword(ctx, "wrong-password") + if err != nil { + t.Fatalf("VerifyPassword(wrong) error = %v", err) + } + if ok { + t.Fatal("VerifyPassword(wrong) = true, want false") + } +} diff --git a/web/backend/launcherconfig/migration.go b/web/backend/launcherconfig/migration.go new file mode 100644 index 000000000..66caa73ae --- /dev/null +++ b/web/backend/launcherconfig/migration.go @@ -0,0 +1,62 @@ +package launcherconfig + +import ( + "context" + "strings" +) + +var ( + loadConfigForMigration = Load + saveConfigForMigration = Save +) + +type dashboardPasswordStore interface { + IsInitialized(ctx context.Context) (bool, error) + SetPassword(ctx context.Context, plain string) error +} + +// LegacyLauncherTokenMigrationResult reports the outcome of converting a +// removed launcher_token value into the current password-based auth flow. +type LegacyLauncherTokenMigrationResult struct { + Migrated bool + // CleanupErr is non-nil when password migration succeeded (or was already in + // place) but removing launcher_token from launcher-config.json failed. + CleanupErr error +} + +// MigrateLegacyLauncherToken converts the removed launcher_token setting into +// the current password-login store, then removes launcher_token from config. +func MigrateLegacyLauncherToken( + ctx context.Context, + store dashboardPasswordStore, + launcherPath string, + fallback Config, +) (LegacyLauncherTokenMigrationResult, error) { + legacyToken := strings.TrimSpace(fallback.LegacyLauncherToken) + if legacyToken == "" || store == nil { + return LegacyLauncherTokenMigrationResult{}, nil + } + + result := LegacyLauncherTokenMigrationResult{} + initialized, err := store.IsInitialized(ctx) + if err != nil { + return result, err + } + if !initialized { + if err = store.SetPassword(ctx, legacyToken); err != nil { + return result, err + } + result.Migrated = true + } + result.CleanupErr = cleanupLegacyLauncherTokenConfig(launcherPath, fallback) + return result, nil +} + +func cleanupLegacyLauncherTokenConfig(launcherPath string, fallback Config) error { + cfg, err := loadConfigForMigration(launcherPath, fallback) + if err != nil { + return err + } + cfg.LegacyLauncherToken = "" + return saveConfigForMigration(launcherPath, cfg) +} diff --git a/web/backend/launcherconfig/migration_test.go b/web/backend/launcherconfig/migration_test.go new file mode 100644 index 000000000..c5c5fa2c9 --- /dev/null +++ b/web/backend/launcherconfig/migration_test.go @@ -0,0 +1,135 @@ +package launcherconfig + +import ( + "context" + "errors" + "os" + "path/filepath" + "testing" +) + +type stubMigrationPasswordStore struct { + initialized bool + password string +} + +func (s *stubMigrationPasswordStore) IsInitialized(context.Context) (bool, error) { + return s.initialized, nil +} + +func (s *stubMigrationPasswordStore) SetPassword(_ context.Context, plain string) error { + s.password = plain + s.initialized = true + return nil +} + +func TestMigrateLegacyLauncherToken(t *testing.T) { + dir := t.TempDir() + launcherPath := filepath.Join(dir, FileName) + cfg := Config{ + Port: DefaultPort, + LegacyLauncherToken: "legacy-password", + } + if err := os.WriteFile( + launcherPath, + []byte("{\n \"port\": 18800,\n \"launcher_token\": \"legacy-password\"\n}\n"), + 0o600, + ); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + + store := NewPasswordStore(launcherPath, Default()) + result, err := MigrateLegacyLauncherToken(context.Background(), store, launcherPath, cfg) + if err != nil { + t.Fatalf("MigrateLegacyLauncherToken() error = %v", err) + } + if !result.Migrated { + t.Fatal("MigrateLegacyLauncherToken().Migrated = false, want true") + } + if result.CleanupErr != nil { + t.Fatalf("MigrateLegacyLauncherToken().CleanupErr = %v, want nil", result.CleanupErr) + } + + loaded, err := Load(launcherPath, Default()) + if err != nil { + t.Fatalf("Load() error = %v", err) + } + if loaded.LegacyLauncherToken != "" { + t.Fatalf("legacy launcher token = %q, want empty", loaded.LegacyLauncherToken) + } + if loaded.DashboardPasswordHash == "" { + t.Fatal("dashboard password hash should be set after migration") + } + ok, err := store.VerifyPassword(context.Background(), "legacy-password") + if err != nil { + t.Fatalf("VerifyPassword() error = %v", err) + } + if !ok { + t.Fatal("VerifyPassword() = false, want true") + } +} + +func TestMigrateLegacyLauncherTokenCleanupFailureIsNonFatal(t *testing.T) { + dir := t.TempDir() + launcherPath := filepath.Join(dir, FileName) + cfg := Config{ + Port: DefaultPort, + LegacyLauncherToken: "legacy-password", + } + if err := os.WriteFile( + launcherPath, + []byte("{\n \"port\": 18800,\n \"launcher_token\": \"legacy-password\"\n}\n"), + 0o600, + ); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + + store := &stubMigrationPasswordStore{} + origSave := saveConfigForMigration + saveConfigForMigration = func(string, Config) error { + return errors.New("write launcher config") + } + t.Cleanup(func() { + saveConfigForMigration = origSave + }) + + result, err := MigrateLegacyLauncherToken(context.Background(), store, launcherPath, cfg) + if err != nil { + t.Fatalf("MigrateLegacyLauncherToken() error = %v, want nil", err) + } + if !result.Migrated { + t.Fatal("MigrateLegacyLauncherToken().Migrated = false, want true") + } + if result.CleanupErr == nil { + t.Fatal("MigrateLegacyLauncherToken().CleanupErr = nil, want non-nil") + } + if store.password != "legacy-password" { + t.Fatalf("password = %q, want legacy-password", store.password) + } + + loaded, err := Load(launcherPath, Default()) + if err != nil { + t.Fatalf("Load() error = %v", err) + } + if loaded.LegacyLauncherToken != "legacy-password" { + t.Fatalf( + "legacy launcher token = %q, want legacy-password after cleanup failure", + loaded.LegacyLauncherToken, + ) + } +} + +func TestMigrateLegacyLauncherTokenNoopWithoutToken(t *testing.T) { + launcherPath := filepath.Join(t.TempDir(), FileName) + store := NewPasswordStore(launcherPath, Default()) + result, err := MigrateLegacyLauncherToken(context.Background(), store, launcherPath, Default()) + if err != nil { + t.Fatalf("MigrateLegacyLauncherToken() error = %v", err) + } + if result.Migrated { + t.Fatal("MigrateLegacyLauncherToken().Migrated = true, want false") + } + if result.CleanupErr != nil { + t.Fatalf("MigrateLegacyLauncherToken().CleanupErr = %v, want nil", result.CleanupErr) + } +} diff --git a/web/backend/launcherconfig/password_store.go b/web/backend/launcherconfig/password_store.go new file mode 100644 index 000000000..3813384bb --- /dev/null +++ b/web/backend/launcherconfig/password_store.go @@ -0,0 +1,92 @@ +package launcherconfig + +import ( + "context" + "errors" + "strings" + "sync" + + "golang.org/x/crypto/bcrypt" +) + +const passwordBcryptCost = 12 + +// PasswordStore keeps the dashboard bcrypt hash in launcher-config.json. +// It is used on platforms where the SQLite-backed dashboard auth store is not +// available. +type PasswordStore struct { + path string + fallback Config + mu sync.Mutex +} + +// NewPasswordStore returns a config-backed password store. +func NewPasswordStore(path string, fallback Config) *PasswordStore { + return &PasswordStore{ + path: path, + fallback: fallback, + } +} + +// IsInitialized reports whether a dashboard password hash exists in config. +func (s *PasswordStore) IsInitialized(ctx context.Context) (bool, error) { + if err := ctx.Err(); err != nil { + return false, err + } + cfg, err := s.load() + if err != nil { + return false, err + } + return strings.TrimSpace(cfg.DashboardPasswordHash) != "", nil +} + +// SetPassword hashes plain with bcrypt and writes it to launcher-config.json. +func (s *PasswordStore) SetPassword(ctx context.Context, plain string) error { + if err := ctx.Err(); err != nil { + return err + } + if len([]rune(plain)) == 0 { + return errors.New("password must not be empty") + } + hash, err := bcrypt.GenerateFromPassword([]byte(plain), passwordBcryptCost) + if err != nil { + return err + } + + s.mu.Lock() + defer s.mu.Unlock() + + cfg, err := Load(s.path, s.fallback) + if err != nil { + return err + } + cfg.DashboardPasswordHash = string(hash) + cfg.LegacyLauncherToken = "" + return Save(s.path, cfg) +} + +// VerifyPassword returns true iff plain matches the stored bcrypt hash. +func (s *PasswordStore) VerifyPassword(ctx context.Context, plain string) (bool, error) { + if err := ctx.Err(); err != nil { + return false, err + } + cfg, err := s.load() + if err != nil { + return false, err + } + hash := strings.TrimSpace(cfg.DashboardPasswordHash) + if hash == "" { + return false, nil + } + err = bcrypt.CompareHashAndPassword([]byte(hash), []byte(plain)) + if errors.Is(err, bcrypt.ErrMismatchedHashAndPassword) { + return false, nil + } + return err == nil, err +} + +func (s *PasswordStore) load() (Config, error) { + s.mu.Lock() + defer s.mu.Unlock() + return Load(s.path, s.fallback) +} diff --git a/web/backend/main.go b/web/backend/main.go index e42558398..f5362174b 100644 --- a/web/backend/main.go +++ b/web/backend/main.go @@ -12,12 +12,12 @@ package main import ( + "context" "errors" "flag" "fmt" "net" "net/http" - "net/url" "os" "os/signal" "path/filepath" @@ -51,7 +51,6 @@ var ( servers []*http.Server serverAddr string // browserLaunchURL is opened by openBrowser() (auto-open + tray "open console"). - // Includes ?token= for same-machine dashboard login; keep serverAddr without secrets for other use. browserLaunchURL string apiHandler *api.Handler @@ -62,11 +61,34 @@ func shouldEnableLauncherFileLogging(enableConsole, debug bool) bool { return !enableConsole || debug } -func dashboardTokenConfigHelpPath(source launcherconfig.DashboardTokenSource, launcherPath string) string { - if source != launcherconfig.DashboardTokenSourceConfig { - return "" +func shouldEnableLocalAutoLogin(noBrowser bool, probeHost string) bool { + return !noBrowser && isLoopbackLaunchHost(probeHost) +} + +func isLoopbackLaunchHost(host string) bool { + host = strings.TrimSpace(host) + if strings.EqualFold(host, "localhost") { + return true } - return launcherPath + host = strings.Trim(host, "[]") + if i := strings.LastIndex(host, "%"); i >= 0 { + host = host[:i] + } + ip := net.ParseIP(host) + return ip != nil && ip.IsLoopback() +} + +func launcherBrowserLaunchSuffix( + needsSetup bool, + localAutoLogin *middleware.LauncherDashboardLocalAutoLogin, +) string { + if needsSetup { + return middleware.LauncherDashboardSetupPath + } + if localAutoLogin != nil { + return localAutoLogin.URLPath() + } + return "" } func resolveLauncherHostInput(flagHost string, explicitFlag bool, envHost string) (string, bool, error) { @@ -318,24 +340,6 @@ func firstNonEmpty(values ...string) string { return "" } -// maskSecret masks a secret for display. It always shows up to the first 3 -// runes. The last 4 runes are only appended when at least 5 runes remain -// hidden in the middle (i.e. string length >= 12), so an 8-char minimum -// password never exposes its tail. Strings of 3 chars or fewer are fully -// masked. -func maskSecret(s string) string { - runes := []rune(s) - n := len(runes) - const prefixLen, suffixLen, minHidden = 3, 4, 5 - if n < prefixLen+suffixLen+minHidden { - if n <= prefixLen { - return "**********" - } - return string(runes[:prefixLen]) + "**********" - } - return string(runes[:prefixLen]) + "**********" + string(runes[n-suffixLen:]) -} - func main() { port := flag.String("port", "18800", "Port to listen on") host := flag.String("host", "", "Host to listen on (overrides -public when set)") @@ -503,15 +507,11 @@ func main() { } listeners := openResult.Listeners - dashboardToken, dashboardSigningKey, _, dashErr := launcherconfig.EnsureDashboardSecrets( - launcherCfg, - ) + dashboardSessionCookie, dashErr := middleware.NewLauncherDashboardSessionCookie() if dashErr != nil { logger.Fatalf("Dashboard auth setup failed: %v", dashErr) } - dashboardSessionCookie := middleware.SessionCookieValue(dashboardSigningKey, dashboardToken) - fmt.Println("dashboardToken: ", dashboardToken) // Open the bcrypt password store (creates the DB file on first run). authStore, authStoreErr := dashboardauth.New(picoHome) var passwordStore api.PasswordStore @@ -522,23 +522,62 @@ func main() { logger.InfoC( "web", fmt.Sprintf( - "Dashboard password store unavailable on this platform; falling back to token login: %v", + "Dashboard SQLite password store unavailable on this platform; using launcher-config password storage: %v", authStoreErr, ), ) + passwordStore = launcherconfig.NewPasswordStore(launcherPath, launcherCfg) authStoreErr = nil } else { logger.ErrorC("web", fmt.Sprintf("Warning: could not open auth store: %v", authStoreErr)) } + migrationResult, migrationErr := launcherconfig.MigrateLegacyLauncherToken( + context.Background(), + passwordStore, + launcherPath, + launcherCfg, + ) + if migrationErr != nil { + logger.Fatalf("Failed to migrate legacy launcher token to password login: %v", migrationErr) + } + if migrationResult.Migrated { + logger.InfoC("web", "Migrated legacy launcher token to dashboard password login") + } + if migrationResult.CleanupErr != nil { + logger.WarnC( + "web", + fmt.Sprintf( + "Legacy launcher token password migration succeeded, but failed to remove launcher_token from %s: %v", + launcherPath, + migrationResult.CleanupErr, + ), + ) + } + + var localAutoLogin *middleware.LauncherDashboardLocalAutoLogin + needsInitialSetup := false + if passwordStore != nil { + initialized, initErr := passwordStore.IsInitialized(context.Background()) + if initErr != nil { + logger.ErrorC("web", fmt.Sprintf("Warning: could not check dashboard password state: %v", initErr)) + } else if !initialized { + needsInitialSetup = true + } else if shouldEnableLocalAutoLogin(*noBrowser, openResult.ProbeHost) { + localAutoLogin, err = middleware.NewLauncherDashboardLocalAutoLogin(5 * time.Minute) + if err != nil { + logger.Fatalf("Failed to create local auto-login grant: %v", err) + } + } + } + // Initialize Server components mux := http.NewServeMux() api.RegisterLauncherAuthRoutes(mux, api.LauncherAuthRouteOpts{ - DashboardToken: dashboardToken, - SessionCookie: dashboardSessionCookie, - PasswordStore: passwordStore, - StoreError: authStoreErr, + SessionCookie: dashboardSessionCookie, + PasswordStore: passwordStore, + StoreError: authStoreErr, }) // API Routes (e.g. /api/status) @@ -561,7 +600,7 @@ func main() { dashAuth := middleware.LauncherDashboardAuth(middleware.LauncherDashboardAuthConfig{ ExpectedCookie: dashboardSessionCookie, - Token: dashboardToken, + LocalAutoLogin: localAutoLogin, }, accessControlledMux) // Apply middleware stack @@ -573,13 +612,21 @@ func main() { ), ) - // Print startup banner and token (console mode only). + // Print startup banner (console mode only). if enableConsole || debug { consoleHosts := launcherConsoleHosts(hostInput, effectivePublic) fmt.Print(utils.Banner) fmt.Println() - fmt.Println(" Open the following URL in your browser:") + if needsInitialSetup { + if *noBrowser { + fmt.Println(" First-time setup: open /launcher-setup to create the dashboard password.") + } else { + fmt.Println(" Launcher will open /launcher-setup automatically.") + } + fmt.Println() + } + fmt.Println(" Dashboard address:") fmt.Println() for _, host := range consoleHosts { fmt.Printf(" >> http://%s <<\n", net.JoinHostPort(host, effectivePort)) @@ -599,11 +646,7 @@ func main() { // Share the local URL with the launcher runtime. serverAddr = fmt.Sprintf("http://%s", net.JoinHostPort(openResult.ProbeHost, effectivePort)) - if dashboardToken != "" { - browserLaunchURL = serverAddr + "?token=" + url.QueryEscape(dashboardToken) - } else { - browserLaunchURL = serverAddr - } + browserLaunchURL = serverAddr + launcherBrowserLaunchSuffix(needsInitialSetup, localAutoLogin) // Auto-open browser will be handled by the launcher runtime. diff --git a/web/backend/main_test.go b/web/backend/main_test.go index 6df5370b1..aea02927e 100644 --- a/web/backend/main_test.go +++ b/web/backend/main_test.go @@ -12,7 +12,7 @@ import ( "time" "github.com/sipeed/picoclaw/pkg/netbind" - "github.com/sipeed/picoclaw/web/backend/launcherconfig" + "github.com/sipeed/picoclaw/web/backend/middleware" ) func TestShouldEnableLauncherFileLogging(t *testing.T) { @@ -43,60 +43,50 @@ func TestShouldEnableLauncherFileLogging(t *testing.T) { } } -func TestDashboardTokenConfigHelpPath(t *testing.T) { - const launcherPath = "/tmp/launcher-config.json" - +func TestShouldEnableLocalAutoLogin(t *testing.T) { tests := []struct { - name string - source launcherconfig.DashboardTokenSource - want string + name string + noBrowser bool + probeHost string + wantEnable bool }{ - { - name: "env token does not expose config path", - source: launcherconfig.DashboardTokenSourceEnv, - want: "", - }, - { - name: "config token exposes config path", - source: launcherconfig.DashboardTokenSourceConfig, - want: launcherPath, - }, - { - name: "random token does not expose config path", - source: launcherconfig.DashboardTokenSourceRandom, - want: "", - }, + {name: "loopback localhost", probeHost: "localhost", wantEnable: true}, + {name: "loopback ipv4", probeHost: "127.0.0.1", wantEnable: true}, + {name: "loopback ipv6", probeHost: "::1", wantEnable: true}, + {name: "browser disabled", noBrowser: true, probeHost: "localhost", wantEnable: false}, + {name: "non-loopback host", probeHost: "192.168.1.50", wantEnable: false}, + {name: "non-loopback hostname", probeHost: "example.com", wantEnable: false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - if got := dashboardTokenConfigHelpPath(tt.source, launcherPath); got != tt.want { - t.Fatalf("dashboardTokenConfigHelpPath(%q, %q) = %q, want %q", tt.source, launcherPath, got, tt.want) + if got := shouldEnableLocalAutoLogin(tt.noBrowser, tt.probeHost); got != tt.wantEnable { + t.Fatalf( + "shouldEnableLocalAutoLogin(%t, %q) = %t, want %t", + tt.noBrowser, + tt.probeHost, + got, + tt.wantEnable, + ) } }) } } -func TestMaskSecret(t *testing.T) { - tests := []struct { - input string - want string - }{ - {"sdhjflsjdflksdf", "sdh**********ksdf"}, - {"abcdefghijklmnopqrstuvwxyz", "abc**********wxyz"}, - {"abcdefghijkl", "abc**********ijkl"}, - {"abcdefgh", "abc**********"}, - {"abcdefghijk", "abc**********"}, - {"abcdefg", "abc**********"}, - {"abcd", "abc**********"}, - {"abc", "**********"}, - {"", "**********"}, +func TestLauncherBrowserLaunchSuffix(t *testing.T) { + autoLogin, err := middleware.NewLauncherDashboardLocalAutoLogin(time.Minute) + if err != nil { + t.Fatalf("NewLauncherDashboardLocalAutoLogin() error = %v", err) } - for _, tt := range tests { - if got := maskSecret(tt.input); got != tt.want { - t.Errorf("maskSecret(%q) = %q, want %q", tt.input, got, tt.want) - } + if got := launcherBrowserLaunchSuffix(true, autoLogin); got != middleware.LauncherDashboardSetupPath { + t.Fatalf("setup suffix = %q", got) + } + if got := launcherBrowserLaunchSuffix(false, autoLogin); !strings.HasPrefix(got, "/launcher-auto-login?nonce=") { + t.Fatalf("auto-login suffix = %q", got) + } + if got := launcherBrowserLaunchSuffix(false, nil); got != "" { + t.Fatalf("empty suffix = %q, want empty", got) } } diff --git a/web/backend/middleware/launcher_dashboard_auth.go b/web/backend/middleware/launcher_dashboard_auth.go index d72bd0f00..fd59958a9 100644 --- a/web/backend/middleware/launcher_dashboard_auth.go +++ b/web/backend/middleware/launcher_dashboard_auth.go @@ -1,41 +1,88 @@ package middleware import ( - "crypto/hmac" - "crypto/sha256" + "crypto/rand" "crypto/subtle" - "encoding/hex" + "encoding/base64" + "errors" "net/http" + "net/url" "path" "strings" + "sync" "time" ) -// LauncherDashboardCookieName is the HttpOnly cookie set after a successful token login. +// LauncherDashboardCookieName is the HttpOnly cookie set after a successful password login. const LauncherDashboardCookieName = "picoclaw_launcher_auth" -// launcherDashboardSessionMaxAgeSec is the session cookie lifetime (7 days). -const launcherDashboardSessionMaxAgeSec = 7 * 24 * 3600 +// launcherDashboardSessionMaxAgeSec is the dashboard session cookie lifetime (31 days). +const launcherDashboardSessionMaxAgeSec = 31 * 24 * 3600 -const launcherSessionMACLabel = "picoclaw-launcher-v1" +const ( + launcherSessionCookieBytes = 32 + launcherGrantNonceBytes = 32 + // LauncherDashboardLocalAutoLoginPath is the one-shot local browser + // bootstrap endpoint used by the launcher-managed auto-open flow. + LauncherDashboardLocalAutoLoginPath = "/launcher-auto-login" + // LauncherDashboardSetupPath is the setup page used before the dashboard + // password is initialized. + LauncherDashboardSetupPath = "/launcher-setup" +) -// SessionCookieValue is the expected cookie value for the given signing key and dashboard token. -func SessionCookieValue(signingKey []byte, dashboardToken string) string { - mac := hmac.New(sha256.New, signingKey) - _, _ = mac.Write([]byte(launcherSessionMACLabel)) - _, _ = mac.Write([]byte{0}) - _, _ = mac.Write([]byte(dashboardToken)) - return hex.EncodeToString(mac.Sum(nil)) +// NewLauncherDashboardSessionCookie creates the per-process session cookie value. +func NewLauncherDashboardSessionCookie() (string, error) { + return randomURLToken(launcherSessionCookieBytes) +} + +func randomURLToken(n int) (string, error) { + buf := make([]byte, n) + if _, err := rand.Read(buf); err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(buf), nil } // LauncherDashboardAuthConfig holds runtime material for dashboard access checks. type LauncherDashboardAuthConfig struct { ExpectedCookie string - Token string + // LocalAutoLogin enables one-shot startup auto-login. + LocalAutoLogin *LauncherDashboardLocalAutoLogin // SecureCookie sets the session cookie's Secure flag. If nil, DefaultLauncherDashboardSecureCookie is used. SecureCookie func(*http.Request) bool } +// LauncherDashboardLocalAutoLogin is an in-memory, one-shot startup grant. +// It is not a reusable credential; it only lets the launcher-opened browser +// receive the current process session cookie. +type LauncherDashboardLocalAutoLogin struct { + grant *launcherDashboardOneTimeGrant +} + +type launcherDashboardOneTimeGrant struct { + mu sync.Mutex + expires time.Time + consumed bool + nonce string + now func() time.Time +} + +// NewLauncherDashboardLocalAutoLogin creates a one-shot local auto-login grant. +func NewLauncherDashboardLocalAutoLogin(ttl time.Duration) (*LauncherDashboardLocalAutoLogin, error) { + grant, err := newLauncherDashboardOneTimeGrant(ttl) + if err != nil { + return nil, err + } + return &LauncherDashboardLocalAutoLogin{ + grant: grant, + }, nil +} + +// URLPath returns the one-shot local auto-login URL path including its nonce. +func (a *LauncherDashboardLocalAutoLogin) URLPath() string { + return launcherGrantQueryPath(LauncherDashboardLocalAutoLoginPath, a.grant) +} + // DefaultLauncherDashboardSecureCookie mirrors typical production HTTPS detection (TLS or X-Forwarded-Proto). func DefaultLauncherDashboardSecureCookie(r *http.Request) bool { if r.TLS != nil { @@ -44,7 +91,7 @@ func DefaultLauncherDashboardSecureCookie(r *http.Request) bool { return strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https") } -// SetLauncherDashboardSessionCookie writes the HttpOnly session cookie after successful dashboard token login. +// SetLauncherDashboardSessionCookie writes the HttpOnly session cookie after successful dashboard password login. func SetLauncherDashboardSessionCookie( w http.ResponseWriter, r *http.Request, @@ -82,12 +129,13 @@ func ClearLauncherDashboardSessionCookie(w http.ResponseWriter, r *http.Request, }) } -// LauncherDashboardAuth requires a valid session cookie or Authorization: Bearer -// before calling next. Public paths are login page and /api/auth/* handlers. +// LauncherDashboardAuth requires a valid session cookie before calling next. +// Public paths are login/setup pages and /api/auth/* handlers. func LauncherDashboardAuth(cfg LauncherDashboardAuthConfig, next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { p := canonicalAuthPath(r.URL.Path) - if handled := tryLauncherQueryTokenLogin(w, r, p, cfg); handled { + if p == LauncherDashboardLocalAutoLoginPath { + handleLauncherLocalAutoLogin(w, r, cfg) return } if isPublicLauncherDashboardPath(r.Method, p) { @@ -105,45 +153,84 @@ func LauncherDashboardAuth(cfg LauncherDashboardAuthConfig, next http.Handler) h // canonicalAuthPath matches path cleaning used for routing decisions so // prefixes like /assets/../ cannot bypass auth (CVE-class traversal). -// tryLauncherQueryTokenLogin validates ?token= on GET only (non-/api), sets the session -// cookie when correct, and redirects with 303 so the follow-up is a plain GET without side effects. -// Invalid token is rejected like any other unauthenticated browser request. -func tryLauncherQueryTokenLogin( - w http.ResponseWriter, - r *http.Request, - canonicalPath string, - cfg LauncherDashboardAuthConfig, -) bool { - if r.Method != http.MethodGet { - return false +func handleLauncherLocalAutoLogin(w http.ResponseWriter, r *http.Request, cfg LauncherDashboardAuthConfig) { + if validLauncherDashboardAuth(r, cfg) { + http.Redirect(w, r, "/", http.StatusSeeOther) + return } - if canonicalPath == "/api" || strings.HasPrefix(canonicalPath, "/api/") { - return false + if r.Method != http.MethodGet && r.Method != http.MethodHead { + w.WriteHeader(http.StatusMethodNotAllowed) + _, _ = w.Write([]byte("method not allowed")) + return } - qToken := strings.TrimSpace(r.URL.Query().Get("token")) - if qToken == "" { - return false + if r.Method == http.MethodHead { + rejectLauncherDashboardAuth(w, r, LauncherDashboardLocalAutoLoginPath) + return } - if len(qToken) != len(cfg.Token) || subtle.ConstantTimeCompare([]byte(qToken), []byte(cfg.Token)) != 1 { - rejectLauncherDashboardAuth(w, r, canonicalPath) - return true + if cfg.LocalAutoLogin != nil && cfg.LocalAutoLogin.consume(r.URL.Query().Get("nonce")) { + SetLauncherDashboardSessionCookie(w, r, cfg.ExpectedCookie, cfg.SecureCookie) + http.Redirect(w, r, "/", http.StatusSeeOther) + return } - SetLauncherDashboardSessionCookie(w, r, cfg.ExpectedCookie, cfg.SecureCookie) - http.Redirect(w, r, redirectAfterQueryTokenLogin(r, canonicalPath), http.StatusSeeOther) - return true + rejectLauncherDashboardAuth(w, r, LauncherDashboardLocalAutoLoginPath) } -func redirectAfterQueryTokenLogin(r *http.Request, canonicalPath string) string { - if canonicalPath == "/launcher-login" { - return "/" +func (a *LauncherDashboardLocalAutoLogin) consume(nonce string) bool { + if a == nil || a.grant == nil { + return false } - q := r.URL.Query() - q.Del("token") - enc := q.Encode() - if enc != "" { - return canonicalPath + "?" + enc + return a.grant.use(nonce, nil) == nil +} + +func newLauncherDashboardOneTimeGrant(ttl time.Duration) (*launcherDashboardOneTimeGrant, error) { + nonce, err := randomURLToken(launcherGrantNonceBytes) + if err != nil { + return nil, err } - return canonicalPath + return &launcherDashboardOneTimeGrant{ + expires: time.Now().Add(ttl), + nonce: nonce, + now: time.Now, + }, nil +} + +func launcherGrantQueryPath(basePath string, grant *launcherDashboardOneTimeGrant) string { + if grant == nil { + return basePath + } + return basePath + "?nonce=" + url.QueryEscape(grant.nonce) +} + +// ErrInvalidLauncherDashboardGrant reports that an auto-login grant is missing, +// expired, already consumed, or otherwise invalid. +var ErrInvalidLauncherDashboardGrant = errors.New("invalid launcher dashboard grant") + +func (g *launcherDashboardOneTimeGrant) use(nonce string, fn func() error) error { + if g == nil { + return ErrInvalidLauncherDashboardGrant + } + if len(nonce) != len(g.nonce) || + subtle.ConstantTimeCompare([]byte(nonce), []byte(g.nonce)) != 1 { + return ErrInvalidLauncherDashboardGrant + } + + g.mu.Lock() + defer g.mu.Unlock() + + now := time.Now + if g.now != nil { + now = g.now + } + if g.consumed || !now().Before(g.expires) { + return ErrInvalidLauncherDashboardGrant + } + if fn != nil { + if err := fn(); err != nil { + return err + } + } + g.consumed = true + return nil } func canonicalAuthPath(raw string) string { @@ -206,14 +293,6 @@ func validLauncherDashboardAuth(r *http.Request, cfg LauncherDashboardAuthConfig return true } } - auth := r.Header.Get("Authorization") - const prefix = "Bearer " - if strings.HasPrefix(auth, prefix) { - token := strings.TrimSpace(auth[len(prefix):]) - if len(token) == len(cfg.Token) && subtle.ConstantTimeCompare([]byte(token), []byte(cfg.Token)) == 1 { - return true - } - } return false } diff --git a/web/backend/middleware/launcher_dashboard_auth_test.go b/web/backend/middleware/launcher_dashboard_auth_test.go index 7b7418998..871b6f607 100644 --- a/web/backend/middleware/launcher_dashboard_auth_test.go +++ b/web/backend/middleware/launcher_dashboard_auth_test.go @@ -4,26 +4,37 @@ import ( "net/http" "net/http/httptest" "testing" + "time" ) -func TestSessionCookieValue_Deterministic(t *testing.T) { - key := make([]byte, 32) - for i := range key { - key[i] = byte(i) +func TestNewLauncherDashboardSessionCookie(t *testing.T) { + a, err := NewLauncherDashboardSessionCookie() + if err != nil { + t.Fatalf("NewLauncherDashboardSessionCookie() error = %v", err) } - a := SessionCookieValue(key, "tok-a") - b := SessionCookieValue(key, "tok-a") - if a != b || a == "" { - t.Fatalf("SessionCookieValue mismatch or empty: %q vs %q", a, b) + b, err := NewLauncherDashboardSessionCookie() + if err != nil { + t.Fatalf("NewLauncherDashboardSessionCookie() second error = %v", err) } - c := SessionCookieValue(key, "tok-b") - if c == a { - t.Fatal("SessionCookieValue should differ for different tokens") + if a == "" || b == "" { + t.Fatalf("session cookie values should be non-empty: %q %q", a, b) + } + if a == b { + t.Fatal("session cookie values should be random") } } +func mustLocalAutoLogin(t *testing.T, ttl time.Duration) *LauncherDashboardLocalAutoLogin { + t.Helper() + autoLogin, err := NewLauncherDashboardLocalAutoLogin(ttl) + if err != nil { + t.Fatalf("NewLauncherDashboardLocalAutoLogin() error = %v", err) + } + return autoLogin +} + func TestLauncherDashboardAuth_AllowsPublicPaths(t *testing.T) { - cfg := LauncherDashboardAuthConfig{ExpectedCookie: "deadbeef", Token: "x"} + cfg := LauncherDashboardAuthConfig{ExpectedCookie: "deadbeef"} next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusTeapot) }) @@ -34,9 +45,11 @@ func TestLauncherDashboardAuth_AllowsPublicPaths(t *testing.T) { want int }{ {http.MethodGet, "/launcher-login", http.StatusTeapot}, + {http.MethodGet, "/launcher-setup", http.StatusTeapot}, {http.MethodGet, "/assets/index.js", http.StatusTeapot}, {http.MethodPost, "/api/auth/login", http.StatusTeapot}, {http.MethodGet, "/api/auth/status", http.StatusTeapot}, + {http.MethodPost, "/api/auth/setup", http.StatusTeapot}, {http.MethodPost, "/api/auth/logout", http.StatusTeapot}, {http.MethodGet, "/api/auth/logout", http.StatusUnauthorized}, {http.MethodGet, "/api/config", http.StatusUnauthorized}, @@ -51,68 +64,143 @@ func TestLauncherDashboardAuth_AllowsPublicPaths(t *testing.T) { } } -func TestLauncherDashboardAuth_URLTokenBootstrapGET(t *testing.T) { - const tok = "secret" - cfg := LauncherDashboardAuthConfig{ExpectedCookie: "deadbeef", Token: tok} +func TestLauncherDashboardAuth_QueryTokenDoesNotAuthenticate(t *testing.T) { + cfg := LauncherDashboardAuthConfig{ExpectedCookie: "deadbeef"} next := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusTeapot) + t.Fatal("next handler should not run without session cookie") }) h := LauncherDashboardAuth(cfg, next) rec := httptest.NewRecorder() - req := httptest.NewRequest(http.MethodGet, "/?token="+tok, nil) + req := httptest.NewRequest(http.MethodGet, "/?token=secret", nil) h.ServeHTTP(rec, req) - if rec.Code != http.StatusSeeOther { - t.Fatalf("GET /?token=valid: status = %d, want %d", rec.Code, http.StatusSeeOther) + if rec.Code != http.StatusFound || rec.Header().Get("Location") != "/launcher-login" { + t.Fatalf("GET /?token=secret: code=%d loc=%q", rec.Code, rec.Header().Get("Location")) } - if got := rec.Header().Get("Location"); got != "/" { - t.Fatalf("Location = %q, want %q", got, "/") +} + +func TestLauncherDashboardAuth_LocalAutoLogin(t *testing.T) { + const cookieVal = "session-cookie-value" + autoLogin := mustLocalAutoLogin(t, time.Minute) + cfg := LauncherDashboardAuthConfig{ + ExpectedCookie: cookieVal, + LocalAutoLogin: autoLogin, } - if c := rec.Result().Cookies(); len(c) != 1 || c[0].Name != LauncherDashboardCookieName { - t.Fatalf("expected one session cookie, got %#v", c) + next := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + }) + h := LauncherDashboardAuth(cfg, next) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, LauncherDashboardLocalAutoLoginPath, nil) + h.ServeHTTP(rec, req) + if rec.Code != http.StatusFound || rec.Header().Get("Location") != "/launcher-login" || + len(rec.Result().Cookies()) != 0 { + t.Fatalf( + "auto-login without nonce code=%d loc=%q cookies=%#v", + rec.Code, + rec.Header().Get("Location"), + rec.Result().Cookies(), + ) } - rec1b := httptest.NewRecorder() - req1b := httptest.NewRequest(http.MethodGet, "/config?token="+tok+"&keep=1", nil) - h.ServeHTTP(rec1b, req1b) - if rec1b.Code != http.StatusSeeOther { - t.Fatalf("GET /config?token=valid: status = %d", rec1b.Code) - } - if got := rec1b.Header().Get("Location"); got != "/config?keep=1" { - t.Fatalf("Location = %q, want /config?keep=1", got) + rec = httptest.NewRecorder() + req = httptest.NewRequest(http.MethodGet, LauncherDashboardLocalAutoLoginPath+"?nonce=wrong", nil) + h.ServeHTTP(rec, req) + if rec.Code != http.StatusFound || rec.Header().Get("Location") != "/launcher-login" || + len(rec.Result().Cookies()) != 0 { + t.Fatalf( + "auto-login with wrong nonce code=%d loc=%q cookies=%#v", + rec.Code, + rec.Header().Get("Location"), + rec.Result().Cookies(), + ) } - recBad := httptest.NewRecorder() - reqBad := httptest.NewRequest(http.MethodGet, "/?token=wrong", nil) - h.ServeHTTP(recBad, reqBad) - if recBad.Code != http.StatusFound || recBad.Header().Get("Location") != "/launcher-login" { - t.Fatalf("GET /?token=invalid: code=%d loc=%q", recBad.Code, recBad.Header().Get("Location")) + rec = httptest.NewRecorder() + req = httptest.NewRequest(http.MethodHead, autoLogin.URLPath(), nil) + h.ServeHTTP(rec, req) + if rec.Code != http.StatusFound || rec.Header().Get("Location") != "/launcher-login" || + len(rec.Result().Cookies()) != 0 { + t.Fatalf( + "auto-login HEAD code=%d loc=%q cookies=%#v", + rec.Code, + rec.Header().Get("Location"), + rec.Result().Cookies(), + ) } - rec2 := httptest.NewRecorder() - req2 := httptest.NewRequest(http.MethodGet, "/api/config?token="+tok, nil) - h.ServeHTTP(rec2, req2) - if rec2.Code != http.StatusUnauthorized { - t.Fatalf("GET /api with token query: status = %d, want %d", rec2.Code, http.StatusUnauthorized) + rec = httptest.NewRecorder() + req = httptest.NewRequest(http.MethodGet, autoLogin.URLPath(), nil) + h.ServeHTTP(rec, req) + if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/" { + t.Fatalf("local auto-login code=%d loc=%q", rec.Code, rec.Header().Get("Location")) + } + cookies := rec.Result().Cookies() + if len(cookies) != 1 || cookies[0].Name != LauncherDashboardCookieName || cookies[0].Value != cookieVal { + t.Fatalf("cookies = %#v", cookies) + } + if cookies[0].MaxAge != 31*24*3600 { + t.Fatalf("session cookie MaxAge = %d, want 31 days", cookies[0].MaxAge) } - rec3 := httptest.NewRecorder() - req3 := httptest.NewRequest(http.MethodGet, "/?token=", nil) - h.ServeHTTP(rec3, req3) - if rec3.Code != http.StatusFound { - t.Fatalf("GET /?token=empty: status = %d, want redirect", rec3.Code) + rec = httptest.NewRecorder() + req = httptest.NewRequest(http.MethodGet, "/", nil) + req.AddCookie(&http.Cookie{Name: LauncherDashboardCookieName, Value: cookieVal}) + h.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("cookie auth after auto-login status = %d", rec.Code) } - recLogin := httptest.NewRecorder() - reqLogin := httptest.NewRequest(http.MethodGet, "/launcher-login?token="+tok, nil) - h.ServeHTTP(recLogin, reqLogin) - if recLogin.Code != http.StatusSeeOther || recLogin.Header().Get("Location") != "/" { - t.Fatalf("GET /launcher-login?token=valid: code=%d loc=%q", recLogin.Code, recLogin.Header().Get("Location")) + rec = httptest.NewRecorder() + req = httptest.NewRequest(http.MethodGet, autoLogin.URLPath(), nil) + req.AddCookie(&http.Cookie{Name: LauncherDashboardCookieName, Value: cookieVal}) + h.ServeHTTP(rec, req) + if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/" { + t.Fatalf("auto-login path with existing session code=%d loc=%q", rec.Code, rec.Header().Get("Location")) + } + + rec = httptest.NewRecorder() + req = httptest.NewRequest(http.MethodGet, autoLogin.URLPath(), nil) + h.ServeHTTP(rec, req) + if rec.Code != http.StatusFound || rec.Header().Get("Location") != "/launcher-login" { + t.Fatalf("consumed auto-login code=%d loc=%q", rec.Code, rec.Header().Get("Location")) + } +} + +func TestLauncherDashboardAuth_LocalAutoLoginRequiresValidNonceAndUnexpired(t *testing.T) { + const cookieVal = "session-cookie-value" + newHandler := func(autoLogin *LauncherDashboardLocalAutoLogin) http.Handler { + return LauncherDashboardAuth(LauncherDashboardAuthConfig{ + ExpectedCookie: cookieVal, + LocalAutoLogin: autoLogin, + }, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + })) + } + + autoLogin := mustLocalAutoLogin(t, time.Minute) + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, autoLogin.URLPath(), nil) + req.RemoteAddr = "192.168.1.50:12345" + req.Host = "192.168.1.50:18800" + newHandler(autoLogin).ServeHTTP(rec, req) + if rec.Code != http.StatusSeeOther || len(rec.Result().Cookies()) != 1 { + t.Fatalf("capability auto-login code=%d cookies=%#v", rec.Code, rec.Result().Cookies()) + } + + expired := mustLocalAutoLogin(t, -time.Second) + h := newHandler(expired) + rec = httptest.NewRecorder() + req = httptest.NewRequest(http.MethodGet, expired.URLPath(), nil) + h.ServeHTTP(rec, req) + if rec.Code != http.StatusFound || len(rec.Result().Cookies()) != 0 { + t.Fatalf("expired auto-login code=%d cookies=%#v", rec.Code, rec.Result().Cookies()) } } func TestLauncherDashboardAuth_DotDotCannotBypass(t *testing.T) { - cfg := LauncherDashboardAuthConfig{ExpectedCookie: "deadbeef", Token: "x"} + cfg := LauncherDashboardAuthConfig{ExpectedCookie: "deadbeef"} next := http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) { t.Fatal("next handler should not run without auth") }) @@ -132,14 +220,9 @@ func TestLauncherDashboardAuth_DotDotCannotBypass(t *testing.T) { } } -func TestLauncherDashboardAuth_CookieAndBearer(t *testing.T) { - key := make([]byte, 32) - for i := range key { - key[i] = 0xab - } - token := "dashboard-secret-9" - cookieVal := SessionCookieValue(key, token) - cfg := LauncherDashboardAuthConfig{ExpectedCookie: cookieVal, Token: token} +func TestLauncherDashboardAuth_CookieOnly(t *testing.T) { + cookieVal := "session-cookie-value" + cfg := LauncherDashboardAuthConfig{ExpectedCookie: cookieVal} next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) }) @@ -154,16 +237,16 @@ func TestLauncherDashboardAuth_CookieAndBearer(t *testing.T) { } rec2 := httptest.NewRecorder() - req2 := httptest.NewRequest(http.MethodGet, "/", nil) - req2.Header.Set("Authorization", "Bearer "+token) + req2 := httptest.NewRequest(http.MethodGet, "/api/config", nil) + req2.Header.Set("Authorization", "Bearer dashboard-secret-9") h.ServeHTTP(rec2, req2) - if rec2.Code != http.StatusOK { - t.Fatalf("bearer auth: status = %d", rec2.Code) + if rec2.Code != http.StatusUnauthorized { + t.Fatalf("bearer auth should not be accepted: status = %d", rec2.Code) } } func TestLauncherDashboardAuth_WebSocketUnauthorizedDoesNotRedirect(t *testing.T) { - cfg := LauncherDashboardAuthConfig{ExpectedCookie: "deadbeef", Token: "x"} + cfg := LauncherDashboardAuthConfig{ExpectedCookie: "deadbeef"} next := http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) { t.Fatal("next handler should not run without auth") }) diff --git a/web/backend/middleware/referrer_policy.go b/web/backend/middleware/referrer_policy.go index 5ac066614..6cb14669d 100644 --- a/web/backend/middleware/referrer_policy.go +++ b/web/backend/middleware/referrer_policy.go @@ -2,8 +2,8 @@ package middleware import "net/http" -// ReferrerPolicyNoReferrer sets Referrer-Policy: no-referrer on every response so sensitive -// query parameters (e.g. ?token= for dashboard bootstrap) are not leaked via the Referer header. +// ReferrerPolicyNoReferrer sets Referrer-Policy: no-referrer on every response +// so sensitive paths and query parameters are not leaked via the Referer header. func ReferrerPolicyNoReferrer(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Referrer-Policy", "no-referrer") diff --git a/web/frontend/src/api/launcher-auth.ts b/web/frontend/src/api/launcher-auth.ts index d6bd93c4d..c7318d962 100644 --- a/web/frontend/src/api/launcher-auth.ts +++ b/web/frontend/src/api/launcher-auth.ts @@ -2,16 +2,26 @@ * Dashboard launcher auth API. * Uses plain fetch (not launcherFetch) to avoid redirect loops on auth pages. */ +export type LoginResult = + | { ok: true } + | { ok: false; status: number; error: string } + export async function postLauncherDashboardLogin( password: string, -): Promise { +): Promise { const res = await fetch("/api/auth/login", { method: "POST", headers: { "Content-Type": "application/json" }, credentials: "same-origin", body: JSON.stringify({ password: password.trim() }), }) - return res.ok + if (res.ok) return { ok: true } + + return { + ok: false, + status: res.status, + error: await readLauncherAuthError(res), + } } export type LauncherAuthStatus = { @@ -57,12 +67,16 @@ export async function postLauncherDashboardSetup( }), }) if (res.ok) return { ok: true } - let msg = "Unknown error" + return { ok: false, error: await readLauncherAuthError(res) } +} + +async function readLauncherAuthError(res: Response): Promise { + let msg = `Request failed with status ${res.status}` try { const j = (await res.json()) as { error?: string } if (j.error) msg = j.error } catch { /* ignore */ } - return { ok: false, error: msg } + return msg } diff --git a/web/frontend/src/api/system.ts b/web/frontend/src/api/system.ts index 8623c7e78..dfc48b6b8 100644 --- a/web/frontend/src/api/system.ts +++ b/web/frontend/src/api/system.ts @@ -11,7 +11,6 @@ export interface LauncherConfig { port: number public: boolean allowed_cidrs: string[] - launcher_token: string } export interface SystemVersionInfo { diff --git a/web/frontend/src/components/app-header.tsx b/web/frontend/src/components/app-header.tsx index e94975075..465d218be 100644 --- a/web/frontend/src/components/app-header.tsx +++ b/web/frontend/src/components/app-header.tsx @@ -295,6 +295,22 @@ export function AppHeader() { {/* Theme Toggle */} + + + + + {/* Logout */}
) diff --git a/web/frontend/src/components/config/config-page.tsx b/web/frontend/src/components/config/config-page.tsx index 0ad2031f7..f50503dec 100644 --- a/web/frontend/src/components/config/config-page.tsx +++ b/web/frontend/src/components/config/config-page.tsx @@ -7,6 +7,7 @@ import { toast } from "sonner" import { patchAppConfig } from "@/api/channels" import { launcherFetch } from "@/api/http" +import { postLauncherDashboardSetup } from "@/api/launcher-auth" import { getAutoStartStatus, getLauncherConfig, @@ -94,7 +95,8 @@ export function ConfigPage() { port: String(launcherConfig.port), publicAccess: launcherConfig.public, allowedCIDRsText: (launcherConfig.allowed_cidrs ?? []).join("\n"), - launcherToken: launcherConfig.launcher_token ?? "", + dashboardPassword: "", + dashboardPasswordConfirm: "", } setLauncherForm(parsed) setLauncherBaseline(parsed) @@ -107,8 +109,14 @@ export function ConfigPage() { }, [autoStartStatus]) const configDirty = JSON.stringify(form) !== JSON.stringify(baseline) - const launcherDirty = - JSON.stringify(launcherForm) !== JSON.stringify(launcherBaseline) + const launcherSettingsDirty = + launcherForm.port !== launcherBaseline.port || + launcherForm.publicAccess !== launcherBaseline.publicAccess || + launcherForm.allowedCIDRsText !== launcherBaseline.allowedCIDRsText + const launcherPasswordDirty = + launcherForm.dashboardPassword.trim() !== "" || + launcherForm.dashboardPasswordConfirm.trim() !== "" + const launcherDirty = launcherSettingsDirty || launcherPasswordDirty const autoStartDirty = autoStartEnabled !== autoStartBaseline const isDirty = configDirty || launcherDirty || autoStartDirty @@ -143,6 +151,19 @@ export function ConfigPage() { const handleSave = async () => { try { setSaving(true) + const password = launcherForm.dashboardPassword.trim() + const confirm = launcherForm.dashboardPasswordConfirm.trim() + if (launcherPasswordDirty) { + if (!password) { + throw new Error(t("pages.config.dashboard_password_required")) + } + if (password !== confirm) { + throw new Error(t("pages.config.dashboard_password_mismatch")) + } + if (Array.from(password).length < 8) { + throw new Error(t("pages.config.dashboard_password_min_length")) + } + } if (configDirty) { const workspace = form.workspace.trim() @@ -255,7 +276,8 @@ export function ConfigPage() { queryClient.invalidateQueries({ queryKey: ["config"] }) } - if (launcherDirty) { + let savedLauncherForm: LauncherForm | null = null + if (launcherSettingsDirty) { const port = parseIntField(launcherForm.port, "Service port", { min: 1, max: 65535, @@ -265,7 +287,6 @@ export function ConfigPage() { port, public: launcherForm.publicAccess, allowed_cidrs: allowedCIDRs, - launcher_token: launcherForm.launcherToken.trim(), }) const parsedLauncher: LauncherForm = { port: String(savedLauncherConfig.port), @@ -273,8 +294,10 @@ export function ConfigPage() { allowedCIDRsText: (savedLauncherConfig.allowed_cidrs ?? []).join( "\n", ), - launcherToken: savedLauncherConfig.launcher_token ?? "", + dashboardPassword: "", + dashboardPasswordConfirm: "", } + savedLauncherForm = parsedLauncher setLauncherForm(parsedLauncher) setLauncherBaseline(parsedLauncher) queryClient.setQueryData( @@ -283,6 +306,23 @@ export function ConfigPage() { ) } + if (launcherPasswordDirty) { + const result = await postLauncherDashboardSetup(password, confirm) + if (!result.ok) { + throw new Error(result.error) + } + + const clearedLauncherForm = savedLauncherForm ?? { + ...launcherForm, + dashboardPassword: "", + dashboardPasswordConfirm: "", + } + setLauncherForm(clearedLauncherForm) + if (savedLauncherForm) { + setLauncherBaseline(savedLauncherForm) + } + } + if (autoStartDirty) { if (!autoStartSupported) { throw new Error(t("pages.config.autostart_unsupported")) @@ -304,6 +344,22 @@ export function ConfigPage() { } } + const actionButtons = ( +
+ + +
+ ) + return (
) : (
- {isDirty && ( -
- {t("pages.config.unsaved_changes")} -
- )} - -
- - -
+ {!isDirty && actionButtons}
)}
+ {isDirty && ( +
+
+
+ {t("pages.config.unsaved_changes")} +
+ {actionButtons} +
+
+ )}
) } diff --git a/web/frontend/src/components/config/config-sections.tsx b/web/frontend/src/components/config/config-sections.tsx index 21f89d7c1..25c335ab1 100644 --- a/web/frontend/src/components/config/config-sections.tsx +++ b/web/frontend/src/components/config/config-sections.tsx @@ -519,23 +519,48 @@ export function LauncherSection({ return ( onFieldChange("launcherToken", e.target.value)} + autoComplete="new-password" + placeholder={t("pages.config.dashboard_password_placeholder")} + onChange={(e) => + onFieldChange("dashboardPassword", e.target.value) + } /> + {launcherForm.dashboardPassword.trim() !== "" && ( + + + onFieldChange("dashboardPasswordConfirm", e.target.value) + } + /> + + )} + { const [authError, setAuthError] = useState(null) // Session guard: proactively check auth status on every page load. - // This catches the case where ?token= auto-login bypassed the login/setup UI. useEffect(() => { if (isAuthPage) return void getLauncherAuthStatus() @@ -55,7 +54,7 @@ const RootLayout = () => { setAuthError( err instanceof Error ? err.message - : "Auth service unavailable, please try to delete the launcher-auth.db at picoclaw home directory and restart the application.", + : "Auth service unavailable. Reset dashboard password storage and restart the application.", ) } }) diff --git a/web/frontend/src/routes/launcher-login.tsx b/web/frontend/src/routes/launcher-login.tsx index caa548c79..1e8d7cc28 100644 --- a/web/frontend/src/routes/launcher-login.tsx +++ b/web/frontend/src/routes/launcher-login.tsx @@ -28,7 +28,7 @@ import { useTheme } from "@/hooks/use-theme" function LauncherLoginPage() { const { t, i18n } = useTranslation() const { theme, toggleTheme } = useTheme() - const [token, setToken] = React.useState("") + const [password, setPassword] = React.useState("") const [submitting, setSubmitting] = React.useState(false) const [error, setError] = React.useState("") @@ -45,17 +45,25 @@ function LauncherLoginPage() { }) }, []) - const loginWithToken = React.useCallback( - async (tokenValue: string) => { + const loginWithPassword = React.useCallback( + async (passwordValue: string) => { setError("") setSubmitting(true) try { - const ok = await postLauncherDashboardLogin(tokenValue) - if (ok) { + const result = await postLauncherDashboardLogin(passwordValue) + if (result.ok) { globalThis.location.assign("/") return } - setError(t("launcherLogin.errorInvalid")) + if (result.status === 409) { + globalThis.location.assign("/launcher-setup") + return + } + if (result.status === 401) { + setError(t("launcherLogin.errorInvalid")) + return + } + setError(result.error) } catch { setError(t("launcherLogin.errorNetwork")) } finally { @@ -67,7 +75,7 @@ function LauncherLoginPage() { const onSubmit = async (e: React.FormEvent) => { e.preventDefault() - await loginWithToken(token) + await loginWithPassword(password) } return ( @@ -112,17 +120,17 @@ function LauncherLoginPage() {
-
From d0507df894aa7d75daca9650ec621d3d47b1a749 Mon Sep 17 00:00:00 2001 From: sky5454 Date: Tue, 21 Apr 2026 23:23:50 +0800 Subject: [PATCH 20/31] chore(isolation): fix govet shadow declaration of "err" shadows --- pkg/isolation/platform_windows.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/isolation/platform_windows.go b/pkg/isolation/platform_windows.go index 9434976f7..9b39c85cf 100644 --- a/pkg/isolation/platform_windows.go +++ b/pkg/isolation/platform_windows.go @@ -76,7 +76,7 @@ func postStartPlatformIsolation(cmd *exec.Cmd, isolation config.IsolationConfig, info := windows.JOBOBJECT_EXTENDED_LIMIT_INFORMATION{} info.BasicLimitInformation.LimitFlags = windows.JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE - if _, err := windows.SetInformationJobObject( + if _, err = windows.SetInformationJobObject( job, windows.JobObjectExtendedLimitInformation, uintptr(unsafe.Pointer(&info)), From 023ca2e4c12e1dbba1ac080a4da9e512e44d57d1 Mon Sep 17 00:00:00 2001 From: Guoguo <16666742+imguoguo@users.noreply.github.com> Date: Wed, 22 Apr 2026 10:07:14 +0800 Subject: [PATCH 21/31] ci(release): split tag creation and release into separate workflows (#2614) - Add `create-tag.yml`: creates annotated tag at a specified commit or latest main HEAD, with duplicate tag and commit validation - Simplify `release.yml`: only accepts existing tags, removes create_tag toggle, validates tag via GitHub API before checkout - Always checkout main branch (fetch-depth: 0 fetches full history), then create tag at the specified commit Co-authored-by: Claude Opus 4.6 --- .github/workflows/create-tag.yml | 60 ++++++++++++++++++++++++++++++++ .github/workflows/release.yml | 36 +++++++------------ 2 files changed, 72 insertions(+), 24 deletions(-) create mode 100644 .github/workflows/create-tag.yml diff --git a/.github/workflows/create-tag.yml b/.github/workflows/create-tag.yml new file mode 100644 index 000000000..4da3f79cb --- /dev/null +++ b/.github/workflows/create-tag.yml @@ -0,0 +1,60 @@ +name: Create Tag + +on: + workflow_dispatch: + inputs: + tag: + description: "Tag name (required, e.g. v0.2.0)" + required: true + type: string + commit: + description: "Target commit SHA (leave empty for latest main)" + required: false + type: string + default: "" + +jobs: + create-tag: + name: Create Git Tag + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + fetch-depth: 0 + ref: main + + - name: Validate commit exists + if: ${{ inputs.commit != '' }} + shell: bash + run: | + if ! git cat-file -t "${{ inputs.commit }}" &>/dev/null; then + echo "::error::Commit '${{ inputs.commit }}' does not exist." + exit 1 + fi + + - name: Check tag does not already exist + shell: bash + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + if gh api "repos/${{ github.repository }}/git/ref/tags/${{ inputs.tag }}" --silent 2>/dev/null; then + echo "::error::Tag '${{ inputs.tag }}' already exists." + exit 1 + fi + + - name: Create and push tag + shell: bash + run: | + TARGET="${{ inputs.commit || 'HEAD' }}" + COMMIT_SHA=$(git rev-parse "$TARGET") + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git tag -a "${{ inputs.tag }}" "$COMMIT_SHA" -m "Release ${{ inputs.tag }}" + git push origin "${{ inputs.tag }}" + echo "### Tag Created" >> "$GITHUB_STEP_SUMMARY" + echo "- **Tag:** \`${{ inputs.tag }}\`" >> "$GITHUB_STEP_SUMMARY" + echo "- **Commit:** \`${COMMIT_SHA}\`" >> "$GITHUB_STEP_SUMMARY" + echo "- **Branch:** \`$(git branch -r --contains "$COMMIT_SHA" | head -1 | xargs)\`" >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1480d410d..a52b6df8f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,10 +1,10 @@ -name: Create Tag and Release +name: Release on: workflow_dispatch: inputs: tag: - description: "Release tag (required, e.g. v0.2.0)" + description: "Existing tag to release (e.g. v0.2.0)" required: true type: string prerelease: @@ -24,35 +24,23 @@ on: default: true jobs: - create-tag: - name: Create Git Tag - runs-on: ubuntu-latest - permissions: - contents: write - steps: - - name: Checkout - uses: actions/checkout@v6 - with: - fetch-depth: 0 - - - name: Create and push tag - shell: bash - env: - RELEASE_TAG: ${{ inputs.tag }} - run: | - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - git tag -a "$RELEASE_TAG" -m "Release $RELEASE_TAG" - git push origin "$RELEASE_TAG" - release: name: GoReleaser Release - needs: create-tag runs-on: ubuntu-latest permissions: contents: write packages: write steps: + - name: Verify tag exists + shell: bash + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + if ! gh api "repos/${{ github.repository }}/git/ref/tags/${{ inputs.tag }}" --silent 2>/dev/null; then + echo "::error::Tag '${{ inputs.tag }}' does not exist. Create it first using the 'Create Tag' workflow." + exit 1 + fi + - name: Checkout tag uses: actions/checkout@v6 with: From 3316ee6923e940834e1bdc1da5be924015f660b0 Mon Sep 17 00:00:00 2001 From: Mauro Date: Wed, 22 Apr 2026 05:28:04 +0200 Subject: [PATCH 22/31] feat(web): download files on frontend (#2563) * feat(web): download attachments in frontend * fix: proxy pico media and force svg downloads * feat(web): hide ephemeral media refs from persisted session history --- pkg/agent/agent_media.go | 19 ++ pkg/agent/agent_test.go | 3 + pkg/agent/pipeline_execute.go | 18 +- pkg/channels/pico/pico.go | 208 ++++++++++++++++++ pkg/channels/pico/pico_test.go | 97 ++++++++ pkg/providers/protocoltypes/types.go | 9 + pkg/providers/types.go | 1 + web/backend/api/pico.go | 104 ++++++--- web/backend/api/pico_test.go | 55 +++++ web/backend/api/session.go | 158 ++++++++++--- web/backend/api/session_test.go | 130 +++++++++++ web/frontend/src/api/sessions.ts | 6 + .../src/components/chat/assistant-message.tsx | 69 +++++- .../src/components/chat/chat-page.tsx | 1 + web/frontend/src/features/chat/history.ts | 43 +++- web/frontend/src/features/chat/protocol.ts | 54 ++++- web/frontend/src/store/chat.ts | 3 +- web/frontend/vite.config.ts | 4 + 18 files changed, 909 insertions(+), 73 deletions(-) diff --git a/pkg/agent/agent_media.go b/pkg/agent/agent_media.go index e8314c10d..a773d2ebb 100644 --- a/pkg/agent/agent_media.go +++ b/pkg/agent/agent_media.go @@ -105,6 +105,25 @@ func buildArtifactTags(store media.MediaStore, refs []string) []string { return tags } +func buildProviderAttachments(store media.MediaStore, refs []string) []providers.Attachment { + if store == nil || len(refs) == 0 { + return nil + } + + attachments := make([]providers.Attachment, 0, len(refs)) + for _, ref := range refs { + attachment := providers.Attachment{Ref: ref} + if _, meta, err := store.ResolveWithMeta(ref); err == nil { + attachment.Filename = meta.Filename + attachment.ContentType = meta.ContentType + attachment.Type = inferMediaType(meta.Filename, meta.ContentType) + } + attachments = append(attachments, attachment) + } + + return attachments +} + // detectMIME determines the MIME type from metadata or magic-bytes detection. // Returns empty string if detection fails. func detectMIME(localPath string, meta media.MediaMeta) string { diff --git a/pkg/agent/agent_test.go b/pkg/agent/agent_test.go index 5cdac186c..61c8afa37 100644 --- a/pkg/agent/agent_test.go +++ b/pkg/agent/agent_test.go @@ -1051,6 +1051,9 @@ func TestProcessMessage_MediaToolHandledSkipsFollowUpLLMAndFinalText(t *testing. if last.Role != "assistant" || last.Content != "Requested output delivered via tool attachment." { t.Fatalf("expected handled assistant summary in history, got %+v", last) } + if len(last.Attachments) != 1 { + t.Fatalf("expected handled assistant summary attachments in history, got %+v", last.Attachments) + } } func TestProcessMessage_HandledToolProcessesQueuedSteeringBeforeReturning(t *testing.T) { diff --git a/pkg/agent/pipeline_execute.go b/pkg/agent/pipeline_execute.go index 76ada0e64..87254619c 100644 --- a/pkg/agent/pipeline_execute.go +++ b/pkg/agent/pipeline_execute.go @@ -33,6 +33,7 @@ func (p *Pipeline) ExecuteTools( ts.setPhase(TurnPhaseTools) messages := exec.messages + handledAttachments := make([]providers.Attachment, 0) toolLoop: for i, tc := range normalizedToolCalls { @@ -144,6 +145,11 @@ toolLoop: }) hookResult.IsError = true hookResult.ForLLM = fmt.Sprintf("failed to deliver attachment: %v", err) + } else { + handledAttachments = append( + handledAttachments, + buildProviderAttachments(al.mediaStore, hookResult.Media)..., + ) } } else if al.bus != nil { al.bus.PublishOutboundMedia(ctx, outboundMedia) @@ -503,6 +509,11 @@ toolLoop: "error": err.Error(), }) toolResult = tools.ErrorResult(fmt.Sprintf("failed to deliver attachment: %v", err)).WithError(err) + } else { + handledAttachments = append( + handledAttachments, + buildProviderAttachments(al.mediaStore, toolResult.Media)..., + ) } } else if al.bus != nil { al.bus.PublishOutboundMedia(ctx, outboundMedia) @@ -656,11 +667,12 @@ toolLoop: // No pending steering: finalize or break depending on allResponsesHandled if exec.allResponsesHandled { summaryMsg := providers.Message{ - Role: "assistant", - Content: handledToolResponseSummary, + Role: "assistant", + Content: handledToolResponseSummary, + Attachments: append([]providers.Attachment(nil), handledAttachments...), } if !ts.opts.NoHistory { - ts.agent.Sessions.AddMessage(ts.sessionKey, summaryMsg.Role, summaryMsg.Content) + ts.agent.Sessions.AddFullMessage(ts.sessionKey, summaryMsg) ts.recordPersistedMessage(summaryMsg) ts.ingestMessage(turnCtx, al, summaryMsg) if err := ts.agent.Sessions.Save(ts.sessionKey); err != nil { diff --git a/pkg/channels/pico/pico.go b/pkg/channels/pico/pico.go index 8b41023f0..4d1fad1ed 100644 --- a/pkg/channels/pico/pico.go +++ b/pkg/channels/pico/pico.go @@ -5,7 +5,11 @@ import ( "encoding/base64" "encoding/json" "fmt" + "mime" "net/http" + "net/url" + "os" + "path/filepath" "strings" "sync" "sync/atomic" @@ -251,6 +255,10 @@ func (c *PicoChannel) ServeHTTP(w http.ResponseWriter, r *http.Request) { case "/ws", "/ws/": c.handleWebSocket(w, r) default: + if strings.HasPrefix(path, "/media/") { + c.handleMediaDownload(w, r) + return + } http.NotFound(w, r) } } @@ -317,6 +325,206 @@ func (c *PicoChannel) SendPlaceholder(ctx context.Context, chatID string) (strin return msgID, nil } +// SendMedia implements channels.MediaSender for the Pico web UI. +// Media is delivered as a normal assistant message carrying structured +// attachments plus an authenticated same-origin download URL. +func (c *PicoChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) ([]string, error) { + if !c.IsRunning() { + return nil, channels.ErrNotRunning + } + + store := c.GetMediaStore() + if store == nil { + return nil, fmt.Errorf("no media store available: %w", channels.ErrSendFailed) + } + + attachments := make([]map[string]any, 0, len(msg.Parts)) + caption := "" + + for _, part := range msg.Parts { + localPath, meta, err := store.ResolveWithMeta(part.Ref) + if err != nil { + logger.ErrorCF("pico", "Failed to resolve media ref", map[string]any{ + "ref": part.Ref, + "error": err.Error(), + }) + continue + } + + filename := strings.TrimSpace(part.Filename) + if filename == "" { + filename = strings.TrimSpace(meta.Filename) + } + if filename == "" { + filename = filepath.Base(localPath) + } + + contentType := strings.TrimSpace(part.ContentType) + if contentType == "" { + contentType = strings.TrimSpace(meta.ContentType) + } + if contentType == "" { + contentType = "application/octet-stream" + } + + attachmentType := strings.TrimSpace(part.Type) + if attachmentType == "" { + attachmentType = picoInferAttachmentType(filename, contentType) + } + + attachmentURL, err := picoDownloadURLForRef(part.Ref) + if err != nil { + logger.ErrorCF("pico", "Failed to build media download URL", map[string]any{ + "ref": part.Ref, + "error": err.Error(), + }) + continue + } + + attachments = append(attachments, map[string]any{ + "type": attachmentType, + "url": attachmentURL, + "filename": filename, + "content_type": contentType, + }) + + if caption == "" && strings.TrimSpace(part.Caption) != "" { + caption = strings.TrimSpace(part.Caption) + } + } + + if len(attachments) == 0 { + return nil, fmt.Errorf("no deliverable media parts: %w", channels.ErrSendFailed) + } + + msgID := uuid.New().String() + outMsg := newMessage(TypeMessageCreate, map[string]any{ + PayloadKeyContent: caption, + "attachments": attachments, + "message_id": msgID, + }) + + if err := c.broadcastToSession(msg.ChatID, outMsg); err != nil { + return nil, err + } + + return []string{msgID}, nil +} + +func picoDownloadURLForRef(ref string) (string, error) { + refID, err := picoMediaRefID(ref) + if err != nil { + return "", err + } + return "/pico/media/" + url.PathEscape(refID), nil +} + +func picoMediaRefID(ref string) (string, error) { + refID := strings.TrimSpace(strings.TrimPrefix(ref, "media://")) + if refID == "" || strings.Contains(refID, "/") { + return "", fmt.Errorf("invalid media ref %q", ref) + } + return refID, nil +} + +func picoInferAttachmentType(filename, contentType string) string { + contentType = strings.ToLower(strings.TrimSpace(contentType)) + filename = strings.ToLower(strings.TrimSpace(filename)) + + switch { + case strings.HasPrefix(contentType, "image/"): + return "image" + case strings.HasPrefix(contentType, "audio/"): + return "audio" + case strings.HasPrefix(contentType, "video/"): + return "video" + } + + switch ext := filepath.Ext(filename); ext { + case ".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".svg": + return "image" + case ".mp3", ".wav", ".ogg", ".m4a", ".flac", ".aac", ".wma", ".opus": + return "audio" + case ".mp4", ".avi", ".mov", ".webm", ".mkv": + return "video" + default: + return "file" + } +} + +func picoAllowsInlineDisplay(filename, contentType string) bool { + contentType = strings.ToLower(strings.TrimSpace(contentType)) + filename = strings.ToLower(strings.TrimSpace(filename)) + + if strings.Contains(contentType, "svg") || filepath.Ext(filename) == ".svg" { + return false + } + + return picoInferAttachmentType(filename, contentType) == "image" +} + +func (c *PicoChannel) handleMediaDownload(w http.ResponseWriter, r *http.Request) { + if !c.IsRunning() { + http.Error(w, "channel not running", http.StatusServiceUnavailable) + return + } + if !c.authenticate(r) { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + + refID := strings.TrimSpace(strings.TrimPrefix(strings.TrimPrefix(r.URL.Path, "/pico/media/"), "/")) + if refID == "" { + http.NotFound(w, r) + return + } + + store := c.GetMediaStore() + if store == nil { + http.Error(w, "media store unavailable", http.StatusServiceUnavailable) + return + } + + localPath, meta, err := store.ResolveWithMeta("media://" + refID) + if err != nil { + http.NotFound(w, r) + return + } + + file, err := os.Open(localPath) + if err != nil { + http.Error(w, "failed to open media", http.StatusInternalServerError) + return + } + defer file.Close() + + info, err := file.Stat() + if err != nil { + http.Error(w, "failed to stat media", http.StatusInternalServerError) + return + } + + filename := strings.TrimSpace(meta.Filename) + if filename == "" { + filename = filepath.Base(localPath) + } + contentType := strings.TrimSpace(meta.ContentType) + if contentType == "" { + contentType = "application/octet-stream" + } + + dispositionType := "attachment" + if picoAllowsInlineDisplay(filename, contentType) { + dispositionType = "inline" + } + + if cd := mime.FormatMediaType(dispositionType, map[string]string{"filename": filename}); cd != "" { + w.Header().Set("Content-Disposition", cd) + } + w.Header().Set("Content-Type", contentType) + http.ServeContent(w, r, filename, info.ModTime(), file) +} + // broadcastToSession sends a message to all connections with a matching session. func (c *PicoChannel) broadcastToSession(chatID string, msg PicoMessage) error { // chatID format: "pico:" diff --git a/pkg/channels/pico/pico_test.go b/pkg/channels/pico/pico_test.go index 59db705eb..f0d179527 100644 --- a/pkg/channels/pico/pico_test.go +++ b/pkg/channels/pico/pico_test.go @@ -4,12 +4,17 @@ import ( "context" "errors" "fmt" + "net/http/httptest" + "os" + "path/filepath" + "strings" "sync" "testing" "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/channels" "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/media" ) func newTestPicoChannel(t *testing.T) *PicoChannel { @@ -123,6 +128,98 @@ func TestBroadcastToSession_TargetsOnlyRequestedSession(t *testing.T) { } } +func TestSendMedia_ResolvesMediaBeforeDelivery(t *testing.T) { + ch := newTestPicoChannel(t) + store := media.NewFileMediaStore() + ch.SetMediaStore(store) + + if err := ch.Start(context.Background()); err != nil { + t.Fatalf("Start() error = %v", err) + } + defer ch.Stop(context.Background()) + + localPath := filepath.Join(t.TempDir(), "report.txt") + if err := os.WriteFile(localPath, []byte("attachment body"), 0o600); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + + ref, err := store.Store(localPath, media.MediaMeta{ + Filename: "report.txt", + ContentType: "text/plain", + }, "test-scope") + if err != nil { + t.Fatalf("Store() error = %v", err) + } + + closedConn := &picoConn{id: "closed", sessionID: "sess-1"} + closedConn.closed.Store(true) + ch.addConnForTest(closedConn) + + _, err = ch.SendMedia(context.Background(), bus.OutboundMediaMessage{ + ChatID: "pico:sess-1", + Parts: []bus.MediaPart{{ + Ref: ref, + Type: "file", + Filename: "report.txt", + ContentType: "text/plain", + }}, + }) + if !errors.Is(err, channels.ErrSendFailed) { + t.Fatalf("SendMedia() error = %v, want ErrSendFailed", err) + } +} + +func TestPicoDownloadURLForRef(t *testing.T) { + got, err := picoDownloadURLForRef("media://attachment-1") + if err != nil { + t.Fatalf("picoDownloadURLForRef() error = %v", err) + } + if got != "/pico/media/attachment-1" { + t.Fatalf("picoDownloadURLForRef() = %q, want %q", got, "/pico/media/attachment-1") + } +} + +func TestHandleMediaDownload_ServesStoredFile(t *testing.T) { + ch := newTestPicoChannel(t) + store := media.NewFileMediaStore() + ch.SetMediaStore(store) + + if err := ch.Start(context.Background()); err != nil { + t.Fatalf("Start() error = %v", err) + } + defer ch.Stop(context.Background()) + + localPath := filepath.Join(t.TempDir(), "report.txt") + if err := os.WriteFile(localPath, []byte("downloadable"), 0o600); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + + ref, err := store.Store(localPath, media.MediaMeta{ + Filename: "report.txt", + ContentType: "text/plain", + }, "test-scope") + if err != nil { + t.Fatalf("Store() error = %v", err) + } + + refID := strings.TrimPrefix(ref, "media://") + req := httptest.NewRequest("GET", "/pico/media/"+refID, nil) + req.Header.Set("Authorization", "Bearer test-token") + rec := httptest.NewRecorder() + + ch.ServeHTTP(rec, req) + + if rec.Code != 200 { + t.Fatalf("status = %d, want 200", rec.Code) + } + if body := rec.Body.String(); body != "downloadable" { + t.Fatalf("body = %q, want %q", body, "downloadable") + } + if got := rec.Header().Get("Content-Type"); got != "text/plain" { + t.Fatalf("Content-Type = %q, want %q", got, "text/plain") + } +} + func (c *PicoChannel) addConnForTest(pc *picoConn) { c.connsMu.Lock() defer c.connsMu.Unlock() diff --git a/pkg/providers/protocoltypes/types.go b/pkg/providers/protocoltypes/types.go index 194c1aa6f..89f68928a 100644 --- a/pkg/providers/protocoltypes/types.go +++ b/pkg/providers/protocoltypes/types.go @@ -62,10 +62,19 @@ type ContentBlock struct { CacheControl *CacheControl `json:"cache_control,omitempty"` } +type Attachment struct { + Type string `json:"type,omitempty"` + Ref string `json:"ref,omitempty"` + URL string `json:"url,omitempty"` + Filename string `json:"filename,omitempty"` + ContentType string `json:"content_type,omitempty"` +} + type Message struct { Role string `json:"role"` Content string `json:"content"` Media []string `json:"media,omitempty"` + Attachments []Attachment `json:"attachments,omitempty"` ReasoningContent string `json:"reasoning_content,omitempty"` SystemParts []ContentBlock `json:"system_parts,omitempty"` // structured system blocks for cache-aware adapters ToolCalls []ToolCall `json:"tool_calls,omitempty"` diff --git a/pkg/providers/types.go b/pkg/providers/types.go index fae252d13..23406bc45 100644 --- a/pkg/providers/types.go +++ b/pkg/providers/types.go @@ -19,6 +19,7 @@ type ( GoogleExtra = protocoltypes.GoogleExtra ContentBlock = protocoltypes.ContentBlock CacheControl = protocoltypes.CacheControl + Attachment = protocoltypes.Attachment ) type LLMProvider interface { diff --git a/web/backend/api/pico.go b/web/backend/api/pico.go index ffd0796c7..66b0bb92d 100644 --- a/web/backend/api/pico.go +++ b/web/backend/api/pico.go @@ -24,6 +24,8 @@ func (h *Handler) registerPicoRoutes(mux *http.ServeMux) { // This allows the frontend to connect via the same port as the web UI, // avoiding the need to expose extra ports for WebSocket communication. mux.HandleFunc("GET /pico/ws", h.handleWebSocketProxy()) + mux.HandleFunc("GET /pico/media/{id}", h.handlePicoMediaProxy()) + mux.HandleFunc("HEAD /pico/media/{id}", h.handlePicoMediaProxy()) } // createWsProxy creates a reverse proxy to the current gateway WebSocket endpoint. @@ -55,6 +57,53 @@ func (h *Handler) createWsProxy(origProtocol string, upstreamProtocol string) *h return wsProxy } +func (h *Handler) createPicoHTTPProxy(token string) *httputil.ReverseProxy { + return &httputil.ReverseProxy{ + Rewrite: func(r *httputil.ProxyRequest) { + target := h.gatewayProxyURL() + r.SetURL(target) + r.Out.Header.Set("Authorization", "Bearer "+token) + }, + ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) { + logger.Errorf("Failed to proxy Pico HTTP request: %v", err) + http.Error(w, "Gateway unavailable: "+err.Error(), http.StatusBadGateway) + }, + } +} + +func (h *Handler) gatewayAvailableForProxy() bool { + gateway.mu.Lock() + ensurePicoTokenCachedLocked(h.configPath) + cachedPID := gateway.pidData + trackedCmd := gateway.cmd + gateway.mu.Unlock() + + if pidData := h.sanitizeGatewayPidData(ppid.ReadPidFileWithCheck(globalConfigDir()), nil); pidData != nil { + gateway.mu.Lock() + gateway.pidData = pidData + setGatewayRuntimeStatusLocked("running") + gateway.mu.Unlock() + return true + } + + if cachedPID == nil { + return false + } + + if isCmdProcessAliveLocked(trackedCmd) { + return true + } + + gateway.mu.Lock() + if gateway.cmd == trackedCmd { + gateway.pidData = nil + setGatewayRuntimeStatusLocked("stopped") + } + available := gateway.pidData != nil + gateway.mu.Unlock() + return available +} + func decodePicoSettings(cfg *config.Config) (config.PicoSettings, bool) { if cfg == nil { return config.PicoSettings{}, false @@ -101,37 +150,7 @@ func (h *Handler) writePicoInfoResponse( // on the upstream gateway request. func (h *Handler) handleWebSocketProxy() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - gateway.mu.Lock() - ensurePicoTokenCachedLocked(h.configPath) - cachedPID := gateway.pidData - trackedCmd := gateway.cmd - gateway.mu.Unlock() - - gatewayAvailable := false - // Prefer fresh PID file data when available. - if pidData := h.sanitizeGatewayPidData(ppid.ReadPidFileWithCheck(globalConfigDir()), nil); pidData != nil { - gateway.mu.Lock() - gateway.pidData = pidData - setGatewayRuntimeStatusLocked("running") - gatewayAvailable = true - gateway.mu.Unlock() - } else if cachedPID != nil { - // No PID file now: keep availability only while tracked process is - // still alive (covers short PID-file races at startup/restart). - if isCmdProcessAliveLocked(trackedCmd) { - gatewayAvailable = true - } else { - gateway.mu.Lock() - if gateway.cmd == trackedCmd { - gateway.pidData = nil - setGatewayRuntimeStatusLocked("stopped") - } - gatewayAvailable = gateway.pidData != nil - gateway.mu.Unlock() - } - } - - if !gatewayAvailable { + if !h.gatewayAvailableForProxy() { logger.Warnf("Gateway not available for WebSocket proxy") http.Error(w, "Gateway not available", http.StatusServiceUnavailable) return @@ -153,6 +172,29 @@ func (h *Handler) handleWebSocketProxy() http.HandlerFunc { } } +func (h *Handler) handlePicoMediaProxy() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if !h.gatewayAvailableForProxy() { + logger.Warnf("Gateway not available for Pico media proxy") + http.Error(w, "Gateway not available", http.StatusServiceUnavailable) + return + } + + gateway.mu.Lock() + uiToken := gateway.picoToken + gateway.mu.Unlock() + + token := tokenPrefix + uiToken + if token == "" { + logger.Warnf("Missing Pico token for media proxy") + http.Error(w, "Invalid Pico token", http.StatusForbidden) + return + } + + h.createPicoHTTPProxy(token).ServeHTTP(w, r) + } +} + // handleGetPicoInfo returns non-secret Pico connection info for the launcher UI. // // GET /api/pico/info diff --git a/web/backend/api/pico_test.go b/web/backend/api/pico_test.go index a56cd9ba2..6bdf0c6ca 100644 --- a/web/backend/api/pico_test.go +++ b/web/backend/api/pico_test.go @@ -9,6 +9,7 @@ import ( "os" "path/filepath" "strconv" + "strings" "testing" "github.com/sipeed/picoclaw/pkg/config" @@ -649,6 +650,54 @@ func TestHandleWebSocketProxyLoadsPidDataOnDemand(t *testing.T) { } } +func TestCreatePicoHTTPProxyInjectsGatewayAuth(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "config.json") + h := NewHandler(configPath) + + cfg := config.DefaultConfig() + cfg.Gateway.Host = "127.0.0.1" + cfg.Gateway.Port = 18790 + bc := cfg.Channels["pico"] + bc.Enabled = true + decoded, err := bc.GetDecoded() + if err != nil { + t.Fatalf("GetDecoded() error = %v", err) + } + decoded.(*config.PicoSettings).SetToken("ui-token") + if err := config.SaveConfig(configPath, cfg); err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + proxy := h.createPicoHTTPProxy(tokenPrefix + "test-token" + "ui-token") + var capturedPath string + var capturedAuth string + proxy.Transport = roundTripFunc(func(req *http.Request) (*http.Response, error) { + capturedPath = req.URL.Path + capturedAuth = req.Header.Get("Authorization") + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader("proxied")), + Request: req, + }, nil + }) + + req := httptest.NewRequest(http.MethodGet, "/pico/media/attachment-1", nil) + rec := httptest.NewRecorder() + proxy.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) + } + if capturedPath != "/pico/media/attachment-1" { + t.Fatalf("capturedPath = %q, want %q", capturedPath, "/pico/media/attachment-1") + } + expected := "Bearer " + tokenPrefix + "test-token" + "ui-token" + if capturedAuth != expected { + t.Fatalf("Authorization = %q, want %q", capturedAuth, expected) + } +} + func TestHandleWebSocketProxyRejectsStalePidDataAfterProcessExit(t *testing.T) { tmpDir := t.TempDir() t.Setenv("HOME", tmpDir) @@ -797,3 +846,9 @@ func mustGatewayTestPort(t *testing.T, rawURL string) int { return port } + +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (fn roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { + return fn(req) +} diff --git a/web/backend/api/session.go b/web/backend/api/session.go index 0143f5737..0483b57cc 100644 --- a/web/backend/api/session.go +++ b/web/backend/api/session.go @@ -46,9 +46,17 @@ type sessionListItem struct { } type sessionChatMessage struct { - Role string `json:"role"` - Content string `json:"content"` - Media []string `json:"media,omitempty"` + Role string `json:"role"` + Content string `json:"content"` + Media []string `json:"media,omitempty"` + Attachments []sessionChatAttachment `json:"attachments,omitempty"` +} + +type sessionChatAttachment struct { + Type string `json:"type,omitempty"` + URL string `json:"url,omitempty"` + Filename string `json:"filename,omitempty"` + ContentType string `json:"content_type,omitempty"` } // legacyPicoSessionPrefix is the legacy key prefix used by older Pico JSON/JSONL @@ -398,10 +406,12 @@ func (h *Handler) findLegacyPicoSession(dir, sessionID string) (picoLegacySessio } func buildSessionListItem(sessionID string, sess sessionFile, toolFeedbackMaxArgsLength int) sessionListItem { + transcript := visibleSessionMessages(sess.Messages, toolFeedbackMaxArgsLength) + preview := "" - for _, msg := range sess.Messages { + for _, msg := range transcript { if msg.Role == "user" { - preview = sessionMessagePreview(msg) + preview = sessionChatMessagePreview(msg) } if preview != "" { break @@ -414,13 +424,11 @@ func buildSessionListItem(sessionID string, sess sessionFile, toolFeedbackMaxArg } title := preview - validMessageCount := len(visibleSessionMessages(sess.Messages, toolFeedbackMaxArgsLength)) - return sessionListItem{ ID: sessionID, Title: title, Preview: preview, - MessageCount: validMessageCount, + MessageCount: len(transcript), Created: sess.Created.Format(time.RFC3339), Updated: sess.Updated.Format(time.RFC3339), } @@ -441,16 +449,25 @@ func truncateRunes(s string, maxLen int) string { return string(runes[:maxLen]) + "..." } -func sessionMessageVisible(msg providers.Message) bool { - return strings.TrimSpace(msg.Content) != "" || len(msg.Media) > 0 +func sessionChatMessageVisible(msg sessionChatMessage) bool { + return strings.TrimSpace(msg.Content) != "" || len(msg.Media) > 0 || len(msg.Attachments) > 0 } -func sessionMessagePreview(msg providers.Message) string { +func sessionChatMessagePreview(msg sessionChatMessage) string { if content := strings.TrimSpace(msg.Content); content != "" { return content } + if len(msg.Attachments) > 0 { + if strings.EqualFold(strings.TrimSpace(msg.Attachments[0].Type), "image") { + return "[image]" + } + return "[attachment]" + } if len(msg.Media) > 0 { - return "[image]" + if strings.HasPrefix(strings.TrimSpace(msg.Media[0]), "data:image/") { + return "[image]" + } + return "[attachment]" } return "" } @@ -459,17 +476,21 @@ func visibleSessionMessages(messages []providers.Message, toolFeedbackMaxArgsLen transcript := make([]sessionChatMessage, 0, len(messages)) for _, msg := range messages { + attachments := sessionAttachments(msg) + switch msg.Role { case "tool": continue case "user": - if sessionMessageVisible(msg) { - transcript = append(transcript, sessionChatMessage{ - Role: "user", - Content: msg.Content, - Media: append([]string(nil), msg.Media...), - }) + chatMsg := sessionChatMessage{ + Role: "user", + Content: msg.Content, + Media: append([]string(nil), msg.Media...), + Attachments: attachments, + } + if sessionChatMessageVisible(chatMsg) { + transcript = append(transcript, chatMsg) } case "assistant": @@ -492,15 +513,25 @@ func visibleSessionMessages(messages []providers.Message, toolFeedbackMaxArgsLen // Pico web chat can persist both visible `message` tool output and a // later plain assistant reply in the same turn. Hide only the fixed // internal summary that marks handled tool delivery. - if !sessionMessageVisible(msg) || assistantMessageInternalOnly(msg) { + content := msg.Content + if assistantMessageInternalOnly(msg) { + if len(attachments) == 0 { + continue + } + content = "" + } + + chatMsg := sessionChatMessage{ + Role: "assistant", + Content: content, + Media: append([]string(nil), msg.Media...), + Attachments: attachments, + } + if !sessionChatMessageVisible(chatMsg) { continue } - transcript = append(transcript, sessionChatMessage{ - Role: "assistant", - Content: msg.Content, - Media: append([]string(nil), msg.Media...), - }) + transcript = append(transcript, chatMsg) } } @@ -518,11 +549,88 @@ func filterSessionChatMessages(messages []sessionChatMessage) []sessionChatMessa return filtered } +func sessionAttachments(msg providers.Message) []sessionChatAttachment { + if len(msg.Attachments) == 0 { + return nil + } + + attachments := make([]sessionChatAttachment, 0, len(msg.Attachments)) + for _, attachment := range msg.Attachments { + urlValue, ok := sessionAttachmentURL(attachment) + if !ok { + continue + } + attachmentType := strings.TrimSpace(attachment.Type) + if attachmentType == "" { + attachmentType = sessionAttachmentType(attachment) + } + attachments = append(attachments, sessionChatAttachment{ + Type: attachmentType, + URL: urlValue, + Filename: strings.TrimSpace(attachment.Filename), + ContentType: strings.TrimSpace(attachment.ContentType), + }) + } + + if len(attachments) == 0 { + return nil + } + return attachments +} + +func sessionAttachmentURL(attachment providers.Attachment) (string, bool) { + if rawURL := strings.TrimSpace(attachment.URL); rawURL != "" { + return rawURL, true + } + + ref := strings.TrimSpace(attachment.Ref) + if ref == "" { + return "", false + } + if strings.HasPrefix(ref, "media://") { + // Persisted session history must only expose durable attachment locations. + // media:// refs depend on the live in-memory MediaStore and may stop + // resolving after a restart or cleanup, so omit them from reopened history. + return "", false + } + return ref, true +} + +func sessionAttachmentType(attachment providers.Attachment) string { + contentType := strings.ToLower(strings.TrimSpace(attachment.ContentType)) + filename := strings.ToLower(strings.TrimSpace(attachment.Filename)) + rawRef := strings.ToLower(strings.TrimSpace(attachment.Ref)) + rawURL := strings.ToLower(strings.TrimSpace(attachment.URL)) + + switch { + case strings.HasPrefix(contentType, "image/"), + strings.HasPrefix(rawRef, "data:image/"), + strings.HasPrefix(rawURL, "data:image/"): + return "image" + case strings.HasPrefix(contentType, "audio/"): + return "audio" + case strings.HasPrefix(contentType, "video/"): + return "video" + } + + switch ext := filepath.Ext(filename); ext { + case ".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".svg": + return "image" + case ".mp3", ".wav", ".ogg", ".m4a", ".flac", ".aac", ".wma", ".opus": + return "audio" + case ".mp4", ".avi", ".mov", ".webm", ".mkv": + return "video" + default: + return "file" + } +} + func assistantMessageTransientThought(msg providers.Message) bool { return strings.TrimSpace(msg.Content) == "" && strings.TrimSpace(msg.ReasoningContent) != "" && len(msg.ToolCalls) == 0 && - len(msg.Media) == 0 + len(msg.Media) == 0 && + len(msg.Attachments) == 0 } func assistantMessageInternalOnly(msg providers.Message) bool { diff --git a/web/backend/api/session_test.go b/web/backend/api/session_test.go index f6c643bde..d2efb3879 100644 --- a/web/backend/api/session_test.go +++ b/web/backend/api/session_test.go @@ -218,6 +218,136 @@ func TestHandleGetSession_JSONLStorage(t *testing.T) { } } +func TestHandleGetSession_HidesHandledToolAttachmentsBackedByMediaRefs(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + dir := sessionsTestDir(t, configPath) + store, err := memory.NewJSONLStore(dir) + if err != nil { + t.Fatalf("NewJSONLStore() error = %v", err) + } + + sessionKey := legacyPicoSessionPrefix + "attachment-history" + for _, msg := range []providers.Message{ + {Role: "user", Content: "send me the report"}, + { + Role: "assistant", + Content: handledToolResponseSummaryText, + Attachments: []providers.Attachment{{ + Type: "file", + Ref: "media://attachment-1", + Filename: "report.txt", + ContentType: "text/plain", + }}, + }, + } { + if err := store.AddFullMessage(nil, sessionKey, msg); err != nil { + t.Fatalf("AddFullMessage() error = %v", err) + } + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/sessions/attachment-history", nil) + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + var resp struct { + Messages []sessionChatMessage `json:"messages"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + + if len(resp.Messages) != 1 { + t.Fatalf("len(resp.Messages) = %d, want 1", len(resp.Messages)) + } + if resp.Messages[0].Role != "user" || resp.Messages[0].Content != "send me the report" { + t.Fatalf("message = %#v, want only user request", resp.Messages[0]) + } +} + +func TestHandleGetSession_ExposesHandledToolAttachmentsWithDurableURL(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + dir := sessionsTestDir(t, configPath) + store, err := memory.NewJSONLStore(dir) + if err != nil { + t.Fatalf("NewJSONLStore() error = %v", err) + } + + sessionKey := legacyPicoSessionPrefix + "attachment-history-durable" + for _, msg := range []providers.Message{ + {Role: "user", Content: "send me the report"}, + { + Role: "assistant", + Content: handledToolResponseSummaryText, + Attachments: []providers.Attachment{{ + Type: "file", + URL: "https://example.com/report.txt", + Filename: "report.txt", + ContentType: "text/plain", + }}, + }, + } { + if err := store.AddFullMessage(nil, sessionKey, msg); err != nil { + t.Fatalf("AddFullMessage() error = %v", err) + } + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/sessions/attachment-history-durable", nil) + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + var resp struct { + Messages []sessionChatMessage `json:"messages"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + + if len(resp.Messages) != 2 { + t.Fatalf("len(resp.Messages) = %d, want 2", len(resp.Messages)) + } + + assistant := resp.Messages[1] + if assistant.Role != "assistant" { + t.Fatalf("assistant role = %q, want assistant", assistant.Role) + } + if assistant.Content != "" { + t.Fatalf("assistant content = %q, want empty string", assistant.Content) + } + if len(assistant.Attachments) != 1 { + t.Fatalf("len(assistant.Attachments) = %d, want 1", len(assistant.Attachments)) + } + if assistant.Attachments[0].URL != "https://example.com/report.txt" { + t.Fatalf( + "attachment url = %q, want %q", + assistant.Attachments[0].URL, + "https://example.com/report.txt", + ) + } + if assistant.Attachments[0].Filename != "report.txt" { + t.Fatalf("attachment filename = %q, want %q", assistant.Attachments[0].Filename, "report.txt") + } +} + func TestHandleSessions_JSONLScopeDiscovery(t *testing.T) { configPath, cleanup := setupOAuthTestEnv(t) defer cleanup() diff --git a/web/frontend/src/api/sessions.ts b/web/frontend/src/api/sessions.ts index dd0fa1f53..912fbecd8 100644 --- a/web/frontend/src/api/sessions.ts +++ b/web/frontend/src/api/sessions.ts @@ -15,6 +15,12 @@ export interface SessionDetail { role: "user" | "assistant" content: string media?: string[] + attachments?: { + type?: "image" | "audio" | "video" | "file" + url: string + filename?: string + content_type?: string + }[] }[] summary: string created: string diff --git a/web/frontend/src/components/chat/assistant-message.tsx b/web/frontend/src/components/chat/assistant-message.tsx index 5c2235982..2901b574a 100644 --- a/web/frontend/src/components/chat/assistant-message.tsx +++ b/web/frontend/src/components/chat/assistant-message.tsx @@ -3,6 +3,8 @@ import { IconCheck, IconChevronDown, IconCopy, + IconDownload, + IconFileText, } from "@tabler/icons-react" import { useAtom } from "jotai" import { useState } from "react" @@ -16,21 +18,30 @@ import remarkGfm from "remark-gfm" import { Button } from "@/components/ui/button" import { formatMessageTime } from "@/hooks/use-pico-chat" import { cn } from "@/lib/utils" -import { showThoughtsAtom } from "@/store/chat" +import { type ChatAttachment, showThoughtsAtom } from "@/store/chat" interface AssistantMessageProps { content: string + attachments?: ChatAttachment[] isThought?: boolean timestamp?: string | number } export function AssistantMessage({ content, + attachments = [], isThought = false, timestamp = "", }: AssistantMessageProps) { const { t } = useTranslation() const [isCopied, setIsCopied] = useState(false) + const hasText = content.trim().length > 0 + const imageAttachments = attachments.filter( + (attachment) => attachment.type === "image", + ) + const fileAttachments = attachments.filter( + (attachment) => attachment.type !== "image", + ) const [isExpanded, setIsExpanded] = useAtom(showThoughtsAtom) const formattedTimestamp = timestamp !== "" ? formatMessageTime(timestamp) : "" @@ -83,7 +94,7 @@ export function AssistantMessage({ />
)} - {(!isThought || isExpanded) && ( + {(!isThought || isExpanded) && hasText && (
)} - {!isThought && ( + + {(imageAttachments.length > 0 || fileAttachments.length > 0) && ( +
+ {imageAttachments.length > 0 && ( + + )} + + {fileAttachments.length > 0 && ( +
+ {fileAttachments.map((attachment, index) => ( + + + + + {attachment.filename || "Download attachment"} + + + + + ))} +
+ )} +
+ )} + + {!isThought && hasText && ( + )} +
+ )} + + {imageAttachments.length > 0 && ( +
+ {imageAttachments.map((attachment, index) => ( + + {attachment.filename +
+ + ))} +
+ )} + + {fileAttachments.length > 0 && ( +
+ {fileAttachments.map((attachment, index) => ( + +
+
- )} -
- )} - - {!isThought && hasText && ( - - )} -
+
+ + {attachment.filename || "Download file"} + + + {attachment.filename?.split(".").pop()?.toUpperCase() || "FILE"} + +
+
+ +
+
+ ))} +
+ )}
) } From 451db2f5d8018c0d6ba1e922b65e0be6bce95413 Mon Sep 17 00:00:00 2001 From: lxowalle <83055338+lxowalle@users.noreply.github.com> Date: Thu, 23 Apr 2026 10:35:50 +0800 Subject: [PATCH 25/31] Feat(channels): unify animated tool feedback across chat channels and Pico (#2622) * feat(channels): unify tool feedback animation across discord telegram and feishu * fix(tool-feedback): unify fallback and single-message delivery * fix(channels): finalize tool feedback in place * fix ci * feat: improve tool feedback * fix review blockers in pico token cache and tool feedback fix(provider): preserve function thought signatures fix(feishu): recover tool feedback after edit fallback * * delete dead code * fix(pico): clean up tool feedback progress state * fix ci * fix(web): preserve tool feedback line breaks in chat * fix(channels): preserve tool feedback progress state fix(pico): preserve context usage when finalizing tool feedback chore: record branch review pass fix: preserve tool feedback finalization state fix(web): handle pico history update fallback * fix ci --- cmd/picoclaw/internal/auth/wecom_test.go | 20 +- docs/channels/discord/README.md | 44 +- pkg/agent/agent.go | 1 + pkg/agent/agent_test.go | 366 +++++++++- pkg/agent/agent_utils.go | 93 +++ pkg/agent/hooks_test.go | 159 +++++ pkg/agent/pipeline_execute.go | 44 +- pkg/agent/pipeline_llm.go | 18 +- pkg/agent/subturn_test.go | 32 + pkg/agent/turn_state.go | 6 +- pkg/channels/discord/discord.go | 190 +++++- pkg/channels/discord/discord_test.go | 245 +++++++ pkg/channels/feishu/feishu_64.go | 202 +++++- pkg/channels/feishu/feishu_64_test.go | 111 +++ pkg/channels/manager.go | 162 ++++- pkg/channels/manager_test.go | 643 +++++++++++++++++- pkg/channels/matrix/matrix.go | 133 +++- pkg/channels/matrix/matrix_test.go | 29 + pkg/channels/pico/pico.go | 158 ++++- pkg/channels/pico/pico_test.go | 266 ++++++++ pkg/channels/pico/protocol.go | 1 + pkg/channels/telegram/command_registration.go | 6 +- .../telegram/command_registration_test.go | 16 +- pkg/channels/telegram/telegram.go | 223 +++++- .../telegram_group_command_filter_test.go | 2 +- pkg/channels/telegram/telegram_test.go | 229 ++++++- pkg/channels/tool_feedback_animator.go | 240 +++++++ pkg/channels/tool_feedback_animator_test.go | 121 ++++ pkg/config/config.go | 2 +- pkg/providers/cli/toolcall_utils.go | 17 +- pkg/providers/common/common.go | 107 ++- pkg/providers/common/common_test.go | 143 ++++ pkg/providers/protocoltypes/types.go | 3 +- pkg/providers/toolcall_utils_test.go | 24 + pkg/utils/tool_feedback.go | 58 +- pkg/utils/tool_feedback_test.go | 42 +- web/backend/api/pico.go | 4 + web/backend/api/pico_test.go | 52 ++ web/backend/api/session.go | 82 ++- web/backend/api/session_test.go | 330 ++++++++- .../src/components/chat/assistant-message.tsx | 4 +- web/frontend/src/features/chat/protocol.ts | 117 +++- web/frontend/src/i18n/locales/en.json | 6 +- web/frontend/src/i18n/locales/zh.json | 6 +- 44 files changed, 4569 insertions(+), 188 deletions(-) create mode 100644 pkg/channels/tool_feedback_animator.go create mode 100644 pkg/channels/tool_feedback_animator_test.go create mode 100644 pkg/providers/toolcall_utils_test.go diff --git a/cmd/picoclaw/internal/auth/wecom_test.go b/cmd/picoclaw/internal/auth/wecom_test.go index c152481be..aafd39e69 100644 --- a/cmd/picoclaw/internal/auth/wecom_test.go +++ b/cmd/picoclaw/internal/auth/wecom_test.go @@ -3,6 +3,7 @@ package auth import ( "bytes" "context" + "net" "net/http" "net/http/httptest" "net/url" @@ -19,6 +20,19 @@ import ( "github.com/sipeed/picoclaw/pkg/config" ) +func newIPv4TestServer(t *testing.T, handler http.Handler) *httptest.Server { + t.Helper() + + server := httptest.NewUnstartedServer(handler) + listener, err := net.Listen("tcp4", "127.0.0.1:0") + require.NoError(t, err) + + server.Listener = listener + server.Start() + t.Cleanup(server.Close) + return server +} + func TestNewWeComCommand(t *testing.T) { cmd := newWeComCommand() @@ -53,7 +67,7 @@ func TestBuildWeComQRCodePageURL(t *testing.T) { } func TestFetchWeComQRCode(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + server := newIPv4TestServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { assert.Equal(t, "/generate", r.URL.Path) assert.Equal(t, wecomQRSourceID, r.URL.Query().Get("source")) assert.Equal(t, wecomQRSourceID, r.URL.Query().Get("sourceID")) @@ -61,7 +75,6 @@ func TestFetchWeComQRCode(t *testing.T) { w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"data":{"scode":"scode-1","auth_url":"https://example.com/qr"}}`)) })) - defer server.Close() opts := normalizeWeComQRFlowOptions(wecomQRFlowOptions{ HTTPClient: server.Client(), @@ -78,7 +91,7 @@ func TestFetchWeComQRCode(t *testing.T) { func TestPollWeComQRCodeResult(t *testing.T) { var calls atomic.Int32 - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + server := newIPv4TestServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { call := calls.Add(1) assert.Equal(t, "/query", r.URL.Path) assert.Equal(t, "scode-1", r.URL.Query().Get("scode")) @@ -92,7 +105,6 @@ func TestPollWeComQRCodeResult(t *testing.T) { _, _ = w.Write([]byte(`{"data":{"status":"success","bot_info":{"botid":"bot-1","secret":"secret-1"}}}`)) } })) - defer server.Close() var output bytes.Buffer opts := normalizeWeComQRFlowOptions(wecomQRFlowOptions{ diff --git a/docs/channels/discord/README.md b/docs/channels/discord/README.md index 771289d28..741bc64a1 100644 --- a/docs/channels/discord/README.md +++ b/docs/channels/discord/README.md @@ -8,26 +8,56 @@ Discord is a free voice, video, and text chat application designed for communiti ```json { + "agents": { + "defaults": { + "tool_feedback": { + "enabled": true, + "max_args_length": 300 + } + } + }, "channel_list": { "discord": { "enabled": true, "type": "discord", "token": "YOUR_BOT_TOKEN", "allow_from": ["YOUR_USER_ID"], + "placeholder": { + "enabled": true, + "text": ["Thinking... 💭"] + }, "group_trigger": { "mention_only": false - } + }, + "reasoning_channel_id": "" } } } ``` -| Field | Type | Required | Description | -| ------------- | ------ | -------- | --------------------------------------------------------------------------- | -| enabled | bool | Yes | Whether to enable the Discord channel | -| token | string | Yes | Discord Bot Token | -| allow_from | array | No | Allowlist of user IDs; empty means all users are allowed | -| group_trigger | object | No | Group trigger settings (example: { "mention_only": false }) | +| Field | Type | Required | Description | +| -------------------- | ------ | -------- | --------------------------------------------------------------------------- | +| enabled | bool | Yes | Whether to enable the Discord channel | +| token | string | Yes | Discord Bot Token | +| allow_from | array | No | Allowlist of user IDs; empty means all users are allowed | +| placeholder | object | No | Placeholder message config shown while the agent is working | +| group_trigger | object | No | Group trigger settings (example: { "mention_only": false }) | +| reasoning_channel_id | string | No | Optional target channel ID for reasoning/thinking output | + +## Visible Execution Feedback + +Discord can show three different kinds of "working" feedback: + +1. Typing indicator: automatic, no extra config needed. +2. Placeholder message: enable `channel_list.discord.placeholder.enabled` to send a visible `Thinking...` message that is later edited into the final reply. +3. Tool execution feedback: enable `agents.defaults.tool_feedback.enabled` to send a short message before each tool call, for example: + +```text +🔧 `web_search` +Checking the latest PicoClaw release notes before I answer. +``` + +If you only see `Bot is typing`, check that `placeholder.enabled` or `tool_feedback.enabled` is actually set in your runtime config. ## Setup diff --git a/pkg/agent/agent.go b/pkg/agent/agent.go index 3c242eecb..3e9bd845e 100644 --- a/pkg/agent/agent.go +++ b/pkg/agent/agent.go @@ -112,6 +112,7 @@ const ( pendingTurnPrefix = "pending-" metadataKeyMessageKind = "message_kind" messageKindThought = "thought" + messageKindToolFeedback = "tool_feedback" metadataKeyAccountID = "account_id" metadataKeyGuildID = "guild_id" metadataKeyTeamID = "team_id" diff --git a/pkg/agent/agent_test.go b/pkg/agent/agent_test.go index 313569153..2addc0535 100644 --- a/pkg/agent/agent_test.go +++ b/pkg/agent/agent_test.go @@ -24,6 +24,7 @@ import ( "github.com/sipeed/picoclaw/pkg/routing" "github.com/sipeed/picoclaw/pkg/session" "github.com/sipeed/picoclaw/pkg/tools" + "github.com/sipeed/picoclaw/pkg/utils" ) type fakeChannel struct{ id string } @@ -1761,6 +1762,157 @@ func (m *toolFeedbackProvider) GetDefaultModel() string { return "heartbeat-tool-feedback-model" } +type toolFeedbackReasoningProvider struct { + filePath string + calls int +} + +func (m *toolFeedbackReasoningProvider) Chat( + ctx context.Context, + messages []providers.Message, + tools []providers.ToolDefinition, + model string, + opts map[string]any, +) (*providers.LLMResponse, error) { + m.calls++ + if m.calls == 1 { + return &providers.LLMResponse{ + ReasoningContent: "Read README.md first to confirm the context that needs to be changed.", + ToolCalls: []providers.ToolCall{{ + ID: "call_reasoning_read_file", + Type: "function", + Name: "read_file", + Arguments: map[string]any{"path": m.filePath}, + }}, + }, nil + } + + return &providers.LLMResponse{ + Content: "DONE", + ToolCalls: []providers.ToolCall{}, + }, nil +} + +func (m *toolFeedbackReasoningProvider) GetDefaultModel() string { + return "tool-feedback-reasoning-model" +} + +func TestToolFeedbackExplanationFromResponse_UsesCurrentContentFirst(t *testing.T) { + response := &providers.LLMResponse{ + Content: "Read README.md first", + ReasoningContent: "current reasoning fallback", + } + messages := []providers.Message{ + {Role: "user", Content: "check file"}, + {Role: "assistant", Content: "Previous turn explanation"}, + {Role: "tool", Content: "tool output", ToolCallID: "call_1"}, + } + + got := toolFeedbackExplanationFromResponse(response, messages, 300) + if got != "Read README.md first" { + t.Fatalf("toolFeedbackExplanationFromResponse() = %q, want current content", got) + } +} + +func TestToolFeedbackExplanationFromResponse_UsesExplicitToolCallExtraContent(t *testing.T) { + response := &providers.LLMResponse{ + ToolCalls: []providers.ToolCall{{ + ID: "call_1", + Name: "read_file", + ExtraContent: &providers.ExtraContent{ + ToolFeedbackExplanation: "Read README.md first to confirm the current project structure.", + }, + }}, + } + messages := []providers.Message{ + {Role: "user", Content: "check file"}, + {Role: "assistant", Content: ""}, + {Role: "tool", Content: "tool output", ToolCallID: "call_1"}, + } + + got := toolFeedbackExplanationFromResponse(response, messages, 300) + if got != "Read README.md first to confirm the current project structure." { + t.Fatalf("toolFeedbackExplanationFromResponse() = %q, want explicit tool feedback explanation", got) + } +} + +func TestToolFeedbackExplanationForToolCall_PrefersToolSpecificExtraContent(t *testing.T) { + response := &providers.LLMResponse{ + Content: "Shared explanation", + ToolCalls: []providers.ToolCall{ + { + ID: "call_1", + Name: "read_file", + ExtraContent: &providers.ExtraContent{ + ToolFeedbackExplanation: "Read README.md first.", + }, + }, + { + ID: "call_2", + Name: "edit_file", + ExtraContent: &providers.ExtraContent{ + ToolFeedbackExplanation: "Update config example after reading it.", + }, + }, + }, + } + + got1 := toolFeedbackExplanationForToolCall(response, response.ToolCalls[0], nil, 300) + got2 := toolFeedbackExplanationForToolCall(response, response.ToolCalls[1], nil, 300) + if got1 != "Read README.md first." { + t.Fatalf("toolFeedbackExplanationForToolCall() first = %q, want tool-specific explanation", got1) + } + if got2 != "Update config example after reading it." { + t.Fatalf("toolFeedbackExplanationForToolCall() second = %q, want tool-specific explanation", got2) + } +} + +func TestToolFeedbackExplanationForToolCall_DoesNotReuseAnotherToolCallExplanation(t *testing.T) { + response := &providers.LLMResponse{ + ToolCalls: []providers.ToolCall{ + { + ID: "call_1", + Name: "read_file", + }, + { + ID: "call_2", + Name: "edit_file", + ExtraContent: &providers.ExtraContent{ + ToolFeedbackExplanation: "Update config example after reading it.", + }, + }, + }, + } + messages := []providers.Message{ + {Role: "user", Content: "inspect the config and update the example"}, + } + + got := toolFeedbackExplanationForToolCall(response, response.ToolCalls[0], messages, 300) + want := utils.ToolFeedbackContinuationHint + ": inspect the config and update the example" + if got != want { + t.Fatalf("toolFeedbackExplanationForToolCall() = %q, want %q", got, want) + } +} + +func TestToolFeedbackExplanationFromResponse_DoesNotUseReasoningContent(t *testing.T) { + response := &providers.LLMResponse{ + Content: "", + ReasoningContent: "hidden reasoning should not be shown", + } + messages := []providers.Message{ + {Role: "user", Content: "check file"}, + {Role: "assistant", Content: "Previous turn explanation"}, + {Role: "user", Content: "Inspect README.md and update the config example."}, + {Role: "tool", Content: "tool output", ToolCallID: "call_1"}, + } + + got := toolFeedbackExplanationFromResponse(response, messages, 300) + want := utils.ToolFeedbackContinuationHint + ": Inspect README.md and update the config example." + if got != want { + t.Fatalf("toolFeedbackExplanationFromResponse() = %q, want latest user content fallback", got) + } +} + type picoInterleavedContentProvider struct { calls int } @@ -3728,7 +3880,16 @@ func TestProcessMessage_PublishesToolFeedbackWhenEnabled(t *testing.T) { t.Fatalf("unexpected tool feedback context: %+v", outbound.Context) } if !strings.Contains(outbound.Content, "`read_file`") { - t.Fatalf("tool feedback content = %q, want read_file preview", outbound.Content) + t.Fatalf("tool feedback content = %q, want read_file summary", outbound.Content) + } + if !strings.Contains(outbound.Content, utils.ToolFeedbackContinuationHint) { + t.Fatalf("tool feedback content = %q, want continuation hint fallback", outbound.Content) + } + if !strings.Contains(outbound.Content, "check tool feedback") { + t.Fatalf("tool feedback content = %q, want current user intent fallback", outbound.Content) + } + if strings.Contains(outbound.Content, "Previous turn explanation") { + t.Fatalf("tool feedback content = %q, want no previous assistant fallback", outbound.Content) } if outbound.AgentID != "main" { t.Fatalf("tool feedback agent_id = %q, want main", outbound.AgentID) @@ -3744,6 +3905,130 @@ func TestProcessMessage_PublishesToolFeedbackWhenEnabled(t *testing.T) { } } +func TestProcessMessage_DoesNotLeakReasoningContentInToolFeedback(t *testing.T) { + tmpDir := t.TempDir() + heartbeatFile := filepath.Join(tmpDir, "tool-feedback-reasoning.txt") + if err := os.WriteFile(heartbeatFile, []byte("tool feedback task"), 0o644); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + ModelName: "test-model", + MaxTokens: 4096, + MaxToolIterations: 10, + ToolFeedback: config.ToolFeedbackConfig{ + Enabled: true, + MaxArgsLength: 300, + }, + }, + }, + Tools: config.ToolsConfig{ + ReadFile: config.ReadFileToolConfig{ + Enabled: true, + }, + }, + } + + msgBus := bus.NewMessageBus() + provider := &toolFeedbackReasoningProvider{filePath: heartbeatFile} + al := NewAgentLoop(cfg, msgBus, provider) + + response, err := al.processMessage(context.Background(), testInboundMessage(bus.InboundMessage{ + Channel: "telegram", + SenderID: "user-1", + ChatID: "chat-1", + Content: "check reasoning fallback", + })) + if err != nil { + t.Fatalf("processMessage() error = %v", err) + } + if response != "DONE" { + t.Fatalf("processMessage() response = %q, want %q", response, "DONE") + } + + select { + case outbound := <-msgBus.OutboundChan(): + if !strings.Contains(outbound.Content, "`read_file`") { + t.Fatalf("tool feedback content = %q, want read_file summary", outbound.Content) + } + if !strings.Contains(outbound.Content, utils.ToolFeedbackContinuationHint) { + t.Fatalf("tool feedback content = %q, want continuation hint fallback", outbound.Content) + } + if !strings.Contains(outbound.Content, "check reasoning fallback") { + t.Fatalf("tool feedback content = %q, want current user intent fallback", outbound.Content) + } + if strings.Contains(outbound.Content, "Read README.md first") { + t.Fatalf("tool feedback content = %q, should not leak hidden reasoning", outbound.Content) + } + case <-time.After(2 * time.Second): + t.Fatal("expected outbound tool feedback without leaking reasoning") + } +} + +func TestProcessMessage_DoesNotPublishToolFeedbackForDiscordWhenDisabled(t *testing.T) { + assertToolFeedbackNotPublishedWhenDisabled(t, "discord") +} + +func assertToolFeedbackNotPublishedWhenDisabled(t *testing.T, channel string) { + t.Helper() + + tmpDir := t.TempDir() + heartbeatFile := filepath.Join(tmpDir, "tool-feedback-"+channel+".txt") + if err := os.WriteFile(heartbeatFile, []byte("tool feedback task"), 0o644); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + ModelName: "test-model", + MaxTokens: 4096, + MaxToolIterations: 10, + }, + }, + Tools: config.ToolsConfig{ + ReadFile: config.ReadFileToolConfig{ + Enabled: true, + }, + }, + } + + msgBus := bus.NewMessageBus() + provider := &toolFeedbackProvider{filePath: heartbeatFile} + al := NewAgentLoop(cfg, msgBus, provider) + + response, err := al.processMessage(context.Background(), testInboundMessage(bus.InboundMessage{ + Channel: channel, + SenderID: "user-1", + ChatID: "chat-1", + Content: "check tool feedback", + })) + if err != nil { + t.Fatalf("processMessage() error = %v", err) + } + if response != "HEARTBEAT_OK" { + t.Fatalf("processMessage() response = %q, want %q", response, "HEARTBEAT_OK") + } + + select { + case outbound := <-msgBus.OutboundChan(): + t.Fatalf("expected no outbound tool feedback for %s when disabled, got %+v", channel, outbound) + case <-time.After(200 * time.Millisecond): + } +} + +func TestProcessMessage_DoesNotPublishToolFeedbackForTelegramWhenDisabled(t *testing.T) { + assertToolFeedbackNotPublishedWhenDisabled(t, "telegram") +} + +func TestProcessMessage_DoesNotPublishToolFeedbackForFeishuWhenDisabled(t *testing.T) { + assertToolFeedbackNotPublishedWhenDisabled(t, "feishu") +} + func TestProcessMessage_MessageToolPublishesOutboundWithTurnMetadata(t *testing.T) { cfg := config.DefaultConfig() cfg.Agents.Defaults.Workspace = t.TempDir() @@ -3918,6 +4203,85 @@ func TestRunAgentLoop_PicoSkipsInterimPublishWhenNotAllowed(t *testing.T) { } } +func TestRun_PicoToolFeedbackSuppressesDuplicateInterimAssistantContent(t *testing.T) { + tmpDir := t.TempDir() + + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + ModelName: "test-model", + MaxTokens: 4096, + MaxToolIterations: 10, + ToolFeedback: config.ToolFeedbackConfig{ + Enabled: true, + }, + }, + }, + } + + msgBus := bus.NewMessageBus() + provider := &picoInterleavedContentProvider{} + al := NewAgentLoop(cfg, msgBus, provider) + + agent := al.GetRegistry().GetDefaultAgent() + if agent == nil { + t.Fatal("expected default agent") + } + agent.Tools.Register(&toolLimitTestTool{}) + + runCtx, runCancel := context.WithCancel(context.Background()) + defer runCancel() + + runDone := make(chan error, 1) + go func() { + runDone <- al.Run(runCtx) + }() + + if err := msgBus.PublishInbound(context.Background(), bus.InboundMessage{ + Channel: "pico", + SenderID: "user-1", + ChatID: "session-1", + Content: "run with tools", + }); err != nil { + t.Fatalf("PublishInbound() error = %v", err) + } + + outputs := make([]string, 0, 2) + deadline := time.After(2 * time.Second) + for len(outputs) < 2 { + select { + case outbound := <-msgBus.OutboundChan(): + outputs = append(outputs, outbound.Content) + case <-deadline: + t.Fatalf("timed out waiting for pico outputs, got %v", outputs) + } + } + + if outputs[0] != "🔧 `tool_limit_test_tool`\nintermediate model text" { + t.Fatalf("first outbound content = %q, want tool feedback summary", outputs[0]) + } + if outputs[1] != "final model text" { + t.Fatalf("second outbound content = %q, want %q", outputs[1], "final model text") + } + + runCancel() + select { + case err := <-runDone: + if err != nil { + t.Fatalf("Run() error = %v", err) + } + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for Run() to exit") + } + + select { + case outbound := <-msgBus.OutboundChan(): + t.Fatalf("unexpected extra pico output after tool feedback + final reply: %+v", outbound) + case <-time.After(200 * time.Millisecond): + } +} + func TestResolveMediaRefs_ResolvesToBase64(t *testing.T) { store := media.NewFileMediaStore() dir := t.TempDir() diff --git a/pkg/agent/agent_utils.go b/pkg/agent/agent_utils.go index 2574f0222..ff98dad68 100644 --- a/pkg/agent/agent_utils.go +++ b/pkg/agent/agent_utils.go @@ -11,6 +11,7 @@ import ( "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/commands" + "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/providers" "github.com/sipeed/picoclaw/pkg/session" "github.com/sipeed/picoclaw/pkg/utils" @@ -84,6 +85,98 @@ func outboundMessageForTurn(ts *turnState, content string) bus.OutboundMessage { } } +func outboundMessageForTurnWithKind(ts *turnState, content, kind string) bus.OutboundMessage { + msg := outboundMessageForTurn(ts, content) + if strings.TrimSpace(kind) == "" { + return msg + } + if msg.Context.Raw == nil { + msg.Context.Raw = make(map[string]string, 1) + } + msg.Context.Raw[metadataKeyMessageKind] = kind + return msg +} + +func latestUserContent(messages []providers.Message) string { + for i := len(messages) - 1; i >= 0; i-- { + msg := messages[i] + if msg.Role != "user" { + continue + } + if content := strings.TrimSpace(msg.Content); content != "" { + return content + } + } + return "" +} + +func toolFeedbackExplanationFromResponse( + response *providers.LLMResponse, + messages []providers.Message, + maxLen int, +) string { + if response == nil { + return "" + } + explanation := strings.TrimSpace(response.Content) + if explanation == "" { + explanation = toolFeedbackExplanationFromToolCalls(response.ToolCalls) + } + if explanation == "" { + explanation = toolFeedbackExplanationFromMessages(messages) + } + return utils.Truncate(explanation, maxLen) +} + +func toolFeedbackExplanationFromToolCalls(toolCalls []providers.ToolCall) string { + for _, tc := range toolCalls { + if tc.ExtraContent == nil { + continue + } + if explanation := strings.TrimSpace(tc.ExtraContent.ToolFeedbackExplanation); explanation != "" { + return explanation + } + } + return "" +} + +func toolFeedbackExplanationForToolCall( + response *providers.LLMResponse, + toolCall providers.ToolCall, + messages []providers.Message, + maxLen int, +) string { + if toolCall.ExtraContent != nil { + if explanation := strings.TrimSpace(toolCall.ExtraContent.ToolFeedbackExplanation); explanation != "" { + return utils.Truncate(explanation, maxLen) + } + } + if response == nil { + return utils.Truncate(toolFeedbackExplanationFromMessages(messages), maxLen) + } + + explanation := strings.TrimSpace(response.Content) + if explanation == "" { + explanation = toolFeedbackExplanationFromMessages(messages) + } + return utils.Truncate(explanation, maxLen) +} + +func toolFeedbackExplanationFromMessages(messages []providers.Message) string { + explanation := latestUserContent(messages) + if explanation != "" { + return utils.ToolFeedbackContinuationHint + ": " + explanation + } + return "" +} + +func shouldPublishToolFeedback(cfg *config.Config, ts *turnState) bool { + if ts == nil || ts.channel == "" || ts.opts.SuppressToolFeedback { + return false + } + return cfg != nil && cfg.Agents.Defaults.IsToolFeedbackEnabled() +} + func cloneEventArguments(args map[string]any) map[string]any { if len(args) == 0 { return nil diff --git a/pkg/agent/hooks_test.go b/pkg/agent/hooks_test.go index f024cba04..1cfa341a7 100644 --- a/pkg/agent/hooks_test.go +++ b/pkg/agent/hooks_test.go @@ -4,6 +4,7 @@ import ( "context" "errors" "os" + "strings" "sync" "testing" "time" @@ -403,6 +404,24 @@ func (h *toolRewriteHook) AfterTool( return next, HookDecision{Action: HookActionModify}, nil } +type toolRenameHook struct{} + +func (h *toolRenameHook) BeforeTool( + ctx context.Context, + call *ToolCallHookRequest, +) (*ToolCallHookRequest, HookDecision, error) { + next := call.Clone() + next.Tool = "echo_text_rewritten" + return next, HookDecision{Action: HookActionModify}, nil +} + +func (h *toolRenameHook) AfterTool( + ctx context.Context, + result *ToolResultHookResponse, +) (*ToolResultHookResponse, HookDecision, error) { + return result.Clone(), HookDecision{Action: HookActionContinue}, nil +} + func TestAgentLoop_Hooks_ToolInterceptorCanRewrite(t *testing.T) { provider := &toolHookProvider{} al, agent, cleanup := newHookTestLoop(t, provider) @@ -430,6 +449,75 @@ func TestAgentLoop_Hooks_ToolInterceptorCanRewrite(t *testing.T) { } } +type echoTextRewrittenTool struct{} + +func (t *echoTextRewrittenTool) Name() string { + return "echo_text_rewritten" +} + +func (t *echoTextRewrittenTool) Description() string { + return "echo a rewritten text argument" +} + +func (t *echoTextRewrittenTool) Parameters() map[string]any { + return map[string]any{ + "type": "object", + "properties": map[string]any{ + "text": map[string]any{ + "type": "string", + }, + }, + } +} + +func (t *echoTextRewrittenTool) Execute(ctx context.Context, args map[string]any) *tools.ToolResult { + text, _ := args["text"].(string) + return tools.SilentResult("rewritten:" + text) +} + +func TestAgentLoop_Hooks_ToolFeedbackUsesRewrittenToolName(t *testing.T) { + provider := &toolHookProvider{} + al, agent, cleanup := newHookTestLoop(t, provider) + defer cleanup() + + al.cfg.Agents.Defaults.ToolFeedback.Enabled = true + al.RegisterTool(&echoTextTool{}) + al.RegisterTool(&echoTextRewrittenTool{}) + if err := al.MountHook(NamedHook("tool-rename", &toolRenameHook{})); err != nil { + t.Fatalf("MountHook failed: %v", err) + } + + _, err := al.runAgentLoop(context.Background(), agent, processOptions{ + SessionKey: "session-1", + Channel: "cli", + ChatID: "direct", + UserMessage: "run tool", + DefaultResponse: defaultResponse, + EnableSummary: false, + SendResponse: false, + }) + if err != nil { + t.Fatalf("runAgentLoop failed: %v", err) + } + + msgBus, ok := al.bus.(*bus.MessageBus) + if !ok { + t.Fatalf("expected concrete MessageBus, got %T", al.bus) + } + + select { + case outbound := <-msgBus.OutboundChan(): + if !strings.Contains(outbound.Content, "`echo_text_rewritten`") { + t.Fatalf("tool feedback content = %q, want rewritten tool name", outbound.Content) + } + if strings.Contains(outbound.Content, "`echo_text`") { + t.Fatalf("tool feedback content = %q, want no original tool name", outbound.Content) + } + case <-time.After(2 * time.Second): + t.Fatal("expected outbound tool feedback") + } +} + type denyApprovalHook struct{} func (h *denyApprovalHook) ApproveTool(ctx context.Context, req *ToolApprovalRequest) (ApprovalDecision, error) { @@ -804,6 +892,77 @@ func TestAgentLoop_HookRespond_BusFallback(t *testing.T) { } } +func TestAgentLoop_HookRespond_ResponseHandledMediaPreservesOutboundContext(t *testing.T) { + provider := &multiToolProvider{ + toolCalls: []providers.ToolCall{ + {ID: "call-1", Name: "media_tool", Arguments: map[string]any{}}, + }, + finalContent: "done", + } + al, agent, cleanup := newHookTestLoop(t, provider) + defer cleanup() + + hook := &respondWithMediaHook{ + respondTools: map[string]bool{"media_tool": true}, + media: []string{"media://test/image.png"}, + responseHandled: true, + forLLM: "media sent successfully", + } + if err := al.MountHook(NamedHook("media-hook", hook)); err != nil { + t.Fatalf("MountHook failed: %v", err) + } + + telegramChannel := &fakeMediaChannel{fakeChannel: fakeChannel{id: "rid-telegram"}} + al.channelManager = newStartedTestChannelManager(t, + al.bus.(*bus.MessageBus), al.mediaStore, "telegram", telegramChannel) + + _, err := al.runAgentLoop(context.Background(), agent, processOptions{ + Dispatch: DispatchRequest{ + SessionKey: "session-topic-media", + SessionScope: &session.SessionScope{ + Version: session.ScopeVersionV1, + AgentID: agent.ID, + Channel: "telegram", + Dimensions: []string{"chat"}, + Values: map[string]string{ + "chat": "forum:-100123/42", + }, + }, + InboundContext: &bus.InboundContext{ + Channel: "telegram", + ChatID: "-100123", + TopicID: "42", + ChatType: "group", + SenderID: "user1", + }, + UserMessage: "send media", + }, + DefaultResponse: defaultResponse, + EnableSummary: false, + SendResponse: false, + }) + if err != nil { + t.Fatalf("runAgentLoop failed: %v", err) + } + + if len(telegramChannel.sentMedia) != 1 { + t.Fatalf("expected exactly 1 sent media message, got %d", len(telegramChannel.sentMedia)) + } + sent := telegramChannel.sentMedia[0] + if sent.Context.Channel != "telegram" || sent.Context.ChatID != "-100123" || sent.Context.TopicID != "42" { + t.Fatalf("unexpected media context: %+v", sent.Context) + } + if sent.AgentID != agent.ID { + t.Fatalf("sent media agent_id = %q, want %q", sent.AgentID, agent.ID) + } + if sent.SessionKey != "session-topic-media" { + t.Fatalf("sent media session_key = %q, want session-topic-media", sent.SessionKey) + } + if sent.Scope == nil || sent.Scope.Values["chat"] != "forum:-100123/42" { + t.Fatalf("unexpected sent media scope: %+v", sent.Scope) + } +} + type multiToolProvider struct { mu sync.Mutex callCount int diff --git a/pkg/agent/pipeline_execute.go b/pkg/agent/pipeline_execute.go index 87254619c..48e72e096 100644 --- a/pkg/agent/pipeline_execute.go +++ b/pkg/agent/pipeline_execute.go @@ -80,21 +80,16 @@ toolLoop: }, ) - if al.cfg.Agents.Defaults.IsToolFeedbackEnabled() && - ts.channel != "" && - !ts.opts.SuppressToolFeedback { - argsJSON, _ := json.Marshal(toolArgs) - feedbackPreview := utils.Truncate( - string(argsJSON), + if shouldPublishToolFeedback(al.cfg, ts) { + toolFeedbackExplanation := toolFeedbackExplanationForToolCall( + exec.response, + tc, + messages, al.cfg.Agents.Defaults.GetToolFeedbackMaxArgsLength(), ) - feedbackMsg := utils.FormatToolFeedbackMessage(toolName, feedbackPreview) + feedbackMsg := utils.FormatToolFeedbackMessage(toolName, toolFeedbackExplanation) fbCtx, fbCancel := context.WithTimeout(turnCtx, 3*time.Second) - _ = al.bus.PublishOutbound(fbCtx, bus.OutboundMessage{ - Channel: ts.channel, - ChatID: ts.chatID, - Content: feedbackMsg, - }) + _ = al.bus.PublishOutbound(fbCtx, outboundMessageForTurnWithKind(ts, feedbackMsg, messageKindToolFeedback)) fbCancel() } @@ -131,7 +126,16 @@ toolLoop: outboundMedia := bus.OutboundMediaMessage{ Channel: ts.channel, ChatID: ts.chatID, - Parts: parts, + Context: outboundContextFromInbound( + ts.opts.Dispatch.InboundContext, + ts.channel, + ts.chatID, + ts.opts.Dispatch.ReplyToMessageID(), + ), + AgentID: ts.agent.ID, + SessionKey: ts.sessionKey, + Scope: outboundScopeFromSessionScope(ts.opts.Dispatch.SessionScope), + Parts: parts, } if al.channelManager != nil && ts.channel != "" && !constants.IsInternalChannel(ts.channel) { if err := al.channelManager.SendMedia(ctx, outboundMedia); err != nil { @@ -353,16 +357,16 @@ toolLoop: }, ) - if al.cfg.Agents.Defaults.IsToolFeedbackEnabled() && - ts.channel != "" && - !ts.opts.SuppressToolFeedback { - feedbackPreview := utils.Truncate( - string(argsJSON), + if shouldPublishToolFeedback(al.cfg, ts) { + toolFeedbackExplanation := toolFeedbackExplanationForToolCall( + exec.response, + tc, + messages, al.cfg.Agents.Defaults.GetToolFeedbackMaxArgsLength(), ) - feedbackMsg := utils.FormatToolFeedbackMessage(tc.Name, feedbackPreview) + feedbackMsg := utils.FormatToolFeedbackMessage(toolName, toolFeedbackExplanation) fbCtx, fbCancel := context.WithTimeout(turnCtx, 3*time.Second) - _ = al.bus.PublishOutbound(fbCtx, outboundMessageForTurn(ts, feedbackMsg)) + _ = al.bus.PublishOutbound(fbCtx, outboundMessageForTurnWithKind(ts, feedbackMsg, messageKindToolFeedback)) fbCancel() } diff --git a/pkg/agent/pipeline_llm.go b/pkg/agent/pipeline_llm.go index c426c25c9..29940bc01 100644 --- a/pkg/agent/pipeline_llm.go +++ b/pkg/agent/pipeline_llm.go @@ -424,7 +424,11 @@ func (p *Pipeline) CallLLM( } logger.DebugCF("agent", "LLM response", llmResponseFields) - if al.bus != nil && ts.channel == "pico" && len(exec.response.ToolCalls) > 0 && ts.opts.AllowInterimPicoPublish { + if al.bus != nil && + ts.channel == "pico" && + len(exec.response.ToolCalls) > 0 && + ts.opts.AllowInterimPicoPublish && + !shouldPublishToolFeedback(al.cfg, ts) { if strings.TrimSpace(exec.response.Content) != "" { outCtx, outCancel := context.WithTimeout(turnCtx, 3*time.Second) publishErr := al.bus.PublishOutbound(outCtx, bus.OutboundMessage{ @@ -496,7 +500,19 @@ func (p *Pipeline) CallLLM( } for _, tc := range exec.normalizedToolCalls { argumentsJSON, _ := json.Marshal(tc.Arguments) + toolFeedbackExplanation := toolFeedbackExplanationForToolCall( + exec.response, + tc, + exec.messages, + al.cfg.Agents.Defaults.GetToolFeedbackMaxArgsLength(), + ) extraContent := tc.ExtraContent + if strings.TrimSpace(toolFeedbackExplanation) != "" { + if extraContent == nil { + extraContent = &providers.ExtraContent{} + } + extraContent.ToolFeedbackExplanation = toolFeedbackExplanation + } thoughtSignature := "" if tc.Function != nil { thoughtSignature = tc.Function.ThoughtSignature diff --git a/pkg/agent/subturn_test.go b/pkg/agent/subturn_test.go index 6a2ba835d..040063249 100644 --- a/pkg/agent/subturn_test.go +++ b/pkg/agent/subturn_test.go @@ -1650,6 +1650,38 @@ func TestGrandchildAbort_CascadingCancellation(t *testing.T) { } } +func TestNestedSubTurn_GracefulFinishSignalsDirectChildren(t *testing.T) { + parentCtx := context.Background() + parentTS := &turnState{ + ctx: parentCtx, + turnID: "parent-graceful", + depth: 1, + pendingResults: make(chan *tools.ToolResult, 16), + } + parentTS.ctx, parentTS.cancelFunc = context.WithCancel(parentCtx) + + childTS := &turnState{ + ctx: context.Background(), + turnID: "child-graceful", + depth: 2, + parentTurnState: parentTS, + pendingResults: make(chan *tools.ToolResult, 16), + } + + if childTS.IsParentEnded() { + t.Fatal("IsParentEnded should be false before parent finishes") + } + + parentTS.Finish(false) + + if !parentTS.parentEnded.Load() { + t.Fatal("parentEnded should be true after graceful finish") + } + if !childTS.IsParentEnded() { + t.Fatal("nested child should observe parent graceful finish") + } +} + // TestSpawnDuringAbort_RaceCondition verifies behavior when trying to spawn // a sub-turn while the parent is being aborted. func TestSpawnDuringAbort_RaceCondition(t *testing.T) { diff --git a/pkg/agent/turn_state.go b/pkg/agent/turn_state.go index edf8654b5..8b5fd4e2c 100644 --- a/pkg/agent/turn_state.go +++ b/pkg/agent/turn_state.go @@ -554,9 +554,9 @@ func (ts *turnState) Finish(isHardAbort bool) { ts.mu.Unlock() }) - // If this is a graceful finish (not hard abort), signal to children - if !isHardAbort && ts.parentTurnState == nil { - // This is a root turn finishing gracefully + // Any graceful finish must signal direct children so nested SubTurns can + // observe parent completion and decide whether to stop or continue. + if !isHardAbort { ts.parentEnded.Store(true) } diff --git a/pkg/channels/discord/discord.go b/pkg/channels/discord/discord.go index 28f7277d3..514b9b3b1 100644 --- a/pkg/channels/discord/discord.go +++ b/pkg/channels/discord/discord.go @@ -45,9 +45,12 @@ type DiscordChannel struct { cancel context.CancelFunc typingMu sync.Mutex typingStop map[string]chan struct{} // chatID → stop signal - botUserID string // stored for mention checking + progress *channels.ToolFeedbackAnimator + botUserID string // stored for mention checking bus *bus.MessageBus tts tts.TTSProvider + playTTSFn func(context.Context, *discordgo.VoiceConnection, string, uint64) + ttsVoiceFn func(string) (*discordgo.VoiceConnection, bool) voiceMu sync.RWMutex voiceSSRC map[string]map[uint32]string // guildID -> ssrc -> userID @@ -84,7 +87,7 @@ func NewDiscordChannel( channels.WithReasoningChannelID(bc.ReasoningChannelID), ) - return &DiscordChannel{ + ch := &DiscordChannel{ BaseChannel: base, bc: bc, session: session, @@ -93,7 +96,11 @@ func NewDiscordChannel( typingStop: make(map[string]chan struct{}), bus: bus, voiceSSRC: make(map[string]map[uint32]string), - }, nil + } + ch.playTTSFn = ch.playTTS + ch.ttsVoiceFn = ch.voiceConnectionForTTS + ch.progress = channels.NewToolFeedbackAnimator(ch.EditMessage) + return ch, nil } func (c *DiscordChannel) Start(ctx context.Context) error { @@ -142,6 +149,9 @@ func (c *DiscordChannel) Stop(ctx context.Context) error { if c.cancel != nil { c.cancel() } + if c.progress != nil { + c.progress.StopAll() + } if err := c.session.Close(); err != nil { return fmt.Errorf("failed to close discord session: %w", err) @@ -164,32 +174,88 @@ func (c *DiscordChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]s return nil, nil } - if c.tts != nil { - if ch, err := c.session.State.Channel(channelID); err == nil && ch.GuildID != "" { - if vc, ok := c.session.VoiceConnections[ch.GuildID]; ok && vc != nil { - // Cancel any previous TTS playback - c.ttsMu.Lock() - if c.cancelTTS != nil { - c.cancelTTS() - } - ttsCtx, ttsCancel := context.WithCancel(c.ctx) - c.ttsPlayID++ - playID := c.ttsPlayID - c.cancelTTS = ttsCancel - c.ttsMu.Unlock() - - go c.playTTS(ttsCtx, vc, msg.Content, playID) + isToolFeedback := outboundMessageIsToolFeedback(msg) + if isToolFeedback { + if msgID, handled, err := c.progress.Update(ctx, channelID, msg.Content); handled { + if err != nil { + return nil, err } + return []string{msgID}, nil + } + } + trackedMsgID, hasTrackedMsg := c.currentToolFeedbackMessage(channelID) + c.maybeStartTTS(channelID, msg.Content, isToolFeedback) + if !isToolFeedback { + if msgIDs, handled := c.FinalizeToolFeedbackMessage(ctx, msg); handled { + return msgIDs, nil } } - msgID, err := c.sendChunk(ctx, channelID, msg.Content, msg.ReplyToMessageID) + content := msg.Content + if isToolFeedback { + content = channels.InitialAnimatedToolFeedbackContent(msg.Content) + } + msgID, err := c.sendChunk(ctx, channelID, content, msg.ReplyToMessageID) if err != nil { return nil, err } + if isToolFeedback { + c.RecordToolFeedbackMessage(channelID, msgID, msg.Content) + } else if hasTrackedMsg { + c.dismissTrackedToolFeedbackMessage(ctx, channelID, trackedMsgID) + } return []string{msgID}, nil } +func (c *DiscordChannel) maybeStartTTS(channelID, content string, isToolFeedback bool) { + if c.tts == nil || isToolFeedback { + return + } + + voiceFn := c.ttsVoiceFn + if voiceFn == nil { + voiceFn = c.voiceConnectionForTTS + } + vc, ok := voiceFn(channelID) + if !ok || vc == nil { + return + } + + // Cancel any previous TTS playback. + c.ttsMu.Lock() + if c.cancelTTS != nil { + c.cancelTTS() + } + ttsCtx, ttsCancel := context.WithCancel(c.ctx) + c.ttsPlayID++ + playID := c.ttsPlayID + c.cancelTTS = ttsCancel + playFn := c.playTTSFn + c.ttsMu.Unlock() + + if playFn == nil { + playFn = c.playTTS + } + go playFn(ttsCtx, vc, content, playID) +} + +func (c *DiscordChannel) voiceConnectionForTTS(channelID string) (*discordgo.VoiceConnection, bool) { + if c.session == nil || c.session.State == nil { + return nil, false + } + + ch, err := c.session.State.Channel(channelID) + if err != nil || ch == nil || ch.GuildID == "" { + return nil, false + } + + vc, ok := c.session.VoiceConnections[ch.GuildID] + if !ok || vc == nil { + return nil, false + } + return vc, true +} + // SendMedia implements the channels.MediaSender interface. func (c *DiscordChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) ([]string, error) { if !c.IsRunning() { @@ -200,6 +266,7 @@ func (c *DiscordChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMes if channelID == "" { return nil, fmt.Errorf("channel ID is empty") } + trackedMsgID, hasTrackedMsg := c.currentToolFeedbackMessage(channelID) store := c.GetMediaStore() if store == nil { @@ -281,6 +348,9 @@ func (c *DiscordChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMes if r.err != nil { return nil, fmt.Errorf("discord send media: %w", channels.ErrTemporary) } + if hasTrackedMsg { + c.dismissTrackedToolFeedbackMessage(ctx, channelID, trackedMsgID) + } return []string{r.id}, nil case <-sendCtx.Done(): // Close all file readers @@ -295,10 +365,15 @@ func (c *DiscordChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMes // EditMessage implements channels.MessageEditor. func (c *DiscordChannel) EditMessage(ctx context.Context, chatID string, messageID string, content string) error { - _, err := c.session.ChannelMessageEdit(chatID, messageID, content) + _, err := c.session.ChannelMessageEdit(chatID, messageID, content, discordgo.WithContext(ctx)) return err } +// DeleteMessage implements channels.MessageDeleter. +func (c *DiscordChannel) DeleteMessage(ctx context.Context, chatID string, messageID string) error { + return c.session.ChannelMessageDelete(chatID, messageID, discordgo.WithContext(ctx)) +} + // SendPlaceholder implements channels.PlaceholderCapable. // It sends a placeholder message that will later be edited to the actual // response via EditMessage (channels.MessageEditor). @@ -317,6 +392,81 @@ func (c *DiscordChannel) SendPlaceholder(ctx context.Context, chatID string) (st return msg.ID, nil } +func outboundMessageIsToolFeedback(msg bus.OutboundMessage) bool { + if len(msg.Context.Raw) == 0 { + return false + } + return strings.EqualFold(strings.TrimSpace(msg.Context.Raw["message_kind"]), "tool_feedback") +} + +func (c *DiscordChannel) currentToolFeedbackMessage(chatID string) (string, bool) { + if c.progress == nil { + return "", false + } + return c.progress.Current(chatID) +} + +func (c *DiscordChannel) takeToolFeedbackMessage(chatID string) (string, string, bool) { + if c.progress == nil { + return "", "", false + } + return c.progress.Take(chatID) +} + +func (c *DiscordChannel) RecordToolFeedbackMessage(chatID, messageID, content string) { + if c.progress == nil { + return + } + c.progress.Record(chatID, messageID, content) +} + +func (c *DiscordChannel) ClearToolFeedbackMessage(chatID string) { + if c.progress == nil { + return + } + c.progress.Clear(chatID) +} + +func (c *DiscordChannel) DismissToolFeedbackMessage(ctx context.Context, chatID string) { + msgID, ok := c.currentToolFeedbackMessage(chatID) + if !ok { + return + } + c.dismissTrackedToolFeedbackMessage(ctx, chatID, msgID) +} + +func (c *DiscordChannel) dismissTrackedToolFeedbackMessage(ctx context.Context, chatID, messageID string) { + if strings.TrimSpace(chatID) == "" || strings.TrimSpace(messageID) == "" { + return + } + c.ClearToolFeedbackMessage(chatID) + _ = c.DeleteMessage(ctx, chatID, messageID) +} + +func (c *DiscordChannel) finalizeTrackedToolFeedbackMessage( + ctx context.Context, + chatID string, + content string, + editFn func(context.Context, string, string, string) error, +) ([]string, bool) { + msgID, baseContent, ok := c.takeToolFeedbackMessage(chatID) + if !ok || editFn == nil { + return nil, false + } + if err := editFn(ctx, chatID, msgID, content); err != nil { + c.RecordToolFeedbackMessage(chatID, msgID, baseContent) + return nil, false + } + return []string{msgID}, true +} + +func (c *DiscordChannel) FinalizeToolFeedbackMessage(ctx context.Context, msg bus.OutboundMessage) ([]string, bool) { + if outboundMessageIsToolFeedback(msg) { + return nil, false + } + return c.finalizeTrackedToolFeedbackMessage(ctx, msg.ChatID, msg.Content, c.EditMessage) +} + func (c *DiscordChannel) sendChunk(ctx context.Context, channelID, content, replyToID string) (string, error) { // Use the passed ctx for timeout control sendCtx, cancel := context.WithTimeout(ctx, sendTimeout) diff --git a/pkg/channels/discord/discord_test.go b/pkg/channels/discord/discord_test.go index 0cd5328f4..d42b0bc52 100644 --- a/pkg/channels/discord/discord_test.go +++ b/pkg/channels/discord/discord_test.go @@ -1,13 +1,37 @@ package discord import ( + "context" + "io" "net/http" + "net/http/httptest" "net/url" + "reflect" + "sync" "testing" + "time" "github.com/bwmarrin/discordgo" + + "github.com/sipeed/picoclaw/pkg/audio/tts" + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/channels" ) +type stubTTSProvider struct{} + +func (stubTTSProvider) Name() string { return "stub-tts" } + +func (stubTTSProvider) Synthesize(context.Context, string) (io.ReadCloser, error) { + return io.NopCloser(&noopReader{}), nil +} + +type noopReader struct{} + +func (*noopReader) Read(p []byte) (int, error) { + return 0, io.EOF +} + func TestApplyDiscordProxy_CustomProxy(t *testing.T) { session, err := discordgo.New("Bot test-token") if err != nil { @@ -89,3 +113,224 @@ func TestApplyDiscordProxy_InvalidProxyURL(t *testing.T) { t.Fatal("applyDiscordProxy() expected error for invalid proxy URL, got nil") } } + +func TestSend_NonToolFeedbackDeletesTrackedProgressMessage(t *testing.T) { + var ( + mu sync.Mutex + requests []string + ) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + requests = append(requests, r.Method+" "+r.URL.Path) + mu.Unlock() + + switch { + case r.Method == http.MethodPatch && r.URL.Path == "/channels/chat-1/messages/prog-1": + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"id":"prog-1"}`) + default: + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + })) + defer server.Close() + + origChannels := discordgo.EndpointChannels + discordgo.EndpointChannels = server.URL + "/channels/" + defer func() { + discordgo.EndpointChannels = origChannels + }() + + session, err := discordgo.New("Bot test-token") + if err != nil { + t.Fatalf("discordgo.New() error: %v", err) + } + session.Client = server.Client() + + ch := &DiscordChannel{ + BaseChannel: channels.NewBaseChannel("discord", nil, bus.NewMessageBus(), nil), + session: session, + ctx: context.Background(), + typingStop: make(map[string]chan struct{}), + voiceSSRC: make(map[string]map[uint32]string), + } + ch.progress = channels.NewToolFeedbackAnimator(ch.EditMessage) + ch.SetRunning(true) + ch.RecordToolFeedbackMessage("chat-1", "prog-1", "🔧 `read_file`") + + ids, err := ch.Send(context.Background(), bus.OutboundMessage{ + ChatID: "chat-1", + Content: "final reply", + Context: bus.InboundContext{ + Channel: "discord", + ChatID: "chat-1", + }, + }) + if err != nil { + t.Fatalf("Send() error = %v", err) + } + if got, want := ids, []string{"prog-1"}; !reflect.DeepEqual(got, want) { + t.Fatalf("Send() ids = %v, want %v", got, want) + } + if _, ok := ch.currentToolFeedbackMessage("chat-1"); ok { + t.Fatal("expected tracked tool feedback message to be cleared") + } + + mu.Lock() + defer mu.Unlock() + wantRequests := []string{ + "PATCH /channels/chat-1/messages/prog-1", + } + if !reflect.DeepEqual(requests, wantRequests) { + t.Fatalf("requests = %v, want %v", requests, wantRequests) + } +} + +func TestEditMessage_UsesContextCancellation(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + select { + case <-r.Context().Done(): + return + case <-time.After(time.Second): + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"id":"msg-1"}`) + } + })) + defer server.Close() + + origChannels := discordgo.EndpointChannels + discordgo.EndpointChannels = server.URL + "/channels/" + defer func() { + discordgo.EndpointChannels = origChannels + }() + + session, err := discordgo.New("Bot test-token") + if err != nil { + t.Fatalf("discordgo.New() error: %v", err) + } + session.Client = server.Client() + + ch := &DiscordChannel{ + BaseChannel: channels.NewBaseChannel("discord", nil, bus.NewMessageBus(), nil), + session: session, + } + + ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) + defer cancel() + + start := time.Now() + err = ch.EditMessage(ctx, "chat-1", "msg-1", "still running") + elapsed := time.Since(start) + + if err == nil { + t.Fatal("expected EditMessage() to fail when context times out") + } + if elapsed >= 500*time.Millisecond { + t.Fatalf("EditMessage() ignored context timeout, elapsed=%v", elapsed) + } +} + +func TestFinalizeTrackedToolFeedbackMessage_StopsTrackingBeforeEdit(t *testing.T) { + ch := &DiscordChannel{ + progress: channels.NewToolFeedbackAnimator(nil), + } + ch.RecordToolFeedbackMessage("chat-1", "msg-1", "🔧 `read_file`") + + msgIDs, handled := ch.finalizeTrackedToolFeedbackMessage( + context.Background(), + "chat-1", + "final reply", + func(_ context.Context, chatID, messageID, content string) error { + if _, ok := ch.currentToolFeedbackMessage(chatID); ok { + t.Fatal("expected tracked tool feedback to be stopped before edit") + } + if chatID != "chat-1" || messageID != "msg-1" || content != "final reply" { + t.Fatalf("unexpected edit args: %s %s %s", chatID, messageID, content) + } + return nil + }, + ) + if !handled { + t.Fatal("expected finalizeTrackedToolFeedbackMessage to handle tracked message") + } + if got, want := msgIDs, []string{"msg-1"}; !reflect.DeepEqual(got, want) { + t.Fatalf("finalizeTrackedToolFeedbackMessage() ids = %v, want %v", got, want) + } +} + +func TestSend_NonToolFeedbackFinalizerStillStartsTTS(t *testing.T) { + var ( + mu sync.Mutex + requests []string + ) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + requests = append(requests, r.Method+" "+r.URL.Path) + mu.Unlock() + + switch { + case r.Method == http.MethodPatch && r.URL.Path == "/channels/chat-1/messages/prog-1": + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"id":"prog-1"}`) + default: + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + })) + defer server.Close() + + origChannels := discordgo.EndpointChannels + discordgo.EndpointChannels = server.URL + "/channels/" + defer func() { + discordgo.EndpointChannels = origChannels + }() + + session, err := discordgo.New("Bot test-token") + if err != nil { + t.Fatalf("discordgo.New() error: %v", err) + } + session.Client = server.Client() + + ttsStarted := make(chan string, 1) + ch := &DiscordChannel{ + BaseChannel: channels.NewBaseChannel("discord", nil, bus.NewMessageBus(), nil), + session: session, + ctx: context.Background(), + typingStop: make(map[string]chan struct{}), + voiceSSRC: make(map[string]map[uint32]string), + tts: tts.TTSProvider(stubTTSProvider{}), + } + ch.ttsVoiceFn = func(string) (*discordgo.VoiceConnection, bool) { + return &discordgo.VoiceConnection{}, true + } + ch.playTTSFn = func(_ context.Context, _ *discordgo.VoiceConnection, text string, _ uint64) { + ttsStarted <- text + } + ch.progress = channels.NewToolFeedbackAnimator(ch.EditMessage) + ch.SetRunning(true) + ch.RecordToolFeedbackMessage("chat-1", "prog-1", "🔧 `read_file`") + + ids, err := ch.Send(context.Background(), bus.OutboundMessage{ + ChatID: "chat-1", + Content: "final reply", + Context: bus.InboundContext{ + Channel: "discord", + ChatID: "chat-1", + }, + }) + if err != nil { + t.Fatalf("Send() error = %v", err) + } + if got, want := ids, []string{"prog-1"}; !reflect.DeepEqual(got, want) { + t.Fatalf("Send() ids = %v, want %v", got, want) + } + + select { + case got := <-ttsStarted: + if got != "final reply" { + t.Fatalf("TTS content = %q, want final reply", got) + } + case <-time.After(2 * time.Second): + t.Fatal("expected TTS to start for finalized tracked tool feedback reply") + } +} diff --git a/pkg/channels/feishu/feishu_64.go b/pkg/channels/feishu/feishu_64.go index 02ee47d69..8f3ae39d9 100644 --- a/pkg/channels/feishu/feishu_64.go +++ b/pkg/channels/feishu/feishu_64.go @@ -49,6 +49,9 @@ type FeishuChannel struct { mu sync.Mutex cancel context.CancelFunc + + progress *channels.ToolFeedbackAnimator + deleteMessageFn func(context.Context, string, string) error } type cachedMessage struct { @@ -74,6 +77,8 @@ func NewFeishuChannel(bc *config.Channel, cfg *config.FeishuSettings, bus *bus.M tokenCache: tc, client: lark.NewClient(cfg.AppID, cfg.AppSecret.String(), opts...), } + ch.deleteMessageFn = ch.deleteMessageAPI + ch.progress = channels.NewToolFeedbackAnimator(ch.EditMessage) ch.SetOwner(ch) return ch, nil } @@ -132,6 +137,9 @@ func (c *FeishuChannel) Stop(ctx context.Context) error { } c.wsClient = nil c.mu.Unlock() + if c.progress != nil { + c.progress.StopAll() + } c.SetRunning(false) logger.InfoC("feishu", "Feishu channel stopped") @@ -149,17 +157,55 @@ func (c *FeishuChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]st return nil, fmt.Errorf("chat ID is empty: %w", channels.ErrSendFailed) } + isToolFeedback := outboundMessageIsToolFeedback(msg) + if isToolFeedback { + if msgID, handled, err := c.progress.Update(ctx, msg.ChatID, msg.Content); handled { + if err != nil { + // Feishu can fall back to plain text for a previous progress + // message, and those messages cannot be patched through the card + // edit API. Drop the stale tracker and recreate the progress + // message so later tool feedback is not blocked. + c.resetTrackedToolFeedbackAfterEditFailure(ctx, msg.ChatID) + } else { + return []string{msgID}, nil + } + } + } else { + if msgIDs, handled := c.FinalizeToolFeedbackMessage(ctx, msg); handled { + return msgIDs, nil + } + } + trackedMsgID, hasTrackedMsg := c.currentToolFeedbackMessage(msg.ChatID) + // Build interactive card with markdown content - cardContent, err := buildMarkdownCard(msg.Content) + sendContent := msg.Content + if isToolFeedback { + sendContent = channels.InitialAnimatedToolFeedbackContent(msg.Content) + } + cardContent, err := buildMarkdownCard(sendContent) if err != nil { // If card build fails, fall back to plain text - return nil, c.sendText(ctx, msg.ChatID, msg.Content) + msgID, sendErr := c.sendText(ctx, msg.ChatID, sendContent) + if sendErr != nil { + return nil, sendErr + } + if isToolFeedback { + c.RecordToolFeedbackMessage(msg.ChatID, msgID, msg.Content) + } else if hasTrackedMsg { + c.dismissTrackedToolFeedbackMessage(ctx, msg.ChatID, trackedMsgID) + } + return []string{msgID}, nil } // First attempt: try sending as interactive card - err = c.sendCard(ctx, msg.ChatID, cardContent) + msgID, err := c.sendCard(ctx, msg.ChatID, cardContent) if err == nil { - return nil, nil + if isToolFeedback { + c.RecordToolFeedbackMessage(msg.ChatID, msgID, msg.Content) + } else if hasTrackedMsg { + c.dismissTrackedToolFeedbackMessage(ctx, msg.ChatID, trackedMsgID) + } + return []string{msgID}, nil } // Check if error is due to card table limit (error code 11310) @@ -174,9 +220,14 @@ func (c *FeishuChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]st }) // Second attempt: fall back to plain text message - textErr := c.sendText(ctx, msg.ChatID, msg.Content) + msgID, textErr := c.sendText(ctx, msg.ChatID, sendContent) if textErr == nil { - return nil, nil + if isToolFeedback { + c.RecordToolFeedbackMessage(msg.ChatID, msgID, msg.Content) + } else if hasTrackedMsg { + c.dismissTrackedToolFeedbackMessage(ctx, msg.ChatID, trackedMsgID) + } + return []string{msgID}, nil } // If text also fails, return the text error return nil, textErr @@ -210,6 +261,31 @@ func (c *FeishuChannel) EditMessage(ctx context.Context, chatID, messageID, cont return nil } +// DeleteMessage implements channels.MessageDeleter. +func (c *FeishuChannel) DeleteMessage(ctx context.Context, chatID, messageID string) error { + deleteFn := c.deleteMessageFn + if deleteFn == nil { + deleteFn = c.deleteMessageAPI + } + return deleteFn(ctx, chatID, messageID) +} + +func (c *FeishuChannel) deleteMessageAPI(ctx context.Context, chatID, messageID string) error { + req := larkim.NewDeleteMessageReqBuilder(). + MessageId(messageID). + Build() + + resp, err := c.client.Im.V1.Message.Delete(ctx, req) + if err != nil { + return fmt.Errorf("feishu delete: %w", err) + } + if !resp.Success() { + c.invalidateTokenOnAuthError(resp.Code) + return fmt.Errorf("feishu delete api error (code=%d msg=%s)", resp.Code, resp.Msg) + } + return nil +} + // SendPlaceholder implements channels.PlaceholderCapable. // Sends an interactive card with placeholder text and returns its message ID. func (c *FeishuChannel) SendPlaceholder(ctx context.Context, chatID string) (string, error) { @@ -251,6 +327,93 @@ func (c *FeishuChannel) SendPlaceholder(ctx context.Context, chatID string) (str return "", nil } +func outboundMessageIsToolFeedback(msg bus.OutboundMessage) bool { + if len(msg.Context.Raw) == 0 { + return false + } + return strings.EqualFold(strings.TrimSpace(msg.Context.Raw["message_kind"]), "tool_feedback") +} + +func (c *FeishuChannel) currentToolFeedbackMessage(chatID string) (string, bool) { + if c.progress == nil { + return "", false + } + return c.progress.Current(chatID) +} + +func (c *FeishuChannel) takeToolFeedbackMessage(chatID string) (string, string, bool) { + if c.progress == nil { + return "", "", false + } + return c.progress.Take(chatID) +} + +func (c *FeishuChannel) RecordToolFeedbackMessage(chatID, messageID, content string) { + if c.progress == nil { + return + } + c.progress.Record(chatID, messageID, content) +} + +func (c *FeishuChannel) ClearToolFeedbackMessage(chatID string) { + if c.progress == nil { + return + } + c.progress.Clear(chatID) +} + +func (c *FeishuChannel) DismissToolFeedbackMessage(ctx context.Context, chatID string) { + msgID, ok := c.currentToolFeedbackMessage(chatID) + if !ok { + return + } + c.dismissTrackedToolFeedbackMessage(ctx, chatID, msgID) +} + +func (c *FeishuChannel) resetTrackedToolFeedbackAfterEditFailure(ctx context.Context, chatID string) { + msgID, ok := c.currentToolFeedbackMessage(chatID) + if !ok { + return + } + c.dismissTrackedToolFeedbackMessage(ctx, chatID, msgID) +} + +func (c *FeishuChannel) dismissTrackedToolFeedbackMessage(ctx context.Context, chatID, messageID string) { + if strings.TrimSpace(chatID) == "" || strings.TrimSpace(messageID) == "" { + return + } + c.ClearToolFeedbackMessage(chatID) + deleteFn := c.deleteMessageFn + if deleteFn == nil { + deleteFn = c.deleteMessageAPI + } + _ = deleteFn(ctx, chatID, messageID) +} + +func (c *FeishuChannel) finalizeTrackedToolFeedbackMessage( + ctx context.Context, + chatID string, + content string, + editFn func(context.Context, string, string, string) error, +) ([]string, bool) { + msgID, baseContent, ok := c.takeToolFeedbackMessage(chatID) + if !ok || editFn == nil { + return nil, false + } + if err := editFn(ctx, chatID, msgID, content); err != nil { + c.RecordToolFeedbackMessage(chatID, msgID, baseContent) + return nil, false + } + return []string{msgID}, true +} + +func (c *FeishuChannel) FinalizeToolFeedbackMessage(ctx context.Context, msg bus.OutboundMessage) ([]string, bool) { + if outboundMessageIsToolFeedback(msg) { + return nil, false + } + return c.finalizeTrackedToolFeedbackMessage(ctx, msg.ChatID, msg.Content, c.EditMessage) +} + // ReactToMessage implements channels.ReactionCapable. // Adds a reaction (randomly chosen from config) and returns an undo function to remove it. func (c *FeishuChannel) ReactToMessage(ctx context.Context, chatID, messageID string) (func(), error) { @@ -323,6 +486,7 @@ func (c *FeishuChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMess if !c.IsRunning() { return nil, channels.ErrNotRunning } + trackedMsgID, hasTrackedMsg := c.currentToolFeedbackMessage(msg.ChatID) if msg.ChatID == "" { return nil, fmt.Errorf("chat ID is empty: %w", channels.ErrSendFailed) @@ -339,6 +503,10 @@ func (c *FeishuChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMess } } + if hasTrackedMsg { + c.dismissTrackedToolFeedbackMessage(ctx, msg.ChatID, trackedMsgID) + } + return nil, nil } @@ -801,7 +969,7 @@ func appendMediaTags(content, messageType string, mediaRefs []string) string { } // sendCard sends an interactive card message to a chat. -func (c *FeishuChannel) sendCard(ctx context.Context, chatID, cardContent string) error { +func (c *FeishuChannel) sendCard(ctx context.Context, chatID, cardContent string) (string, error) { req := larkim.NewCreateMessageReqBuilder(). ReceiveIdType(larkim.ReceiveIdTypeChatId). Body(larkim.NewCreateMessageReqBodyBuilder(). @@ -813,23 +981,26 @@ func (c *FeishuChannel) sendCard(ctx context.Context, chatID, cardContent string resp, err := c.client.Im.V1.Message.Create(ctx, req) if err != nil { - return fmt.Errorf("feishu send card: %w", channels.ErrTemporary) + return "", fmt.Errorf("feishu send card: %w", channels.ErrTemporary) } if !resp.Success() { c.invalidateTokenOnAuthError(resp.Code) - return fmt.Errorf("feishu api error (code=%d msg=%s): %w", resp.Code, resp.Msg, channels.ErrTemporary) + return "", fmt.Errorf("feishu api error (code=%d msg=%s): %w", resp.Code, resp.Msg, channels.ErrTemporary) } logger.DebugCF("feishu", "Feishu card message sent", map[string]any{ "chat_id": chatID, }) - return nil + if resp.Data != nil && resp.Data.MessageId != nil { + return *resp.Data.MessageId, nil + } + return "", nil } // sendText sends a plain text message to a chat (fallback when card fails). -func (c *FeishuChannel) sendText(ctx context.Context, chatID, text string) error { +func (c *FeishuChannel) sendText(ctx context.Context, chatID, text string) (string, error) { content, _ := json.Marshal(map[string]string{"text": text}) req := larkim.NewCreateMessageReqBuilder(). @@ -843,18 +1014,21 @@ func (c *FeishuChannel) sendText(ctx context.Context, chatID, text string) error resp, err := c.client.Im.V1.Message.Create(ctx, req) if err != nil { - return fmt.Errorf("feishu send text: %w", channels.ErrTemporary) + return "", fmt.Errorf("feishu send text: %w", channels.ErrTemporary) } if !resp.Success() { - return fmt.Errorf("feishu text api error (code=%d msg=%s): %w", resp.Code, resp.Msg, channels.ErrTemporary) + return "", fmt.Errorf("feishu text api error (code=%d msg=%s): %w", resp.Code, resp.Msg, channels.ErrTemporary) } logger.DebugCF("feishu", "Feishu text message sent (fallback)", map[string]any{ "chat_id": chatID, }) - return nil + if resp.Data != nil && resp.Data.MessageId != nil { + return *resp.Data.MessageId, nil + } + return "", nil } // sendImage uploads an image and sends it as a message. diff --git a/pkg/channels/feishu/feishu_64_test.go b/pkg/channels/feishu/feishu_64_test.go index 9010abf69..48fdf0f74 100644 --- a/pkg/channels/feishu/feishu_64_test.go +++ b/pkg/channels/feishu/feishu_64_test.go @@ -3,9 +3,13 @@ package feishu import ( + "context" + "errors" "testing" larkim "github.com/larksuite/oapi-sdk-go/v3/service/im/v1" + + "github.com/sipeed/picoclaw/pkg/channels" ) func TestExtractContent(t *testing.T) { @@ -279,3 +283,110 @@ func TestExtractFeishuSenderID(t *testing.T) { }) } } + +func TestFinalizeTrackedToolFeedbackMessage_ClearAfterSuccessfulEdit(t *testing.T) { + ch := &FeishuChannel{ + progress: channels.NewToolFeedbackAnimator(nil), + } + ch.RecordToolFeedbackMessage("chat-1", "msg-1", "🔧 `read_file`") + + msgIDs, handled := ch.finalizeTrackedToolFeedbackMessage( + context.Background(), + "chat-1", + "final reply", + func(_ context.Context, chatID, messageID, content string) error { + if chatID != "chat-1" || messageID != "msg-1" || content != "final reply" { + t.Fatalf("unexpected edit args: %s %s %s", chatID, messageID, content) + } + return nil + }, + ) + if !handled { + t.Fatal("expected finalizeTrackedToolFeedbackMessage to handle tracked message") + } + if len(msgIDs) != 1 || msgIDs[0] != "msg-1" { + t.Fatalf("unexpected msgIDs: %v", msgIDs) + } + if _, ok := ch.currentToolFeedbackMessage("chat-1"); ok { + t.Fatal("expected tracked tool feedback to be cleared after successful edit") + } +} + +func TestFinalizeTrackedToolFeedbackMessage_StopsTrackingBeforeEdit(t *testing.T) { + ch := &FeishuChannel{ + progress: channels.NewToolFeedbackAnimator(nil), + } + ch.RecordToolFeedbackMessage("chat-1", "msg-1", "🔧 `read_file`") + + msgIDs, handled := ch.finalizeTrackedToolFeedbackMessage( + context.Background(), + "chat-1", + "final reply", + func(_ context.Context, chatID, messageID, content string) error { + if _, ok := ch.currentToolFeedbackMessage(chatID); ok { + t.Fatal("expected tracked tool feedback to be stopped before edit") + } + if chatID != "chat-1" || messageID != "msg-1" || content != "final reply" { + t.Fatalf("unexpected edit args: %s %s %s", chatID, messageID, content) + } + return nil + }, + ) + if !handled { + t.Fatal("expected finalizeTrackedToolFeedbackMessage to handle tracked message") + } + if len(msgIDs) != 1 || msgIDs[0] != "msg-1" { + t.Fatalf("unexpected msgIDs: %v", msgIDs) + } +} + +func TestFinalizeTrackedToolFeedbackMessage_EditFailureKeepsTrackedMessage(t *testing.T) { + ch := &FeishuChannel{ + progress: channels.NewToolFeedbackAnimator(nil), + } + ch.RecordToolFeedbackMessage("chat-1", "msg-1", "🔧 `read_file`") + + msgIDs, handled := ch.finalizeTrackedToolFeedbackMessage( + context.Background(), + "chat-1", + "final reply", + func(context.Context, string, string, string) error { + return errors.New("edit failed") + }, + ) + if handled { + t.Fatal("expected finalizeTrackedToolFeedbackMessage to report unhandled on edit failure") + } + if len(msgIDs) != 0 { + t.Fatalf("unexpected msgIDs: %v", msgIDs) + } + if msgID, ok := ch.currentToolFeedbackMessage("chat-1"); !ok || msgID != "msg-1" { + t.Fatalf("expected tracked tool feedback to remain after failed edit, got (%q, %v)", msgID, ok) + } +} + +func TestResetTrackedToolFeedbackAfterEditFailure_DismissesTrackedMessage(t *testing.T) { + var ( + deletedChatID string + deletedMsgID string + ) + + ch := &FeishuChannel{ + progress: channels.NewToolFeedbackAnimator(nil), + deleteMessageFn: func(_ context.Context, chatID, messageID string) error { + deletedChatID = chatID + deletedMsgID = messageID + return nil + }, + } + ch.RecordToolFeedbackMessage("chat-1", "msg-1", "🔧 `read_file`") + + ch.resetTrackedToolFeedbackAfterEditFailure(context.Background(), "chat-1") + + if deletedChatID != "chat-1" || deletedMsgID != "msg-1" { + t.Fatalf("unexpected delete target: chat=%q msg=%q", deletedChatID, deletedMsgID) + } + if _, ok := ch.currentToolFeedbackMessage("chat-1"); ok { + t.Fatal("expected tracked tool feedback to be cleared after edit failure reset") + } +} diff --git a/pkg/channels/manager.go b/pkg/channels/manager.go index 928676cbc..2ffb1bb10 100644 --- a/pkg/channels/manager.go +++ b/pkg/channels/manager.go @@ -14,6 +14,7 @@ import ( "net" "net/http" "sort" + "strings" "sync" "time" @@ -25,6 +26,7 @@ import ( "github.com/sipeed/picoclaw/pkg/health" "github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/media" + "github.com/sipeed/picoclaw/pkg/utils" ) const ( @@ -96,6 +98,23 @@ type Manager struct { channelHashes map[string]string // channel name → config hash } +type toolFeedbackMessageTracker interface { + RecordToolFeedbackMessage(chatID, messageID, content string) + ClearToolFeedbackMessage(chatID string) +} + +type toolFeedbackMessageCleaner interface { + DismissToolFeedbackMessage(ctx context.Context, chatID string) +} + +type toolFeedbackMessageTargetResolver interface { + ToolFeedbackMessageChatID(chatID string, outboundCtx *bus.InboundContext) string +} + +type toolFeedbackMessageContentPreparer interface { + PrepareToolFeedbackMessageContent(content string) string +} + type asyncTask struct { cancel context.CancelFunc } @@ -108,6 +127,13 @@ func outboundMessageChatID(msg bus.OutboundMessage) string { return msg.ChatID } +func outboundMessageIsToolFeedback(msg bus.OutboundMessage) bool { + if len(msg.Context.Raw) == 0 { + return false + } + return strings.EqualFold(strings.TrimSpace(msg.Context.Raw["message_kind"]), "tool_feedback") +} + func outboundMediaChannel(msg bus.OutboundMediaMessage) string { return msg.Context.Channel } @@ -116,6 +142,47 @@ func outboundMediaChatID(msg bus.OutboundMediaMessage) string { return msg.ChatID } +func trackedToolFeedbackMessageChatID(ch Channel, chatID string, outboundCtx *bus.InboundContext) string { + if resolver, ok := ch.(toolFeedbackMessageTargetResolver); ok { + if resolved := strings.TrimSpace(resolver.ToolFeedbackMessageChatID(chatID, outboundCtx)); resolved != "" { + return resolved + } + } + return strings.TrimSpace(chatID) +} + +func dismissTrackedToolFeedbackMessage( + ctx context.Context, + ch Channel, + chatID string, + outboundCtx *bus.InboundContext, +) { + trackedChatID := trackedToolFeedbackMessageChatID(ch, chatID, outboundCtx) + if trackedChatID == "" { + return + } + if cleaner, ok := ch.(toolFeedbackMessageCleaner); ok { + cleaner.DismissToolFeedbackMessage(ctx, trackedChatID) + return + } + if tracker, ok := ch.(toolFeedbackMessageTracker); ok { + tracker.ClearToolFeedbackMessage(trackedChatID) + } +} + +func prepareToolFeedbackMessageContent(ch Channel, content string) string { + prepared := strings.TrimSpace(content) + if prepared == "" { + return "" + } + if preparer, ok := ch.(toolFeedbackMessageContentPreparer); ok { + if candidate := strings.TrimSpace(preparer.PrepareToolFeedbackMessageContent(prepared)); candidate != "" { + return candidate + } + } + return prepared +} + // RecordPlaceholder registers a placeholder message for later editing. // Implements PlaceholderRecorder. func (m *Manager) RecordPlaceholder(channel, chatID, placeholderID string) { @@ -196,7 +263,19 @@ func (m *Manager) preSend(ctx context.Context, name string, msg bus.OutboundMess } } - // 3. If a stream already finalized this message, delete the placeholder and skip send + isToolFeedback := outboundMessageIsToolFeedback(msg) + + // 3. If a stream already finalized this chat, stale tool feedback must be + // dropped without consuming the final-response marker. Streaming finalization + // bypasses the worker queue, so older queued feedback can arrive before the + // normal final outbound message that cleans up the marker and placeholder. + if isToolFeedback { + if _, loaded := m.streamActive.Load(key); loaded { + return nil, true + } + } + + // 4. If a stream already finalized this message, delete the placeholder and skip send if _, loaded := m.streamActive.LoadAndDelete(key); loaded { if v, loaded := m.placeholders.LoadAndDelete(key); loaded { if entry, ok := v.(placeholderEntry); ok && entry.id != "" { @@ -208,14 +287,29 @@ func (m *Manager) preSend(ctx context.Context, name string, msg bus.OutboundMess } } } + if !isToolFeedback { + dismissTrackedToolFeedbackMessage(ctx, ch, chatID, &msg.Context) + } return nil, true } - // 4. Try editing placeholder + // 5. Try editing placeholder if v, loaded := m.placeholders.LoadAndDelete(key); loaded { if entry, ok := v.(placeholderEntry); ok && entry.id != "" { if editor, ok := ch.(MessageEditor); ok { - if err := editor.EditMessage(ctx, chatID, entry.id, msg.Content); err == nil { + content := msg.Content + trackedContent := msg.Content + if isToolFeedback { + trackedContent = prepareToolFeedbackMessageContent(ch, msg.Content) + content = InitialAnimatedToolFeedbackContent(trackedContent) + } + if err := editor.EditMessage(ctx, chatID, entry.id, content); err == nil { + trackedChatID := trackedToolFeedbackMessageChatID(ch, chatID, &msg.Context) + if tracker, ok := ch.(toolFeedbackMessageTracker); ok && isToolFeedback { + tracker.RecordToolFeedbackMessage(trackedChatID, entry.id, trackedContent) + } else if !isToolFeedback { + dismissTrackedToolFeedbackMessage(ctx, ch, chatID, &msg.Context) + } return []string{entry.id}, true } // edit failed → fall through to normal Send @@ -312,22 +406,35 @@ func (m *Manager) GetStreamer(ctx context.Context, channelName, chatID string) ( // Mark streamActive on Finalize so preSend knows to clean up the placeholder key := channelName + ":" + chatID return &finalizeHookStreamer{ - Streamer: streamer, - onFinalize: func() { m.streamActive.Store(key, true) }, + Streamer: streamer, + onFinalize: func(finalizeCtx context.Context) { + dismissTrackedToolFeedbackMessage( + finalizeCtx, + ch, + chatID, + &bus.InboundContext{ + Channel: channelName, + ChatID: chatID, + }, + ) + m.streamActive.Store(key, true) + }, }, true } // finalizeHookStreamer wraps a Streamer to run a hook on Finalize. type finalizeHookStreamer struct { Streamer - onFinalize func() + onFinalize func(context.Context) } func (s *finalizeHookStreamer) Finalize(ctx context.Context, content string) error { if err := s.Streamer.Finalize(ctx, content); err != nil { return err } - s.onFinalize() + if s.onFinalize != nil { + s.onFinalize(ctx) + } return nil } @@ -769,18 +876,21 @@ func (m *Manager) runWorker(ctx context.Context, name string, w *channelWorker) // Collect all message chunks to send var chunks []string - // Step 1: Try marker-based splitting if enabled - if m.config != nil && m.config.Agents.Defaults.SplitOnMarker { + // Step 1: Try marker-based splitting if enabled. + // Tool feedback must stay a single message, so it skips marker splitting. + if m.config != nil && m.config.Agents.Defaults.SplitOnMarker && !outboundMessageIsToolFeedback(msg) { if markerChunks := SplitByMarker(msg.Content); len(markerChunks) > 1 { for _, chunk := range markerChunks { - chunks = append(chunks, splitByLength(chunk, maxLen)...) + chunkMsg := msg + chunkMsg.Content = chunk + chunks = append(chunks, splitOutboundMessageContent(chunkMsg, maxLen)...) } } } // Step 2: Fallback to length-based splitting if no chunks from marker if len(chunks) == 0 { - chunks = splitByLength(msg.Content, maxLen) + chunks = splitOutboundMessageContent(msg, maxLen) } // Step 3: Send all chunks @@ -795,12 +905,25 @@ func (m *Manager) runWorker(ctx context.Context, name string, w *channelWorker) } } -// splitByLength splits content by maxLen if needed, otherwise returns single chunk. -func splitByLength(content string, maxLen int) []string { - if maxLen > 0 && len([]rune(content)) > maxLen { - return SplitMessage(content, maxLen) +// splitOutboundMessageContent splits regular outbound content by maxLen, but +// keeps tool feedback in a single message by truncating the explanation body. +func splitOutboundMessageContent(msg bus.OutboundMessage, maxLen int) []string { + if maxLen > 0 { + if outboundMessageIsToolFeedback(msg) { + animationSafeLen := maxLen - MaxToolFeedbackAnimationFrameLength() + if animationSafeLen <= 0 { + animationSafeLen = maxLen + } + if len([]rune(msg.Content)) > animationSafeLen { + return []string{utils.FitToolFeedbackMessage(msg.Content, animationSafeLen)} + } + return []string{msg.Content} + } + if len([]rune(msg.Content)) > maxLen { + return SplitMessage(msg.Content, maxLen) + } } - return []string{content} + return []string{msg.Content} } // sendWithRetry sends a message through the channel with rate limiting and @@ -1264,13 +1387,16 @@ func (m *Manager) SendMessage(ctx context.Context, msg bus.OutboundMessage) erro if mlp, ok := w.ch.(MessageLengthProvider); ok { maxLen = mlp.MaxMessageLength() } - if maxLen > 0 && len([]rune(msg.Content)) > maxLen { - for _, chunk := range SplitMessage(msg.Content, maxLen) { + if chunks := splitOutboundMessageContent(msg, maxLen); len(chunks) > 1 { + for _, chunk := range chunks { chunkMsg := msg chunkMsg.Content = chunk m.sendWithRetry(ctx, channelName, w, chunkMsg) } } else { + if len(chunks) == 1 { + msg.Content = chunks[0] + } m.sendWithRetry(ctx, channelName, w, msg) } return nil diff --git a/pkg/channels/manager_test.go b/pkg/channels/manager_test.go index 881993d9c..273c90468 100644 --- a/pkg/channels/manager_test.go +++ b/pkg/channels/manager_test.go @@ -13,6 +13,8 @@ import ( "golang.org/x/time/rate" "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/utils" ) // mockChannel is a test double that delegates Send to a configurable function. @@ -76,8 +78,9 @@ func (m *mockMediaChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaM type mockDeletingMediaChannel struct { mockMediaChannel - deleteCalls int - lastDeleted struct { + deleteCalls int + dismissedChatID string + lastDeleted struct { chatID string messageID string } @@ -94,6 +97,48 @@ func (m *mockDeletingMediaChannel) DeleteMessage( return nil } +func (m *mockDeletingMediaChannel) DismissToolFeedbackMessage(_ context.Context, chatID string) { + m.dismissedChatID = chatID +} + +type mockStreamer struct { + finalizeFn func(context.Context, string) error +} + +func (m *mockStreamer) Update(context.Context, string) error { return nil } + +func (m *mockStreamer) Finalize(ctx context.Context, content string) error { + if m.finalizeFn != nil { + return m.finalizeFn(ctx, content) + } + return nil +} + +func (m *mockStreamer) Cancel(context.Context) {} + +type mockStreamingChannel struct { + mockMessageEditor + streamer Streamer + resolveChatIDFn func(chatID string, outboundCtx *bus.InboundContext) string +} + +func (m *mockStreamingChannel) BeginStream(context.Context, string) (Streamer, error) { + if m.streamer == nil { + return nil, errors.New("missing streamer") + } + return m.streamer, nil +} + +func (m *mockStreamingChannel) ToolFeedbackMessageChatID( + chatID string, + outboundCtx *bus.InboundContext, +) string { + if m.resolveChatIDFn != nil { + return m.resolveChatIDFn(chatID, outboundCtx) + } + return chatID +} + // newTestManager creates a minimal Manager suitable for unit tests. func newTestManager() *Manager { return &Manager{ @@ -715,13 +760,72 @@ func TestSendWithRetry_ExponentialBackoff(t *testing.T) { // mockMessageEditor is a channel that supports MessageEditor. type mockMessageEditor struct { mockChannel - editFn func(ctx context.Context, chatID, messageID, content string) error + editFn func(ctx context.Context, chatID, messageID, content string) error + finalizeFn func(ctx context.Context, msg bus.OutboundMessage) ([]string, bool) + finalizeCalled bool + recordedChatID string + recordedMessageID string + recordedContent string + clearedChatID string + dismissedChatID string } func (m *mockMessageEditor) EditMessage(ctx context.Context, chatID, messageID, content string) error { return m.editFn(ctx, chatID, messageID, content) } +func (m *mockMessageEditor) RecordToolFeedbackMessage(chatID, messageID, content string) { + m.recordedChatID = chatID + m.recordedMessageID = messageID + m.recordedContent = content +} + +func (m *mockMessageEditor) ClearToolFeedbackMessage(chatID string) { + m.clearedChatID = chatID +} + +func (m *mockMessageEditor) DismissToolFeedbackMessage(_ context.Context, chatID string) { + m.dismissedChatID = chatID +} + +func (m *mockMessageEditor) FinalizeToolFeedbackMessage( + ctx context.Context, + msg bus.OutboundMessage, +) ([]string, bool) { + m.finalizeCalled = true + if m.finalizeFn == nil { + return nil, false + } + return m.finalizeFn(ctx, msg) +} + +type mockResolvedToolFeedbackEditor struct { + mockMessageEditor + resolveChatIDFn func(chatID string, outboundCtx *bus.InboundContext) string +} + +func (m *mockResolvedToolFeedbackEditor) ToolFeedbackMessageChatID( + chatID string, + outboundCtx *bus.InboundContext, +) string { + if m.resolveChatIDFn != nil { + return m.resolveChatIDFn(chatID, outboundCtx) + } + return chatID +} + +type mockPreparedToolFeedbackEditor struct { + mockMessageEditor + prepareFn func(content string) string +} + +func (m *mockPreparedToolFeedbackEditor) PrepareToolFeedbackMessageContent(content string) string { + if m.prepareFn != nil { + return m.prepareFn(content) + } + return content +} + func TestPreSend_PlaceholderEditSuccess(t *testing.T) { m := newTestManager() var sendCalled bool @@ -766,6 +870,539 @@ func TestPreSend_PlaceholderEditSuccess(t *testing.T) { } } +func TestPreSend_ToolFeedbackPlaceholderEditRecordsTrackedMessage(t *testing.T) { + m := newTestManager() + + ch := &mockMessageEditor{ + editFn: func(_ context.Context, chatID, messageID, content string) error { + if chatID != "123" || messageID != "456" || content != "hello" { + t.Fatalf("unexpected edit args: %s %s %s", chatID, messageID, content) + } + return nil + }, + } + + m.RecordPlaceholder("test", "123", "456") + + msg := testOutboundMessage(bus.OutboundMessage{ + Channel: "test", + ChatID: "123", + Content: "hello", + Context: bus.InboundContext{ + Channel: "test", + ChatID: "123", + Raw: map[string]string{ + "message_kind": "tool_feedback", + }, + }, + }) + _, edited := m.preSend(context.Background(), "test", msg, ch) + if !edited { + t.Fatal("expected preSend to edit placeholder") + } + if ch.recordedChatID != "123" || ch.recordedMessageID != "456" { + t.Fatalf("expected tracked message 123/456, got %q/%q", ch.recordedChatID, ch.recordedMessageID) + } +} + +func TestPreSend_ToolFeedbackPlaceholderEditUsesResolvedTrackedChatID(t *testing.T) { + m := newTestManager() + + ch := &mockResolvedToolFeedbackEditor{ + mockMessageEditor: mockMessageEditor{ + editFn: func(_ context.Context, chatID, messageID, content string) error { + if chatID != "-100123" || messageID != "456" || content != "hello" { + t.Fatalf("unexpected edit args: %s %s %s", chatID, messageID, content) + } + return nil + }, + }, + resolveChatIDFn: func(chatID string, outboundCtx *bus.InboundContext) string { + if chatID != "-100123" { + t.Fatalf("expected raw chat ID, got %q", chatID) + } + if outboundCtx == nil || outboundCtx.TopicID != "42" { + t.Fatalf("expected topic-aware outbound context, got %+v", outboundCtx) + } + return chatID + "/" + outboundCtx.TopicID + }, + } + + m.RecordPlaceholder("test", "-100123", "456") + + msg := testOutboundMessage(bus.OutboundMessage{ + Channel: "test", + ChatID: "-100123", + Content: "hello", + Context: bus.InboundContext{ + Channel: "test", + ChatID: "-100123", + TopicID: "42", + Raw: map[string]string{ + "message_kind": "tool_feedback", + }, + }, + }) + _, edited := m.preSend(context.Background(), "test", msg, ch) + if !edited { + t.Fatal("expected preSend to edit placeholder") + } + if ch.recordedChatID != "-100123/42" || ch.recordedMessageID != "456" { + t.Fatalf("expected resolved tracked message -100123/42/456, got %q/%q", + ch.recordedChatID, ch.recordedMessageID) + } +} + +func TestPreSend_ToolFeedbackPlaceholderEditUsesPreparedContent(t *testing.T) { + m := newTestManager() + + const rawContent = "🔧 `read_file`\n" + "" + const preparedContent = "🔧 `read_file`\n<raw>" + + ch := &mockPreparedToolFeedbackEditor{ + mockMessageEditor: mockMessageEditor{ + editFn: func(_ context.Context, chatID, messageID, content string) error { + if chatID != "123" || messageID != "456" { + t.Fatalf("unexpected edit target: %s/%s", chatID, messageID) + } + if content != InitialAnimatedToolFeedbackContent(preparedContent) { + t.Fatalf("unexpected prepared content: %q", content) + } + return nil + }, + }, + prepareFn: func(content string) string { + if content != rawContent { + t.Fatalf("unexpected raw tool feedback: %q", content) + } + return preparedContent + }, + } + + m.RecordPlaceholder("test", "123", "456") + + msg := testOutboundMessage(bus.OutboundMessage{ + Channel: "test", + ChatID: "123", + Content: rawContent, + Context: bus.InboundContext{ + Channel: "test", + ChatID: "123", + Raw: map[string]string{ + "message_kind": "tool_feedback", + }, + }, + }) + + _, edited := m.preSend(context.Background(), "test", msg, ch) + if !edited { + t.Fatal("expected preSend to edit placeholder") + } + if ch.recordedContent != preparedContent { + t.Fatalf("expected tracked content %q, got %q", preparedContent, ch.recordedContent) + } +} + +func TestPreSend_NonToolFeedbackLeavesTrackedMessageForChannelSend(t *testing.T) { + m := newTestManager() + ch := &mockMessageEditor{} + + msg := testOutboundMessage(bus.OutboundMessage{ + Channel: "test", + ChatID: "123", + Content: "final reply", + Context: bus.InboundContext{ + Channel: "test", + ChatID: "123", + }, + }) + + _, edited := m.preSend(context.Background(), "test", msg, ch) + if edited { + t.Fatal("expected preSend to fall through when no placeholder exists") + } + if ch.dismissedChatID != "" { + t.Fatalf("expected tracked tool feedback cleanup to be deferred to channel send, got %q", ch.dismissedChatID) + } +} + +func TestPreSend_NonToolFeedbackDefersTrackedMessageFinalizationToChannelSend(t *testing.T) { + m := newTestManager() + ch := &mockMessageEditor{ + finalizeFn: func(_ context.Context, msg bus.OutboundMessage) ([]string, bool) { + if msg.ChatID != "123" || msg.Content != "final reply" { + t.Fatalf("unexpected finalize msg: %+v", msg) + } + return []string{"tool-msg-1"}, true + }, + } + + msg := testOutboundMessage(bus.OutboundMessage{ + Channel: "test", + ChatID: "123", + Content: "final reply", + Context: bus.InboundContext{ + Channel: "test", + ChatID: "123", + }, + }) + + msgIDs, handled := m.preSend(context.Background(), "test", msg, ch) + if handled { + t.Fatalf("expected preSend to defer to channel Send, got msgIDs=%v", msgIDs) + } + if len(msgIDs) != 0 { + t.Fatalf("expected no msgIDs from preSend, got %v", msgIDs) + } + if ch.dismissedChatID != "" { + t.Fatalf("expected tracked cleanup to remain in channel Send, got %q", ch.dismissedChatID) + } + if ch.finalizeCalled { + t.Fatal("expected preSend to skip channel tool feedback finalization") + } +} + +func TestPreSend_StaleToolFeedbackDoesNotConsumeStreamActiveMarker(t *testing.T) { + m := newTestManager() + m.streamActive.Store("test:123", true) + m.RecordPlaceholder("test", "123", "placeholder-1") + + var editedContent string + ch := &mockMessageEditor{ + editFn: func(_ context.Context, chatID, messageID, content string) error { + if chatID != "123" || messageID != "placeholder-1" { + t.Fatalf("unexpected edit target: %s/%s", chatID, messageID) + } + editedContent = content + return nil + }, + } + + toolFeedback := testOutboundMessage(bus.OutboundMessage{ + Channel: "test", + ChatID: "123", + Content: "🔧 `read_file`\nReading config", + Context: bus.InboundContext{ + Channel: "test", + ChatID: "123", + Raw: map[string]string{ + "message_kind": "tool_feedback", + }, + }, + }) + + msgIDs, handled := m.preSend(context.Background(), "test", toolFeedback, ch) + if !handled { + t.Fatal("expected stale tool feedback to be dropped after stream finalize") + } + if len(msgIDs) != 0 { + t.Fatalf("expected no delivered message IDs for stale feedback, got %v", msgIDs) + } + if _, ok := m.streamActive.Load("test:123"); !ok { + t.Fatal("expected streamActive marker to remain for the final outbound message") + } + if _, ok := m.placeholders.Load("test:123"); !ok { + t.Fatal("expected placeholder cleanup to remain deferred to the final outbound message") + } + if ch.editedMessages != 0 { + t.Fatalf("expected no placeholder edit for stale feedback, got %d edits", ch.editedMessages) + } + + finalMsg := testOutboundMessage(bus.OutboundMessage{ + Channel: "test", + ChatID: "123", + Content: "final streamed reply", + Context: bus.InboundContext{ + Channel: "test", + ChatID: "123", + }, + }) + + _, handled = m.preSend(context.Background(), "test", finalMsg, ch) + if !handled { + t.Fatal("expected final outbound message to consume streamActive marker") + } + if _, ok := m.streamActive.Load("test:123"); ok { + t.Fatal("expected streamActive marker to be cleared by final outbound message") + } + if _, ok := m.placeholders.Load("test:123"); ok { + t.Fatal("expected placeholder to be cleaned up by final outbound message") + } + if editedContent != "final streamed reply" { + t.Fatalf("editedContent = %q, want final streamed reply", editedContent) + } +} + +func TestPreSendMedia_LeavesTrackedMessageForChannelSend(t *testing.T) { + m := newTestManager() + ch := &mockDeletingMediaChannel{} + + m.preSendMedia(context.Background(), "test", bus.OutboundMediaMessage{ + ChatID: "123", + Context: bus.InboundContext{ + Channel: "test", + ChatID: "123", + }, + }, ch) + + if ch.dismissedChatID != "" { + t.Fatalf( + "expected tracked tool feedback cleanup to be deferred to channel media send, got %q", + ch.dismissedChatID, + ) + } +} + +func TestSplitOutboundMessageContent_ToolFeedbackTruncatesInsteadOfSplitting(t *testing.T) { + msg := testOutboundMessage(bus.OutboundMessage{ + Channel: "test", + ChatID: "123", + Content: "\U0001f527 `read_file`\nRead README.md first to confirm the current project structure before editing the config example.", + Context: bus.InboundContext{ + Channel: "test", + ChatID: "123", + Raw: map[string]string{ + "message_kind": "tool_feedback", + }, + }, + }) + + chunks := splitOutboundMessageContent(msg, 40) + if len(chunks) != 1 { + t.Fatalf("len(chunks) = %d, want 1", len(chunks)) + } + want := utils.FitToolFeedbackMessage(msg.Content, 40-MaxToolFeedbackAnimationFrameLength()) + if chunks[0] != want { + t.Fatalf("chunk = %q, want %q", chunks[0], want) + } +} + +func TestSplitOutboundMessageContent_ToolFeedbackReservesAnimationFrame(t *testing.T) { + msg := testOutboundMessage(bus.OutboundMessage{ + Channel: "test", + ChatID: "123", + Content: "🔧 `read_file`\n1234567890", + Context: bus.InboundContext{ + Channel: "test", + ChatID: "123", + Raw: map[string]string{ + "message_kind": "tool_feedback", + }, + }, + }) + + chunks := splitOutboundMessageContent(msg, len([]rune(msg.Content))) + if len(chunks) != 1 { + t.Fatalf("len(chunks) = %d, want 1", len(chunks)) + } + + animated := formatAnimatedToolFeedbackContent(chunks[0], strings.Repeat(".", MaxToolFeedbackAnimationFrameLength())) + if got, maxLen := len([]rune(animated)), len([]rune(msg.Content)); got > maxLen { + t.Fatalf("animated len = %d, want <= %d; content=%q", got, maxLen, animated) + } +} + +func TestGetStreamer_FinalizeDismissesTrackedToolFeedback(t *testing.T) { + m := newTestManager() + ch := &mockStreamingChannel{ + mockMessageEditor: mockMessageEditor{}, + streamer: &mockStreamer{ + finalizeFn: func(_ context.Context, content string) error { + if content != "final reply" { + t.Fatalf("unexpected finalize content: %q", content) + } + return nil + }, + }, + } + m.channels["test"] = ch + + streamer, ok := m.GetStreamer(context.Background(), "test", "123") + if !ok { + t.Fatal("expected streamer to be available") + } + if err := streamer.Finalize(context.Background(), "final reply"); err != nil { + t.Fatalf("Finalize() error = %v", err) + } + if ch.dismissedChatID != "123" { + t.Fatalf("expected tracked tool feedback to be dismissed for chat 123, got %q", ch.dismissedChatID) + } + if _, ok := m.streamActive.Load("test:123"); !ok { + t.Fatal("expected streamActive marker to be recorded after finalize") + } +} + +func TestGetStreamer_FinalizeDismissesResolvedTrackedToolFeedback(t *testing.T) { + m := newTestManager() + ch := &mockStreamingChannel{ + mockMessageEditor: mockMessageEditor{}, + streamer: &mockStreamer{ + finalizeFn: func(_ context.Context, content string) error { + if content != "final reply" { + t.Fatalf("unexpected finalize content: %q", content) + } + return nil + }, + }, + resolveChatIDFn: func(chatID string, outboundCtx *bus.InboundContext) string { + if outboundCtx == nil { + t.Fatal("expected outbound context during stream finalize") + } + if outboundCtx.ChatID != "-100123/42" { + t.Fatalf("unexpected outbound context: %+v", outboundCtx) + } + return outboundCtx.ChatID + }, + } + m.channels["test"] = ch + + streamer, ok := m.GetStreamer(context.Background(), "test", "-100123/42") + if !ok { + t.Fatal("expected streamer to be available") + } + if err := streamer.Finalize(context.Background(), "final reply"); err != nil { + t.Fatalf("Finalize() error = %v", err) + } + if ch.dismissedChatID != "-100123/42" { + t.Fatalf("expected resolved tracked tool feedback dismissal, got %q", ch.dismissedChatID) + } + if _, ok := m.streamActive.Load("test:-100123/42"); !ok { + t.Fatal("expected streamActive marker to be recorded after finalize") + } +} + +func TestPreSend_PlaceholderEditSuccessDismissesResolvedTrackedToolFeedback(t *testing.T) { + m := newTestManager() + + ch := &mockResolvedToolFeedbackEditor{ + mockMessageEditor: mockMessageEditor{ + editFn: func(_ context.Context, chatID, messageID, content string) error { + if chatID != "-100123" || messageID != "456" || content != "done" { + t.Fatalf("unexpected edit args: %s %s %s", chatID, messageID, content) + } + return nil + }, + }, + resolveChatIDFn: func(chatID string, outboundCtx *bus.InboundContext) string { + if outboundCtx == nil || outboundCtx.TopicID != "42" { + t.Fatalf("expected topic-aware outbound context, got %+v", outboundCtx) + } + return chatID + "/" + outboundCtx.TopicID + }, + } + + m.RecordPlaceholder("test", "-100123", "456") + + msg := testOutboundMessage(bus.OutboundMessage{ + Channel: "test", + ChatID: "-100123", + Content: "done", + Context: bus.InboundContext{ + Channel: "test", + ChatID: "-100123", + TopicID: "42", + }, + }) + + _, edited := m.preSend(context.Background(), "test", msg, ch) + if !edited { + t.Fatal("expected preSend to edit placeholder") + } + if ch.dismissedChatID != "-100123/42" { + t.Fatalf("expected resolved tracked dismissal, got %q", ch.dismissedChatID) + } +} + +func TestGetStreamer_FinalizeFailureDoesNotDismissTrackedToolFeedback(t *testing.T) { + m := newTestManager() + ch := &mockStreamingChannel{ + mockMessageEditor: mockMessageEditor{}, + streamer: &mockStreamer{ + finalizeFn: func(context.Context, string) error { + return errors.New("finalize failed") + }, + }, + } + m.channels["test"] = ch + + streamer, ok := m.GetStreamer(context.Background(), "test", "123") + if !ok { + t.Fatal("expected streamer to be available") + } + if err := streamer.Finalize(context.Background(), "final reply"); err == nil { + t.Fatal("expected Finalize() to fail") + } + if ch.dismissedChatID != "" { + t.Fatalf("expected no tool feedback dismissal on finalize failure, got %q", ch.dismissedChatID) + } + if _, ok := m.streamActive.Load("test:123"); ok { + t.Fatal("expected no streamActive marker after finalize failure") + } +} + +func TestRunWorker_ToolFeedbackSkipsMarkerSplitting(t *testing.T) { + m := newTestManager() + m.config = &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + SplitOnMarker: true, + }, + }, + } + + var ( + mu sync.Mutex + received []string + ) + ch := &mockChannelWithLength{ + mockChannel: mockChannel{ + sendFn: func(_ context.Context, msg bus.OutboundMessage) error { + mu.Lock() + received = append(received, msg.Content) + mu.Unlock() + return nil + }, + }, + maxLen: 200, + } + + w := &channelWorker{ + ch: ch, + queue: make(chan bus.OutboundMessage, 1), + done: make(chan struct{}), + limiter: rate.NewLimiter(rate.Inf, 1), + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go m.runWorker(ctx, "test", w) + + content := "🔧 `read_file`\nRead current config first.<|[SPLIT]|>Then update the example." + w.queue <- testOutboundMessage(bus.OutboundMessage{ + Channel: "test", + ChatID: "123", + Content: content, + Context: bus.InboundContext{ + Channel: "test", + ChatID: "123", + Raw: map[string]string{ + "message_kind": "tool_feedback", + }, + }, + }) + + time.Sleep(100 * time.Millisecond) + + mu.Lock() + defer mu.Unlock() + if len(received) != 1 { + t.Fatalf("len(received) = %d, want 1", len(received)) + } + if received[0] != content { + t.Fatalf("received[0] = %q, want %q", received[0], content) + } +} + func TestPreSend_PlaceholderEditFails_FallsThrough(t *testing.T) { m := newTestManager() diff --git a/pkg/channels/matrix/matrix.go b/pkg/channels/matrix/matrix.go index 40e1b0a36..04599d6d2 100644 --- a/pkg/channels/matrix/matrix.go +++ b/pkg/channels/matrix/matrix.go @@ -46,6 +46,13 @@ const ( var matrixMentionHrefRegexp = regexp.MustCompile(`(?i)]+href=["']([^"']+)["']`) +func outboundMessageIsToolFeedback(msg bus.OutboundMessage) bool { + if len(msg.Context.Raw) == 0 { + return false + } + return strings.EqualFold(strings.TrimSpace(msg.Context.Raw["message_kind"]), "tool_feedback") +} + type roomKindCacheEntry struct { isGroup bool expiresAt time.Time @@ -192,6 +199,7 @@ type MatrixChannel struct { cryptoHelper *cryptohelper.CryptoHelper cryptoDbPath string + progress *channels.ToolFeedbackAnimator } func NewMatrixChannel( @@ -236,7 +244,7 @@ func NewMatrixChannel( channels.WithReasoningChannelID(bc.ReasoningChannelID), ) - return &MatrixChannel{ + ch := &MatrixChannel{ BaseChannel: base, bc: bc, client: client, @@ -248,7 +256,9 @@ func NewMatrixChannel( localpartMentionR: localpartMentionRegexp(matrixLocalpart(client.UserID)), typingMu: sync.Mutex{}, cryptoDbPath: cryptoDatabasePath, - }, nil + } + ch.progress = channels.NewToolFeedbackAnimator(ch.EditMessage) + return ch, nil } func (c *MatrixChannel) Start(ctx context.Context) error { @@ -297,6 +307,9 @@ func (c *MatrixChannel) Stop(ctx context.Context) error { c.cancel() } c.stopTypingSessions(ctx) + if c.progress != nil { + c.progress.StopAll() + } // Close crypto helper if initialized if c.cryptoHelper != nil { @@ -398,11 +411,36 @@ func (c *MatrixChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]st return nil, nil } + isToolFeedback := outboundMessageIsToolFeedback(msg) + if isToolFeedback { + if msgID, handled, err := c.progress.Update(ctx, msg.ChatID, content); handled { + if err != nil { + return nil, err + } + return []string{msgID}, nil + } + } + trackedMsgID, hasTrackedMsg := c.currentToolFeedbackMessage(msg.ChatID) + if !isToolFeedback { + if msgIDs, handled := c.FinalizeToolFeedbackMessage(ctx, msg); handled { + return msgIDs, nil + } + } + if isToolFeedback { + content = channels.InitialAnimatedToolFeedbackContent(content) + } + resp, err := c.client.SendMessageEvent(ctx, roomID, event.EventMessage, c.messageContent(content)) if err != nil { return nil, fmt.Errorf("matrix send: %w", channels.ErrTemporary) } - return []string{resp.EventID.String()}, nil + msgID := resp.EventID.String() + if isToolFeedback { + c.RecordToolFeedbackMessage(msg.ChatID, msgID, msg.Content) + } else if hasTrackedMsg { + c.dismissTrackedToolFeedbackMessage(ctx, msg.ChatID, trackedMsgID) + } + return []string{msgID}, nil } func (c *MatrixChannel) messageContent(text string) *event.MessageEventContent { @@ -419,6 +457,8 @@ func (c *MatrixChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMess if !c.IsRunning() { return nil, channels.ErrNotRunning } + trackedMsgID, hasTrackedMsg := c.currentToolFeedbackMessage(msg.ChatID) + sendCtx := ctx if sendCtx == nil { sendCtx = context.Background() @@ -529,6 +569,10 @@ func (c *MatrixChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMess } } + if hasTrackedMsg { + c.dismissTrackedToolFeedbackMessage(ctx, msg.ChatID, trackedMsgID) + } + return eventIDs, nil } @@ -612,6 +656,89 @@ func (c *MatrixChannel) EditMessage(ctx context.Context, chatID string, messageI return err } +// DeleteMessage implements channels.MessageDeleter. +func (c *MatrixChannel) DeleteMessage(ctx context.Context, chatID string, messageID string) error { + roomID := id.RoomID(strings.TrimSpace(chatID)) + if roomID == "" { + return fmt.Errorf("matrix room ID is empty") + } + eventID := id.EventID(strings.TrimSpace(messageID)) + if eventID == "" { + return fmt.Errorf("matrix message ID is empty") + } + + _, err := c.client.RedactEvent(ctx, roomID, eventID) + return err +} + +func (c *MatrixChannel) currentToolFeedbackMessage(chatID string) (string, bool) { + if c.progress == nil { + return "", false + } + return c.progress.Current(chatID) +} + +func (c *MatrixChannel) takeToolFeedbackMessage(chatID string) (string, string, bool) { + if c.progress == nil { + return "", "", false + } + return c.progress.Take(chatID) +} + +func (c *MatrixChannel) RecordToolFeedbackMessage(chatID, messageID, content string) { + if c.progress == nil { + return + } + c.progress.Record(chatID, messageID, content) +} + +func (c *MatrixChannel) ClearToolFeedbackMessage(chatID string) { + if c.progress == nil { + return + } + c.progress.Clear(chatID) +} + +func (c *MatrixChannel) DismissToolFeedbackMessage(ctx context.Context, chatID string) { + msgID, ok := c.currentToolFeedbackMessage(chatID) + if !ok { + return + } + c.dismissTrackedToolFeedbackMessage(ctx, chatID, msgID) +} + +func (c *MatrixChannel) dismissTrackedToolFeedbackMessage(ctx context.Context, chatID, messageID string) { + if strings.TrimSpace(chatID) == "" || strings.TrimSpace(messageID) == "" { + return + } + c.ClearToolFeedbackMessage(chatID) + _ = c.DeleteMessage(ctx, chatID, messageID) +} + +func (c *MatrixChannel) finalizeTrackedToolFeedbackMessage( + ctx context.Context, + chatID string, + content string, + editFn func(context.Context, string, string, string) error, +) ([]string, bool) { + msgID, baseContent, ok := c.takeToolFeedbackMessage(chatID) + if !ok || editFn == nil { + return nil, false + } + if err := editFn(ctx, chatID, msgID, content); err != nil { + c.RecordToolFeedbackMessage(chatID, msgID, baseContent) + return nil, false + } + return []string{msgID}, true +} + +func (c *MatrixChannel) FinalizeToolFeedbackMessage(ctx context.Context, msg bus.OutboundMessage) ([]string, bool) { + if outboundMessageIsToolFeedback(msg) { + return nil, false + } + return c.finalizeTrackedToolFeedbackMessage(ctx, msg.ChatID, msg.Content, c.EditMessage) +} + func (c *MatrixChannel) handleMemberEvent(ctx context.Context, evt *event.Event) { if !c.config.JoinOnInvite { return diff --git a/pkg/channels/matrix/matrix_test.go b/pkg/channels/matrix/matrix_test.go index 07f08f32b..066f08059 100644 --- a/pkg/channels/matrix/matrix_test.go +++ b/pkg/channels/matrix/matrix_test.go @@ -14,6 +14,7 @@ import ( "maunium.net/go/mautrix/event" "maunium.net/go/mautrix/id" + "github.com/sipeed/picoclaw/pkg/channels" "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/media" ) @@ -41,6 +42,34 @@ func TestMatrixLocalpartMentionRegexp(t *testing.T) { } } +func TestFinalizeTrackedToolFeedbackMessage_StopsTrackingBeforeEdit(t *testing.T) { + ch := &MatrixChannel{ + progress: channels.NewToolFeedbackAnimator(nil), + } + ch.RecordToolFeedbackMessage("!room:matrix.org", "$event1", "🔧 `read_file`") + + msgIDs, handled := ch.finalizeTrackedToolFeedbackMessage( + context.Background(), + "!room:matrix.org", + "final reply", + func(_ context.Context, chatID, messageID, content string) error { + if _, ok := ch.currentToolFeedbackMessage(chatID); ok { + t.Fatal("expected tracked tool feedback to be stopped before edit") + } + if chatID != "!room:matrix.org" || messageID != "$event1" || content != "final reply" { + t.Fatalf("unexpected edit args: %s %s %s", chatID, messageID, content) + } + return nil + }, + ) + if !handled { + t.Fatal("expected finalizeTrackedToolFeedbackMessage to handle tracked message") + } + if len(msgIDs) != 1 || msgIDs[0] != "$event1" { + t.Fatalf("finalizeTrackedToolFeedbackMessage() ids = %v, want [$event1]", msgIDs) + } +} + func TestStripUserMention(t *testing.T) { userID := id.UserID("@picoclaw:matrix.org") diff --git a/pkg/channels/pico/pico.go b/pkg/channels/pico/pico.go index 4d1fad1ed..31360b3de 100644 --- a/pkg/channels/pico/pico.go +++ b/pkg/channels/pico/pico.go @@ -50,6 +50,17 @@ func outboundMessageIsThought(msg bus.OutboundMessage) bool { return strings.EqualFold(strings.TrimSpace(msg.Context.Raw["message_kind"]), MessageKindThought) } +func outboundMessageIsToolFeedback(msg bus.OutboundMessage) bool { + if len(msg.Context.Raw) == 0 { + return false + } + return strings.EqualFold(strings.TrimSpace(msg.Context.Raw["message_kind"]), "tool_feedback") +} + +func outboundMessageFinalizesTrackedToolFeedback(msg bus.OutboundMessage) bool { + return !outboundMessageIsToolFeedback(msg) && !outboundMessageIsThought(msg) +} + // writeJSON sends a JSON message to the connection with write locking. func (pc *picoConn) writeJSON(v any) error { if pc.closed.Load() { @@ -82,6 +93,8 @@ type PicoChannel struct { connsMu sync.RWMutex ctx context.Context cancel context.CancelFunc + progress *channels.ToolFeedbackAnimator + deleteMessageFn func(context.Context, string, string) error } // NewPicoChannel creates a new Pico Protocol channel. @@ -110,7 +123,7 @@ func NewPicoChannel( return false } - return &PicoChannel{ + ch := &PicoChannel{ BaseChannel: base, bc: bc, config: cfg, @@ -121,7 +134,10 @@ func NewPicoChannel( }, connections: make(map[string]*picoConn), sessionConnections: make(map[string]map[string]*picoConn), - }, nil + } + ch.progress = channels.NewToolFeedbackAnimator(ch.EditMessage) + ch.deleteMessageFn = ch.DeleteMessage + return ch, nil } // createAndAddConnection checks MaxConnections and registers a connection atomically. @@ -239,6 +255,9 @@ func (c *PicoChannel) Stop(ctx context.Context) error { if c.cancel != nil { c.cancel() } + if c.progress != nil { + c.progress.StopAll() + } logger.InfoC("pico", "Pico Protocol channel stopped") return nil @@ -269,26 +288,133 @@ func (c *PicoChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]stri return nil, channels.ErrNotRunning } isThought := outboundMessageIsThought(msg) + isToolFeedback := outboundMessageIsToolFeedback(msg) + if isToolFeedback { + if msgID, handled, err := c.progress.Update(ctx, msg.ChatID, msg.Content); handled { + if err != nil { + return nil, err + } + return []string{msgID}, nil + } + } + trackedMsgID, hasTrackedMsg := c.currentToolFeedbackMessage(msg.ChatID) + if outboundMessageFinalizesTrackedToolFeedback(msg) { + if msgIDs, handled := c.FinalizeToolFeedbackMessage(ctx, msg); handled { + return msgIDs, nil + } + } + + content := msg.Content + if isToolFeedback { + content = channels.InitialAnimatedToolFeedbackContent(msg.Content) + } + msgID := uuid.New().String() payload := map[string]any{ - PayloadKeyContent: msg.Content, + PayloadKeyContent: content, PayloadKeyThought: isThought, + "message_id": msgID, } setContextUsagePayload(payload, msg.ContextUsage) outMsg := newMessage(TypeMessageCreate, payload) - return nil, c.broadcastToSession(msg.ChatID, outMsg) + if err := c.broadcastToSession(msg.ChatID, outMsg); err != nil { + return nil, err + } + if isToolFeedback { + c.RecordToolFeedbackMessage(msg.ChatID, msgID, msg.Content) + } else if hasTrackedMsg && outboundMessageFinalizesTrackedToolFeedback(msg) { + c.dismissTrackedToolFeedbackMessage(ctx, msg.ChatID, trackedMsgID) + } + return []string{msgID}, nil } // EditMessage implements channels.MessageEditor. func (c *PicoChannel) EditMessage(ctx context.Context, chatID string, messageID string, content string) error { - outMsg := newMessage(TypeMessageUpdate, map[string]any{ + return c.editMessage(ctx, chatID, messageID, content, nil) +} + +// DeleteMessage implements channels.MessageDeleter. +func (c *PicoChannel) DeleteMessage(ctx context.Context, chatID string, messageID string) error { + outMsg := newMessage(TypeMessageDelete, map[string]any{ "message_id": messageID, - "content": content, }) return c.broadcastToSession(chatID, outMsg) } +func (c *PicoChannel) currentToolFeedbackMessage(chatID string) (string, bool) { + if c.progress == nil { + return "", false + } + return c.progress.Current(chatID) +} + +func (c *PicoChannel) takeToolFeedbackMessage(chatID string) (string, string, bool) { + if c.progress == nil { + return "", "", false + } + return c.progress.Take(chatID) +} + +func (c *PicoChannel) RecordToolFeedbackMessage(chatID, messageID, content string) { + if c.progress == nil { + return + } + c.progress.Record(chatID, messageID, content) +} + +func (c *PicoChannel) ClearToolFeedbackMessage(chatID string) { + if c.progress == nil { + return + } + c.progress.Clear(chatID) +} + +func (c *PicoChannel) DismissToolFeedbackMessage(ctx context.Context, chatID string) { + msgID, ok := c.currentToolFeedbackMessage(chatID) + if !ok { + return + } + c.dismissTrackedToolFeedbackMessage(ctx, chatID, msgID) +} + +func (c *PicoChannel) dismissTrackedToolFeedbackMessage(ctx context.Context, chatID, messageID string) { + if strings.TrimSpace(chatID) == "" || strings.TrimSpace(messageID) == "" { + return + } + c.ClearToolFeedbackMessage(chatID) + deleteFn := c.deleteMessageFn + if deleteFn == nil { + deleteFn = c.DeleteMessage + } + _ = deleteFn(ctx, chatID, messageID) +} + +func (c *PicoChannel) finalizeTrackedToolFeedbackMessage( + ctx context.Context, + chatID string, + content string, + editFn func(context.Context, string, string, string, *bus.ContextUsage) error, + contextUsage *bus.ContextUsage, +) ([]string, bool) { + msgID, baseContent, ok := c.takeToolFeedbackMessage(chatID) + if !ok || editFn == nil { + return nil, false + } + if err := editFn(ctx, chatID, msgID, content, contextUsage); err != nil { + c.RecordToolFeedbackMessage(chatID, msgID, baseContent) + return nil, false + } + return []string{msgID}, true +} + +func (c *PicoChannel) FinalizeToolFeedbackMessage(ctx context.Context, msg bus.OutboundMessage) ([]string, bool) { + if !outboundMessageFinalizesTrackedToolFeedback(msg) { + return nil, false + } + return c.finalizeTrackedToolFeedbackMessage(ctx, msg.ChatID, msg.Content, c.editMessage, msg.ContextUsage) +} + // StartTyping implements channels.TypingCapable. func (c *PicoChannel) StartTyping(ctx context.Context, chatID string) (func(), error) { startMsg := newMessage(TypeTypingStart, nil) @@ -332,6 +458,7 @@ func (c *PicoChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessag if !c.IsRunning() { return nil, channels.ErrNotRunning } + trackedMsgID, hasTrackedMsg := c.currentToolFeedbackMessage(msg.ChatID) store := c.GetMediaStore() if store == nil { @@ -407,6 +534,9 @@ func (c *PicoChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessag if err := c.broadcastToSession(msg.ChatID, outMsg); err != nil { return nil, err } + if hasTrackedMsg { + c.dismissTrackedToolFeedbackMessage(ctx, msg.ChatID, trackedMsgID) + } return []string{msgID}, nil } @@ -939,3 +1069,19 @@ func setContextUsagePayload(payload map[string]any, u *bus.ContextUsage) { "used_percent": u.UsedPercent, } } + +func (c *PicoChannel) editMessage( + ctx context.Context, + chatID string, + messageID string, + content string, + contextUsage *bus.ContextUsage, +) error { + payload := map[string]any{ + "message_id": messageID, + "content": content, + } + setContextUsagePayload(payload, contextUsage) + outMsg := newMessage(TypeMessageUpdate, payload) + return c.broadcastToSession(chatID, outMsg) +} diff --git a/pkg/channels/pico/pico_test.go b/pkg/channels/pico/pico_test.go index f0d179527..22ed5451a 100644 --- a/pkg/channels/pico/pico_test.go +++ b/pkg/channels/pico/pico_test.go @@ -4,12 +4,16 @@ import ( "context" "errors" "fmt" + "net/http" "net/http/httptest" "os" "path/filepath" "strings" "sync" "testing" + "time" + + "github.com/gorilla/websocket" "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/channels" @@ -32,6 +36,163 @@ func newTestPicoChannel(t *testing.T) *PicoChannel { return ch } +func TestFinalizeTrackedToolFeedbackMessage_StopsTrackingBeforeEdit(t *testing.T) { + ch := &PicoChannel{ + progress: channels.NewToolFeedbackAnimator(nil), + } + ch.RecordToolFeedbackMessage("pico:chat-1", "msg-1", "🔧 `read_file`") + + msgIDs, handled := ch.finalizeTrackedToolFeedbackMessage( + context.Background(), + "pico:chat-1", + "final reply", + func(_ context.Context, chatID, messageID, content string, contextUsage *bus.ContextUsage) error { + if _, ok := ch.currentToolFeedbackMessage(chatID); ok { + t.Fatal("expected tracked tool feedback to be stopped before edit") + } + if chatID != "pico:chat-1" || messageID != "msg-1" || content != "final reply" { + t.Fatalf("unexpected edit args: %s %s %s", chatID, messageID, content) + } + if contextUsage != nil { + t.Fatalf("unexpected context usage: %+v", contextUsage) + } + return nil + }, + nil, + ) + if !handled { + t.Fatal("expected finalizeTrackedToolFeedbackMessage to handle tracked message") + } + if len(msgIDs) != 1 || msgIDs[0] != "msg-1" { + t.Fatalf("finalizeTrackedToolFeedbackMessage() ids = %v, want [msg-1]", msgIDs) + } +} + +func TestDismissTrackedToolFeedbackMessage_DeletesProgressMessage(t *testing.T) { + ch := &PicoChannel{ + progress: channels.NewToolFeedbackAnimator(nil), + } + ch.RecordToolFeedbackMessage("pico:chat-1", "msg-1", "🔧 `read_file`") + + var deleted struct { + chatID string + messageID string + } + ch.deleteMessageFn = func(_ context.Context, chatID string, messageID string) error { + deleted.chatID = chatID + deleted.messageID = messageID + return nil + } + + ch.DismissToolFeedbackMessage(context.Background(), "pico:chat-1") + + if deleted.chatID != "pico:chat-1" || deleted.messageID != "msg-1" { + t.Fatalf("unexpected delete target: %+v", deleted) + } + if _, ok := ch.currentToolFeedbackMessage("pico:chat-1"); ok { + t.Fatal("expected tracked tool feedback to be cleared after dismissal") + } +} + +func TestSend_ThoughtMessageDoesNotFinalizeTrackedToolFeedback(t *testing.T) { + ch := newTestPicoChannel(t) + + if err := ch.Start(context.Background()); err != nil { + t.Fatalf("Start() error = %v", err) + } + defer ch.Stop(context.Background()) + + clientConn, received, cleanup := newTestPicoWebSocket(t) + defer cleanup() + ch.addConnForTest(&picoConn{id: "conn-1", conn: clientConn, sessionID: "sess-1"}) + + ch.RecordToolFeedbackMessage("pico:sess-1", "msg-progress", "🔧 `read_file`\nReading config") + + if _, err := ch.Send(context.Background(), bus.OutboundMessage{ + ChatID: "pico:sess-1", + Content: "thinking trace", + Context: bus.InboundContext{ + Channel: "pico", + ChatID: "pico:sess-1", + Raw: map[string]string{ + "message_kind": MessageKindThought, + }, + }, + }); err != nil { + t.Fatalf("Send(thought) error = %v", err) + } + + select { + case msg := <-received: + if msg.Type != TypeMessageCreate { + t.Fatalf("thought message type = %q, want %q", msg.Type, TypeMessageCreate) + } + payload := msg.Payload + if got := payload[PayloadKeyContent]; got != "thinking trace" { + t.Fatalf("thought content = %#v, want %q", got, "thinking trace") + } + if got := payload[PayloadKeyThought]; got != true { + t.Fatalf("thought flag = %#v, want true", got) + } + if got := payload["message_id"]; got == "msg-progress" || got == nil || got == "" { + t.Fatalf("thought message_id = %#v, want new non-progress id", got) + } + case <-time.After(time.Second): + t.Fatal("expected thought message to be delivered") + } + + if msgID, ok := ch.currentToolFeedbackMessage("pico:sess-1"); !ok || msgID != "msg-progress" { + t.Fatalf("tracked tool feedback = (%q, %v), want (msg-progress, true)", msgID, ok) + } + + if _, err := ch.Send(context.Background(), bus.OutboundMessage{ + ChatID: "pico:sess-1", + Content: "final reply", + Context: bus.InboundContext{ + Channel: "pico", + ChatID: "pico:sess-1", + }, + ContextUsage: &bus.ContextUsage{ + UsedTokens: 321, + TotalTokens: 4096, + CompressAtTokens: 3072, + UsedPercent: 8, + }, + }); err != nil { + t.Fatalf("Send(final) error = %v", err) + } + + select { + case msg := <-received: + if msg.Type != TypeMessageUpdate { + t.Fatalf("final message type = %q, want %q", msg.Type, TypeMessageUpdate) + } + payload := msg.Payload + if got := payload["message_id"]; got != "msg-progress" { + t.Fatalf("final message_id = %#v, want %q", got, "msg-progress") + } + if got := payload[PayloadKeyContent]; got != "final reply" { + t.Fatalf("final content = %#v, want %q", got, "final reply") + } + rawUsage, ok := payload["context_usage"].(map[string]any) + if !ok { + t.Fatalf("final context_usage = %#v, want map payload", payload["context_usage"]) + } + if got, ok := rawUsage["used_tokens"].(float64); !ok || got != 321 { + t.Fatalf("used_tokens = %#v, want 321", rawUsage["used_tokens"]) + } + if got, ok := rawUsage["total_tokens"].(float64); !ok || got != 4096 { + t.Fatalf("total_tokens = %#v, want 4096", rawUsage["total_tokens"]) + } + case <-time.After(time.Second): + t.Fatal("expected final reply to finalize tracked tool feedback") + } + + if _, ok := ch.currentToolFeedbackMessage("pico:sess-1"); ok { + t.Fatal("expected tracked tool feedback to be cleared after final reply") + } +} + func TestCreateAndAddConnection_RespectsMaxConnectionsConcurrently(t *testing.T) { ch := newTestPicoChannel(t) @@ -169,6 +330,75 @@ func TestSendMedia_ResolvesMediaBeforeDelivery(t *testing.T) { } } +func TestSendMedia_DismissesTrackedToolFeedbackMessage(t *testing.T) { + ch := newTestPicoChannel(t) + store := media.NewFileMediaStore() + ch.SetMediaStore(store) + + if err := ch.Start(context.Background()); err != nil { + t.Fatalf("Start() error = %v", err) + } + defer ch.Stop(context.Background()) + + clientConn, received, cleanup := newTestPicoWebSocket(t) + defer cleanup() + ch.addConnForTest(&picoConn{id: "conn-1", conn: clientConn, sessionID: "sess-1"}) + + localPath := filepath.Join(t.TempDir(), "report.txt") + if err := os.WriteFile(localPath, []byte("attachment body"), 0o600); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + + ref, err := store.Store(localPath, media.MediaMeta{ + Filename: "report.txt", + ContentType: "text/plain", + }, "test-scope") + if err != nil { + t.Fatalf("Store() error = %v", err) + } + + ch.RecordToolFeedbackMessage("pico:sess-1", "msg-progress", "🔧 `read_file`") + + var deleted struct { + chatID string + messageID string + } + ch.deleteMessageFn = func(_ context.Context, chatID string, messageID string) error { + deleted.chatID = chatID + deleted.messageID = messageID + return nil + } + + _, err = ch.SendMedia(context.Background(), bus.OutboundMediaMessage{ + ChatID: "pico:sess-1", + Parts: []bus.MediaPart{{ + Ref: ref, + Type: "file", + Filename: "report.txt", + ContentType: "text/plain", + }}, + }) + if err != nil { + t.Fatalf("SendMedia() error = %v", err) + } + + select { + case msg := <-received: + if msg.Type != TypeMessageCreate { + t.Fatalf("message type = %q, want %q", msg.Type, TypeMessageCreate) + } + case <-time.After(time.Second): + t.Fatal("expected media message to be delivered") + } + + if deleted.chatID != "pico:sess-1" || deleted.messageID != "msg-progress" { + t.Fatalf("unexpected delete target: %+v", deleted) + } + if _, ok := ch.currentToolFeedbackMessage("pico:sess-1"); ok { + t.Fatal("expected tracked tool feedback to be cleared after media delivery") + } +} + func TestPicoDownloadURLForRef(t *testing.T) { got, err := picoDownloadURLForRef("media://attachment-1") if err != nil { @@ -240,3 +470,39 @@ func (c *PicoChannel) addConnForTest(pc *picoConn) { } bySession[pc.id] = pc } + +func newTestPicoWebSocket(t *testing.T) (*websocket.Conn, <-chan PicoMessage, func()) { + t.Helper() + + received := make(chan PicoMessage, 4) + upgrader := websocket.Upgrader{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + t.Errorf("Upgrade() error = %v", err) + return + } + defer conn.Close() + for { + var msg PicoMessage + if err := conn.ReadJSON(&msg); err != nil { + return + } + received <- msg + } + })) + + wsURL := "ws" + strings.TrimPrefix(server.URL, "http") + clientConn, resp, err := websocket.DefaultDialer.Dial(wsURL, nil) + if err != nil { + server.Close() + t.Fatalf("Dial() error = %v", err) + } + + cleanup := func() { + clientConn.Close() + server.Close() + } + defer resp.Body.Close() + return clientConn, received, cleanup +} diff --git a/pkg/channels/pico/protocol.go b/pkg/channels/pico/protocol.go index 051beed1b..8a27b8c93 100644 --- a/pkg/channels/pico/protocol.go +++ b/pkg/channels/pico/protocol.go @@ -12,6 +12,7 @@ const ( // TypeMessageCreate is sent from server to client. TypeMessageCreate = "message.create" TypeMessageUpdate = "message.update" + TypeMessageDelete = "message.delete" TypeMediaCreate = "media.create" TypeTypingStart = "typing.start" TypeTypingStop = "typing.stop" diff --git a/pkg/channels/telegram/command_registration.go b/pkg/channels/telegram/command_registration.go index d3152ec3d..c6b362601 100644 --- a/pkg/channels/telegram/command_registration.go +++ b/pkg/channels/telegram/command_registration.go @@ -66,6 +66,10 @@ func (c *TelegramChannel) startCommandRegistration(ctx context.Context, defs []c if register == nil { register = c.RegisterCommands } + delayFn := c.commandRegDelayFn + if delayFn == nil { + delayFn = commandRegistrationDelay + } regCtx, cancel := context.WithCancel(ctx) c.commandRegCancel = cancel @@ -91,7 +95,7 @@ func (c *TelegramChannel) startCommandRegistration(ctx context.Context, defs []c return } - delay := commandRegistrationDelay(attempt) + delay := delayFn(attempt) logger.WarnCF("telegram", "Telegram command registration failed; will retry", map[string]any{ "error": err.Error(), "retry_after": delay.String(), diff --git a/pkg/channels/telegram/command_registration_test.go b/pkg/channels/telegram/command_registration_test.go index 26f891b2e..c30c6f68d 100644 --- a/pkg/channels/telegram/command_registration_test.go +++ b/pkg/channels/telegram/command_registration_test.go @@ -31,14 +31,12 @@ func TestStartCommandRegistration_DoesNotBlock(t *testing.T) { } func TestStartCommandRegistration_RetriesUntilSuccessThenStops(t *testing.T) { - ch := &TelegramChannel{} + ch := &TelegramChannel{ + commandRegDelayFn: func(int) time.Duration { return 5 * time.Millisecond }, + } ctx, cancel := context.WithCancel(context.Background()) defer cancel() - origBackoff := commandRegistrationBackoff - commandRegistrationBackoff = []time.Duration{5 * time.Millisecond} - defer func() { commandRegistrationBackoff = origBackoff }() - var attempts atomic.Int32 ch.registerFunc = func(context.Context, []commands.Definition) error { n := attempts.Add(1) @@ -69,12 +67,10 @@ func TestStartCommandRegistration_RetriesUntilSuccessThenStops(t *testing.T) { } func TestStartCommandRegistration_StopsAfterCancel(t *testing.T) { - ch := &TelegramChannel{} + ch := &TelegramChannel{ + commandRegDelayFn: func(int) time.Duration { return 5 * time.Millisecond }, + } ctx, cancel := context.WithCancel(context.Background()) - - origBackoff := commandRegistrationBackoff - commandRegistrationBackoff = []time.Duration{5 * time.Millisecond} - defer func() { commandRegistrationBackoff = origBackoff }() defer cancel() var attempts atomic.Int32 diff --git a/pkg/channels/telegram/telegram.go b/pkg/channels/telegram/telegram.go index 2a9cfe4ae..cebebfed6 100644 --- a/pkg/channels/telegram/telegram.go +++ b/pkg/channels/telegram/telegram.go @@ -45,16 +45,18 @@ var ( type TelegramChannel struct { *channels.BaseChannel - bot *telego.Bot - bh *th.BotHandler - bc *config.Channel - chatIDs map[string]int64 - ctx context.Context - cancel context.CancelFunc - tgCfg *config.TelegramSettings + bot *telego.Bot + bh *th.BotHandler + bc *config.Channel + chatIDs map[string]int64 + ctx context.Context + cancel context.CancelFunc + tgCfg *config.TelegramSettings + progress *channels.ToolFeedbackAnimator - registerFunc func(context.Context, []commands.Definition) error - commandRegCancel context.CancelFunc + registerFunc func(context.Context, []commands.Definition) error + commandRegDelayFn func(int) time.Duration + commandRegCancel context.CancelFunc } func NewTelegramChannel( @@ -104,13 +106,15 @@ func NewTelegramChannel( channels.WithReasoningChannelID(bc.ReasoningChannelID), ) - return &TelegramChannel{ + ch := &TelegramChannel{ BaseChannel: base, bot: bot, bc: bc, chatIDs: make(map[string]int64), tgCfg: telegramCfg, - }, nil + } + ch.progress = channels.NewToolFeedbackAnimator(ch.EditMessage) + return ch, nil } func (c *TelegramChannel) Start(ctx context.Context) error { @@ -168,6 +172,9 @@ func (c *TelegramChannel) Stop(ctx context.Context) error { if c.cancel != nil { c.cancel() } + if c.progress != nil { + c.progress.StopAll() + } if c.commandRegCancel != nil { c.commandRegCancel() } @@ -191,12 +198,36 @@ func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([] return nil, nil } + isToolFeedback := outboundMessageIsToolFeedback(msg) + toolFeedbackContent := msg.Content + if isToolFeedback { + toolFeedbackContent = fitToolFeedbackForTelegram(msg.Content, useMarkdownV2, 4096) + } + trackedChatID := telegramToolFeedbackChatKey(msg.ChatID, &msg.Context) + if isToolFeedback { + if msgID, handled, err := c.progress.Update(ctx, trackedChatID, toolFeedbackContent); handled { + if err != nil { + return nil, err + } + return []string{msgID}, nil + } + } + trackedMsgID, hasTrackedMsg := c.currentToolFeedbackMessage(trackedChatID) + if !isToolFeedback { + if msgIDs, handled := c.finalizeToolFeedbackMessageForChat(ctx, trackedChatID, msg); handled { + return msgIDs, nil + } + } + // The Manager already splits messages to ≤4000 chars (WithMaxMessageLength), // so msg.Content is guaranteed to be within that limit. We still need to // check if HTML expansion pushes it beyond Telegram's 4096-char API limit. replyToID := msg.ReplyToMessageID var messageIDs []string queue := []string{msg.Content} + if isToolFeedback { + queue = []string{channels.InitialAnimatedToolFeedbackContent(toolFeedbackContent)} + } for len(queue) > 0 { chunk := queue[0] queue = queue[1:] @@ -204,6 +235,13 @@ func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([] content := parseContent(chunk, useMarkdownV2) if len([]rune(content)) > 4096 { + if isToolFeedback { + fittedChunk := fitToolFeedbackForTelegram(chunk, useMarkdownV2, 4096) + if fittedChunk != "" && fittedChunk != chunk { + queue = append([]string{fittedChunk}, queue...) + continue + } + } runeChunk := []rune(chunk) ratio := float64(len(runeChunk)) / float64(len([]rune(content))) smallerLen := int(float64(4096) * ratio * 0.95) // 5% safety margin @@ -270,6 +308,12 @@ func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([] replyToID = "" } + if isToolFeedback && len(messageIDs) > 0 { + c.RecordToolFeedbackMessage(trackedChatID, messageIDs[0], toolFeedbackContent) + } else if !isToolFeedback && hasTrackedMsg { + c.dismissTrackedToolFeedbackMessage(ctx, trackedChatID, trackedMsgID) + } + return messageIDs, nil } @@ -437,6 +481,89 @@ func (c *TelegramChannel) DeleteMessage(ctx context.Context, chatID string, mess }) } +func outboundMessageIsToolFeedback(msg bus.OutboundMessage) bool { + if len(msg.Context.Raw) == 0 { + return false + } + return strings.EqualFold(strings.TrimSpace(msg.Context.Raw["message_kind"]), "tool_feedback") +} + +func (c *TelegramChannel) currentToolFeedbackMessage(chatID string) (string, bool) { + if c.progress == nil { + return "", false + } + return c.progress.Current(chatID) +} + +func (c *TelegramChannel) takeToolFeedbackMessage(chatID string) (string, string, bool) { + if c.progress == nil { + return "", "", false + } + return c.progress.Take(chatID) +} + +func (c *TelegramChannel) RecordToolFeedbackMessage(chatID, messageID, content string) { + if c.progress == nil { + return + } + c.progress.Record(chatID, messageID, content) +} + +func (c *TelegramChannel) ClearToolFeedbackMessage(chatID string) { + if c.progress == nil { + return + } + c.progress.Clear(chatID) +} + +func (c *TelegramChannel) DismissToolFeedbackMessage(ctx context.Context, chatID string) { + msgID, ok := c.currentToolFeedbackMessage(chatID) + if !ok { + return + } + c.dismissTrackedToolFeedbackMessage(ctx, chatID, msgID) +} + +func (c *TelegramChannel) dismissTrackedToolFeedbackMessage(ctx context.Context, chatID, messageID string) { + if strings.TrimSpace(chatID) == "" || strings.TrimSpace(messageID) == "" { + return + } + c.ClearToolFeedbackMessage(chatID) + _ = c.DeleteMessage(ctx, chatID, messageID) +} + +func (c *TelegramChannel) finalizeTrackedToolFeedbackMessage( + ctx context.Context, + chatID string, + content string, + editFn func(context.Context, string, string, string) error, +) ([]string, bool) { + msgID, baseContent, ok := c.takeToolFeedbackMessage(chatID) + if !ok || editFn == nil { + return nil, false + } + if err := editFn(ctx, chatID, msgID, content); err != nil { + c.RecordToolFeedbackMessage(chatID, msgID, baseContent) + return nil, false + } + return []string{msgID}, true +} + +func (c *TelegramChannel) FinalizeToolFeedbackMessage(ctx context.Context, msg bus.OutboundMessage) ([]string, bool) { + if outboundMessageIsToolFeedback(msg) { + return nil, false + } + return c.finalizeToolFeedbackMessageForChat(ctx, telegramToolFeedbackChatKey(msg.ChatID, &msg.Context), msg) +} + +func (c *TelegramChannel) finalizeToolFeedbackMessageForChat( + ctx context.Context, + chatID string, + msg bus.OutboundMessage, +) ([]string, bool) { + return c.finalizeTrackedToolFeedbackMessage(ctx, chatID, msg.Content, c.EditMessage) +} + // SendPlaceholder implements channels.PlaceholderCapable. // It sends a placeholder message (e.g. "Thinking... 💭") that will later be // edited to the actual response via EditMessage (channels.MessageEditor). @@ -468,6 +595,8 @@ func (c *TelegramChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMe if !c.IsRunning() { return nil, channels.ErrNotRunning } + trackedChatID := telegramToolFeedbackChatKey(msg.ChatID, &msg.Context) + trackedMsgID, hasTrackedMsg := c.currentToolFeedbackMessage(trackedChatID) chatID, threadID, err := resolveTelegramOutboundTarget(msg.ChatID, &msg.Context) if err != nil { @@ -576,6 +705,10 @@ func (c *TelegramChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMe } } + if hasTrackedMsg { + c.dismissTrackedToolFeedbackMessage(ctx, trackedChatID, trackedMsgID) + } + return messageIDs, nil } @@ -947,6 +1080,60 @@ func parseContent(text string, useMarkdownV2 bool) string { return markdownToTelegramHTML(text) } +func fitToolFeedbackForTelegram(content string, useMarkdownV2 bool, maxParsedLen int) string { + content = strings.TrimSpace(content) + if content == "" || maxParsedLen <= 0 { + return "" + } + animationSafeLen := maxParsedLen - channels.MaxToolFeedbackAnimationFrameLength() + if animationSafeLen <= 0 { + animationSafeLen = maxParsedLen + } + if len([]rune(parseContent(content, useMarkdownV2))) <= animationSafeLen { + return content + } + + low := 1 + high := len([]rune(content)) + best := utils.Truncate(content, 1) + + for low <= high { + mid := (low + high) / 2 + candidate := utils.FitToolFeedbackMessage(content, mid) + if candidate == "" { + high = mid - 1 + continue + } + if len([]rune(parseContent(candidate, useMarkdownV2))) <= animationSafeLen { + best = candidate + low = mid + 1 + continue + } + high = mid - 1 + } + + return best +} + +func (c *TelegramChannel) PrepareToolFeedbackMessageContent(content string) string { + if c == nil || c.tgCfg == nil { + return strings.TrimSpace(content) + } + return fitToolFeedbackForTelegram(content, c.tgCfg.UseMarkdownV2, 4096) +} + +func telegramToolFeedbackChatKey(chatID string, outboundCtx *bus.InboundContext) string { + resolvedChatID, threadID, err := resolveTelegramOutboundTarget(chatID, outboundCtx) + if err != nil || threadID == 0 { + return strings.TrimSpace(chatID) + } + return fmt.Sprintf("%d/%d", resolvedChatID, threadID) +} + +func (c *TelegramChannel) ToolFeedbackMessageChatID(chatID string, outboundCtx *bus.InboundContext) string { + return telegramToolFeedbackChatKey(chatID, outboundCtx) +} + // parseTelegramChatID splits "chatID/threadID" into its components. // Returns threadID=0 when no "/" is present (non-forum messages). func parseTelegramChatID(chatID string) (int64, int, error) { @@ -1097,7 +1284,7 @@ func (c *TelegramChannel) BeginStream(ctx context.Context, chatID string) (chann return nil, fmt.Errorf("streaming disabled in config") } - cid, _, err := parseTelegramChatID(chatID) + cid, threadID, err := parseTelegramChatID(chatID) if err != nil { return nil, err } @@ -1106,6 +1293,7 @@ func (c *TelegramChannel) BeginStream(ctx context.Context, chatID string) (chann return &telegramStreamer{ bot: c.bot, chatID: cid, + threadID: threadID, draftID: cryptoRandInt(), throttleInterval: time.Duration(streamCfg.ThrottleSeconds) * time.Second, minGrowth: streamCfg.MinGrowthChars, @@ -1118,6 +1306,7 @@ func (c *TelegramChannel) BeginStream(ctx context.Context, chatID string) (chann type telegramStreamer struct { bot *telego.Bot chatID int64 + threadID int draftID int throttleInterval time.Duration minGrowth int @@ -1145,10 +1334,11 @@ func (s *telegramStreamer) Update(ctx context.Context, content string) error { htmlContent := markdownToTelegramHTML(content) err := s.bot.SendMessageDraft(ctx, &telego.SendMessageDraftParams{ - ChatID: s.chatID, - DraftID: s.draftID, - Text: htmlContent, - ParseMode: telego.ModeHTML, + ChatID: s.chatID, + MessageThreadID: s.threadID, + DraftID: s.draftID, + Text: htmlContent, + ParseMode: telego.ModeHTML, }) if err != nil { // First error → degrade silently (e.g. no forum mode) @@ -1167,6 +1357,7 @@ func (s *telegramStreamer) Update(ctx context.Context, content string) error { func (s *telegramStreamer) Finalize(ctx context.Context, content string) error { htmlContent := markdownToTelegramHTML(content) tgMsg := tu.Message(tu.ID(s.chatID), htmlContent) + tgMsg.MessageThreadID = s.threadID tgMsg.ParseMode = telego.ModeHTML if _, err := s.bot.SendMessage(ctx, tgMsg); err != nil { diff --git a/pkg/channels/telegram/telegram_group_command_filter_test.go b/pkg/channels/telegram/telegram_group_command_filter_test.go index 614b2ca7f..20b2004a9 100644 --- a/pkg/channels/telegram/telegram_group_command_filter_test.go +++ b/pkg/channels/telegram/telegram_group_command_filter_test.go @@ -108,7 +108,7 @@ func TestHandleMessage_GroupMentionOnly_BotCommandEntity(t *testing.T) { t.Fatalf("handleMessage error: %v", err) } - ctx, cancel := context.WithTimeout(context.Background(), 200*time.Microsecond) + ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) defer cancel() select { case <-ctx.Done(): diff --git a/pkg/channels/telegram/telegram_test.go b/pkg/channels/telegram/telegram_test.go index 3d147b337..69c76b430 100644 --- a/pkg/channels/telegram/telegram_test.go +++ b/pkg/channels/telegram/telegram_test.go @@ -98,8 +98,12 @@ func (s *multipartRecordingConstructor) MultipartRequest( // successResponse returns a ta.Response that telego will treat as a successful SendMessage. func successResponse(t *testing.T) *ta.Response { + return successResponseWithMessageID(t, 1) +} + +func successResponseWithMessageID(t *testing.T, messageID int) *ta.Response { t.Helper() - msg := &telego.Message{MessageID: 1} + msg := &telego.Message{MessageID: messageID} b, err := json.Marshal(msg) require.NoError(t, err) return &ta.Response{Ok: true, Result: b} @@ -142,6 +146,7 @@ func newTestChannelWithConstructor( chatIDs: make(map[string]int64), bc: &config.Channel{Type: config.ChannelTelegram, Enabled: true}, tgCfg: &config.TelegramSettings{}, + progress: channels.NewToolFeedbackAnimator(nil), } } @@ -266,6 +271,176 @@ func TestSend_ShortMessage_SingleCall(t *testing.T) { assert.Len(t, caller.calls, 1, "short message should result in exactly one SendMessage call") } +func TestSend_NonToolFeedbackDeletesTrackedProgressMessage(t *testing.T) { + caller := &stubCaller{ + callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) { + switch { + case strings.Contains(url, "editMessageText"): + return successResponseWithMessageID(t, 1), nil + default: + t.Fatalf("unexpected API call: %s", url) + return nil, nil + } + }, + } + ch := newTestChannel(t, caller) + ch.RecordToolFeedbackMessage("12345", "1", "🔧 `read_file`") + + ids, err := ch.Send(context.Background(), bus.OutboundMessage{ + ChatID: "12345", + Content: "final reply", + }) + + assert.NoError(t, err) + assert.Equal(t, []string{"1"}, ids) + require.Len(t, caller.calls, 1) + assert.Contains(t, caller.calls[0].URL, "editMessageText") + _, ok := ch.currentToolFeedbackMessage("12345") + assert.False(t, ok, "tracked tool feedback should be cleared after final reply") +} + +func TestSend_ToolFeedbackTrackingIsTopicScoped(t *testing.T) { + nextMessageID := 0 + caller := &stubCaller{ + callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) { + nextMessageID++ + return successResponseWithMessageID(t, nextMessageID), nil + }, + } + ch := newTestChannel(t, caller) + + _, err := ch.Send(context.Background(), bus.OutboundMessage{ + ChatID: "-1001234567890", + Content: "🔧 `read_file`", + Context: bus.InboundContext{ + Channel: "telegram", + ChatID: "-1001234567890", + TopicID: "42", + Raw: map[string]string{ + "message_kind": "tool_feedback", + }, + }, + }) + require.NoError(t, err) + + _, ok := ch.currentToolFeedbackMessage("-1001234567890") + assert.False(t, ok, "base chat should not track topic-specific tool feedback") + + msgID, ok := ch.currentToolFeedbackMessage("-1001234567890/42") + require.True(t, ok, "topic chat should track tool feedback") + assert.Equal(t, "1", msgID) +} + +func TestSend_TopicReplyDoesNotFinalizeDifferentTopicToolFeedback(t *testing.T) { + nextMessageID := 0 + caller := &stubCaller{ + callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) { + nextMessageID++ + return successResponseWithMessageID(t, nextMessageID), nil + }, + } + ch := newTestChannel(t, caller) + + _, err := ch.Send(context.Background(), bus.OutboundMessage{ + ChatID: "-1001234567890", + Content: "🔧 `read_file`", + Context: bus.InboundContext{ + Channel: "telegram", + ChatID: "-1001234567890", + TopicID: "42", + Raw: map[string]string{ + "message_kind": "tool_feedback", + }, + }, + }) + require.NoError(t, err) + + ids, err := ch.Send(context.Background(), bus.OutboundMessage{ + ChatID: "-1001234567890", + Content: "final reply in another topic", + Context: bus.InboundContext{ + Channel: "telegram", + ChatID: "-1001234567890", + TopicID: "43", + }, + }) + require.NoError(t, err) + require.Len(t, caller.calls, 2) + assert.Equal(t, []string{"2"}, ids) + assert.Contains(t, caller.calls[1].URL, "sendMessage") + assert.NotContains(t, caller.calls[1].URL, "editMessageText") + + _, ok := ch.currentToolFeedbackMessage("-1001234567890/42") + assert.True(t, ok, "tool feedback in the original topic should remain tracked") +} + +func TestFinalizeTrackedToolFeedbackMessage_StopsTrackingBeforeEdit(t *testing.T) { + ch := newTestChannel(t, &stubCaller{ + callFn: func(context.Context, string, *ta.RequestData) (*ta.Response, error) { + t.Fatal("unexpected API call") + return nil, nil + }, + }) + ch.RecordToolFeedbackMessage("12345", "1", "🔧 `read_file`") + + msgIDs, handled := ch.finalizeTrackedToolFeedbackMessage( + context.Background(), + "12345", + "final reply", + func(_ context.Context, chatID, messageID, content string) error { + _, ok := ch.currentToolFeedbackMessage(chatID) + assert.False(t, ok, "tracked tool feedback should be stopped before edit") + assert.Equal(t, "12345", chatID) + assert.Equal(t, "1", messageID) + assert.Equal(t, "final reply", content) + return nil + }, + ) + + assert.True(t, handled) + assert.Equal(t, []string{"1"}, msgIDs) +} + +func TestSend_ToolFeedbackStaysSingleMessageAfterHTMLExpansion(t *testing.T) { + caller := &stubCaller{ + callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) { + return successResponse(t), nil + }, + } + ch := newTestChannel(t, caller) + + _, err := ch.Send(context.Background(), bus.OutboundMessage{ + ChatID: "12345", + Content: "🔧 `read_file`\n" + strings.Repeat("<", 2000), + Context: bus.InboundContext{ + Channel: "telegram", + ChatID: "12345", + Raw: map[string]string{ + "message_kind": "tool_feedback", + }, + }, + }) + + assert.NoError(t, err) + assert.Len(t, caller.calls, 1, "tool feedback should stay a single Telegram message after HTML escaping") +} + +func TestFitToolFeedbackForTelegram_ReservesAnimationFrame(t *testing.T) { + content := "🔧 `read_file`\n" + strings.Repeat("a", 4096) + + fitted := fitToolFeedbackForTelegram(content, false, 4096) + animated := strings.Replace( + fitted, + "`\n", + strings.Repeat(".", channels.MaxToolFeedbackAnimationFrameLength())+"`\n", + 1, + ) + + if got := len([]rune(parseContent(animated, false))); got > 4096 { + t.Fatalf("animated parsed length = %d, want <= 4096", got) + } +} + func TestSend_LongMessage_SingleCall(t *testing.T) { // With WithMaxMessageLength(4000), the Manager pre-splits messages before // they reach Send(). A message at exactly 4000 chars should go through @@ -560,6 +735,58 @@ func TestSend_UsesContextTopicIDWhenChatIDDoesNotIncludeThread(t *testing.T) { assert.Equal(t, "Hello from topic context", params.Text) } +func TestBeginStream_UpdateUsesForumThreadID(t *testing.T) { + caller := &stubCaller{ + callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) { + return &ta.Response{Ok: true, Result: []byte("true")}, nil + }, + } + ch := newTestChannel(t, caller) + ch.tgCfg.Streaming.Enabled = true + + streamer, err := ch.BeginStream(context.Background(), "-1001234567890/42") + require.NoError(t, err) + require.NoError(t, streamer.Update(context.Background(), "partial")) + require.Len(t, caller.calls, 1) + assert.Contains(t, caller.calls[0].URL, "sendMessageDraft") + + var params struct { + ChatID int64 `json:"chat_id"` + MessageThreadID int `json:"message_thread_id"` + Text string `json:"text"` + } + require.NoError(t, json.Unmarshal(caller.calls[0].Data.BodyRaw, ¶ms)) + assert.Equal(t, int64(-1001234567890), params.ChatID) + assert.Equal(t, 42, params.MessageThreadID) + assert.Equal(t, "partial", params.Text) +} + +func TestBeginStream_FinalizeUsesForumThreadID(t *testing.T) { + caller := &stubCaller{ + callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) { + return successResponse(t), nil + }, + } + ch := newTestChannel(t, caller) + ch.tgCfg.Streaming.Enabled = true + + streamer, err := ch.BeginStream(context.Background(), "-1001234567890/42") + require.NoError(t, err) + require.NoError(t, streamer.Finalize(context.Background(), "final")) + require.Len(t, caller.calls, 1) + assert.Contains(t, caller.calls[0].URL, "sendMessage") + + var params struct { + ChatID int64 `json:"chat_id"` + MessageThreadID int `json:"message_thread_id"` + Text string `json:"text"` + } + require.NoError(t, json.Unmarshal(caller.calls[0].Data.BodyRaw, ¶ms)) + assert.Equal(t, int64(-1001234567890), params.ChatID) + assert.Equal(t, 42, params.MessageThreadID) + assert.Equal(t, "final", params.Text) +} + func TestHandleMessage_ForumTopic_SetsMetadata(t *testing.T) { messageBus := bus.NewMessageBus() ch := &TelegramChannel{ diff --git a/pkg/channels/tool_feedback_animator.go b/pkg/channels/tool_feedback_animator.go new file mode 100644 index 000000000..b424612bf --- /dev/null +++ b/pkg/channels/tool_feedback_animator.go @@ -0,0 +1,240 @@ +package channels + +import ( + "context" + "strings" + "sync" + "time" +) + +const toolFeedbackAnimationInterval = 3 * time.Second + +const initialToolFeedbackAnimationFrame = "" + +var toolFeedbackAnimationFrames = []string{"..", "."} + +// MaxToolFeedbackAnimationFrameLength returns the largest frame suffix length +// so callers can reserve room before sending messages to length-limited APIs. +func MaxToolFeedbackAnimationFrameLength() int { + maxLen := len([]rune(initialToolFeedbackAnimationFrame)) + for _, frame := range toolFeedbackAnimationFrames { + if frameLen := len([]rune(frame)); frameLen > maxLen { + maxLen = frameLen + } + } + return maxLen +} + +type toolFeedbackAnimationState struct { + messageID string + baseContent string + stop chan struct{} + done chan struct{} +} + +type ToolFeedbackAnimator struct { + mu sync.Mutex + editFn func(ctx context.Context, chatID, messageID, content string) error + entries map[string]*toolFeedbackAnimationState +} + +func NewToolFeedbackAnimator( + editFn func(ctx context.Context, chatID, messageID, content string) error, +) *ToolFeedbackAnimator { + return &ToolFeedbackAnimator{ + editFn: editFn, + entries: make(map[string]*toolFeedbackAnimationState), + } +} + +func (a *ToolFeedbackAnimator) Current(chatID string) (string, bool) { + if a == nil || strings.TrimSpace(chatID) == "" { + return "", false + } + a.mu.Lock() + defer a.mu.Unlock() + entry, ok := a.entries[chatID] + if !ok || strings.TrimSpace(entry.messageID) == "" { + return "", false + } + return entry.messageID, true +} + +func (a *ToolFeedbackAnimator) Record(chatID, messageID, content string) { + if a == nil { + return + } + chatID = strings.TrimSpace(chatID) + messageID = strings.TrimSpace(messageID) + content = strings.TrimSpace(content) + if chatID == "" || messageID == "" || content == "" { + return + } + + entry := &toolFeedbackAnimationState{ + messageID: messageID, + baseContent: content, + stop: make(chan struct{}), + done: make(chan struct{}), + } + + var previous *toolFeedbackAnimationState + a.mu.Lock() + if old, ok := a.entries[chatID]; ok { + previous = old + } + a.entries[chatID] = entry + a.mu.Unlock() + + stopToolFeedbackAnimation(previous) + go a.run(chatID, entry) +} + +func (a *ToolFeedbackAnimator) Clear(chatID string) { + if a == nil || strings.TrimSpace(chatID) == "" { + return + } + entry := a.detach(chatID) + stopToolFeedbackAnimation(entry) +} + +func (a *ToolFeedbackAnimator) Take(chatID string) (string, string, bool) { + if a == nil || strings.TrimSpace(chatID) == "" { + return "", "", false + } + entry := a.detach(chatID) + if entry == nil || strings.TrimSpace(entry.messageID) == "" { + return "", "", false + } + stopToolFeedbackAnimation(entry) + return entry.messageID, entry.baseContent, true +} + +// Update edits an existing tracked feedback message. If the edit fails, the +// previous feedback state is restored so callers can retry without orphaning +// the old progress message. +func (a *ToolFeedbackAnimator) Update(ctx context.Context, chatID, content string) (string, bool, error) { + if a == nil || a.editFn == nil { + return "", false, nil + } + msgID, baseContent, ok := a.Take(chatID) + if !ok { + return "", false, nil + } + + animatedContent := InitialAnimatedToolFeedbackContent(content) + if err := a.editFn(ctx, strings.TrimSpace(chatID), msgID, animatedContent); err != nil { + a.Record(chatID, msgID, baseContent) + return "", true, err + } + + a.Record(chatID, msgID, content) + return msgID, true, nil +} + +func (a *ToolFeedbackAnimator) StopAll() { + if a == nil { + return + } + a.mu.Lock() + entries := make([]*toolFeedbackAnimationState, 0, len(a.entries)) + for chatID, entry := range a.entries { + entries = append(entries, entry) + delete(a.entries, chatID) + } + a.mu.Unlock() + + for _, entry := range entries { + stopToolFeedbackAnimation(entry) + } +} + +func (a *ToolFeedbackAnimator) detach(chatID string) *toolFeedbackAnimationState { + if a == nil || strings.TrimSpace(chatID) == "" { + return nil + } + a.mu.Lock() + defer a.mu.Unlock() + entry := a.entries[chatID] + delete(a.entries, chatID) + return entry +} + +func (a *ToolFeedbackAnimator) run(chatID string, entry *toolFeedbackAnimationState) { + defer close(entry.done) + + ticker := time.NewTicker(toolFeedbackAnimationInterval) + defer ticker.Stop() + + frameIdx := 1 + + for { + select { + case <-entry.stop: + return + case <-ticker.C: + if a.editFn == nil { + continue + } + frame := toolFeedbackAnimationFrames[frameIdx%len(toolFeedbackAnimationFrames)] + content := formatAnimatedToolFeedbackContent(entry.baseContent, frame) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + _ = a.editFn(ctx, chatID, entry.messageID, content) + cancel() + frameIdx++ + } + } +} + +func InitialAnimatedToolFeedbackContent(baseContent string) string { + return formatAnimatedToolFeedbackContent(baseContent, initialToolFeedbackAnimationFrame) +} + +func formatAnimatedToolFeedbackContent(baseContent, frame string) string { + baseContent = strings.TrimSpace(baseContent) + frame = strings.TrimSpace(frame) + if baseContent == "" { + return "" + } + if frame == "" { + return baseContent + } + lineBreak := strings.IndexByte(baseContent, '\n') + if lineBreak < 0 { + return appendToolFeedbackFrame(baseContent, frame) + } + return appendToolFeedbackFrame(baseContent[:lineBreak], frame) + baseContent[lineBreak:] +} + +func appendToolFeedbackFrame(firstLine, frame string) string { + firstLine = strings.TrimSpace(firstLine) + frame = strings.TrimSpace(frame) + if firstLine == "" { + return "" + } + if frame == "" { + return firstLine + } + + openTick := strings.IndexByte(firstLine, '`') + if openTick >= 0 { + if closeOffset := strings.IndexByte(firstLine[openTick+1:], '`'); closeOffset >= 0 { + closeTick := openTick + 1 + closeOffset + return firstLine[:closeTick] + frame + firstLine[closeTick:] + } + } + + return firstLine + frame +} + +func stopToolFeedbackAnimation(entry *toolFeedbackAnimationState) { + if entry == nil { + return + } + select { + case <-entry.stop: + default: + close(entry.stop) + } + <-entry.done +} diff --git a/pkg/channels/tool_feedback_animator_test.go b/pkg/channels/tool_feedback_animator_test.go new file mode 100644 index 000000000..a23284548 --- /dev/null +++ b/pkg/channels/tool_feedback_animator_test.go @@ -0,0 +1,121 @@ +package channels + +import ( + "context" + "errors" + "testing" +) + +func TestFormatAnimatedToolFeedbackContent(t *testing.T) { + got := formatAnimatedToolFeedbackContent("🔧 `read_file`\nReading config file", "running..") + want := "🔧 `read_filerunning..`\nReading config file" + if got != want { + t.Fatalf("formatAnimatedToolFeedbackContent() = %q, want %q", got, want) + } +} + +func TestInitialAnimatedToolFeedbackContent(t *testing.T) { + got := InitialAnimatedToolFeedbackContent("🔧 `exec`\nRunning command") + want := "🔧 `exec`\nRunning command" + if got != want { + t.Fatalf("InitialAnimatedToolFeedbackContent() = %q, want %q", got, want) + } +} + +func TestFormatAnimatedToolFeedbackContent_WithoutCodeSpan(t *testing.T) { + got := formatAnimatedToolFeedbackContent("hello", "running..") + want := "hellorunning.." + if got != want { + t.Fatalf("formatAnimatedToolFeedbackContent() without code span = %q, want %q", got, want) + } +} + +func TestToolFeedbackAnimator_RecordCurrentAndClear(t *testing.T) { + animator := NewToolFeedbackAnimator(nil) + animator.Record("chat-1", "msg-1", "🔧 `read_file`") + + msgID, ok := animator.Current("chat-1") + if !ok || msgID != "msg-1" { + t.Fatalf("Current() = (%q, %v), want (msg-1, true)", msgID, ok) + } + + animator.Clear("chat-1") + + msgID, ok = animator.Current("chat-1") + if ok || msgID != "" { + t.Fatalf("Current() after Clear = (%q, %v), want (\"\", false)", msgID, ok) + } +} + +func TestToolFeedbackAnimator_TakeStopsTrackingAndReturnsState(t *testing.T) { + animator := NewToolFeedbackAnimator(nil) + animator.Record("chat-1", "msg-1", "🔧 `read_file`\nChecking config") + + msgID, baseContent, ok := animator.Take("chat-1") + if !ok { + t.Fatal("Take() = not found, want tracked message") + } + if msgID != "msg-1" { + t.Fatalf("Take() msgID = %q, want msg-1", msgID) + } + if baseContent != "🔧 `read_file`\nChecking config" { + t.Fatalf("Take() baseContent = %q", baseContent) + } + if _, ok := animator.Current("chat-1"); ok { + t.Fatal("expected tracked message to be removed after Take()") + } +} + +func TestToolFeedbackAnimator_UpdateStopsTrackingBeforeEdit(t *testing.T) { + var animator *ToolFeedbackAnimator + animator = NewToolFeedbackAnimator(func(_ context.Context, chatID, messageID, content string) error { + if _, ok := animator.Current(chatID); ok { + t.Fatal("expected tracked tool feedback to be stopped before edit") + } + if messageID != "msg-1" { + t.Fatalf("messageID = %q, want msg-1", messageID) + } + if content != "🔧 `write_file`\nUpdating config" { + t.Fatalf("content = %q, want updated animated content", content) + } + return nil + }) + defer animator.StopAll() + + animator.Record("chat-1", "msg-1", "🔧 `read_file`\nChecking config") + + msgID, handled, err := animator.Update(context.Background(), "chat-1", "🔧 `write_file`\nUpdating config") + if err != nil { + t.Fatalf("Update() error = %v", err) + } + if !handled { + t.Fatal("Update() handled = false, want true") + } + if msgID != "msg-1" { + t.Fatalf("Update() msgID = %q, want msg-1", msgID) + } +} + +func TestToolFeedbackAnimator_UpdateFailureRestoresTracking(t *testing.T) { + editErr := errors.New("edit failed") + animator := NewToolFeedbackAnimator(func(context.Context, string, string, string) error { + return editErr + }) + defer animator.StopAll() + + animator.Record("chat-1", "msg-1", "🔧 `read_file`\nChecking config") + + msgID, handled, err := animator.Update(context.Background(), "chat-1", "🔧 `write_file`\nUpdating config") + if !handled { + t.Fatal("Update() handled = false, want true") + } + if !errors.Is(err, editErr) { + t.Fatalf("Update() error = %v, want editErr", err) + } + if msgID != "" { + t.Fatalf("Update() msgID = %q, want empty on failed edit", msgID) + } + if currentID, ok := animator.Current("chat-1"); !ok || currentID != "msg-1" { + t.Fatalf("Current() after failed Update = (%q, %v), want (msg-1, true)", currentID, ok) + } +} diff --git a/pkg/config/config.go b/pkg/config/config.go index a39cb55ae..161108638 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -286,7 +286,7 @@ func (d *AgentDefaults) GetMaxMediaSize() int { return DefaultMaxMediaSize } -// GetToolFeedbackMaxArgsLength returns the max args preview length for tool feedback messages. +// GetToolFeedbackMaxArgsLength returns the max visible text length for tool feedback messages. func (d *AgentDefaults) GetToolFeedbackMaxArgsLength() int { if d.ToolFeedback.MaxArgsLength > 0 { return d.ToolFeedback.MaxArgsLength diff --git a/pkg/providers/cli/toolcall_utils.go b/pkg/providers/cli/toolcall_utils.go index b480082eb..1f58c9a26 100644 --- a/pkg/providers/cli/toolcall_utils.go +++ b/pkg/providers/cli/toolcall_utils.go @@ -55,6 +55,12 @@ func buildCLIToolsPrompt(tools []ToolDefinition) string { func NormalizeToolCall(tc ToolCall) ToolCall { normalized := tc + if normalized.ThoughtSignature == "" && + normalized.ExtraContent != nil && + normalized.ExtraContent.Google != nil { + normalized.ThoughtSignature = normalized.ExtraContent.Google.ThoughtSignature + } + // Ensure Name is populated from Function if not set if normalized.Name == "" && normalized.Function != nil { normalized.Name = normalized.Function.Name @@ -77,8 +83,9 @@ func NormalizeToolCall(tc ToolCall) ToolCall { argsJSON, _ := json.Marshal(normalized.Arguments) if normalized.Function == nil { normalized.Function = &FunctionCall{ - Name: normalized.Name, - Arguments: string(argsJSON), + Name: normalized.Name, + Arguments: string(argsJSON), + ThoughtSignature: normalized.ThoughtSignature, } } else { if normalized.Function.Name == "" { @@ -90,6 +97,12 @@ func NormalizeToolCall(tc ToolCall) ToolCall { if normalized.Function.Arguments == "" { normalized.Function.Arguments = string(argsJSON) } + if normalized.Function.ThoughtSignature == "" { + normalized.Function.ThoughtSignature = normalized.ThoughtSignature + } + if normalized.ThoughtSignature == "" { + normalized.ThoughtSignature = normalized.Function.ThoughtSignature + } } return normalized diff --git a/pkg/providers/common/common.go b/pkg/providers/common/common.go index 90142fb8b..0a702e85e 100644 --- a/pkg/providers/common/common.go +++ b/pkg/providers/common/common.go @@ -70,11 +70,23 @@ func NewHTTPClient(proxy string) *http.Client { // It mirrors protocoltypes.Message but omits SystemParts, which is an // internal field that would be unknown to third-party endpoints. type openaiMessage struct { - Role string `json:"role"` - Content string `json:"content"` - ReasoningContent string `json:"reasoning_content,omitempty"` - ToolCalls []ToolCall `json:"tool_calls,omitempty"` - ToolCallID string `json:"tool_call_id,omitempty"` + Role string `json:"role"` + Content string `json:"content"` + ReasoningContent string `json:"reasoning_content,omitempty"` + ToolCalls []openaiToolCall `json:"tool_calls,omitempty"` + ToolCallID string `json:"tool_call_id,omitempty"` +} + +type openaiToolCall struct { + ID string `json:"id"` + Type string `json:"type,omitempty"` + Function *openaiFunctionCall `json:"function,omitempty"` +} + +type openaiFunctionCall struct { + Name string `json:"name"` + Arguments string `json:"arguments"` + ThoughtSignature string `json:"thought_signature,omitempty"` } // SerializeMessages converts internal Message structs to the OpenAI wire format. @@ -84,12 +96,13 @@ type openaiMessage struct { func SerializeMessages(messages []Message) []any { out := make([]any, 0, len(messages)) for _, m := range messages { + toolCalls := serializeToolCalls(m.ToolCalls) if len(m.Media) == 0 { out = append(out, openaiMessage{ Role: m.Role, Content: m.Content, ReasoningContent: m.ReasoningContent, - ToolCalls: m.ToolCalls, + ToolCalls: toolCalls, ToolCallID: m.ToolCallID, }) continue @@ -132,8 +145,8 @@ func SerializeMessages(messages []Message) []any { if m.ToolCallID != "" { msg["tool_call_id"] = m.ToolCallID } - if len(m.ToolCalls) > 0 { - msg["tool_calls"] = m.ToolCalls + if len(toolCalls) > 0 { + msg["tool_calls"] = toolCalls } if m.ReasoningContent != "" { msg["reasoning_content"] = m.ReasoningContent @@ -143,6 +156,55 @@ func SerializeMessages(messages []Message) []any { return out } +func serializeToolCalls(toolCalls []ToolCall) []openaiToolCall { + if len(toolCalls) == 0 { + return nil + } + + out := make([]openaiToolCall, 0, len(toolCalls)) + for _, tc := range toolCalls { + wireCall := openaiToolCall{ + ID: tc.ID, + Type: tc.Type, + } + + if tc.Function != nil { + thoughtSignature := tc.Function.ThoughtSignature + if thoughtSignature == "" { + thoughtSignature = tc.ThoughtSignature + } + if thoughtSignature == "" && tc.ExtraContent != nil && tc.ExtraContent.Google != nil { + thoughtSignature = tc.ExtraContent.Google.ThoughtSignature + } + wireCall.Function = &openaiFunctionCall{ + Name: tc.Function.Name, + Arguments: tc.Function.Arguments, + ThoughtSignature: thoughtSignature, + } + } else if tc.Name != "" || len(tc.Arguments) > 0 || tc.ThoughtSignature != "" { + thoughtSignature := tc.ThoughtSignature + if thoughtSignature == "" && tc.ExtraContent != nil && tc.ExtraContent.Google != nil { + thoughtSignature = tc.ExtraContent.Google.ThoughtSignature + } + argsJSON := "{}" + if len(tc.Arguments) > 0 { + if encoded, err := json.Marshal(tc.Arguments); err == nil { + argsJSON = string(encoded) + } + } + wireCall.Function = &openaiFunctionCall{ + Name: tc.Name, + Arguments: argsJSON, + ThoughtSignature: thoughtSignature, + } + } + + out = append(out, wireCall) + } + + return out +} + func parseDataAudioURL(mediaURL string) (format, data string, ok bool) { if !strings.HasPrefix(mediaURL, "data:audio/") { return "", "", false @@ -178,13 +240,15 @@ func ParseResponse(body io.Reader) (*LLMResponse, error) { ID string `json:"id"` Type string `json:"type"` Function *struct { - Name string `json:"name"` - Arguments json.RawMessage `json:"arguments"` + Name string `json:"name"` + Arguments json.RawMessage `json:"arguments"` + ThoughtSignature string `json:"thought_signature"` } `json:"function"` ExtraContent *struct { Google *struct { ThoughtSignature string `json:"thought_signature"` } `json:"google"` + ToolFeedbackExplanation string `json:"tool_feedback_explanation"` } `json:"extra_content"` } `json:"tool_calls"` } `json:"message"` @@ -210,9 +274,11 @@ func ParseResponse(body io.Reader) (*LLMResponse, error) { arguments := make(map[string]any) name := "" - // Extract thought_signature from Gemini/Google-specific extra content thoughtSignature := "" - if tc.ExtraContent != nil && tc.ExtraContent.Google != nil { + if tc.Function != nil { + thoughtSignature = tc.Function.ThoughtSignature + } + if thoughtSignature == "" && tc.ExtraContent != nil && tc.ExtraContent.Google != nil { thoughtSignature = tc.ExtraContent.Google.ThoughtSignature } @@ -228,11 +294,20 @@ func ParseResponse(body io.Reader) (*LLMResponse, error) { ThoughtSignature: thoughtSignature, } - if thoughtSignature != "" { - toolCall.ExtraContent = &ExtraContent{ - Google: &GoogleExtra{ + if thoughtSignature != "" || tc.ExtraContent != nil { + extraContent := &ExtraContent{ + ToolFeedbackExplanation: "", + } + if tc.ExtraContent != nil { + extraContent.ToolFeedbackExplanation = tc.ExtraContent.ToolFeedbackExplanation + } + if thoughtSignature != "" { + extraContent.Google = &GoogleExtra{ ThoughtSignature: thoughtSignature, - }, + } + } + if extraContent.Google != nil || strings.TrimSpace(extraContent.ToolFeedbackExplanation) != "" { + toolCall.ExtraContent = extraContent } } diff --git a/pkg/providers/common/common_test.go b/pkg/providers/common/common_test.go index c107bb665..a42d778f1 100644 --- a/pkg/providers/common/common_test.go +++ b/pkg/providers/common/common_test.go @@ -162,6 +162,104 @@ func TestSerializeMessages_StripsSystemParts(t *testing.T) { } } +func TestSerializeMessages_StripsInternalToolCallExtraContent(t *testing.T) { + messages := []Message{ + { + Role: "assistant", + ToolCalls: []ToolCall{{ + ID: "call_1", + Type: "function", + Function: &FunctionCall{ + Name: "read_file", + Arguments: `{"path":"README.md"}`, + ThoughtSignature: "sig-1", + }, + ExtraContent: &ExtraContent{ + Google: &GoogleExtra{ + ThoughtSignature: "sig-ignored-here", + }, + ToolFeedbackExplanation: "Read README.md first.", + }, + }}, + }, + } + + result := SerializeMessages(messages) + + data, err := json.Marshal(result) + if err != nil { + t.Fatalf("json.Marshal() error = %v", err) + } + payload := string(data) + if strings.Contains(payload, "extra_content") { + t.Fatalf("serialized payload should not include internal extra_content: %s", payload) + } + if !strings.Contains(payload, "thought_signature") { + t.Fatalf("serialized payload should preserve function thought_signature: %s", payload) + } +} + +func TestSerializeMessages_PreservesTopLevelThoughtSignature(t *testing.T) { + messages := []Message{ + { + Role: "assistant", + ToolCalls: []ToolCall{{ + ID: "call_1", + Type: "function", + ThoughtSignature: "sig-1", + Function: &FunctionCall{ + Name: "read_file", + Arguments: `{"path":"README.md"}`, + }, + }}, + }, + } + + result := SerializeMessages(messages) + + data, err := json.Marshal(result) + if err != nil { + t.Fatalf("json.Marshal() error = %v", err) + } + payload := string(data) + if !strings.Contains(payload, `"thought_signature":"sig-1"`) { + t.Fatalf("serialized payload should preserve top-level thought signature: %s", payload) + } +} + +func TestSerializeMessages_PreservesGoogleExtraThoughtSignature(t *testing.T) { + messages := []Message{ + { + Role: "assistant", + ToolCalls: []ToolCall{{ + ID: "call_1", + Type: "function", + Function: &FunctionCall{ + Name: "read_file", + Arguments: `{"path":"README.md"}`, + }, + ExtraContent: &ExtraContent{ + Google: &GoogleExtra{ThoughtSignature: "sig-1"}, + }, + }}, + }, + } + + result := SerializeMessages(messages) + + data, err := json.Marshal(result) + if err != nil { + t.Fatalf("json.Marshal() error = %v", err) + } + payload := string(data) + if strings.Contains(payload, "extra_content") { + t.Fatalf("serialized payload should not include extra_content: %s", payload) + } + if !strings.Contains(payload, `"thought_signature":"sig-1"`) { + t.Fatalf("serialized payload should preserve google thought signature: %s", payload) + } +} + // --- ParseResponse tests --- func TestParseResponse_BasicContent(t *testing.T) { @@ -234,6 +332,27 @@ func TestParseResponse_WithReasoningContent(t *testing.T) { } } +func TestParseResponse_WithToolFeedbackExplanationExtraContent(t *testing.T) { + body := `{"choices":[{"message":{"content":"","tool_calls":[{"id":"call_1","type":"function","function":{"name":"test_tool","arguments":"{}"},"extra_content":{"tool_feedback_explanation":"Check the current config before editing."}}]},"finish_reason":"tool_calls"}]}` + out, err := ParseResponse(strings.NewReader(body)) + if err != nil { + t.Fatalf("ParseResponse() error = %v", err) + } + if len(out.ToolCalls) != 1 { + t.Fatalf("len(ToolCalls) = %d, want 1", len(out.ToolCalls)) + } + if out.ToolCalls[0].ExtraContent == nil { + t.Fatal("ExtraContent is nil") + } + if out.ToolCalls[0].ExtraContent.ToolFeedbackExplanation != "Check the current config before editing." { + t.Fatalf( + "ToolFeedbackExplanation = %q, want %q", + out.ToolCalls[0].ExtraContent.ToolFeedbackExplanation, + "Check the current config before editing.", + ) + } +} + func TestParseResponse_InvalidJSON(t *testing.T) { _, err := ParseResponse(strings.NewReader("not json")) if err == nil { @@ -626,3 +745,27 @@ func TestParseResponse_WithThoughtSignature(t *testing.T) { out.ToolCalls[0].ExtraContent.Google.ThoughtSignature, "sig123") } } + +func TestParseResponse_WithFunctionThoughtSignature(t *testing.T) { + body := `{"choices":[{"message":{"content":"","tool_calls":[{"id":"call_1","type":"function","function":{"name":"test_tool","arguments":"{}","thought_signature":"sig456"}}]},"finish_reason":"tool_calls"}]}` + out, err := ParseResponse(strings.NewReader(body)) + if err != nil { + t.Fatalf("ParseResponse() error = %v", err) + } + if len(out.ToolCalls) != 1 { + t.Fatalf("len(ToolCalls) = %d, want 1", len(out.ToolCalls)) + } + if out.ToolCalls[0].ThoughtSignature != "sig456" { + t.Fatalf("ThoughtSignature = %q, want %q", out.ToolCalls[0].ThoughtSignature, "sig456") + } + if out.ToolCalls[0].ExtraContent == nil || out.ToolCalls[0].ExtraContent.Google == nil { + t.Fatal("ExtraContent.Google is nil") + } + if out.ToolCalls[0].ExtraContent.Google.ThoughtSignature != "sig456" { + t.Fatalf( + "ExtraContent.Google.ThoughtSignature = %q, want %q", + out.ToolCalls[0].ExtraContent.Google.ThoughtSignature, + "sig456", + ) + } +} diff --git a/pkg/providers/protocoltypes/types.go b/pkg/providers/protocoltypes/types.go index 89f68928a..f3553f8b0 100644 --- a/pkg/providers/protocoltypes/types.go +++ b/pkg/providers/protocoltypes/types.go @@ -11,7 +11,8 @@ type ToolCall struct { } type ExtraContent struct { - Google *GoogleExtra `json:"google,omitempty"` + Google *GoogleExtra `json:"google,omitempty"` + ToolFeedbackExplanation string `json:"tool_feedback_explanation,omitempty"` } type GoogleExtra struct { diff --git a/pkg/providers/toolcall_utils_test.go b/pkg/providers/toolcall_utils_test.go new file mode 100644 index 000000000..a4bb03c2e --- /dev/null +++ b/pkg/providers/toolcall_utils_test.go @@ -0,0 +1,24 @@ +package providers + +import "testing" + +func TestNormalizeToolCall_PreservesExtraContentGoogleThoughtSignature(t *testing.T) { + tc := NormalizeToolCall(ToolCall{ + ID: "call_1", + Name: "search", + Arguments: map[string]any{"q": "pico"}, + ExtraContent: &ExtraContent{ + Google: &GoogleExtra{ThoughtSignature: "sig-1"}, + }, + }) + + if tc.ThoughtSignature != "sig-1" { + t.Fatalf("ThoughtSignature = %q, want sig-1", tc.ThoughtSignature) + } + if tc.Function == nil { + t.Fatal("Function is nil") + } + if tc.Function.ThoughtSignature != "sig-1" { + t.Fatalf("Function.ThoughtSignature = %q, want sig-1", tc.Function.ThoughtSignature) + } +} diff --git a/pkg/utils/tool_feedback.go b/pkg/utils/tool_feedback.go index a6c8895b8..1a8b6c747 100644 --- a/pkg/utils/tool_feedback.go +++ b/pkg/utils/tool_feedback.go @@ -1,9 +1,57 @@ package utils -import "fmt" +import ( + "fmt" + "strings" +) -// FormatToolFeedbackMessage renders the tool name and arguments preview in the -// same markdown shape used by live tool feedback and session reconstruction. -func FormatToolFeedbackMessage(toolName, argsPreview string) string { - return fmt.Sprintf("\U0001f527 `%s`\n```\n%s\n```", toolName, argsPreview) +const ToolFeedbackContinuationHint = "Continuing the current task." + +// FormatToolFeedbackMessage renders the model-provided explanation for why a +// tool is being executed. When the model does not provide one, it keeps only +// the tool line and does not expose raw arguments or fallback text. +func FormatToolFeedbackMessage(toolName, explanation string) string { + toolName = strings.TrimSpace(toolName) + explanation = strings.TrimSpace(explanation) + + if toolName == "" { + return explanation + } + if explanation == "" { + return fmt.Sprintf("\U0001f527 `%s`", toolName) + } + + return fmt.Sprintf("\U0001f527 `%s`\n%s", toolName, explanation) +} + +// FitToolFeedbackMessage keeps tool feedback within a single outbound message. +// It preserves the first line when possible and truncates the explanation body +// instead of letting the message be split into multiple chunks. +func FitToolFeedbackMessage(content string, maxLen int) string { + content = strings.TrimSpace(content) + if content == "" || maxLen <= 0 { + return "" + } + if len([]rune(content)) <= maxLen { + return content + } + + firstLine, rest, hasRest := strings.Cut(content, "\n") + firstLine = strings.TrimSpace(firstLine) + rest = strings.TrimSpace(rest) + + if !hasRest || rest == "" { + return Truncate(firstLine, maxLen) + } + + if len([]rune(firstLine)) >= maxLen { + return Truncate(firstLine, maxLen) + } + + remaining := maxLen - len([]rune(firstLine)) - 1 + if remaining <= 0 { + return Truncate(firstLine, maxLen) + } + + return firstLine + "\n" + Truncate(rest, remaining) } diff --git a/pkg/utils/tool_feedback_test.go b/pkg/utils/tool_feedback_test.go index d7a55ce6b..316ce2408 100644 --- a/pkg/utils/tool_feedback_test.go +++ b/pkg/utils/tool_feedback_test.go @@ -3,9 +3,47 @@ package utils import "testing" func TestFormatToolFeedbackMessage(t *testing.T) { - got := FormatToolFeedbackMessage("read_file", "{\"path\":\"README.md\"}") - want := "\U0001f527 `read_file`\n```\n{\"path\":\"README.md\"}\n```" + got := FormatToolFeedbackMessage( + "read_file", + "I will read README.md first to confirm the current project structure.", + ) + want := "\U0001f527 `read_file`\nI will read README.md first to confirm the current project structure." if got != want { t.Fatalf("FormatToolFeedbackMessage() = %q, want %q", got, want) } } + +func TestFormatToolFeedbackMessage_EmptyExplanationKeepsOnlyToolLine(t *testing.T) { + got := FormatToolFeedbackMessage("read_file", "") + want := "\U0001f527 `read_file`" + if got != want { + t.Fatalf("FormatToolFeedbackMessage() = %q, want %q", got, want) + } +} + +func TestFormatToolFeedbackMessage_EmptyToolNameOmitsToolLine(t *testing.T) { + got := FormatToolFeedbackMessage("", "Continue drafting the final response.") + want := "Continue drafting the final response." + if got != want { + t.Fatalf("FormatToolFeedbackMessage() = %q, want %q", got, want) + } +} + +func TestFitToolFeedbackMessage_TruncatesBodyWithinSingleMessage(t *testing.T) { + got := FitToolFeedbackMessage( + "\U0001f527 `read_file`\nRead README.md first to confirm the current project structure.", + 40, + ) + want := "\U0001f527 `read_file`\nRead README.md first to..." + if got != want { + t.Fatalf("FitToolFeedbackMessage() = %q, want %q", got, want) + } +} + +func TestFitToolFeedbackMessage_TruncatesSingleLineMessage(t *testing.T) { + got := FitToolFeedbackMessage("\U0001f527 `read_file`", 10) + want := "\U0001f527 `read..." + if got != want { + t.Fatalf("FitToolFeedbackMessage() = %q, want %q", got, want) + } +} diff --git a/web/backend/api/pico.go b/web/backend/api/pico.go index 439a41a1c..8eeff4041 100644 --- a/web/backend/api/pico.go +++ b/web/backend/api/pico.go @@ -233,6 +233,10 @@ func (h *Handler) handleRegenPicoToken(w http.ResponseWriter, r *http.Request) { return } + gateway.mu.Lock() + gateway.picoToken = token + gateway.mu.Unlock() + h.writePicoInfoResponse(w, r, cfg, nil) } diff --git a/web/backend/api/pico_test.go b/web/backend/api/pico_test.go index 77fe1039b..6f7cefd4d 100644 --- a/web/backend/api/pico_test.go +++ b/web/backend/api/pico_test.go @@ -393,6 +393,58 @@ func TestHandleGetPicoInfo_OmitsToken(t *testing.T) { } } +func TestHandleRegenPicoToken_RefreshesGatewayTokenCache(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "config.json") + h := NewHandler(configPath) + + if _, err := h.EnsurePicoChannel(); err != nil { + t.Fatalf("EnsurePicoChannel() error = %v", err) + } + + origPicoToken := gateway.picoToken + t.Cleanup(func() { + gateway.mu.Lock() + gateway.picoToken = origPicoToken + gateway.mu.Unlock() + }) + + gateway.mu.Lock() + gateway.picoToken = "stale-token" + gateway.mu.Unlock() + + req := httptest.NewRequest(http.MethodPost, "http://launcher.local/api/pico/token", nil) + rec := httptest.NewRecorder() + h.handleRegenPicoToken(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) + } + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + + bc := cfg.Channels["pico"] + decoded, err := bc.GetDecoded() + if err != nil { + t.Fatalf("GetDecoded() error = %v", err) + } + token := decoded.(*config.PicoSettings).Token.String() + if token == "" { + t.Fatal("expected regenerated pico token to be persisted") + } + if token == "stale-token" { + t.Fatal("expected regenerated pico token to differ from stale cache") + } + + gateway.mu.Lock() + defer gateway.mu.Unlock() + if gateway.picoToken != token { + t.Fatalf("gateway.picoToken = %q, want %q", gateway.picoToken, token) + } +} + func TestHandleWebSocketProxyReloadsGatewayTargetFromConfig(t *testing.T) { origMatcher := gatewayProcessMatcher gatewayProcessMatcher = func(int) (bool, bool) { return true, true } diff --git a/web/backend/api/session.go b/web/backend/api/session.go index 0483b57cc..6ac1eb988 100644 --- a/web/backend/api/session.go +++ b/web/backend/api/session.go @@ -510,6 +510,16 @@ func visibleSessionMessages(messages []providers.Message, toolFeedbackMaxArgsLen transcript = append(transcript, visibleToolMessages...) } + // When assistant content exactly matches the rendered tool summary or + // tool-delivered message, skip it to avoid duplicates. Distinct content + // must remain visible in restored session history. + if len(msg.ToolCalls) > 0 && + len(msg.Media) == 0 && + len(attachments) == 0 && + assistantToolCallContentDuplicated(msg.Content, toolSummaryMessages, visibleToolMessages) { + continue + } + // Pico web chat can persist both visible `message` tool output and a // later plain assistant reply in the same turn. Hide only the fixed // internal summary that marks handled tool delivery. @@ -549,6 +559,43 @@ func filterSessionChatMessages(messages []sessionChatMessage) []sessionChatMessa return filtered } +func assistantToolCallContentDuplicated( + content string, + toolSummaryMessages []sessionChatMessage, + visibleToolMessages []sessionChatMessage, +) bool { + content = strings.TrimSpace(content) + if content == "" { + return false + } + + for _, msg := range toolSummaryMessages { + if toolSummaryContainsContent(msg.Content, content) { + return true + } + } + for _, msg := range visibleToolMessages { + if strings.TrimSpace(msg.Content) == content { + return true + } + } + return false +} + +func toolSummaryContainsContent(summary, content string) bool { + summary = strings.TrimSpace(summary) + content = strings.TrimSpace(content) + if summary == "" || content == "" { + return false + } + if summary == content { + return true + } + + _, body, hasBody := strings.Cut(summary, "\n") + return hasBody && strings.TrimSpace(body) == content +} + func sessionAttachments(msg providers.Message) []sessionChatAttachment { if len(msg.Attachments) == 0 { return nil @@ -663,20 +710,41 @@ func visibleAssistantToolSummaryMessages( } } - argsPreview := strings.TrimSpace(argsJSON) - if argsPreview == "" { - argsPreview = "{}" - } - messages = append(messages, sessionChatMessage{ - Role: "assistant", - Content: utils.FormatToolFeedbackMessage(name, utils.Truncate(argsPreview, toolFeedbackMaxArgsLength)), + Role: "assistant", + Content: utils.FormatToolFeedbackMessage( + name, + visibleAssistantToolSummaryText(tc, toolFeedbackMaxArgsLength), + ), }) } return messages } +func visibleAssistantToolSummaryText( + tc providers.ToolCall, + toolFeedbackMaxArgsLength int, +) string { + if tc.ExtraContent != nil { + if explanation := strings.TrimSpace(tc.ExtraContent.ToolFeedbackExplanation); explanation != "" { + return utils.Truncate(explanation, toolFeedbackMaxArgsLength) + } + } + + argsJSON := "" + if tc.Function != nil { + argsJSON = tc.Function.Arguments + } + if strings.TrimSpace(argsJSON) == "" && len(tc.Arguments) > 0 { + if encodedArgs, err := json.Marshal(tc.Arguments); err == nil { + argsJSON = string(encodedArgs) + } + } + + return utils.Truncate(strings.TrimSpace(argsJSON), toolFeedbackMaxArgsLength) +} + func visibleAssistantToolMessages(toolCalls []providers.ToolCall) []sessionChatMessage { if len(toolCalls) == 0 { return nil diff --git a/web/backend/api/session_test.go b/web/backend/api/session_test.go index d2efb3879..6afb8a94f 100644 --- a/web/backend/api/session_test.go +++ b/web/backend/api/session_test.go @@ -675,7 +675,7 @@ func TestHandleListSessions_MessageCountUsesVisibleTranscript(t *testing.T) { } } -func TestHandleGetSession_PreservesToolSummaryAndAssistantContent(t *testing.T) { +func TestHandleGetSession_DoesNotDuplicateAssistantToolCallContent(t *testing.T) { configPath, cleanup := setupOAuthTestEnv(t) defer cleanup() @@ -690,7 +690,7 @@ func TestHandleGetSession_PreservesToolSummaryAndAssistantContent(t *testing.T) {Role: "user", Content: "check file"}, { Role: "assistant", - Content: "model final reply", + Content: "Read the file before replying.", ToolCalls: []providers.ToolCall{ { ID: "call_1", @@ -699,6 +699,9 @@ func TestHandleGetSession_PreservesToolSummaryAndAssistantContent(t *testing.T) Name: "read_file", Arguments: `{"path":"README.md","start_line":1,"end_line":10}`, }, + ExtraContent: &providers.ExtraContent{ + ToolFeedbackExplanation: "Read the file before replying.", + }, }, }, }, @@ -730,8 +733,8 @@ func TestHandleGetSession_PreservesToolSummaryAndAssistantContent(t *testing.T) if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { t.Fatalf("Unmarshal() error = %v", err) } - if len(resp.Messages) != 3 { - t.Fatalf("len(resp.Messages) = %d, want 3", len(resp.Messages)) + if len(resp.Messages) != 2 { + t.Fatalf("len(resp.Messages) = %d, want 2", len(resp.Messages)) } if resp.Messages[0].Role != "user" || resp.Messages[0].Content != "check file" { t.Fatalf("first message = %#v, want user/check file", resp.Messages[0]) @@ -739,8 +742,153 @@ func TestHandleGetSession_PreservesToolSummaryAndAssistantContent(t *testing.T) if !strings.Contains(resp.Messages[1].Content, "`read_file`") { t.Fatalf("tool summary message = %#v, want read_file summary", resp.Messages[1]) } - if resp.Messages[2].Role != "assistant" || resp.Messages[2].Content != "model final reply" { - t.Fatalf("assistant message = %#v, want model final reply", resp.Messages[2]) + if !strings.Contains(resp.Messages[1].Content, "Read the file before replying.") { + t.Fatalf("tool summary message = %#v, want tool explanation", resp.Messages[1]) + } +} + +func TestHandleGetSession_PreservesDistinctAssistantToolCallContent(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + dir := sessionsTestDir(t, configPath) + store, err := memory.NewJSONLStore(dir) + if err != nil { + t.Fatalf("NewJSONLStore() error = %v", err) + } + + sessionKey := picoSessionPrefix + "detail-tool-summary-distinct-content" + for _, msg := range []providers.Message{ + {Role: "user", Content: "check file"}, + { + Role: "assistant", + Content: "I will summarize the findings after reading the file.", + ToolCalls: []providers.ToolCall{ + { + ID: "call_1", + Type: "function", + Function: &providers.FunctionCall{ + Name: "read_file", + Arguments: `{"path":"README.md","start_line":1,"end_line":10}`, + }, + ExtraContent: &providers.ExtraContent{ + ToolFeedbackExplanation: "Read the file before replying.", + }, + }, + }, + }, + } { + if err := store.AddFullMessage(nil, sessionKey, msg); err != nil { + t.Fatalf("AddFullMessage() error = %v", err) + } + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/sessions/detail-tool-summary-distinct-content", nil) + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + var resp struct { + Messages []struct { + Role string `json:"role"` + Content string `json:"content"` + } `json:"messages"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + if len(resp.Messages) != 3 { + t.Fatalf("len(resp.Messages) = %d, want 3", len(resp.Messages)) + } + if !strings.Contains(resp.Messages[1].Content, "`read_file`") { + t.Fatalf("tool summary message = %#v, want read_file summary", resp.Messages[1]) + } + if resp.Messages[2].Role != "assistant" || + resp.Messages[2].Content != "I will summarize the findings after reading the file." { + t.Fatalf("assistant content = %#v, want preserved distinct content", resp.Messages[2]) + } +} + +func TestHandleGetSession_PreservesMediaWhenAssistantToolCallContentDuplicatesSummary(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + dir := sessionsTestDir(t, configPath) + store, err := memory.NewJSONLStore(dir) + if err != nil { + t.Fatalf("NewJSONLStore() error = %v", err) + } + + sessionKey := picoSessionPrefix + "detail-tool-summary-duplicate-content-with-media" + for _, msg := range []providers.Message{ + {Role: "user", Content: "check screenshot"}, + { + Role: "assistant", + Content: "Reviewing the generated screenshot.", + Media: []string{"data:image/png;base64,abc123"}, + ToolCalls: []providers.ToolCall{ + { + ID: "call_1", + Type: "function", + Function: &providers.FunctionCall{ + Name: "view_image", + Arguments: `{"path":"artifact.png"}`, + }, + ExtraContent: &providers.ExtraContent{ + ToolFeedbackExplanation: "Reviewing the generated screenshot.", + }, + }, + }, + }, + } { + if err := store.AddFullMessage(nil, sessionKey, msg); err != nil { + t.Fatalf("AddFullMessage() error = %v", err) + } + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/sessions/detail-tool-summary-duplicate-content-with-media", nil) + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + var resp struct { + Messages []struct { + Role string `json:"role"` + Content string `json:"content"` + Media []string `json:"media"` + } `json:"messages"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + if len(resp.Messages) != 3 { + t.Fatalf("len(resp.Messages) = %d, want 3", len(resp.Messages)) + } + if !strings.Contains(resp.Messages[1].Content, "`view_image`") { + t.Fatalf("tool summary message = %#v, want view_image summary", resp.Messages[1]) + } + if resp.Messages[2].Role != "assistant" { + t.Fatalf("assistant message role = %q, want assistant", resp.Messages[2].Role) + } + if resp.Messages[2].Content != "Reviewing the generated screenshot." { + t.Fatalf("assistant content = %q, want preserved duplicated content with media", resp.Messages[2].Content) + } + if len(resp.Messages[2].Media) != 1 || resp.Messages[2].Media[0] != "data:image/png;base64,abc123" { + t.Fatalf("assistant media = %#v, want preserved media", resp.Messages[2].Media) } for _, msg := range resp.Messages { if msg.Role == "tool" || strings.Contains(msg.Content, "raw read_file result") { @@ -749,6 +897,90 @@ func TestHandleGetSession_PreservesToolSummaryAndAssistantContent(t *testing.T) } } +func TestHandleGetSession_PreservesAttachmentsWhenAssistantToolCallContentDuplicatesSummary(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + dir := sessionsTestDir(t, configPath) + store, err := memory.NewJSONLStore(dir) + if err != nil { + t.Fatalf("NewJSONLStore() error = %v", err) + } + + sessionKey := picoSessionPrefix + "detail-tool-summary-duplicate-content-with-attachments" + for _, msg := range []providers.Message{ + {Role: "user", Content: "check report"}, + { + Role: "assistant", + Content: "Reviewing the generated report.", + Attachments: []providers.Attachment{{ + Type: "file", + URL: "https://example.com/report.txt", + Filename: "report.txt", + ContentType: "text/plain", + }}, + ToolCalls: []providers.ToolCall{ + { + ID: "call_1", + Type: "function", + Function: &providers.FunctionCall{ + Name: "read_file", + Arguments: `{"path":"report.txt"}`, + }, + ExtraContent: &providers.ExtraContent{ + ToolFeedbackExplanation: "Reviewing the generated report.", + }, + }, + }, + }, + } { + if err := store.AddFullMessage(nil, sessionKey, msg); err != nil { + t.Fatalf("AddFullMessage() error = %v", err) + } + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest( + http.MethodGet, + "/api/sessions/detail-tool-summary-duplicate-content-with-attachments", + nil, + ) + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + var resp struct { + Messages []sessionChatMessage `json:"messages"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + if len(resp.Messages) != 3 { + t.Fatalf("len(resp.Messages) = %d, want 3", len(resp.Messages)) + } + if !strings.Contains(resp.Messages[1].Content, "`read_file`") { + t.Fatalf("tool summary message = %#v, want read_file summary", resp.Messages[1]) + } + if resp.Messages[2].Role != "assistant" { + t.Fatalf("assistant message role = %q, want assistant", resp.Messages[2].Role) + } + if resp.Messages[2].Content != "Reviewing the generated report." { + t.Fatalf("assistant content = %q, want preserved duplicated content", resp.Messages[2].Content) + } + if len(resp.Messages[2].Attachments) != 1 { + t.Fatalf("len(assistant.Attachments) = %d, want 1", len(resp.Messages[2].Attachments)) + } + if resp.Messages[2].Attachments[0].URL != "https://example.com/report.txt" { + t.Fatalf("attachment url = %q, want report URL", resp.Messages[2].Attachments[0].URL) + } +} + func TestHandleGetSession_UsesConfiguredToolFeedbackMaxArgsLength(t *testing.T) { configPath, cleanup := setupOAuthTestEnv(t) defer cleanup() @@ -770,6 +1002,7 @@ func TestHandleGetSession_UsesConfiguredToolFeedbackMaxArgsLength(t *testing.T) } argsJSON := `{"path":"README.md","start_line":1,"end_line":10,"extra":"abcdefghijklmnopqrstuvwxyz"}` + explanation := "Read README.md first to confirm the current project structure before editing the config example." sessionKey := picoSessionPrefix + "detail-tool-summary-max-args" err = store.AddFullMessage(nil, sessionKey, providers.Message{Role: "user", Content: "check file"}) if err != nil { @@ -784,6 +1017,9 @@ func TestHandleGetSession_UsesConfiguredToolFeedbackMaxArgsLength(t *testing.T) Name: "read_file", Arguments: argsJSON, }, + ExtraContent: &providers.ExtraContent{ + ToolFeedbackExplanation: explanation, + }, }}, }) if err != nil { @@ -816,13 +1052,93 @@ func TestHandleGetSession_UsesConfiguredToolFeedbackMaxArgsLength(t *testing.T) t.Fatalf("len(resp.Messages) = %d, want at least 2", len(resp.Messages)) } - wantPreview := utils.Truncate(argsJSON, 20) + wantPreview := utils.Truncate(explanation, 20) if !strings.Contains(resp.Messages[1].Content, wantPreview) { t.Fatalf("tool summary = %q, want preview %q", resp.Messages[1].Content, wantPreview) } if strings.Contains(resp.Messages[1].Content, argsJSON) { t.Fatalf("tool summary = %q, expected configured truncation", resp.Messages[1].Content) } + if !strings.Contains(resp.Messages[1].Content, "`read_file`") { + t.Fatalf("tool summary = %q, want read_file summary", resp.Messages[1].Content) + } +} + +func TestHandleGetSession_FallsBackToLegacyToolArgumentsWhenExplanationMissing(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + cfg.Agents.Defaults.ToolFeedback.MaxArgsLength = 20 + err = config.SaveConfig(configPath, cfg) + if err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + dir := sessionsTestDir(t, configPath) + store, err := memory.NewJSONLStore(dir) + if err != nil { + t.Fatalf("NewJSONLStore() error = %v", err) + } + + argsJSON := `{"path":"README.md","start_line":1,"end_line":10,"extra":"abcdefghijklmnopqrstuvwxyz"}` + sessionKey := picoSessionPrefix + "detail-tool-summary-legacy-args" + if err := store.AddFullMessage( + nil, + sessionKey, + providers.Message{Role: "user", Content: "check file"}, + ); err != nil { + t.Fatalf("AddFullMessage(user) error = %v", err) + } + if err := store.AddFullMessage(nil, sessionKey, providers.Message{ + Role: "assistant", + ToolCalls: []providers.ToolCall{{ + ID: "call_1", + Type: "function", + Function: &providers.FunctionCall{ + Name: "read_file", + Arguments: argsJSON, + }, + }}, + }); err != nil { + t.Fatalf("AddFullMessage(assistant) error = %v", err) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/sessions/detail-tool-summary-legacy-args", nil) + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + var resp struct { + Messages []struct { + Role string `json:"role"` + Content string `json:"content"` + } `json:"messages"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + if len(resp.Messages) < 2 { + t.Fatalf("len(resp.Messages) = %d, want at least 2", len(resp.Messages)) + } + + wantPreview := utils.Truncate(argsJSON, 20) + if !strings.Contains(resp.Messages[1].Content, "`read_file`") { + t.Fatalf("tool summary = %q, want read_file summary", resp.Messages[1].Content) + } + if !strings.Contains(resp.Messages[1].Content, wantPreview) { + t.Fatalf("tool summary = %q, want legacy args preview %q", resp.Messages[1].Content, wantPreview) + } } func TestHandleGetSession_IncludesMediaOnlyMessages(t *testing.T) { diff --git a/web/frontend/src/components/chat/assistant-message.tsx b/web/frontend/src/components/chat/assistant-message.tsx index 4dfc261c2..c09f5a06d 100644 --- a/web/frontend/src/components/chat/assistant-message.tsx +++ b/web/frontend/src/components/chat/assistant-message.tsx @@ -100,8 +100,8 @@ export function AssistantMessage({ className={cn( "prose dark:prose-invert prose-pre:my-2 prose-pre:overflow-x-auto prose-pre:rounded-lg prose-pre:border prose-pre:bg-zinc-100 prose-pre:p-0 prose-pre:text-zinc-900 dark:prose-pre:bg-zinc-950 dark:prose-pre:text-zinc-100 max-w-none [overflow-wrap:anywhere] break-words", isThought - ? "prose-p:my-1.5 px-3 pt-0 pb-3 text-[13px] leading-relaxed opacity-70" - : "prose-p:my-2 p-4 text-[15px] leading-relaxed", + ? "prose-p:my-1.5 prose-p:whitespace-pre-wrap px-3 pt-0 pb-3 text-[13px] leading-relaxed opacity-70" + : "prose-p:my-2 prose-p:whitespace-pre-wrap p-4 text-[15px] leading-relaxed", )} > = 0; i -= 1) { + if (messages[i].role === "user") { + lastUserIndex = i + break + } + } + + for (let i = messages.length - 1; i >= 0; i -= 1) { + if (i <= lastUserIndex) { + break + } + if (isToolFeedbackMessage(messages[i])) { + return i + } + } + return -1 +} + export function handlePicoMessage( message: PicoMessage, expectedSessionId: string, @@ -138,21 +168,88 @@ export function handlePicoMessage( const hasKind = hasAssistantKindPayload(payload) const kind = parseAssistantMessageKind(payload) const attachments = parseAttachments(payload) + const contextUsage = parseContextUsage(payload) + const timestamp = + message.timestamp !== undefined && + Number.isFinite(Number(message.timestamp)) + ? normalizeUnixTimestamp(Number(message.timestamp)) + : Date.now() if (!messageId) { break } updateChatStore((prev) => ({ - messages: prev.messages.map((msg) => - msg.id === messageId - ? { - ...msg, - content, - ...(hasKind ? { kind } : {}), - ...(attachments ? { attachments } : {}), - } - : msg, - ), + messages: (() => { + let found = false + const messages = prev.messages.map((msg) => { + if (msg.id !== messageId) { + return msg + } + found = true + return { + ...msg, + id: messageId, + content, + ...(hasKind ? { kind } : {}), + ...(attachments ? { attachments } : {}), + } + }) + if (found) { + return messages + } + + const fallbackIndex = findToolFeedbackMessageIndex(messages) + if (fallbackIndex >= 0) { + return messages.map((msg, index) => + index === fallbackIndex + ? { + ...msg, + id: messageId, + content, + ...(hasKind ? { kind } : {}), + ...(attachments ? { attachments } : {}), + } + : msg, + ) + } + + return [ + ...messages, + { + id: messageId, + role: "assistant" as const, + content, + ...(hasKind ? { kind } : {}), + ...(attachments ? { attachments } : {}), + timestamp, + }, + ] + })(), + ...(contextUsage ? { contextUsage } : {}), + })) + break + } + + case "message.delete": { + const messageId = payload.message_id as string + if (!messageId) { + break + } + + updateChatStore((prev) => ({ + messages: (() => { + const exactMessages = prev.messages.filter((msg) => msg.id !== messageId) + if (exactMessages.length !== prev.messages.length) { + return exactMessages + } + + const fallbackIndex = findToolFeedbackMessageIndex(prev.messages) + if (fallbackIndex < 0) { + return prev.messages + } + + return prev.messages.filter((_, index) => index !== fallbackIndex) + })(), })) break } diff --git a/web/frontend/src/i18n/locales/en.json b/web/frontend/src/i18n/locales/en.json index 7ded188c1..cf8d91f0c 100644 --- a/web/frontend/src/i18n/locales/en.json +++ b/web/frontend/src/i18n/locales/en.json @@ -605,9 +605,9 @@ "split_on_marker": "Chatty Mode", "split_on_marker_hint": "Split long messages into short ones like real human chatting.", "tool_feedback_enabled": "Tool Feedback", - "tool_feedback_enabled_hint": "Send a short tool-call preview into the current chat before each tool execution.", - "tool_feedback_max_args_length": "Tool Feedback Args Preview Length", - "tool_feedback_max_args_length_hint": "Maximum number of argument characters shown in each tool feedback message. Set to 0 to use the default.", + "tool_feedback_enabled_hint": "Send a short execution note into the current chat before each tool runs.", + "tool_feedback_max_args_length": "Tool Feedback Length", + "tool_feedback_max_args_length_hint": "Maximum number of characters shown in each tool feedback message. Set to 0 to use the default.", "exec_enabled": "Allow Commands", "exec_enabled_hint": "Enable or disable command execution for the app. When disabled, no command requests will run.", "allow_remote": "Allow Remote Commands", diff --git a/web/frontend/src/i18n/locales/zh.json b/web/frontend/src/i18n/locales/zh.json index ca71d7ef8..1d3c571c3 100644 --- a/web/frontend/src/i18n/locales/zh.json +++ b/web/frontend/src/i18n/locales/zh.json @@ -605,9 +605,9 @@ "split_on_marker": "连续短消息", "split_on_marker_hint": "像真人聊天一样,把长难句拆成多条短消息快速发出", "tool_feedback_enabled": "工具反馈", - "tool_feedback_enabled_hint": "在每次执行工具前,先向当前会话发送一条简短的工具调用预览", - "tool_feedback_max_args_length": "工具反馈参数预览长度", - "tool_feedback_max_args_length_hint": "每条工具反馈消息中展示的参数字符上限。设为 0 时使用默认值", + "tool_feedback_enabled_hint": "在每次执行工具前,先向当前会话发送一条简短的执行说明", + "tool_feedback_max_args_length": "工具反馈长度", + "tool_feedback_max_args_length_hint": "每条工具反馈消息中展示的字符上限。设为 0 时使用默认值", "exec_enabled": "允许命令执行", "exec_enabled_hint": "控制应用是否允许执行命令。关闭后,所有命令请求都不会执行", "allow_remote": "允许远程命令执行", From c71146b1d53f4f5e57a25a52c29268ec5b854a40 Mon Sep 17 00:00:00 2001 From: Kunal Karmakar Date: Sun, 19 Apr 2026 04:20:00 +0000 Subject: [PATCH 26/31] Functions deduplication --- pkg/providers/anthropic/provider.go | 20 +----- pkg/providers/anthropic_messages/provider.go | 62 ++---------------- .../anthropic_messages/provider_test.go | 38 ----------- pkg/providers/common/common.go | 63 ++++++++++++++++++ pkg/providers/common/common_test.go | 65 +++++++++++++++++++ pkg/providers/factory_provider.go | 23 +------ pkg/providers/httpapi/gemini_helpers.go | 9 --- pkg/providers/httpapi/gemini_provider.go | 2 +- pkg/providers/openai_compat/provider.go | 15 +++-- 9 files changed, 145 insertions(+), 152 deletions(-) diff --git a/pkg/providers/anthropic/provider.go b/pkg/providers/anthropic/provider.go index d4ceaab2c..4330163df 100644 --- a/pkg/providers/anthropic/provider.go +++ b/pkg/providers/anthropic/provider.go @@ -10,6 +10,7 @@ import ( "github.com/anthropics/anthropic-sdk-go" "github.com/anthropics/anthropic-sdk-go/option" + "github.com/sipeed/picoclaw/pkg/providers/common" "github.com/sipeed/picoclaw/pkg/providers/protocoltypes" ) @@ -42,7 +43,7 @@ func NewProvider(token string) *Provider { } func NewProviderWithBaseURL(token, apiBase string) *Provider { - baseURL := normalizeBaseURL(apiBase) + baseURL := common.NormalizeAnthropicBaseURL(apiBase, defaultBaseURL, false) client := anthropic.NewClient( option.WithAuthToken(token), option.WithBaseURL(baseURL), @@ -385,20 +386,3 @@ func parseResponse(resp *anthropic.Message) *LLMResponse { }, } } - -func normalizeBaseURL(apiBase string) string { - base := strings.TrimSpace(apiBase) - if base == "" { - return defaultBaseURL - } - - base = strings.TrimRight(base, "/") - if before, ok := strings.CutSuffix(base, "/v1"); ok { - base = before - } - if base == "" { - return defaultBaseURL - } - - return base -} diff --git a/pkg/providers/anthropic_messages/provider.go b/pkg/providers/anthropic_messages/provider.go index 1e865b709..dcc31b6f9 100644 --- a/pkg/providers/anthropic_messages/provider.go +++ b/pkg/providers/anthropic_messages/provider.go @@ -16,6 +16,7 @@ import ( "strings" "time" + "github.com/sipeed/picoclaw/pkg/providers/common" "github.com/sipeed/picoclaw/pkg/providers/protocoltypes" ) @@ -51,7 +52,7 @@ func NewProvider(apiKey, apiBase, userAgent string) *Provider { // NewProviderWithTimeout creates a provider with custom request timeout. func NewProviderWithTimeout(apiKey, apiBase, userAgent string, timeoutSeconds int) *Provider { - baseURL := normalizeBaseURL(apiBase) + baseURL := common.NormalizeAnthropicBaseURL(apiBase, defaultBaseURL, true) timeout := defaultRequestTimeout if timeoutSeconds > 0 { timeout = time.Duration(timeoutSeconds) * time.Second @@ -161,7 +162,7 @@ func buildRequestBody( options map[string]any, ) (map[string]any, error) { // max_tokens is required and guaranteed by agent loop - maxTokens, ok := asInt(options["max_tokens"]) + maxTokens, ok := common.AsInt(options["max_tokens"]) if !ok { return nil, fmt.Errorf("max_tokens is required in options") } @@ -173,7 +174,7 @@ func buildRequestBody( } // Set temperature from options - if temp, ok := asFloat(options["temperature"]); ok { + if temp, ok := common.AsFloat(options["temperature"]); ok { result["temperature"] = temp } @@ -361,61 +362,6 @@ func parseResponseBody(body []byte) (*LLMResponse, error) { }, nil } -// normalizeBaseURL ensures the base URL is properly formatted. -// It removes /v1 suffix if present (to avoid duplication) and always appends /v1. -// This handles edge cases like "https://api.example.com/v1/proxy" correctly. -func normalizeBaseURL(apiBase string) string { - base := strings.TrimSpace(apiBase) - if base == "" { - return defaultBaseURL - } - - // Remove trailing slashes - base = strings.TrimRight(base, "/") - - // Remove /v1 suffix if present (will be re-added) - // This prevents duplication for URLs like "https://api.example.com/v1/proxy" - if before, ok := strings.CutSuffix(base, "/v1"); ok { - base = before - } - - // Ensure we don't have an empty string after cutting - if base == "" { - return defaultBaseURL - } - - // Add /v1 suffix (required by Anthropic Messages API) - return base + "/v1" -} - -// Helper functions for type conversion - -func asInt(v any) (int, bool) { - switch val := v.(type) { - case int: - return val, true - case float64: - return int(val), true - case int64: - return int(val), true - default: - return 0, false - } -} - -func asFloat(v any) (float64, bool) { - switch val := v.(type) { - case float64: - return val, true - case int: - return float64(val), true - case int64: - return float64(val), true - default: - return 0, false - } -} - // Anthropic API response structures type anthropicMessageResponse struct { diff --git a/pkg/providers/anthropic_messages/provider_test.go b/pkg/providers/anthropic_messages/provider_test.go index ba9d24b66..6401d84bd 100644 --- a/pkg/providers/anthropic_messages/provider_test.go +++ b/pkg/providers/anthropic_messages/provider_test.go @@ -372,44 +372,6 @@ func TestParseResponseBody(t *testing.T) { } } -func TestNormalizeBaseURL(t *testing.T) { - tests := []struct { - name string - apiBase string - expected string - }{ - { - name: "empty string defaults to official API", - apiBase: "", - expected: "https://api.anthropic.com/v1", - }, - { - name: "URL without /v1 gets it appended", - apiBase: "https://api.example.com/anthropic", - expected: "https://api.example.com/anthropic/v1", - }, - { - name: "URL with /v1 remains unchanged", - apiBase: "https://api.example.com/v1", - expected: "https://api.example.com/v1", - }, - { - name: "URL with trailing slash gets cleaned", - apiBase: "https://api.example.com/anthropic/", - expected: "https://api.example.com/anthropic/v1", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := normalizeBaseURL(tt.apiBase) - if got != tt.expected { - t.Errorf("normalizeBaseURL(%q) = %q, want %q", tt.apiBase, got, tt.expected) - } - }) - } -} - func TestNewProvider(t *testing.T) { provider := NewProvider("test-key", "https://api.example.com", "") if provider == nil { diff --git a/pkg/providers/common/common.go b/pkg/providers/common/common.go index 0a702e85e..afc6877b1 100644 --- a/pkg/providers/common/common.go +++ b/pkg/providers/common/common.go @@ -478,6 +478,69 @@ func AsInt(v any) (int, bool) { } } +// ExtractProtocol extracts the effective protocol and model identifier from a +// model configuration. +// +// The explicit Provider field takes precedence. When Provider is empty, the +// protocol is inferred from Model. Plain model names default to "openai". +// Provider-prefixed models strip the first slash-separated segment from the +// returned model ID. +// +// The returned protocol is normalized to the provider's canonical spelling. +// Examples: +// - Model "openai/gpt-4o" -> ("openai", "gpt-4o") +// - Model "nvidia/z-ai/glm-5.1" -> ("nvidia", "z-ai/glm-5.1") +// - Provider "nvidia", Model "z-ai/glm-5.1" -> ("nvidia", "z-ai/glm-5.1") +// - Provider "openai", Model "openai/gpt-4o" -> ("openai", "openai/gpt-4o") +// - Model "gpt-4o" -> ("openai", "gpt-4o") +func ExtractProtocol(model string) (protocol, modelID string) { + if cfg == nil { + return "", "" + } + + model := strings.TrimSpace(cfg.Model) + if provider := strings.TrimSpace(cfg.Provider); provider != "" { + return NormalizeProvider(provider), model + } + if model == "" { + return "", "" + } + + protocol, rest, found := strings.Cut(model, "/") + if !found { + return "openai", model + } + protocol = strings.TrimSpace(protocol) + if protocol == "" { + return "", strings.TrimSpace(rest) + } + return NormalizeProvider(protocol), strings.TrimSpace(rest) +} + +// NormalizeAnthropicBaseURL ensures the Anthropic base URL is properly formatted. +// It removes a trailing /v1 suffix if present (to avoid duplication), then +// re-appends /v1 when appendV1Suffix is true. An empty apiBase falls back to +// defaultBaseURL. +func NormalizeAnthropicBaseURL(apiBase, defaultBaseURL string, appendV1Suffix bool) string { + base := strings.TrimSpace(apiBase) + if base == "" { + return defaultBaseURL + } + + base = strings.TrimRight(base, "/") + if before, ok := strings.CutSuffix(base, "/v1"); ok { + base = before + } + if base == "" { + return defaultBaseURL + } + + if appendV1Suffix { + return base + "/v1" + } + return base +} + // AsFloat converts various numeric types to float64. func AsFloat(v any) (float64, bool) { switch val := v.(type) { diff --git a/pkg/providers/common/common_test.go b/pkg/providers/common/common_test.go index a42d778f1..56c80e754 100644 --- a/pkg/providers/common/common_test.go +++ b/pkg/providers/common/common_test.go @@ -660,6 +660,71 @@ func TestAsFloat(t *testing.T) { } } +// --- ExtractProtocol tests --- + +func TestExtractProtocol(t *testing.T) { + tests := []struct { + name string + model string + wantProtocol string + wantModelID string + }{ + {"openai with prefix", "openai/gpt-4o", "openai", "gpt-4o"}, + {"anthropic with prefix", "anthropic/claude-sonnet-4.6", "anthropic", "claude-sonnet-4.6"}, + {"no prefix defaults to openai", "gpt-4o", "openai", "gpt-4o"}, + {"groq with prefix", "groq/llama-3.1-70b", "groq", "llama-3.1-70b"}, + {"empty string", "", "openai", ""}, + {"with whitespace", " openai/gpt-4 ", "openai", "gpt-4"}, + {"multiple slashes", "nvidia/meta/llama-3.1-8b", "nvidia", "meta/llama-3.1-8b"}, + {"azure with prefix", "azure/my-gpt5-deployment", "azure", "my-gpt5-deployment"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + protocol, modelID := ExtractProtocol(tt.model) + if protocol != tt.wantProtocol { + t.Errorf("ExtractProtocol(%q) protocol = %q, want %q", tt.model, protocol, tt.wantProtocol) + } + if modelID != tt.wantModelID { + t.Errorf("ExtractProtocol(%q) modelID = %q, want %q", tt.model, modelID, tt.wantModelID) + } + }) + } +} + +// --- NormalizeAnthropicBaseURL tests --- + +func TestNormalizeAnthropicBaseURL(t *testing.T) { + const defaultURL = "https://api.anthropic.com" + const defaultURLWithV1 = "https://api.anthropic.com/v1" + + tests := []struct { + name string + apiBase string + defaultBase string + appendV1Suffix bool + expected string + }{ + {"empty with v1", "", defaultURLWithV1, true, defaultURLWithV1}, + {"empty without v1", "", defaultURL, false, defaultURL}, + {"URL without v1 gets it appended", "https://api.example.com/anthropic", defaultURLWithV1, true, "https://api.example.com/anthropic/v1"}, + {"URL without v1 stays as-is", "https://api.example.com/anthropic", defaultURL, false, "https://api.example.com/anthropic"}, + {"URL with v1 remains unchanged when appending", "https://api.example.com/v1", defaultURLWithV1, true, "https://api.example.com/v1"}, + {"URL with v1 gets it stripped when not appending", "https://api.example.com/v1", defaultURL, false, "https://api.example.com"}, + {"trailing slash cleaned with v1", "https://api.example.com/anthropic/", defaultURLWithV1, true, "https://api.example.com/anthropic/v1"}, + {"trailing slash cleaned without v1", "https://api.example.com/anthropic/", defaultURL, false, "https://api.example.com/anthropic"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := NormalizeAnthropicBaseURL(tt.apiBase, tt.defaultBase, tt.appendV1Suffix) + if got != tt.expected { + t.Errorf("NormalizeAnthropicBaseURL(%q, %q, %v) = %q, want %q", + tt.apiBase, tt.defaultBase, tt.appendV1Suffix, got, tt.expected) + } + }) + } +} + // --- WrapHTMLResponseError tests --- func TestWrapHTMLResponseError(t *testing.T) { diff --git a/pkg/providers/factory_provider.go b/pkg/providers/factory_provider.go index 86d009811..63413b0a1 100644 --- a/pkg/providers/factory_provider.go +++ b/pkg/providers/factory_provider.go @@ -15,6 +15,7 @@ import ( anthropicmessages "github.com/sipeed/picoclaw/pkg/providers/anthropic_messages" "github.com/sipeed/picoclaw/pkg/providers/azure" "github.com/sipeed/picoclaw/pkg/providers/bedrock" + "github.com/sipeed/picoclaw/pkg/providers/common" ) type protocolMeta struct { @@ -102,27 +103,7 @@ func createCodexAuthProvider() (LLMProvider, error) { // - Provider "openai", Model "openai/gpt-4o" -> ("openai", "openai/gpt-4o") // - Model "gpt-4o" -> ("openai", "gpt-4o") func ExtractProtocol(cfg *config.ModelConfig) (protocol, modelID string) { - if cfg == nil { - return "", "" - } - - model := strings.TrimSpace(cfg.Model) - if provider := strings.TrimSpace(cfg.Provider); provider != "" { - return NormalizeProvider(provider), model - } - if model == "" { - return "", "" - } - - protocol, rest, found := strings.Cut(model, "/") - if !found { - return "openai", model - } - protocol = strings.TrimSpace(protocol) - if protocol == "" { - return "", strings.TrimSpace(rest) - } - return NormalizeProvider(protocol), strings.TrimSpace(rest) + return common.ExtractProtocol(model) } // ResolveAPIBase returns the configured API base, or the protocol default when diff --git a/pkg/providers/httpapi/gemini_helpers.go b/pkg/providers/httpapi/gemini_helpers.go index 36d95cf9e..0f1e20ca5 100644 --- a/pkg/providers/httpapi/gemini_helpers.go +++ b/pkg/providers/httpapi/gemini_helpers.go @@ -128,12 +128,3 @@ func sanitizeSchemaForGemini(schema map[string]any) map[string]any { return result } - -func extractProtocol(model string) (protocol, modelID string) { - model = strings.TrimSpace(model) - protocol, modelID, found := strings.Cut(model, "/") - if !found { - return "openai", model - } - return protocol, modelID -} diff --git a/pkg/providers/httpapi/gemini_provider.go b/pkg/providers/httpapi/gemini_provider.go index d488d06f8..dab6acd29 100644 --- a/pkg/providers/httpapi/gemini_provider.go +++ b/pkg/providers/httpapi/gemini_provider.go @@ -303,7 +303,7 @@ func normalizeGeminiModel(model string) string { model = strings.TrimSpace(model) model = strings.TrimPrefix(model, "models/") if strings.Contains(model, "/") { - _, modelID := extractProtocol(model) + _, modelID := common.ExtractProtocol(model) if modelID != "" { return modelID } diff --git a/pkg/providers/openai_compat/provider.go b/pkg/providers/openai_compat/provider.go index 98a70cfd2..29667cd31 100644 --- a/pkg/providers/openai_compat/provider.go +++ b/pkg/providers/openai_compat/provider.go @@ -470,7 +470,9 @@ func (p *Provider) SupportsNativeSearch() bool { return isNativeSearchHost(p.apiBase) } -func isNativeSearchHost(apiBase string) bool { +// isNativeOpenAIOrAzureEndpoint reports whether the given API base points to +// OpenAI's own API or an Azure OpenAI deployment. +func isNativeOpenAIOrAzureEndpoint(apiBase string) bool { u, err := url.Parse(apiBase) if err != nil { return false @@ -479,15 +481,14 @@ func isNativeSearchHost(apiBase string) bool { return host == "api.openai.com" || strings.HasSuffix(host, ".openai.azure.com") } +func isNativeSearchHost(apiBase string) bool { + return isNativeOpenAIOrAzureEndpoint(apiBase) +} + // supportsPromptCacheKey reports whether the given API base is known to // support the prompt_cache_key request field. Currently only OpenAI's own // API and Azure OpenAI support this. All other OpenAI-compatible providers // (Mistral, Gemini, DeepSeek, Groq, etc.) reject unknown fields with 422 errors. func supportsPromptCacheKey(apiBase string) bool { - u, err := url.Parse(apiBase) - if err != nil { - return false - } - host := u.Hostname() - return host == "api.openai.com" || strings.HasSuffix(host, ".openai.azure.com") + return isNativeOpenAIOrAzureEndpoint(apiBase) } From e901e70c1493aede97e5fb7d9022fc76ea264d95 Mon Sep 17 00:00:00 2001 From: Kunal Karmakar Date: Sun, 19 Apr 2026 04:30:21 +0000 Subject: [PATCH 27/31] Fix linting --- pkg/providers/common/common_test.go | 36 ++++++++++++++++++++++++----- 1 file changed, 30 insertions(+), 6 deletions(-) diff --git a/pkg/providers/common/common_test.go b/pkg/providers/common/common_test.go index 56c80e754..71c1bd1d1 100644 --- a/pkg/providers/common/common_test.go +++ b/pkg/providers/common/common_test.go @@ -706,12 +706,36 @@ func TestNormalizeAnthropicBaseURL(t *testing.T) { }{ {"empty with v1", "", defaultURLWithV1, true, defaultURLWithV1}, {"empty without v1", "", defaultURL, false, defaultURL}, - {"URL without v1 gets it appended", "https://api.example.com/anthropic", defaultURLWithV1, true, "https://api.example.com/anthropic/v1"}, - {"URL without v1 stays as-is", "https://api.example.com/anthropic", defaultURL, false, "https://api.example.com/anthropic"}, - {"URL with v1 remains unchanged when appending", "https://api.example.com/v1", defaultURLWithV1, true, "https://api.example.com/v1"}, - {"URL with v1 gets it stripped when not appending", "https://api.example.com/v1", defaultURL, false, "https://api.example.com"}, - {"trailing slash cleaned with v1", "https://api.example.com/anthropic/", defaultURLWithV1, true, "https://api.example.com/anthropic/v1"}, - {"trailing slash cleaned without v1", "https://api.example.com/anthropic/", defaultURL, false, "https://api.example.com/anthropic"}, + { + "URL without v1 gets it appended", + "https://api.example.com/anthropic", defaultURLWithV1, + true, "https://api.example.com/anthropic/v1", + }, + { + "URL without v1 stays as-is", + "https://api.example.com/anthropic", defaultURL, + false, "https://api.example.com/anthropic", + }, + { + "URL with v1 remains unchanged when appending", + "https://api.example.com/v1", defaultURLWithV1, + true, "https://api.example.com/v1", + }, + { + "URL with v1 gets it stripped when not appending", + "https://api.example.com/v1", defaultURL, + false, "https://api.example.com", + }, + { + "trailing slash cleaned with v1", + "https://api.example.com/anthropic/", defaultURLWithV1, + true, "https://api.example.com/anthropic/v1", + }, + { + "trailing slash cleaned without v1", + "https://api.example.com/anthropic/", defaultURL, + false, "https://api.example.com/anthropic", + }, } for _, tt := range tests { From bc077db0ee4f3730183b18cde74ddd7581c4cb47 Mon Sep 17 00:00:00 2001 From: Kunal Karmakar Date: Sun, 19 Apr 2026 06:26:52 +0000 Subject: [PATCH 28/31] Deduplicate ParseDataAudioURL function --- pkg/providers/common/common.go | 68 +------------------ pkg/providers/common/common_test.go | 31 +++++++++ .../responses_common.go | 22 +----- .../responses_common_test.go | 36 ---------- 4 files changed, 36 insertions(+), 121 deletions(-) diff --git a/pkg/providers/common/common.go b/pkg/providers/common/common.go index afc6877b1..5e03bc0c2 100644 --- a/pkg/providers/common/common.go +++ b/pkg/providers/common/common.go @@ -127,7 +127,7 @@ func SerializeMessages(messages []Message) []any { continue } - if format, data, ok := parseDataAudioURL(mediaURL); ok { + if format, data, ok := ParseDataAudioURL(mediaURL); ok { parts = append(parts, map[string]any{ "type": "input_audio", "input_audio": map[string]any{ @@ -205,7 +205,8 @@ func serializeToolCalls(toolCalls []ToolCall) []openaiToolCall { return out } -func parseDataAudioURL(mediaURL string) (format, data string, ok bool) { +// ParseDataAudioURL extracts the format and base64 data from a data:audio/... URL. +func ParseDataAudioURL(mediaURL string) (format, data string, ok bool) { if !strings.HasPrefix(mediaURL, "data:audio/") { return "", "", false } @@ -478,69 +479,6 @@ func AsInt(v any) (int, bool) { } } -// ExtractProtocol extracts the effective protocol and model identifier from a -// model configuration. -// -// The explicit Provider field takes precedence. When Provider is empty, the -// protocol is inferred from Model. Plain model names default to "openai". -// Provider-prefixed models strip the first slash-separated segment from the -// returned model ID. -// -// The returned protocol is normalized to the provider's canonical spelling. -// Examples: -// - Model "openai/gpt-4o" -> ("openai", "gpt-4o") -// - Model "nvidia/z-ai/glm-5.1" -> ("nvidia", "z-ai/glm-5.1") -// - Provider "nvidia", Model "z-ai/glm-5.1" -> ("nvidia", "z-ai/glm-5.1") -// - Provider "openai", Model "openai/gpt-4o" -> ("openai", "openai/gpt-4o") -// - Model "gpt-4o" -> ("openai", "gpt-4o") -func ExtractProtocol(model string) (protocol, modelID string) { - if cfg == nil { - return "", "" - } - - model := strings.TrimSpace(cfg.Model) - if provider := strings.TrimSpace(cfg.Provider); provider != "" { - return NormalizeProvider(provider), model - } - if model == "" { - return "", "" - } - - protocol, rest, found := strings.Cut(model, "/") - if !found { - return "openai", model - } - protocol = strings.TrimSpace(protocol) - if protocol == "" { - return "", strings.TrimSpace(rest) - } - return NormalizeProvider(protocol), strings.TrimSpace(rest) -} - -// NormalizeAnthropicBaseURL ensures the Anthropic base URL is properly formatted. -// It removes a trailing /v1 suffix if present (to avoid duplication), then -// re-appends /v1 when appendV1Suffix is true. An empty apiBase falls back to -// defaultBaseURL. -func NormalizeAnthropicBaseURL(apiBase, defaultBaseURL string, appendV1Suffix bool) string { - base := strings.TrimSpace(apiBase) - if base == "" { - return defaultBaseURL - } - - base = strings.TrimRight(base, "/") - if before, ok := strings.CutSuffix(base, "/v1"); ok { - base = before - } - if base == "" { - return defaultBaseURL - } - - if appendV1Suffix { - return base + "/v1" - } - return base -} - // AsFloat converts various numeric types to float64. func AsFloat(v any) (float64, bool) { switch val := v.(type) { diff --git a/pkg/providers/common/common_test.go b/pkg/providers/common/common_test.go index 71c1bd1d1..1f9a9b827 100644 --- a/pkg/providers/common/common_test.go +++ b/pkg/providers/common/common_test.go @@ -691,6 +691,37 @@ func TestExtractProtocol(t *testing.T) { } } +// --- ParseDataAudioURL tests --- + +func TestParseDataAudioURL(t *testing.T) { + tests := []struct { + name string + mediaURL string + wantFormat string + wantData string + wantOK bool + }{ + {"valid mp3", "data:audio/mp3;base64,SGVsbG8=", "mp3", "SGVsbG8=", true}, + {"valid wav", "data:audio/wav;base64,AAAA", "wav", "AAAA", true}, + {"not audio", "data:image/png;base64,abc", "", "", false}, + {"no comma", "data:audio/mp3;base64", "", "", false}, + {"empty data", "data:audio/mp3;base64,", "", "", false}, + {"empty string", "", "", "", false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + format, data, ok := ParseDataAudioURL(tt.mediaURL) + if ok != tt.wantOK || format != tt.wantFormat || data != tt.wantData { + t.Errorf( + "ParseDataAudioURL(%q) = (%q, %q, %v), want (%q, %q, %v)", + tt.mediaURL, format, data, ok, + tt.wantFormat, tt.wantData, tt.wantOK, + ) + } + }) + } +} + // --- NormalizeAnthropicBaseURL tests --- func TestNormalizeAnthropicBaseURL(t *testing.T) { diff --git a/pkg/providers/openai_responses_common/responses_common.go b/pkg/providers/openai_responses_common/responses_common.go index 839471f69..17b731ed4 100644 --- a/pkg/providers/openai_responses_common/responses_common.go +++ b/pkg/providers/openai_responses_common/responses_common.go @@ -10,6 +10,7 @@ import ( "github.com/openai/openai-go/v3" "github.com/openai/openai-go/v3/responses" + "github.com/sipeed/picoclaw/pkg/providers/common" "github.com/sipeed/picoclaw/pkg/providers/protocoltypes" ) @@ -118,7 +119,7 @@ func BuildMultipartContent(text string, media []string) responses.ResponseInputM }, }) } else if strings.HasPrefix(mediaURL, "data:audio/") { - if format, data, ok := ParseDataAudioURL(mediaURL); ok { + if format, data, ok := common.ParseDataAudioURL(mediaURL); ok { parts = append(parts, responses.ResponseInputContentUnionParam{ OfInputFile: &responses.ResponseInputFileParam{ FileData: openai.Opt(data), @@ -132,25 +133,6 @@ func BuildMultipartContent(text string, media []string) responses.ResponseInputM return parts } -// ParseDataAudioURL extracts the format and base64 data from a data:audio/... URL. -func ParseDataAudioURL(mediaURL string) (format, data string, ok bool) { - if !strings.HasPrefix(mediaURL, "data:audio/") { - return "", "", false - } - payload := strings.TrimPrefix(mediaURL, "data:audio/") - meta, data, found := strings.Cut(payload, ",") - if !found { - return "", "", false - } - format, _, _ = strings.Cut(meta, ";") - format = strings.TrimSpace(format) - data = strings.TrimSpace(data) - if format == "" || data == "" { - return "", "", false - } - return format, data, true -} - // ResolveToolCall extracts the function name and JSON arguments string from a ToolCall. // Returns ok=false if the tool call has no name or if arguments fail to marshal. func ResolveToolCall(tc protocoltypes.ToolCall) (name string, arguments string, ok bool) { diff --git a/pkg/providers/openai_responses_common/responses_common_test.go b/pkg/providers/openai_responses_common/responses_common_test.go index 0d41190b1..ace91edf0 100644 --- a/pkg/providers/openai_responses_common/responses_common_test.go +++ b/pkg/providers/openai_responses_common/responses_common_test.go @@ -506,42 +506,6 @@ func TestParseResponseBody_CanceledStatus(t *testing.T) { } } -// --- ParseDataAudioURL tests --- - -func TestParseDataAudioURL_Valid(t *testing.T) { - format, data, ok := ParseDataAudioURL("data:audio/mp3;base64,SGVsbG8=") - if !ok { - t.Fatal("expected ok=true") - } - if format != "mp3" { - t.Errorf("format = %q, want %q", format, "mp3") - } - if data != "SGVsbG8=" { - t.Errorf("data = %q, want %q", data, "SGVsbG8=") - } -} - -func TestParseDataAudioURL_NotAudio(t *testing.T) { - _, _, ok := ParseDataAudioURL("data:image/png;base64,abc") - if ok { - t.Error("expected ok=false for non-audio URL") - } -} - -func TestParseDataAudioURL_MalformedNoComma(t *testing.T) { - _, _, ok := ParseDataAudioURL("data:audio/mp3;base64") - if ok { - t.Error("expected ok=false for malformed URL") - } -} - -func TestParseDataAudioURL_EmptyData(t *testing.T) { - _, _, ok := ParseDataAudioURL("data:audio/mp3;base64,") - if ok { - t.Error("expected ok=false for empty data") - } -} - // --- BuildMultipartContent tests --- func TestBuildMultipartContent_TextOnly(t *testing.T) { From 4ae11406d2118793848ef9f74627afbb74cd97cb Mon Sep 17 00:00:00 2001 From: Kunal Karmakar Date: Sun, 19 Apr 2026 06:48:28 +0000 Subject: [PATCH 29/31] Deduplicate further functions --- pkg/providers/anthropic/provider.go | 2 +- pkg/providers/anthropic_messages/provider.go | 2 +- pkg/providers/common/anthropic_common.go | 27 ++++ pkg/providers/common/anthropic_common_test.go | 59 +++++++ pkg/providers/common/common_test.go | 58 ------- pkg/providers/common/google_common.go | 70 +++++++++ pkg/providers/common/google_common_test.go | 146 ++++++++++++++++++ pkg/providers/httpapi/gemini_helpers.go | 59 ------- pkg/providers/httpapi/gemini_provider.go | 6 +- pkg/providers/oauth/antigravity_provider.go | 61 +------- .../oauth/antigravity_provider_test.go | 7 - 11 files changed, 311 insertions(+), 186 deletions(-) create mode 100644 pkg/providers/common/anthropic_common.go create mode 100644 pkg/providers/common/anthropic_common_test.go create mode 100644 pkg/providers/common/google_common.go create mode 100644 pkg/providers/common/google_common_test.go diff --git a/pkg/providers/anthropic/provider.go b/pkg/providers/anthropic/provider.go index 4330163df..6f4aadb8b 100644 --- a/pkg/providers/anthropic/provider.go +++ b/pkg/providers/anthropic/provider.go @@ -43,7 +43,7 @@ func NewProvider(token string) *Provider { } func NewProviderWithBaseURL(token, apiBase string) *Provider { - baseURL := common.NormalizeAnthropicBaseURL(apiBase, defaultBaseURL, false) + baseURL := common.NormalizeBaseURL(apiBase, defaultBaseURL, false) client := anthropic.NewClient( option.WithAuthToken(token), option.WithBaseURL(baseURL), diff --git a/pkg/providers/anthropic_messages/provider.go b/pkg/providers/anthropic_messages/provider.go index dcc31b6f9..672fb9324 100644 --- a/pkg/providers/anthropic_messages/provider.go +++ b/pkg/providers/anthropic_messages/provider.go @@ -52,7 +52,7 @@ func NewProvider(apiKey, apiBase, userAgent string) *Provider { // NewProviderWithTimeout creates a provider with custom request timeout. func NewProviderWithTimeout(apiKey, apiBase, userAgent string, timeoutSeconds int) *Provider { - baseURL := common.NormalizeAnthropicBaseURL(apiBase, defaultBaseURL, true) + baseURL := common.NormalizeBaseURL(apiBase, defaultBaseURL, true) timeout := defaultRequestTimeout if timeoutSeconds > 0 { timeout = time.Duration(timeoutSeconds) * time.Second diff --git a/pkg/providers/common/anthropic_common.go b/pkg/providers/common/anthropic_common.go new file mode 100644 index 000000000..92dace9ac --- /dev/null +++ b/pkg/providers/common/anthropic_common.go @@ -0,0 +1,27 @@ +package common + +import "strings" + +// NormalizeBaseURL ensures the Anthropic base URL is properly formatted. +// It removes a trailing /v1 suffix if present (to avoid duplication), then +// re-appends /v1 when appendV1Suffix is true. An empty apiBase falls back to +// defaultBaseURL. +func NormalizeBaseURL(apiBase, defaultBaseURL string, appendV1Suffix bool) string { + base := strings.TrimSpace(apiBase) + if base == "" { + return defaultBaseURL + } + + base = strings.TrimRight(base, "/") + if before, ok := strings.CutSuffix(base, "/v1"); ok { + base = before + } + if base == "" { + return defaultBaseURL + } + + if appendV1Suffix { + return base + "/v1" + } + return base +} diff --git a/pkg/providers/common/anthropic_common_test.go b/pkg/providers/common/anthropic_common_test.go new file mode 100644 index 000000000..7563141b5 --- /dev/null +++ b/pkg/providers/common/anthropic_common_test.go @@ -0,0 +1,59 @@ +package common + +import "testing" + +func TestNormalizeAnthropicBaseURL(t *testing.T) { + const defaultURL = "https://api.anthropic.com" + const defaultURLWithV1 = "https://api.anthropic.com/v1" + + tests := []struct { + name string + apiBase string + defaultBase string + appendV1Suffix bool + expected string + }{ + {"empty with v1", "", defaultURLWithV1, true, defaultURLWithV1}, + {"empty without v1", "", defaultURL, false, defaultURL}, + { + "URL without v1 gets it appended", + "https://api.example.com/anthropic", defaultURLWithV1, + true, "https://api.example.com/anthropic/v1", + }, + { + "URL without v1 stays as-is", + "https://api.example.com/anthropic", defaultURL, + false, "https://api.example.com/anthropic", + }, + { + "URL with v1 remains unchanged when appending", + "https://api.example.com/v1", defaultURLWithV1, + true, "https://api.example.com/v1", + }, + { + "URL with v1 gets it stripped when not appending", + "https://api.example.com/v1", defaultURL, + false, "https://api.example.com", + }, + { + "trailing slash cleaned with v1", + "https://api.example.com/anthropic/", defaultURLWithV1, + true, "https://api.example.com/anthropic/v1", + }, + { + "trailing slash cleaned without v1", + "https://api.example.com/anthropic/", defaultURL, + false, "https://api.example.com/anthropic", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := NormalizeBaseURL(tt.apiBase, tt.defaultBase, tt.appendV1Suffix) + if got != tt.expected { + t.Errorf("NormalizeAnthropicBaseURL(%q, %q, %v) = %q, want %q", + tt.apiBase, tt.defaultBase, tt.appendV1Suffix, got, tt.expected) + } + }) + } +} diff --git a/pkg/providers/common/common_test.go b/pkg/providers/common/common_test.go index 1f9a9b827..84aa1a707 100644 --- a/pkg/providers/common/common_test.go +++ b/pkg/providers/common/common_test.go @@ -722,64 +722,6 @@ func TestParseDataAudioURL(t *testing.T) { } } -// --- NormalizeAnthropicBaseURL tests --- - -func TestNormalizeAnthropicBaseURL(t *testing.T) { - const defaultURL = "https://api.anthropic.com" - const defaultURLWithV1 = "https://api.anthropic.com/v1" - - tests := []struct { - name string - apiBase string - defaultBase string - appendV1Suffix bool - expected string - }{ - {"empty with v1", "", defaultURLWithV1, true, defaultURLWithV1}, - {"empty without v1", "", defaultURL, false, defaultURL}, - { - "URL without v1 gets it appended", - "https://api.example.com/anthropic", defaultURLWithV1, - true, "https://api.example.com/anthropic/v1", - }, - { - "URL without v1 stays as-is", - "https://api.example.com/anthropic", defaultURL, - false, "https://api.example.com/anthropic", - }, - { - "URL with v1 remains unchanged when appending", - "https://api.example.com/v1", defaultURLWithV1, - true, "https://api.example.com/v1", - }, - { - "URL with v1 gets it stripped when not appending", - "https://api.example.com/v1", defaultURL, - false, "https://api.example.com", - }, - { - "trailing slash cleaned with v1", - "https://api.example.com/anthropic/", defaultURLWithV1, - true, "https://api.example.com/anthropic/v1", - }, - { - "trailing slash cleaned without v1", - "https://api.example.com/anthropic/", defaultURL, - false, "https://api.example.com/anthropic", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := NormalizeAnthropicBaseURL(tt.apiBase, tt.defaultBase, tt.appendV1Suffix) - if got != tt.expected { - t.Errorf("NormalizeAnthropicBaseURL(%q, %q, %v) = %q, want %q", - tt.apiBase, tt.defaultBase, tt.appendV1Suffix, got, tt.expected) - } - }) - } -} - // --- WrapHTMLResponseError tests --- func TestWrapHTMLResponseError(t *testing.T) { diff --git a/pkg/providers/common/google_common.go b/pkg/providers/common/google_common.go new file mode 100644 index 000000000..954c0c802 --- /dev/null +++ b/pkg/providers/common/google_common.go @@ -0,0 +1,70 @@ +package common + +import ( + "encoding/json" + "strings" + + "github.com/sipeed/picoclaw/pkg/providers/protocoltypes" +) + +// NormalizeStoredToolCall extracts the tool name, arguments, and thought signature +// from a stored ToolCall. It handles both the top-level fields and the nested +// Function struct used by different API formats. +func NormalizeStoredToolCall(tc protocoltypes.ToolCall) (string, map[string]any, string) { + name := tc.Name + args := tc.Arguments + thoughtSignature := "" + + if name == "" && tc.Function != nil { + name = tc.Function.Name + thoughtSignature = tc.Function.ThoughtSignature + } else if tc.Function != nil { + thoughtSignature = tc.Function.ThoughtSignature + } + + if args == nil { + args = map[string]any{} + } + + if len(args) == 0 && tc.Function != nil && tc.Function.Arguments != "" { + var parsed map[string]any + if err := json.Unmarshal([]byte(tc.Function.Arguments), &parsed); err == nil && parsed != nil { + args = parsed + } + } + + return name, args, thoughtSignature +} + +// ResolveToolResponseName returns the tool name for a given tool call ID. +// It first checks the provided name map, then falls back to inferring the +// name from the call ID format. +func ResolveToolResponseName(toolCallID string, toolCallNames map[string]string) string { + if toolCallID == "" { + return "" + } + + if name, ok := toolCallNames[toolCallID]; ok && name != "" { + return name + } + + return InferToolNameFromCallID(toolCallID) +} + +// InferToolNameFromCallID extracts a tool name from a call ID in the format +// "call__". Returns the original ID if it doesn't match. +func InferToolNameFromCallID(toolCallID string) string { + if !strings.HasPrefix(toolCallID, "call_") { + return toolCallID + } + + rest := strings.TrimPrefix(toolCallID, "call_") + if idx := strings.LastIndex(rest, "_"); idx > 0 { + candidate := rest[:idx] + if candidate != "" { + return candidate + } + } + + return toolCallID +} diff --git a/pkg/providers/common/google_common_test.go b/pkg/providers/common/google_common_test.go new file mode 100644 index 000000000..cc013dcd1 --- /dev/null +++ b/pkg/providers/common/google_common_test.go @@ -0,0 +1,146 @@ +package common + +import ( + "testing" + + "github.com/sipeed/picoclaw/pkg/providers/protocoltypes" +) + +func TestNormalizeStoredToolCall_TopLevelFields(t *testing.T) { + tc := protocoltypes.ToolCall{ + Name: "search", + Arguments: map[string]any{"q": "hello"}, + } + name, args, sig := NormalizeStoredToolCall(tc) + if name != "search" { + t.Errorf("name = %q, want %q", name, "search") + } + if args["q"] != "hello" { + t.Errorf("args[q] = %v, want %q", args["q"], "hello") + } + if sig != "" { + t.Errorf("thoughtSignature = %q, want empty", sig) + } +} + +func TestNormalizeStoredToolCall_FallsBackToFunction(t *testing.T) { + tc := protocoltypes.ToolCall{ + Function: &protocoltypes.FunctionCall{ + Name: "read_file", + Arguments: `{"path":"/tmp"}`, + ThoughtSignature: "sig123", + }, + } + name, args, sig := NormalizeStoredToolCall(tc) + if name != "read_file" { + t.Errorf("name = %q, want %q", name, "read_file") + } + if args["path"] != "/tmp" { + t.Errorf("args[path] = %v, want %q", args["path"], "/tmp") + } + if sig != "sig123" { + t.Errorf("thoughtSignature = %q, want %q", sig, "sig123") + } +} + +func TestNormalizeStoredToolCall_TopLevelNameWithFunctionSig(t *testing.T) { + tc := protocoltypes.ToolCall{ + Name: "search", + Arguments: map[string]any{"q": "hi"}, + Function: &protocoltypes.FunctionCall{ + ThoughtSignature: "thought1", + }, + } + name, _, sig := NormalizeStoredToolCall(tc) + if name != "search" { + t.Errorf("name = %q, want %q", name, "search") + } + if sig != "thought1" { + t.Errorf("thoughtSignature = %q, want %q", sig, "thought1") + } +} + +func TestNormalizeStoredToolCall_NilArgs(t *testing.T) { + tc := protocoltypes.ToolCall{Name: "test"} + _, args, _ := NormalizeStoredToolCall(tc) + if args == nil { + t.Fatal("args should not be nil") + } + if len(args) != 0 { + t.Errorf("args should be empty, got %v", args) + } +} + +func TestNormalizeStoredToolCall_EmptyArgsParseFromFunction(t *testing.T) { + tc := protocoltypes.ToolCall{ + Name: "tool", + Arguments: map[string]any{}, + Function: &protocoltypes.FunctionCall{ + Arguments: `{"key":"val"}`, + }, + } + _, args, _ := NormalizeStoredToolCall(tc) + if args["key"] != "val" { + t.Errorf("args[key] = %v, want %q", args["key"], "val") + } +} + +func TestNormalizeStoredToolCall_InvalidFunctionJSON(t *testing.T) { + tc := protocoltypes.ToolCall{ + Name: "tool", + Function: &protocoltypes.FunctionCall{ + Arguments: `not-json`, + }, + } + _, args, _ := NormalizeStoredToolCall(tc) + if len(args) != 0 { + t.Errorf("args should be empty for invalid JSON, got %v", args) + } +} + +func TestResolveToolResponseName_FromMap(t *testing.T) { + names := map[string]string{"call_1": "search"} + got := ResolveToolResponseName("call_1", names) + if got != "search" { + t.Errorf("got %q, want %q", got, "search") + } +} + +func TestResolveToolResponseName_EmptyID(t *testing.T) { + got := ResolveToolResponseName("", map[string]string{"x": "y"}) + if got != "" { + t.Errorf("got %q, want empty", got) + } +} + +func TestResolveToolResponseName_FallsBackToInfer(t *testing.T) { + got := ResolveToolResponseName("call_search_docs_999", map[string]string{}) + if got != "search_docs" { + t.Errorf("got %q, want %q", got, "search_docs") + } +} + +func TestInferToolNameFromCallID(t *testing.T) { + tests := []struct { + name string + id string + want string + }{ + {"standard format", "call_search_docs_999", "search_docs"}, + {"single name", "call_read_123", "read"}, + {"no call prefix", "some_id", "some_id"}, + {"call prefix no underscore suffix", "call_onlyname", "call_onlyname"}, + {"empty string", "", ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := InferToolNameFromCallID(tt.id) + if got != tt.want { + t.Errorf( + "InferToolNameFromCallID(%q) = %q, want %q", + tt.id, got, tt.want, + ) + } + }) + } +} diff --git a/pkg/providers/httpapi/gemini_helpers.go b/pkg/providers/httpapi/gemini_helpers.go index 0f1e20ca5..249c1b8de 100644 --- a/pkg/providers/httpapi/gemini_helpers.go +++ b/pkg/providers/httpapi/gemini_helpers.go @@ -1,64 +1,5 @@ package httpapi -import ( - "encoding/json" - "strings" -) - -func normalizeStoredToolCall(tc ToolCall) (string, map[string]any, string) { - name := tc.Name - args := tc.Arguments - thoughtSignature := "" - - if name == "" && tc.Function != nil { - name = tc.Function.Name - thoughtSignature = tc.Function.ThoughtSignature - } else if tc.Function != nil { - thoughtSignature = tc.Function.ThoughtSignature - } - - if args == nil { - args = map[string]any{} - } - - if len(args) == 0 && tc.Function != nil && tc.Function.Arguments != "" { - var parsed map[string]any - if err := json.Unmarshal([]byte(tc.Function.Arguments), &parsed); err == nil && parsed != nil { - args = parsed - } - } - - return name, args, thoughtSignature -} - -func resolveToolResponseName(toolCallID string, toolCallNames map[string]string) string { - if toolCallID == "" { - return "" - } - - if name, ok := toolCallNames[toolCallID]; ok && name != "" { - return name - } - - return inferToolNameFromCallID(toolCallID) -} - -func inferToolNameFromCallID(toolCallID string) string { - if !strings.HasPrefix(toolCallID, "call_") { - return toolCallID - } - - rest := strings.TrimPrefix(toolCallID, "call_") - if idx := strings.LastIndex(rest, "_"); idx > 0 { - candidate := rest[:idx] - if candidate != "" { - return candidate - } - } - - return toolCallID -} - func extractPartThoughtSignature(thoughtSignature string, thoughtSignatureSnake string) string { if thoughtSignature != "" { return thoughtSignature diff --git a/pkg/providers/httpapi/gemini_provider.go b/pkg/providers/httpapi/gemini_provider.go index dab6acd29..9ad4693da 100644 --- a/pkg/providers/httpapi/gemini_provider.go +++ b/pkg/providers/httpapi/gemini_provider.go @@ -185,7 +185,7 @@ func (p *GeminiProvider) buildRequestBody( case "user": if msg.ToolCallID != "" { - toolName := resolveToolResponseName(msg.ToolCallID, toolCallNames) + toolName := common.ResolveToolResponseName(msg.ToolCallID, toolCallNames) contents = append(contents, geminiContent{ Role: "user", Parts: []geminiPart{{ @@ -210,7 +210,7 @@ func (p *GeminiProvider) buildRequestBody( content.Parts = append(content.Parts, geminiPart{Text: msg.Content}) } for _, tc := range msg.ToolCalls { - toolName, toolArgs, thoughtSignature := normalizeStoredToolCall(tc) + toolName, toolArgs, thoughtSignature := common.NormalizeStoredToolCall(tc) if toolName == "" { continue } @@ -234,7 +234,7 @@ func (p *GeminiProvider) buildRequestBody( } case "tool": - toolName := resolveToolResponseName(msg.ToolCallID, toolCallNames) + toolName := common.ResolveToolResponseName(msg.ToolCallID, toolCallNames) contents = append(contents, geminiContent{ Role: "user", Parts: []geminiPart{{ diff --git a/pkg/providers/oauth/antigravity_provider.go b/pkg/providers/oauth/antigravity_provider.go index 38526dd7a..1ac2d9c7f 100644 --- a/pkg/providers/oauth/antigravity_provider.go +++ b/pkg/providers/oauth/antigravity_provider.go @@ -14,6 +14,7 @@ import ( "github.com/sipeed/picoclaw/pkg/auth" "github.com/sipeed/picoclaw/pkg/logger" + "github.com/sipeed/picoclaw/pkg/providers/common" ) const ( @@ -221,7 +222,7 @@ func (p *AntigravityProvider) buildRequest( } case "user": if msg.ToolCallID != "" { - toolName := resolveToolResponseName(msg.ToolCallID, toolCallNames) + toolName := common.ResolveToolResponseName(msg.ToolCallID, toolCallNames) // Tool result req.Contents = append(req.Contents, antigravityContent{ Role: "user", @@ -248,7 +249,7 @@ func (p *AntigravityProvider) buildRequest( content.Parts = append(content.Parts, antigravityPart{Text: msg.Content}) } for _, tc := range msg.ToolCalls { - toolName, toolArgs, thoughtSignature := normalizeStoredToolCall(tc) + toolName, toolArgs, thoughtSignature := common.NormalizeStoredToolCall(tc) if toolName == "" { logger.WarnCF( "provider.antigravity", @@ -275,7 +276,7 @@ func (p *AntigravityProvider) buildRequest( req.Contents = append(req.Contents, content) } case "tool": - toolName := resolveToolResponseName(msg.ToolCallID, toolCallNames) + toolName := common.ResolveToolResponseName(msg.ToolCallID, toolCallNames) req.Contents = append(req.Contents, antigravityContent{ Role: "user", Parts: []antigravityPart{{ @@ -328,60 +329,6 @@ func (p *AntigravityProvider) buildRequest( return req } -func normalizeStoredToolCall(tc ToolCall) (string, map[string]any, string) { - name := tc.Name - args := tc.Arguments - thoughtSignature := "" - - if name == "" && tc.Function != nil { - name = tc.Function.Name - thoughtSignature = tc.Function.ThoughtSignature - } else if tc.Function != nil { - thoughtSignature = tc.Function.ThoughtSignature - } - - if args == nil { - args = map[string]any{} - } - - if len(args) == 0 && tc.Function != nil && tc.Function.Arguments != "" { - var parsed map[string]any - if err := json.Unmarshal([]byte(tc.Function.Arguments), &parsed); err == nil && parsed != nil { - args = parsed - } - } - - return name, args, thoughtSignature -} - -func resolveToolResponseName(toolCallID string, toolCallNames map[string]string) string { - if toolCallID == "" { - return "" - } - - if name, ok := toolCallNames[toolCallID]; ok && name != "" { - return name - } - - return inferToolNameFromCallID(toolCallID) -} - -func inferToolNameFromCallID(toolCallID string) string { - if !strings.HasPrefix(toolCallID, "call_") { - return toolCallID - } - - rest := strings.TrimPrefix(toolCallID, "call_") - if idx := strings.LastIndex(rest, "_"); idx > 0 { - candidate := rest[:idx] - if candidate != "" { - return candidate - } - } - - return toolCallID -} - // --- Response parsing --- type antigravityJSONResponse struct { diff --git a/pkg/providers/oauth/antigravity_provider_test.go b/pkg/providers/oauth/antigravity_provider_test.go index 41cb5b0db..2989f8519 100644 --- a/pkg/providers/oauth/antigravity_provider_test.go +++ b/pkg/providers/oauth/antigravity_provider_test.go @@ -48,13 +48,6 @@ func TestBuildRequestUsesFunctionFieldsWhenToolCallNameMissing(t *testing.T) { } } -func TestResolveToolResponseNameInfersNameFromGeneratedCallID(t *testing.T) { - got := resolveToolResponseName("call_search_docs_999", map[string]string{}) - if got != "search_docs" { - t.Fatalf("expected inferred tool name search_docs, got %q", got) - } -} - func TestParseSSEResponse_SplitsThoughtAndVisibleContent(t *testing.T) { p := &AntigravityProvider{} body := "data: {\"response\":{\"candidates\":[{\"content\":{\"parts\":[{\"text\":\"hidden reasoning\",\"thought\":true},{\"text\":\"visible answer\"}],\"role\":\"model\"},\"finishReason\":\"STOP\"}],\"usageMetadata\":{\"promptTokenCount\":8,\"candidatesTokenCount\":17,\"totalTokenCount\":216}}}\n" + From 76164701370498463f610d43503c6d6fd4eb1b1d Mon Sep 17 00:00:00 2001 From: Kunal Karmakar Date: Wed, 22 Apr 2026 05:41:32 +0000 Subject: [PATCH 30/31] Revert deduplication --- pkg/providers/common/common_test.go | 31 ------------------------ pkg/providers/factory_provider.go | 23 ++++++++++++++++-- pkg/providers/httpapi/gemini_helpers.go | 11 +++++++++ pkg/providers/httpapi/gemini_provider.go | 2 +- 4 files changed, 33 insertions(+), 34 deletions(-) diff --git a/pkg/providers/common/common_test.go b/pkg/providers/common/common_test.go index 84aa1a707..3cf2f4285 100644 --- a/pkg/providers/common/common_test.go +++ b/pkg/providers/common/common_test.go @@ -660,37 +660,6 @@ func TestAsFloat(t *testing.T) { } } -// --- ExtractProtocol tests --- - -func TestExtractProtocol(t *testing.T) { - tests := []struct { - name string - model string - wantProtocol string - wantModelID string - }{ - {"openai with prefix", "openai/gpt-4o", "openai", "gpt-4o"}, - {"anthropic with prefix", "anthropic/claude-sonnet-4.6", "anthropic", "claude-sonnet-4.6"}, - {"no prefix defaults to openai", "gpt-4o", "openai", "gpt-4o"}, - {"groq with prefix", "groq/llama-3.1-70b", "groq", "llama-3.1-70b"}, - {"empty string", "", "openai", ""}, - {"with whitespace", " openai/gpt-4 ", "openai", "gpt-4"}, - {"multiple slashes", "nvidia/meta/llama-3.1-8b", "nvidia", "meta/llama-3.1-8b"}, - {"azure with prefix", "azure/my-gpt5-deployment", "azure", "my-gpt5-deployment"}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - protocol, modelID := ExtractProtocol(tt.model) - if protocol != tt.wantProtocol { - t.Errorf("ExtractProtocol(%q) protocol = %q, want %q", tt.model, protocol, tt.wantProtocol) - } - if modelID != tt.wantModelID { - t.Errorf("ExtractProtocol(%q) modelID = %q, want %q", tt.model, modelID, tt.wantModelID) - } - }) - } -} - // --- ParseDataAudioURL tests --- func TestParseDataAudioURL(t *testing.T) { diff --git a/pkg/providers/factory_provider.go b/pkg/providers/factory_provider.go index 63413b0a1..86d009811 100644 --- a/pkg/providers/factory_provider.go +++ b/pkg/providers/factory_provider.go @@ -15,7 +15,6 @@ import ( anthropicmessages "github.com/sipeed/picoclaw/pkg/providers/anthropic_messages" "github.com/sipeed/picoclaw/pkg/providers/azure" "github.com/sipeed/picoclaw/pkg/providers/bedrock" - "github.com/sipeed/picoclaw/pkg/providers/common" ) type protocolMeta struct { @@ -103,7 +102,27 @@ func createCodexAuthProvider() (LLMProvider, error) { // - Provider "openai", Model "openai/gpt-4o" -> ("openai", "openai/gpt-4o") // - Model "gpt-4o" -> ("openai", "gpt-4o") func ExtractProtocol(cfg *config.ModelConfig) (protocol, modelID string) { - return common.ExtractProtocol(model) + if cfg == nil { + return "", "" + } + + model := strings.TrimSpace(cfg.Model) + if provider := strings.TrimSpace(cfg.Provider); provider != "" { + return NormalizeProvider(provider), model + } + if model == "" { + return "", "" + } + + protocol, rest, found := strings.Cut(model, "/") + if !found { + return "openai", model + } + protocol = strings.TrimSpace(protocol) + if protocol == "" { + return "", strings.TrimSpace(rest) + } + return NormalizeProvider(protocol), strings.TrimSpace(rest) } // ResolveAPIBase returns the configured API base, or the protocol default when diff --git a/pkg/providers/httpapi/gemini_helpers.go b/pkg/providers/httpapi/gemini_helpers.go index 249c1b8de..a2b2d63c3 100644 --- a/pkg/providers/httpapi/gemini_helpers.go +++ b/pkg/providers/httpapi/gemini_helpers.go @@ -1,5 +1,7 @@ package httpapi +import "strings" + func extractPartThoughtSignature(thoughtSignature string, thoughtSignatureSnake string) string { if thoughtSignature != "" { return thoughtSignature @@ -69,3 +71,12 @@ func sanitizeSchemaForGemini(schema map[string]any) map[string]any { return result } + +func extractProtocol(model string) (protocol, modelID string) { + model = strings.TrimSpace(model) + protocol, modelID, found := strings.Cut(model, "/") + if !found { + return "openai", model + } + return protocol, modelID +} diff --git a/pkg/providers/httpapi/gemini_provider.go b/pkg/providers/httpapi/gemini_provider.go index 9ad4693da..d1d523757 100644 --- a/pkg/providers/httpapi/gemini_provider.go +++ b/pkg/providers/httpapi/gemini_provider.go @@ -303,7 +303,7 @@ func normalizeGeminiModel(model string) string { model = strings.TrimSpace(model) model = strings.TrimPrefix(model, "models/") if strings.Contains(model, "/") { - _, modelID := common.ExtractProtocol(model) + _, modelID := extractProtocol(model) if modelID != "" { return modelID } From cac4f21746cd1e421af053c7281430e2fee07a6c Mon Sep 17 00:00:00 2001 From: wenjie Date: Thu, 23 Apr 2026 15:39:16 +0800 Subject: [PATCH 31/31] fix(tools): improve web search provider fallback (#2629) - centralize web search provider readiness and resolution logic - fall back when the configured provider is unavailable or invalid - allow native-search-capable models to use built-in search without the client tool - simplify the tools page and add direct access to web search settings - add backend, agent, and integration tests for the new selection behavior --- pkg/agent/agent_init.go | 28 +-- pkg/agent/agent_test.go | 52 +++++ pkg/agent/pipeline_llm.go | 6 +- pkg/agent/turn_coord_test.go | 64 ++++++ pkg/tools/integration/web.go | 216 +++++++++++++----- pkg/tools/integration/web_test.go | 104 +++++++-- pkg/tools/integration_facade.go | 13 ++ web/backend/api/tools.go | 115 +++------- web/backend/api/tools_test.go | 143 ++++++++++++ .../agent/tools/tool-library-tab.tsx | 49 +++- .../src/components/agent/tools/tools-page.tsx | 14 +- .../components/agent/tools/use-tools-page.ts | 6 - .../tools/web-search-general-settings.tsx | 4 +- .../components/agent/tools/web-search-tab.tsx | 21 +- web/frontend/src/i18n/locales/en.json | 10 +- web/frontend/src/i18n/locales/zh.json | 10 +- 16 files changed, 633 insertions(+), 222 deletions(-) diff --git a/pkg/agent/agent_init.go b/pkg/agent/agent_init.go index d7bfc22c7..611d634e8 100644 --- a/pkg/agent/agent_init.go +++ b/pkg/agent/agent_init.go @@ -100,33 +100,7 @@ func registerSharedTools( } if cfg.Tools.IsToolEnabled("web") { - searchTool, err := tools.NewWebSearchTool(tools.WebSearchToolOptions{ - BraveAPIKeys: cfg.Tools.Web.Brave.APIKeys.Values(), - BraveMaxResults: cfg.Tools.Web.Brave.MaxResults, - BraveEnabled: cfg.Tools.Web.Brave.Enabled, - TavilyAPIKeys: cfg.Tools.Web.Tavily.APIKeys.Values(), - TavilyBaseURL: cfg.Tools.Web.Tavily.BaseURL, - TavilyMaxResults: cfg.Tools.Web.Tavily.MaxResults, - TavilyEnabled: cfg.Tools.Web.Tavily.Enabled, - DuckDuckGoMaxResults: cfg.Tools.Web.DuckDuckGo.MaxResults, - DuckDuckGoEnabled: cfg.Tools.Web.DuckDuckGo.Enabled, - PerplexityAPIKeys: cfg.Tools.Web.Perplexity.APIKeys.Values(), - PerplexityMaxResults: cfg.Tools.Web.Perplexity.MaxResults, - PerplexityEnabled: cfg.Tools.Web.Perplexity.Enabled, - SearXNGBaseURL: cfg.Tools.Web.SearXNG.BaseURL, - SearXNGMaxResults: cfg.Tools.Web.SearXNG.MaxResults, - SearXNGEnabled: cfg.Tools.Web.SearXNG.Enabled, - GLMSearchAPIKey: cfg.Tools.Web.GLMSearch.APIKey.String(), - GLMSearchBaseURL: cfg.Tools.Web.GLMSearch.BaseURL, - GLMSearchEngine: cfg.Tools.Web.GLMSearch.SearchEngine, - GLMSearchMaxResults: cfg.Tools.Web.GLMSearch.MaxResults, - GLMSearchEnabled: cfg.Tools.Web.GLMSearch.Enabled, - BaiduSearchAPIKey: cfg.Tools.Web.BaiduSearch.APIKey.String(), - BaiduSearchBaseURL: cfg.Tools.Web.BaiduSearch.BaseURL, - BaiduSearchMaxResults: cfg.Tools.Web.BaiduSearch.MaxResults, - BaiduSearchEnabled: cfg.Tools.Web.BaiduSearch.Enabled, - Proxy: cfg.Tools.Web.Proxy, - }) + searchTool, err := tools.NewWebSearchTool(tools.WebSearchToolOptionsFromConfig(cfg)) if err != nil { logger.ErrorCF("agent", "Failed to create web search tool", map[string]any{"error": err.Error()}) } else if searchTool != nil { diff --git a/pkg/agent/agent_test.go b/pkg/agent/agent_test.go index 2addc0535..b0aa3b468 100644 --- a/pkg/agent/agent_test.go +++ b/pkg/agent/agent_test.go @@ -161,6 +161,58 @@ func newTestAgentLoop( return al, cfg, msgBus, provider, func() { os.RemoveAll(tmpDir) } } +func TestNewAgentLoop_RegistersWebSearchTool(t *testing.T) { + cfg := config.DefaultConfig() + cfg.Agents.Defaults.Workspace = t.TempDir() + + al := NewAgentLoop(cfg, bus.NewMessageBus(), &mockProvider{}) + + agent := al.registry.GetDefaultAgent() + if agent == nil { + t.Fatal("expected default agent") + } + if _, ok := agent.Tools.Get("web_search"); !ok { + t.Fatal("expected web_search tool to be registered") + } +} + +func TestNewAgentLoop_RegistersWebSearchTool_WhenExplicitProviderUnavailable(t *testing.T) { + cfg := config.DefaultConfig() + cfg.Agents.Defaults.Workspace = t.TempDir() + cfg.Tools.Web.Provider = "brave" + cfg.Tools.Web.Brave.Enabled = true + cfg.Tools.Web.Sogou.Enabled = true + + al := NewAgentLoop(cfg, bus.NewMessageBus(), &mockProvider{}) + + agent := al.registry.GetDefaultAgent() + if agent == nil { + t.Fatal("expected default agent") + } + if _, ok := agent.Tools.Get("web_search"); !ok { + t.Fatal("expected web_search tool to fall back to auto provider selection") + } +} + +func TestNewAgentLoop_DoesNotRegisterWebSearchTool_WhenNoReadyProviders(t *testing.T) { + cfg := config.DefaultConfig() + cfg.Agents.Defaults.Workspace = t.TempDir() + cfg.Tools.Web.Provider = "brave" + cfg.Tools.Web.Brave.Enabled = true + cfg.Tools.Web.Sogou.Enabled = false + cfg.Tools.Web.DuckDuckGo.Enabled = false + + al := NewAgentLoop(cfg, bus.NewMessageBus(), &mockProvider{}) + + agent := al.registry.GetDefaultAgent() + if agent == nil { + t.Fatal("expected default agent") + } + if _, ok := agent.Tools.Get("web_search"); ok { + t.Fatal("expected web_search tool to be absent when no providers are ready") + } +} + func TestProcessMessage_IncludesCurrentSenderInDynamicContext(t *testing.T) { tmpDir, err := os.MkdirTemp("", "agent-test-*") if err != nil { diff --git a/pkg/agent/pipeline_llm.go b/pkg/agent/pipeline_llm.go index 29940bc01..7b3fee208 100644 --- a/pkg/agent/pipeline_llm.go +++ b/pkg/agent/pipeline_llm.go @@ -39,10 +39,10 @@ func (p *Pipeline) CallLLM( exec.providerToolDefs = ts.agent.Tools.ToProviderDefs() // Native web search support - _, hasWebSearch := ts.agent.Tools.Get("web_search") - exec.useNativeSearch = al.cfg.Tools.Web.PreferNative && hasWebSearch && + webSearchEnabled := al.cfg.Tools.IsToolEnabled("web") + exec.useNativeSearch = webSearchEnabled && al.cfg.Tools.Web.PreferNative && func() bool { - if ns, ok := ts.agent.Provider.(interface{ SupportsNativeSearch() bool }); ok { + if ns, ok := ts.agent.Provider.(providers.NativeSearchCapable); ok { return ns.SupportsNativeSearch() } return false diff --git a/pkg/agent/turn_coord_test.go b/pkg/agent/turn_coord_test.go index 7a362a662..c059d0a39 100644 --- a/pkg/agent/turn_coord_test.go +++ b/pkg/agent/turn_coord_test.go @@ -36,6 +36,35 @@ func (p *simpleConvProvider) GetDefaultModel() string { return "simple-model" } +type nativeSearchCaptureProvider struct { + lastOpts map[string]any +} + +func (p *nativeSearchCaptureProvider) Chat( + ctx context.Context, + messages []providers.Message, + tools []providers.ToolDefinition, + model string, + opts map[string]any, +) (*providers.LLMResponse, error) { + p.lastOpts = make(map[string]any, len(opts)) + for k, v := range opts { + p.lastOpts[k] = v + } + return &providers.LLMResponse{ + Content: "Using native search", + FinishReason: "stop", + }, nil +} + +func (p *nativeSearchCaptureProvider) GetDefaultModel() string { + return "native-search-model" +} + +func (p *nativeSearchCaptureProvider) SupportsNativeSearch() bool { + return true +} + // toolCallRespProvider returns a tool call response type toolCallRespProvider struct { toolName string @@ -257,6 +286,41 @@ func TestPipeline_CallLLM_WithToolCall(t *testing.T) { } } +func TestPipeline_CallLLM_UsesNativeSearchWithoutClientWebSearchTool(t *testing.T) { + provider := &nativeSearchCaptureProvider{} + al, agent, cleanup := newTurnCoordTestLoop(t, provider) + defer cleanup() + + if _, ok := agent.Tools.Get("web_search"); ok { + t.Fatal("expected no client-side web_search tool to be registered") + } + + al.cfg.Tools.Web.Enabled = true + al.cfg.Tools.Web.PreferNative = true + + pipeline := NewPipeline(al) + ts := newTurnState(agent, makeTestProcessOpts("test-session"), turnEventScope{ + turnID: "turn-1", + context: newTurnContext(nil, nil, nil), + }) + + exec, err := pipeline.SetupTurn(context.Background(), ts) + if err != nil { + t.Fatalf("SetupTurn failed: %v", err) + } + + ctrl, err := pipeline.CallLLM(context.Background(), context.Background(), ts, exec, 1) + if err != nil { + t.Fatalf("CallLLM failed: %v", err) + } + if ctrl != ControlBreak { + t.Fatalf("expected ControlBreak, got %v", ctrl) + } + if got, _ := provider.lastOpts["native_search"].(bool); !got { + t.Fatalf("expected native_search=true, got %#v", provider.lastOpts["native_search"]) + } +} + func TestPipeline_CallLLM_TimeoutRetry(t *testing.T) { errorPrv := &errorProvider{errType: "timeout"} al, agent, cleanup := newTurnCoordTestLoop(t, errorPrv) diff --git a/pkg/tools/integration/web.go b/pkg/tools/integration/web.go index 58db34589..56663ecda 100644 --- a/pkg/tools/integration/web.go +++ b/pkg/tools/integration/web.go @@ -1113,12 +1113,147 @@ type WebSearchToolOptions struct { Proxy string } +func WebSearchToolOptionsFromConfig(cfg *config.Config) WebSearchToolOptions { + return WebSearchToolOptions{ + Provider: cfg.Tools.Web.Provider, + BraveAPIKeys: cfg.Tools.Web.Brave.APIKeys.Values(), + BraveMaxResults: cfg.Tools.Web.Brave.MaxResults, + BraveEnabled: cfg.Tools.Web.Brave.Enabled, + TavilyAPIKeys: cfg.Tools.Web.Tavily.APIKeys.Values(), + TavilyBaseURL: cfg.Tools.Web.Tavily.BaseURL, + TavilyMaxResults: cfg.Tools.Web.Tavily.MaxResults, + TavilyEnabled: cfg.Tools.Web.Tavily.Enabled, + SogouMaxResults: cfg.Tools.Web.Sogou.MaxResults, + SogouEnabled: cfg.Tools.Web.Sogou.Enabled, + DuckDuckGoMaxResults: cfg.Tools.Web.DuckDuckGo.MaxResults, + DuckDuckGoEnabled: cfg.Tools.Web.DuckDuckGo.Enabled, + PerplexityAPIKeys: cfg.Tools.Web.Perplexity.APIKeys.Values(), + PerplexityMaxResults: cfg.Tools.Web.Perplexity.MaxResults, + PerplexityEnabled: cfg.Tools.Web.Perplexity.Enabled, + SearXNGBaseURL: cfg.Tools.Web.SearXNG.BaseURL, + SearXNGMaxResults: cfg.Tools.Web.SearXNG.MaxResults, + SearXNGEnabled: cfg.Tools.Web.SearXNG.Enabled, + GLMSearchAPIKey: cfg.Tools.Web.GLMSearch.APIKey.String(), + GLMSearchBaseURL: cfg.Tools.Web.GLMSearch.BaseURL, + GLMSearchEngine: cfg.Tools.Web.GLMSearch.SearchEngine, + GLMSearchMaxResults: cfg.Tools.Web.GLMSearch.MaxResults, + GLMSearchEnabled: cfg.Tools.Web.GLMSearch.Enabled, + BaiduSearchAPIKey: cfg.Tools.Web.BaiduSearch.APIKey.String(), + BaiduSearchBaseURL: cfg.Tools.Web.BaiduSearch.BaseURL, + BaiduSearchMaxResults: cfg.Tools.Web.BaiduSearch.MaxResults, + BaiduSearchEnabled: cfg.Tools.Web.BaiduSearch.Enabled, + Proxy: cfg.Tools.Web.Proxy, + } +} + +func WebSearchProviderReady(opts WebSearchToolOptions, name string) bool { + return opts.providerReady(name) +} + +func ResolveWebSearchProviderName(opts WebSearchToolOptions, query string) (string, error) { + return opts.resolveProviderName(query) +} + +var ( + knownWebSearchProviders = []string{ + "sogou", + "duckduckgo", + "brave", + "tavily", + "perplexity", + "searxng", + "glm_search", + "baidu_search", + } + autoPrimaryWebSearchProviders = []string{"perplexity", "brave", "searxng", "tavily"} + autoFallbackWebSearchProviders = []string{"baidu_search", "glm_search"} +) + +func isKnownWebSearchProvider(name string) bool { + name = strings.ToLower(strings.TrimSpace(name)) + for _, known := range knownWebSearchProviders { + if name == known { + return true + } + } + return false +} + +func (opts WebSearchToolOptions) providerReady(name string) bool { + switch strings.ToLower(strings.TrimSpace(name)) { + case "sogou": + return opts.SogouEnabled + case "duckduckgo": + return opts.DuckDuckGoEnabled + case "brave": + return opts.BraveEnabled && len(opts.BraveAPIKeys) > 0 + case "tavily": + return opts.TavilyEnabled && len(opts.TavilyAPIKeys) > 0 + case "perplexity": + return opts.PerplexityEnabled && len(opts.PerplexityAPIKeys) > 0 + case "searxng": + return opts.SearXNGEnabled && strings.TrimSpace(opts.SearXNGBaseURL) != "" + case "glm_search": + return opts.GLMSearchEnabled && strings.TrimSpace(opts.GLMSearchAPIKey) != "" + case "baidu_search": + return opts.BaiduSearchEnabled && strings.TrimSpace(opts.BaiduSearchAPIKey) != "" + default: + return false + } +} + +func (opts WebSearchToolOptions) normalizedProviderName() string { + providerName := strings.ToLower(strings.TrimSpace(opts.Provider)) + if providerName != "" && providerName != "auto" && !isKnownWebSearchProvider(providerName) { + // Tolerate stale or manually edited config values at runtime by + // treating them as "auto" and falling back to the next ready provider. + return "auto" + } + return providerName +} + +func (opts WebSearchToolOptions) resolveProviderName(query string) (string, error) { + providerName := opts.normalizedProviderName() + if providerName != "" && providerName != "auto" && opts.providerReady(providerName) { + return providerName, nil + } + + for _, name := range autoPrimaryWebSearchProviders { + if opts.providerReady(name) { + return name, nil + } + } + + sogouReady := opts.providerReady("sogou") + duckReady := opts.providerReady("duckduckgo") + if sogouReady && duckReady { + if prefersDuckDuckGoQuery(query) { + return "duckduckgo", nil + } + return "sogou", nil + } + if sogouReady { + return "sogou", nil + } + if duckReady { + return "duckduckgo", nil + } + + for _, name := range autoFallbackWebSearchProviders { + if opts.providerReady(name) { + return name, nil + } + } + + return "", nil +} + func (opts WebSearchToolOptions) providerByName(name string) (SearchProvider, int, error) { switch strings.ToLower(strings.TrimSpace(name)) { case "", "auto": return nil, 0, nil case "sogou": - if !opts.SogouEnabled { + if !opts.providerReady("sogou") { return nil, 0, nil } client, err := utils.CreateHTTPClient(opts.Proxy, searchTimeout) @@ -1134,7 +1269,7 @@ func (opts WebSearchToolOptions) providerByName(name string) (SearchProvider, in client: client, }, maxResults, nil case "perplexity": - if !opts.PerplexityEnabled { + if !opts.providerReady("perplexity") { return nil, 0, nil } client, err := utils.CreateHTTPClient(opts.Proxy, perplexityTimeout) @@ -1151,7 +1286,7 @@ func (opts WebSearchToolOptions) providerByName(name string) (SearchProvider, in client: client, }, maxResults, nil case "brave": - if !opts.BraveEnabled { + if !opts.providerReady("brave") { return nil, 0, nil } client, err := utils.CreateHTTPClient(opts.Proxy, searchTimeout) @@ -1168,7 +1303,7 @@ func (opts WebSearchToolOptions) providerByName(name string) (SearchProvider, in client: client, }, maxResults, nil case "searxng": - if !opts.SearXNGEnabled { + if !opts.providerReady("searxng") { return nil, 0, nil } client, err := utils.CreateHTTPClient(opts.Proxy, searchTimeout) @@ -1185,7 +1320,7 @@ func (opts WebSearchToolOptions) providerByName(name string) (SearchProvider, in client: client, }, maxResults, nil case "tavily": - if !opts.TavilyEnabled { + if !opts.providerReady("tavily") { return nil, 0, nil } client, err := utils.CreateHTTPClient(opts.Proxy, searchTimeout) @@ -1203,7 +1338,7 @@ func (opts WebSearchToolOptions) providerByName(name string) (SearchProvider, in client: client, }, maxResults, nil case "duckduckgo": - if !opts.DuckDuckGoEnabled { + if !opts.providerReady("duckduckgo") { return nil, 0, nil } client, err := utils.CreateHTTPClient(opts.Proxy, searchTimeout) @@ -1219,7 +1354,7 @@ func (opts WebSearchToolOptions) providerByName(name string) (SearchProvider, in client: client, }, maxResults, nil case "baidu_search": - if !opts.BaiduSearchEnabled { + if !opts.providerReady("baidu_search") { return nil, 0, nil } client, err := utils.CreateHTTPClient(opts.Proxy, perplexityTimeout) @@ -1237,7 +1372,7 @@ func (opts WebSearchToolOptions) providerByName(name string) (SearchProvider, in client: client, }, maxResults, nil case "glm_search": - if !opts.GLMSearchEnabled { + if !opts.providerReady("glm_search") { return nil, 0, nil } client, err := utils.CreateHTTPClient(opts.Proxy, searchTimeout) @@ -1297,62 +1432,35 @@ func prefersDuckDuckGoQuery(text string) bool { } func (opts WebSearchToolOptions) buildProviderResolver() (func(query string) (SearchProvider, int), error) { - providerName := strings.ToLower(strings.TrimSpace(opts.Provider)) - if providerName != "" && providerName != "auto" { - provider, maxResults, err := opts.providerByName(providerName) + providersByName := make(map[string]SearchProvider, len(knownWebSearchProviders)) + maxResultsByName := make(map[string]int, len(knownWebSearchProviders)) + + for _, name := range knownWebSearchProviders { + if !opts.providerReady(name) { + continue + } + provider, maxResults, err := opts.providerByName(name) if err != nil { return nil, err } if provider == nil { - return func(string) (SearchProvider, int) { return nil, 0 }, nil + continue } - return func(string) (SearchProvider, int) { return provider, maxResults }, nil + providersByName[name] = provider + maxResultsByName[name] = maxResults } - for _, name := range []string{"perplexity", "brave", "searxng", "tavily"} { - provider, maxResults, err := opts.providerByName(name) + return func(query string) (SearchProvider, int) { + name, err := opts.resolveProviderName(query) if err != nil { - return nil, err + return nil, 0 } - if provider != nil { - return func(string) (SearchProvider, int) { return provider, maxResults }, nil + provider, ok := providersByName[name] + if !ok { + return nil, 0 } - } - - sogouProvider, sogouMaxResults, err := opts.providerByName("sogou") - if err != nil { - return nil, err - } - duckProvider, duckMaxResults, err := opts.providerByName("duckduckgo") - if err != nil { - return nil, err - } - if sogouProvider != nil && duckProvider != nil { - return func(query string) (SearchProvider, int) { - if prefersDuckDuckGoQuery(query) { - return duckProvider, duckMaxResults - } - return sogouProvider, sogouMaxResults - }, nil - } - if sogouProvider != nil { - return func(string) (SearchProvider, int) { return sogouProvider, sogouMaxResults }, nil - } - if duckProvider != nil { - return func(string) (SearchProvider, int) { return duckProvider, duckMaxResults }, nil - } - - for _, name := range []string{"baidu_search", "glm_search"} { - provider, maxResults, err := opts.providerByName(name) - if err != nil { - return nil, err - } - if provider != nil { - return func(string) (SearchProvider, int) { return provider, maxResults }, nil - } - } - - return func(string) (SearchProvider, int) { return nil, 0 }, nil + return provider, maxResultsByName[name] + }, nil } func NewWebSearchTool(opts WebSearchToolOptions) (*WebSearchTool, error) { diff --git a/pkg/tools/integration/web_test.go b/pkg/tools/integration/web_test.go index 4ad5a3468..d47d8e7c9 100644 --- a/pkg/tools/integration/web_test.go +++ b/pkg/tools/integration/web_test.go @@ -385,24 +385,14 @@ func TestWebFetchTool_PayloadTooLarge(t *testing.T) { } } -// TestWebTool_WebSearch_NoApiKey verifies missing credentials are surfaced at execution time. +// TestWebTool_WebSearch_NoApiKey verifies providers without required credentials are not registered. func TestWebTool_WebSearch_NoApiKey(t *testing.T) { tool, err := NewWebSearchTool(WebSearchToolOptions{BraveEnabled: true, BraveAPIKeys: nil}) if err != nil { t.Fatalf("Unexpected error: %v", err) } - if tool == nil { - t.Fatalf("Expected tool when Brave is enabled, even without API keys") - } - - result := tool.Execute(context.Background(), map[string]any{ - "query": "test query", - }) - if !result.IsError { - t.Fatalf("Expected missing Brave API key to return error") - } - if !strings.Contains(result.ForLLM, "no API key provided") { - t.Fatalf("Unexpected error message: %s", result.ForLLM) + if tool != nil { + t.Fatalf("Expected nil tool when only enabled provider is missing credentials") } // Also nil when nothing is enabled @@ -1878,6 +1868,94 @@ func TestWebTool_AutoProviderPrefersConfiguredProvidersBeforeSogou(t *testing.T) } } +func TestWebTool_ExplicitProviderFallsBackWhenMissingCredentials(t *testing.T) { + tool, err := NewWebSearchTool(WebSearchToolOptions{ + Provider: "brave", + BraveEnabled: true, + SogouEnabled: true, + SogouMaxResults: 5, + }) + if err != nil { + t.Fatalf("NewWebSearchTool() error: %v", err) + } + if _, ok := tool.provider.(*SogouSearchProvider); !ok { + t.Fatalf("expected SogouSearchProvider after fallback, got %T", tool.provider) + } +} + +func TestWebTool_ExplicitProviderFallsBackWhenMissingBaseURL(t *testing.T) { + tool, err := NewWebSearchTool(WebSearchToolOptions{ + Provider: "searxng", + SearXNGEnabled: true, + SogouEnabled: true, + SogouMaxResults: 5, + }) + if err != nil { + t.Fatalf("NewWebSearchTool() error: %v", err) + } + if _, ok := tool.provider.(*SogouSearchProvider); !ok { + t.Fatalf("expected SogouSearchProvider after fallback, got %T", tool.provider) + } +} + +func TestWebTool_AutoProviderSkipsEnabledButUnreadyProviders(t *testing.T) { + tool, err := NewWebSearchTool(WebSearchToolOptions{ + Provider: "auto", + BraveEnabled: true, + SogouEnabled: true, + SogouMaxResults: 5, + }) + if err != nil { + t.Fatalf("NewWebSearchTool() error: %v", err) + } + if _, ok := tool.provider.(*SogouSearchProvider); !ok { + t.Fatalf("expected SogouSearchProvider when Brave has no API key, got %T", tool.provider) + } +} + +func TestResolveWebSearchProviderName_FallsBackFromExplicitUnavailableProvider(t *testing.T) { + got, err := ResolveWebSearchProviderName(WebSearchToolOptions{ + Provider: "brave", + BraveEnabled: true, + SogouEnabled: true, + SogouMaxResults: 5, + }, "") + if err != nil { + t.Fatalf("ResolveWebSearchProviderName() error: %v", err) + } + if got != "sogou" { + t.Fatalf("ResolveWebSearchProviderName() = %q, want sogou", got) + } +} + +func TestWebTool_UnknownExplicitProviderFallsBackToAuto(t *testing.T) { + tool, err := NewWebSearchTool(WebSearchToolOptions{ + Provider: "totally_unknown", + SogouEnabled: true, + SogouMaxResults: 5, + }) + if err != nil { + t.Fatalf("NewWebSearchTool() error: %v", err) + } + if _, ok := tool.provider.(*SogouSearchProvider); !ok { + t.Fatalf("expected SogouSearchProvider after fallback, got %T", tool.provider) + } +} + +func TestResolveWebSearchProviderName_FallsBackFromUnknownProvider(t *testing.T) { + got, err := ResolveWebSearchProviderName(WebSearchToolOptions{ + Provider: "totally_unknown", + SogouEnabled: true, + SogouMaxResults: 5, + }, "") + if err != nil { + t.Fatalf("ResolveWebSearchProviderName() error: %v", err) + } + if got != "sogou" { + t.Fatalf("ResolveWebSearchProviderName() = %q, want sogou", got) + } +} + type stubSearchProvider struct { result string calls []string diff --git a/pkg/tools/integration_facade.go b/pkg/tools/integration_facade.go index 00c00b810..b05a22fe2 100644 --- a/pkg/tools/integration_facade.go +++ b/pkg/tools/integration_facade.go @@ -4,6 +4,7 @@ import ( "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/sipeed/picoclaw/pkg/audio/tts" + "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/media" "github.com/sipeed/picoclaw/pkg/skills" integrationtools "github.com/sipeed/picoclaw/pkg/tools/integration" @@ -72,6 +73,18 @@ func GetPreferredWebSearchLanguage() string { return integrationtools.GetPreferredWebSearchLanguage() } +func WebSearchToolOptionsFromConfig(cfg *config.Config) WebSearchToolOptions { + return integrationtools.WebSearchToolOptionsFromConfig(cfg) +} + +func WebSearchProviderReady(opts WebSearchToolOptions, name string) bool { + return integrationtools.WebSearchProviderReady(opts, name) +} + +func ResolveWebSearchProviderName(opts WebSearchToolOptions, query string) (string, error) { + return integrationtools.ResolveWebSearchProviderName(opts, query) +} + func NewWebSearchTool(opts WebSearchToolOptions) (*WebSearchTool, error) { return integrationtools.NewWebSearchTool(opts) } diff --git a/web/backend/api/tools.go b/web/backend/api/tools.go index 0a1bb50ee..c6c2deaae 100644 --- a/web/backend/api/tools.go +++ b/web/backend/api/tools.go @@ -261,6 +261,8 @@ func buildToolSupport(cfg *config.Config) []toolSupportItem { status, reasonCode = resolveDiscoveryToolSupport(cfg, cfg.Tools.MCP.Discovery.UseRegex) case "tool_search_tool_bm25": status, reasonCode = resolveDiscoveryToolSupport(cfg, cfg.Tools.MCP.Discovery.UseBM25) + case "web_search": + status, reasonCode = resolveWebSearchToolSupport(cfg) case "i2c", "spi": status, reasonCode = resolveHardwareToolSupport(cfg.Tools.IsToolEnabled(entry.ConfigKey)) default: @@ -304,6 +306,13 @@ func resolveDiscoveryToolSupport(cfg *config.Config, methodEnabled bool) (string return "enabled", "" } +func resolveWebSearchToolSupport(cfg *config.Config) (string, string) { + if !cfg.Tools.IsToolEnabled("web") { + return "disabled", "" + } + return "enabled", "" +} + func applyToolState(cfg *config.Config, toolName string, enabled bool) error { switch toolName { case "read_file": @@ -507,6 +516,7 @@ func normalizeWebSearchAPIKeys(apiKeys []string, apiKey string) ([]string, bool) } func buildWebSearchConfigResponse(cfg *config.Config) webSearchConfigResponse { + opts := picotools.WebSearchToolOptionsFromConfig(cfg) current := resolveCurrentWebSearchProvider(cfg) settings := map[string]webSearchProviderConfig{ "sogou": { @@ -563,59 +573,53 @@ func buildWebSearchConfigResponse(cfg *config.Config) webSearchConfigResponse { { ID: "sogou", Label: "Sogou", - Configured: cfg.Tools.Web.Sogou.Enabled, + Configured: picotools.WebSearchProviderReady(opts, "sogou"), Current: current == "sogou", }, { ID: "duckduckgo", Label: "DuckDuckGo", - Configured: cfg.Tools.Web.DuckDuckGo.Enabled, + Configured: picotools.WebSearchProviderReady(opts, "duckduckgo"), Current: current == "duckduckgo", }, { - ID: "brave", - Label: "Brave Search", - Configured: cfg.Tools.Web.Brave.Enabled && - len(cfg.Tools.Web.Brave.APIKeys.Values()) > 0, + ID: "brave", + Label: "Brave Search", + Configured: picotools.WebSearchProviderReady(opts, "brave"), Current: current == "brave", RequiresAuth: true, }, { - ID: "tavily", - Label: "Tavily", - Configured: cfg.Tools.Web.Tavily.Enabled && - len(cfg.Tools.Web.Tavily.APIKeys.Values()) > 0, + ID: "tavily", + Label: "Tavily", + Configured: picotools.WebSearchProviderReady(opts, "tavily"), Current: current == "tavily", RequiresAuth: true, }, { - ID: "perplexity", - Label: "Perplexity", - Configured: cfg.Tools.Web.Perplexity.Enabled && - len(cfg.Tools.Web.Perplexity.APIKeys.Values()) > 0, + ID: "perplexity", + Label: "Perplexity", + Configured: picotools.WebSearchProviderReady(opts, "perplexity"), Current: current == "perplexity", RequiresAuth: true, }, { - ID: "searxng", - Label: "SearXNG", - Configured: cfg.Tools.Web.SearXNG.Enabled && - strings.TrimSpace(cfg.Tools.Web.SearXNG.BaseURL) != "", - Current: current == "searxng", + ID: "searxng", + Label: "SearXNG", + Configured: picotools.WebSearchProviderReady(opts, "searxng"), + Current: current == "searxng", }, { - ID: "glm_search", - Label: "GLM Search", - Configured: cfg.Tools.Web.GLMSearch.Enabled && - cfg.Tools.Web.GLMSearch.APIKey.String() != "", + ID: "glm_search", + Label: "GLM Search", + Configured: picotools.WebSearchProviderReady(opts, "glm_search"), Current: current == "glm_search", RequiresAuth: true, }, { - ID: "baidu_search", - Label: "Baidu Search", - Configured: cfg.Tools.Web.BaiduSearch.Enabled && - cfg.Tools.Web.BaiduSearch.APIKey.String() != "", + ID: "baidu_search", + Label: "Baidu Search", + Configured: picotools.WebSearchProviderReady(opts, "baidu_search"), Current: current == "baidu_search", RequiresAuth: true, }, @@ -637,57 +641,12 @@ func buildWebSearchConfigResponse(cfg *config.Config) webSearchConfigResponse { } func resolveCurrentWebSearchProvider(cfg *config.Config) string { - selected := normalizeWebSearchProvider(cfg.Tools.Web.Provider) - if selected != "" && selected != "auto" && webSearchProviderConfigured(cfg, selected) { - return selected + if cfg == nil || !cfg.Tools.IsToolEnabled("web") { + return "" } - - for _, name := range []string{"perplexity", "brave", "searxng", "tavily"} { - if webSearchProviderConfigured(cfg, name) { - return name - } - } - - if webSearchProviderConfigured(cfg, "sogou") && webSearchProviderConfigured(cfg, "duckduckgo") { - if picotools.GetPreferredWebSearchLanguage() == "en" { - return "duckduckgo" - } - return "sogou" - } - if webSearchProviderConfigured(cfg, "sogou") { - return "sogou" - } - if webSearchProviderConfigured(cfg, "duckduckgo") { - return "duckduckgo" - } - - for _, name := range []string{"baidu_search", "glm_search"} { - if webSearchProviderConfigured(cfg, name) { - return name - } - } - return "" -} - -func webSearchProviderConfigured(cfg *config.Config, name string) bool { - switch name { - case "sogou": - return cfg.Tools.Web.Sogou.Enabled - case "duckduckgo": - return cfg.Tools.Web.DuckDuckGo.Enabled - case "brave": - return cfg.Tools.Web.Brave.Enabled && len(cfg.Tools.Web.Brave.APIKeys.Values()) > 0 - case "tavily": - return cfg.Tools.Web.Tavily.Enabled && len(cfg.Tools.Web.Tavily.APIKeys.Values()) > 0 - case "perplexity": - return cfg.Tools.Web.Perplexity.Enabled && len(cfg.Tools.Web.Perplexity.APIKeys.Values()) > 0 - case "searxng": - return cfg.Tools.Web.SearXNG.Enabled && strings.TrimSpace(cfg.Tools.Web.SearXNG.BaseURL) != "" - case "glm_search": - return cfg.Tools.Web.GLMSearch.Enabled && cfg.Tools.Web.GLMSearch.APIKey.String() != "" - case "baidu_search": - return cfg.Tools.Web.BaiduSearch.Enabled && cfg.Tools.Web.BaiduSearch.APIKey.String() != "" - default: - return false + selected, err := picotools.ResolveWebSearchProviderName(picotools.WebSearchToolOptionsFromConfig(cfg), "") + if err != nil { + return "" } + return selected } diff --git a/web/backend/api/tools_test.go b/web/backend/api/tools_test.go index 5105fc1d2..ffeae9b64 100644 --- a/web/backend/api/tools_test.go +++ b/web/backend/api/tools_test.go @@ -198,6 +198,66 @@ func TestHandleUpdateToolState(t *testing.T) { } } +func TestHandleListTools_ReportsWebSearchEnabledWhenToolIsOn(t *testing.T) { + tests := []struct { + name string + preferNative bool + }{ + {name: "without prefer_native", preferNative: false}, + {name: "with prefer_native", preferNative: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + cfg.Tools.Web.PreferNative = tt.preferNative + cfg.Tools.Web.Provider = "brave" + cfg.Tools.Web.Sogou.Enabled = false + cfg.Tools.Web.DuckDuckGo.Enabled = false + cfg.Tools.Web.Brave.Enabled = true + cfg.Tools.Web.Brave.SetAPIKeys(nil) + if err := config.SaveConfig(configPath, cfg); err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/tools", nil) + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + var resp toolSupportResponse + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + + for _, tool := range resp.Tools { + if tool.Name != "web_search" { + continue + } + if tool.Status != "enabled" || tool.ReasonCode != "" { + t.Fatalf("web_search = %#v, want enabled with no reason code", tool) + } + return + } + + t.Fatal("expected web_search in response") + }) + } +} + func TestHandleGetWebSearchConfig(t *testing.T) { configPath, cleanup := setupOAuthTestEnv(t) defer cleanup() @@ -206,6 +266,7 @@ func TestHandleGetWebSearchConfig(t *testing.T) { if err != nil { t.Fatalf("LoadConfig() error = %v", err) } + cfg.Tools.Web.PreferNative = false cfg.Tools.Web.Provider = "sogou" cfg.Tools.Web.Sogou.Enabled = true cfg.Tools.Web.Sogou.MaxResults = 6 @@ -242,6 +303,48 @@ func TestHandleGetWebSearchConfig(t *testing.T) { } } +func TestHandleGetWebSearchConfig_DoesNotExposeNativeAsCurrentService(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + cfg.Tools.Web.PreferNative = true + cfg.Tools.Web.Provider = "brave" + cfg.Tools.Web.Sogou.Enabled = false + cfg.Tools.Web.DuckDuckGo.Enabled = false + cfg.Tools.Web.Brave.Enabled = true + cfg.Tools.Web.Brave.SetAPIKeys(nil) + if err := config.SaveConfig(configPath, cfg); err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/tools/web-search-config", nil) + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + var resp webSearchConfigResponse + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + if !resp.PreferNative { + t.Fatal("prefer_native should remain true in response") + } + if resp.CurrentService != "" { + t.Fatalf("current_service = %q, want empty when no external provider is ready", resp.CurrentService) + } +} + func TestHandleUpdateWebSearchConfig(t *testing.T) { configPath, cleanup := setupOAuthTestEnv(t) defer cleanup() @@ -393,6 +496,27 @@ func TestResolveCurrentWebSearchProvider_PrefersConfiguredProvidersBeforeSogou(t } } +func TestResolveCurrentWebSearchProvider_FallsBackWhenExplicitProviderUnavailable(t *testing.T) { + cfg := config.DefaultConfig() + cfg.Tools.Web.Provider = "brave" + cfg.Tools.Web.Brave.Enabled = true + cfg.Tools.Web.Sogou.Enabled = true + + if got := resolveCurrentWebSearchProvider(cfg); got != "sogou" { + t.Fatalf("resolveCurrentWebSearchProvider() = %q, want sogou", got) + } +} + +func TestResolveCurrentWebSearchProvider_FallsBackWhenProviderIsUnknown(t *testing.T) { + cfg := config.DefaultConfig() + cfg.Tools.Web.Provider = "totally_unknown" + cfg.Tools.Web.Sogou.Enabled = true + + if got := resolveCurrentWebSearchProvider(cfg); got != "sogou" { + t.Fatalf("resolveCurrentWebSearchProvider() = %q, want sogou", got) + } +} + func TestResolveCurrentWebSearchProvider_UsesPreferredLanguageForSogouAndDuckDuckGo(t *testing.T) { cfg := config.DefaultConfig() cfg.Tools.Web.Provider = "auto" @@ -413,3 +537,22 @@ func TestResolveCurrentWebSearchProvider_UsesPreferredLanguageForSogouAndDuckDuc t.Fatalf("resolveCurrentWebSearchProvider() = %q, want sogou", got) } } + +func TestResolveCurrentWebSearchProvider_IgnoresPreferNativeInConfigView(t *testing.T) { + cfg := config.DefaultConfig() + cfg.ModelList = []*config.ModelConfig{{ + ModelName: "custom-default", + Model: "openai/gpt-4o", + APIKeys: config.SimpleSecureStrings("sk-default"), + }} + cfg.Agents.Defaults.ModelName = "custom-default" + cfg.Tools.Web.PreferNative = true + cfg.Tools.Web.Provider = "brave" + cfg.Tools.Web.Sogou.Enabled = false + cfg.Tools.Web.DuckDuckGo.Enabled = false + cfg.Tools.Web.Brave.Enabled = true + + if got := resolveCurrentWebSearchProvider(cfg); got != "" { + t.Fatalf("resolveCurrentWebSearchProvider() = %q, want empty when only native search would be available", got) + } +} diff --git a/web/frontend/src/components/agent/tools/tool-library-tab.tsx b/web/frontend/src/components/agent/tools/tool-library-tab.tsx index 638a7be23..6bbfeb091 100644 --- a/web/frontend/src/components/agent/tools/tool-library-tab.tsx +++ b/web/frontend/src/components/agent/tools/tool-library-tab.tsx @@ -1,7 +1,8 @@ -import { IconSearch } from "@tabler/icons-react" +import { IconSearch, IconSettings } from "@tabler/icons-react" import { useTranslation } from "react-i18next" import type { ToolSupportItem } from "@/api/tools" +import { Button } from "@/components/ui/button" import { Card, CardContent } from "@/components/ui/card" import { Input } from "@/components/ui/input" import { @@ -29,6 +30,7 @@ interface ToolLibraryTabProps { pendingToolName: string | null onSearchQueryChange: (value: string) => void onStatusFilterChange: (value: ToolStatusFilter) => void + onOpenWebSearchSettings: () => void onToggleTool: (name: string, enabled: boolean) => void } @@ -43,6 +45,7 @@ export function ToolLibraryTab({ pendingToolName, onSearchQueryChange, onStatusFilterChange, + onOpenWebSearchSettings, onToggleTool, }: ToolLibraryTabProps) { const { t } = useTranslation() @@ -131,6 +134,7 @@ export function ToolLibraryTab({ key={tool.name} tool={tool} isPending={pendingToolName === tool.name} + onOpenWebSearchSettings={onOpenWebSearchSettings} onToggleTool={onToggleTool} /> ))} @@ -146,10 +150,12 @@ export function ToolLibraryTab({ function ToolCard({ tool, isPending, + onOpenWebSearchSettings, onToggleTool, }: { tool: ToolSupportItem isPending: boolean + onOpenWebSearchSettings: () => void onToggleTool: (name: string, enabled: boolean) => void }) { const { t } = useTranslation() @@ -157,8 +163,10 @@ function ToolCard({ ? t(`pages.agent.tools.reasons.${tool.reason_code}`) : "" const isEnabled = tool.status === "enabled" + const isToggledOn = tool.status !== "disabled" const isDisabled = tool.status === "disabled" const isBlocked = tool.status === "blocked" + const isWebSearchTool = tool.name === "web_search" return ( - -
+ +
-

+

{tool.name}

- onToggleTool(tool.name, checked)} - className={cn( - "shrink-0", - isEnabled && "shadow-xs ring-1 ring-emerald-500/20", +
+ {isWebSearchTool && ( + )} - /> + onToggleTool(tool.name, checked)} + className={cn( + "shrink-0", + isEnabled && "shadow-xs ring-1 ring-emerald-500/20", + )} + /> +

diff --git a/web/frontend/src/components/agent/tools/tools-page.tsx b/web/frontend/src/components/agent/tools/tools-page.tsx index c490c46ad..7d2d0fac6 100644 --- a/web/frontend/src/components/agent/tools/tools-page.tsx +++ b/web/frontend/src/components/agent/tools/tools-page.tsx @@ -1,3 +1,4 @@ +import { useLayoutEffect, useRef } from "react" import { useTranslation } from "react-i18next" import { PageHeader } from "@/components/page-header" @@ -8,9 +9,9 @@ import { WebSearchTab } from "./web-search-tab" export function ToolsPage() { const { t } = useTranslation() + const scrollContainerRef = useRef(null) const { activeTab, - currentProviderLabel, expandedProvider, groupedTools, pendingToolName, @@ -34,12 +35,19 @@ export function ToolsPage() { updateWebSearchDraft, } = useToolsPage() + useLayoutEffect(() => { + scrollContainerRef.current?.scrollTo({ top: 0 }) + }, [activeTab]) + return (

-
+
{activeTab === "library" ? ( setActiveTab("web-search")} onToggleTool={toggleTool} /> ) : ( [provider.id, provider.label])) }, [webSearchDraft]) - const currentProviderLabel = webSearchDraft?.current_service - ? (providerLabelMap.get(webSearchDraft.current_service) ?? - webSearchDraft.current_service) - : t("pages.agent.tools.web_search.none", "None") - const pendingToolName = toggleToolMutation.isPending ? (toggleToolMutation.variables?.name ?? null) : null @@ -168,7 +163,6 @@ export function useToolsPage() { return { activeTab, - currentProviderLabel, expandedProvider, groupedTools: groupedTools.groupedTools, pendingToolName, diff --git a/web/frontend/src/components/agent/tools/web-search-general-settings.tsx b/web/frontend/src/components/agent/tools/web-search-general-settings.tsx index 33d6572cf..f3c8004b5 100644 --- a/web/frontend/src/components/agent/tools/web-search-general-settings.tsx +++ b/web/frontend/src/components/agent/tools/web-search-general-settings.tsx @@ -36,7 +36,7 @@ export function WebSearchGeneralSettings({ label={t("pages.agent.tools.web_search.provider", "Primary Provider")} description={t( "pages.agent.tools.web_search.provider_description", - "Select the default search engine that agents will fallback to.", + "Select the default provider to use when the web search tool handles a request.", )} >

*Z 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 F{{UFL=DGj? literal 100337 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