Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ make release
- Use targeted tests first for local changes.
- Run `go test ./...` for shared or cross-package changes.
- Run `make` when touching build, CGO, linker flags, or packaging.
- When testing or debugging `csgclaw serve` and a browser page is unnecessary, launch with `--no-browser` to avoid stealing focus or opening extra tabs.
- If you skip verification, say so clearly.

## References
Expand Down
29 changes: 21 additions & 8 deletions cli/serve/serve.go
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ func (c serveCmd) Run(ctx context.Context, run *command.Context, args []string,
fs := run.NewFlagSet("serve", run.Program+" serve [-d|--daemon] [flags]", c.Summary())
daemon := fs.Bool("daemon", false, "run server in background")
fs.BoolVar(daemon, "d", false, "run server in background")
noBrowser := fs.Bool("no-browser", false, "do not open the browser after startup")
logLevel := fs.String("log-level", "info", "log level: debug, info, warn, error")

defaultLogPath, err := defaultServerLogPath()
Expand Down Expand Up @@ -144,9 +145,9 @@ func (c serveCmd) Run(ctx context.Context, run *command.Context, args []string,
}

if *daemon {
return serveBackground(run, cfg, globals, *logPath, *pidPath, *logLevel)
return serveBackground(run, cfg, globals, *logPath, *pidPath, *logLevel, *noBrowser)
}
return serveForegroundWithConfigPath(ctx, run, cfg, globals.Config, globals.Output)
return serveForegroundWithConfigPath(ctx, run, cfg, globals.Config, globals.Output, serveOptions{NoBrowser: *noBrowser})
}

func (stopCmd) Name() string {
Expand Down Expand Up @@ -217,6 +218,7 @@ func (c internalServeCmd) Run(ctx context.Context, run *command.Context, args []
pidPath := fs.String("pid", "", "pid file path")
configPathFlag := fs.String("config", globals.Config, "config file path")
logLevel := fs.String("log-level", "info", "log level: debug, info, warn, error")
noBrowser := fs.Bool("no-browser", false, "do not open the browser after startup")
if err := fs.Parse(args); err != nil {
return err
}
Expand Down Expand Up @@ -259,14 +261,18 @@ func (c internalServeCmd) Run(ctx context.Context, run *command.Context, args []
if err != nil {
return err
}
return startServerWithConfigPath(ctx, run, cfg, svc, imSvc, imBus, feishuSvc, *configPathFlag, globals.Output)
return startServerWithConfigPath(ctx, run, cfg, svc, imSvc, imBus, feishuSvc, *configPathFlag, globals.Output, serveOptions{NoBrowser: *noBrowser})
}

func serveForeground(ctx context.Context, run *command.Context, cfg config.Config, output string) error {
return serveForegroundWithConfigPath(ctx, run, cfg, "", output)
}

func serveForegroundWithConfigPath(ctx context.Context, run *command.Context, cfg config.Config, configPath string, output string) error {
type serveOptions struct {
NoBrowser bool
}

func serveForegroundWithConfigPath(ctx context.Context, run *command.Context, cfg config.Config, configPath string, output string, opts ...serveOptions) error {
_ = preflightDefaultModelProvider(ctx, cfg)
imBus := im.NewBus()
feishuProvider, feishuSvc, err := buildFeishuComponents(configPath)
Expand Down Expand Up @@ -300,10 +306,10 @@ func serveForegroundWithConfigPath(ctx context.Context, run *command.Context, cf
fmt.Fprintf(run.Stdout, "CSGClaw IM is available at: %s\n", imURL)
}

return startServerWithConfigPath(ctx, run, cfg, svc, imSvc, imBus, feishuSvc, configPath, output)
return startServerWithConfigPath(ctx, run, cfg, svc, imSvc, imBus, feishuSvc, configPath, output, opts...)
}

func serveBackground(run *command.Context, cfg config.Config, globals command.GlobalOptions, logPath, pidPath, logLevel string) error {
func serveBackground(run *command.Context, cfg config.Config, globals command.GlobalOptions, logPath, pidPath, logLevel string, noBrowser bool) error {
exe, err := os.Executable()
if err != nil {
return fmt.Errorf("resolve executable: %w", err)
Expand All @@ -324,6 +330,9 @@ func serveBackground(run *command.Context, cfg config.Config, globals command.Gl
if strings.TrimSpace(logLevel) != "" {
childArgs = append(childArgs, "--log-level", logLevel)
}
if noBrowser {
childArgs = append(childArgs, "--no-browser")
}
cmd := exec.Command(exe, childArgs...)
cmd.Stdout = logFile
cmd.Stderr = logFile
Expand Down Expand Up @@ -411,7 +420,11 @@ func startServer(ctx context.Context, run *command.Context, cfg config.Config, s
return startServerWithConfigPath(ctx, run, cfg, svc, imSvc, imBus, feishuSvc, "", output)
}

func startServerWithConfigPath(ctx context.Context, run *command.Context, cfg config.Config, svc *agent.Service, imSvc *im.Service, imBus *im.Bus, feishuSvc *feishu.Service, configPath, output string) error {
func startServerWithConfigPath(ctx context.Context, run *command.Context, cfg config.Config, svc *agent.Service, imSvc *im.Service, imBus *im.Bus, feishuSvc *feishu.Service, configPath, output string, opts ...serveOptions) error {
serveOpts := serveOptions{}
if len(opts) > 0 {
serveOpts = opts[0]
}
_ = EnsureCLIProxy(ctx)
defer func() {
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
Expand Down Expand Up @@ -493,7 +506,7 @@ func startServerWithConfigPath(ctx context.Context, run *command.Context, cfg co
OnReady: func(handler *api.Handler, router chi.Router) {
deliver := channelwiring.WireNotificationParticipantPull(ctx, participantSvc, imSvc, apiURL, cfg.Server.AccessToken)
handler.SetNotificationDeliver(deliver)
if output != "json" && run != nil {
if !serveOpts.NoBrowser && output != "json" && run != nil {
go func() {
if err := WaitForHealthy(apiURL, 5*time.Second); err != nil {
fmt.Fprintln(run.Stdout, "Open this URL in your browser after startup.")
Expand Down
58 changes: 58 additions & 0 deletions cli/serve/serve_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,64 @@ func TestServeForegroundOpensIMURLWhenBrowserAllowed(t *testing.T) {
}
}

func TestServeRunNoBrowserFlagSuppressesBrowserOpen(t *testing.T) {
restore := stubServeDependencies(t)
defer restore()

origRunServer := RunServer
t.Cleanup(func() {
RunServer = origRunServer
})
RunServer = func(opts server.Options) error {
if opts.OnReady != nil {
opts.OnReady(nil, nil)
}
return nil
}

configPath := filepath.Join(t.TempDir(), "config.toml")
cfg := config.Config{
Server: config.ServerConfig{
ListenAddr: "127.0.0.1:18080",
AdvertiseBaseURL: "http://example.test/base",
AccessToken: "pc-secret",
},
Sandbox: config.SandboxConfig{Provider: config.DefaultSandboxProvider},
}
if err := cfg.Save(configPath); err != nil {
t.Fatalf("Save() error = %v", err)
}

openedCh := make(chan string, 1)
waitedCh := make(chan string, 1)
WaitForHealthy = func(rawURL string, _ time.Duration) error {
waitedCh <- rawURL
return nil
}
OpenBrowser = func(rawURL string) error {
openedCh <- rawURL
return nil
}

run := testContext()
if err := NewServeCmd().Run(context.Background(), run, []string{"--no-browser"}, command.GlobalOptions{Config: configPath}); err != nil {
t.Fatalf("Run() error = %v", err)
}
select {
case rawURL := <-waitedCh:
t.Fatalf("WaitForHealthy called for %q, want no browser startup path", rawURL)
default:
}
select {
case rawURL := <-openedCh:
t.Fatalf("OpenBrowser called for %q, want suppressed", rawURL)
default:
}
if got := run.Stdout.(*bytes.Buffer).String(); !strings.Contains(got, "CSGClaw IM is available at: http://example.test/base/") {
t.Fatalf("stdout missing IM URL:\n%s", got)
}
}

func TestServeForegroundPrintsManualIMURLWhenBrowserNotAllowed(t *testing.T) {
restore := stubServeDependencies(t)
defer restore()
Expand Down
4 changes: 4 additions & 0 deletions internal/api/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"os"
"strconv"
"strings"
"sync"
"time"

"csgclaw/internal/agent"
Expand Down Expand Up @@ -54,6 +55,9 @@ type Handler struct {
localRuntimeImages func(context.Context, config.Config) ([]string, error)
notificationDeliver notification.Fanouter
activityDecider ActivityDecider

participantActivityTurnsMu sync.Mutex
participantActivityTurns map[string]participantActivityTurn
}

const createOperationTimeout = 10 * time.Minute
Expand Down
158 changes: 158 additions & 0 deletions internal/api/handler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3631,6 +3631,164 @@ func TestHandleBotSendMessageAcceptsPicoClawThreadContext(t *testing.T) {
}
}

func TestHandleParticipantSendMessageReplacementRefreshesThreadRootSummary(t *testing.T) {
now := time.Now().UTC()
imSvc := im.NewServiceFromBootstrap(im.Bootstrap{
CurrentUserID: "u-admin",
Users: []im.User{
{ID: "u-admin", Name: "admin", Handle: "admin"},
{ID: "manager", Name: "manager", Handle: "manager"},
},
Rooms: []im.Room{
{
ID: "room-1",
IsDirect: true,
Members: []string{"u-admin", "manager"},
Messages: []im.Message{{ID: "msg-user", SenderID: "u-admin", Content: "run it", CreatedAt: now}},
},
},
})
srv := &Handler{im: imSvc, participantBridge: im.NewParticipantBridge(""), serverNoAuth: true}
send := func(t *testing.T, body string) string {
t.Helper()
req := httptest.NewRequest(http.MethodPost, "/api/v1/channels/csgclaw/participants/manager/messages", strings.NewReader(body))
rec := httptest.NewRecorder()
srv.Routes().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
var sent struct {
MessageID string `json:"message_id"`
}
if err := json.NewDecoder(rec.Body).Decode(&sent); err != nil {
t.Fatalf("decode send response: %v", err)
}
return sent.MessageID
}

rootID := send(t, `{"room_id":"room-1","message_id":"assistant-turn-1","text":"\u200b"}`)
send(t, `{"room_id":"room-1","message_id":"assistant-turn-1-tool-1","thread_root_id":"assistant-turn-1","text":"tool activity"}`)
finalID := send(t, `{"room_id":"room-1","message_id":"assistant-turn-1","text":"final answer"}`)
if rootID != "assistant-turn-1" || finalID != rootID {
t.Fatalf("root/final message ids = %q / %q, want assistant-turn-1", rootID, finalID)
}

timeline, err := imSvc.ListMessages("room-1")
if err != nil {
t.Fatalf("ListMessages() error = %v", err)
}
var root im.Message
for _, message := range timeline {
if message.ID == "assistant-turn-1-tool-1" {
t.Fatalf("timeline = %+v, want tool reply hidden from top-level messages", timeline)
}
if message.ID == rootID {
root = message
}
}
if root.Content != "final answer" {
t.Fatalf("root.Content = %q, want final answer", root.Content)
}
if root.Thread == nil || root.Thread.Context.RootExcerpt != "final answer" || root.Thread.ReplyCount != 1 {
t.Fatalf("root.Thread = %+v, want refreshed summary with one reply", root.Thread)
}
}

func TestHandleParticipantSendMessageThreadsTopLevelToolCallsUnderFinalResponse(t *testing.T) {
for _, tc := range []struct {
name string
isDirect bool
}{
{name: "dm", isDirect: true},
{name: "room"},
} {
t.Run(tc.name, func(t *testing.T) {
now := time.Now().UTC()
imSvc := im.NewServiceFromBootstrap(im.Bootstrap{
CurrentUserID: "u-admin",
Users: []im.User{
{ID: "u-admin", Name: "admin", Handle: "admin"},
{ID: "manager", Name: "manager", Handle: "manager"},
},
Rooms: []im.Room{
{
ID: "room-1",
IsDirect: tc.isDirect,
Members: []string{"u-admin", "manager"},
Messages: []im.Message{{ID: "msg-user", SenderID: "u-admin", Content: "use some tools", CreatedAt: now}},
},
},
})
srv := &Handler{im: imSvc, participantBridge: im.NewParticipantBridge(""), serverNoAuth: true}
send := func(t *testing.T, body string) string {
t.Helper()
req := httptest.NewRequest(http.MethodPost, "/api/v1/channels/csgclaw/participants/manager/messages", strings.NewReader(body))
rec := httptest.NewRecorder()
srv.Routes().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
var sent struct {
MessageID string `json:"message_id"`
}
if err := json.NewDecoder(rec.Body).Decode(&sent); err != nil {
t.Fatalf("decode send response: %v", err)
}
return sent.MessageID
}

firstToolID := send(t, `{"room_id":"room-1","text":"🔧 `+"`list_dir`"+`\n`+"```"+`\n{\"path\":\"/workspace\"}\n`+"```"+`"}`)
secondToolID := send(t, `{"room_id":"room-1","text":"🔧 `+"`exec`"+`\n`+"```"+`\n{\"command\":\"pwd\"}\n`+"```"+`"}`)
finalID := send(t, `{"room_id":"room-1","text":"Used two tools."}`)
if finalID == "" || finalID == firstToolID || finalID == secondToolID {
t.Fatalf("message ids first=%q second=%q final=%q, want final root distinct from tool replies", firstToolID, secondToolID, finalID)
}

timeline, err := imSvc.ListMessages("room-1")
if err != nil {
t.Fatalf("ListMessages() error = %v", err)
}
var root im.Message
for _, message := range timeline {
if message.ID == firstToolID || message.ID == secondToolID {
t.Fatalf("timeline = %+v, want tool replies hidden from top-level messages", timeline)
}
if message.ID == finalID {
root = message
}
}
if root.ID == "" {
t.Fatalf("timeline = %+v, want final root %q", timeline, finalID)
}
if root.Content != "Used two tools." {
t.Fatalf("root.Content = %q, want final response", root.Content)
}
if root.Thread == nil || root.Thread.ReplyCount != 2 || root.Thread.Context.RootExcerpt != "Used two tools." {
t.Fatalf("root.Thread = %+v, want refreshed summary with two replies", root.Thread)
}

thread, err := imSvc.GetThread("room-1", root.ID)
if err != nil {
t.Fatalf("GetThread() error = %v", err)
}
if len(thread.Replies) != 2 {
t.Fatalf("thread replies = %+v, want two tool replies", thread.Replies)
}
if thread.Replies[0].ID != firstToolID || thread.Replies[1].ID != secondToolID {
t.Fatalf("reply ids = %q / %q, want %q / %q", thread.Replies[0].ID, thread.Replies[1].ID, firstToolID, secondToolID)
}
for _, reply := range thread.Replies {
if reply.RelatesTo == nil || reply.RelatesTo.RelType != im.RelationTypeThread || reply.RelatesTo.EventID != root.ID {
t.Fatalf("reply.RelatesTo = %+v, want m.thread -> %s", reply.RelatesTo, root.ID)
}
if !strings.HasPrefix(strings.TrimSpace(reply.Content), "🔧 ") {
t.Fatalf("reply.Content = %q, want legacy tool call", reply.Content)
}
}
})
}
}

func TestPublishParticipantEventQueuesUntilParticipantSubscribes(t *testing.T) {
now := time.Now().UTC()
imSvc := im.NewServiceFromBootstrap(im.Bootstrap{
Expand Down
Loading
Loading