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
4 changes: 2 additions & 2 deletions cmd/agents.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,11 +42,11 @@ var agentsListCmd = &cobra.Command{
return err
}
if cfg.OutputFormat != "table" {
printer.Print(unmarshalList(data))
printer.Print(unmarshalNamedList(data, "agents"))
return nil
}
tbl := output.NewTable("ID", "KEY", "NAME", "PROVIDER", "MODEL", "STATUS", "TYPE")
for _, a := range unmarshalList(data) {
for _, a := range unmarshalNamedList(data, "agents") {
tbl.AddRow(str(a, "id"), str(a, "agent_key"), str(a, "display_name"),
str(a, "provider"), str(a, "model"), str(a, "status"), str(a, "agent_type"))
}
Expand Down
41 changes: 41 additions & 0 deletions cmd/list_response_helpers.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package cmd

import "encoding/json"

// unmarshalNamedList handles endpoints that wrap arrays in an object envelope.
func unmarshalNamedList(data json.RawMessage, key string) []map[string]any {
if list := unmarshalList(data); list != nil {
return list
}
var envelope map[string]any
if err := json.Unmarshal(data, &envelope); err == nil {
return mapsFromAnyList(envelope[key])
}
return nil
}

func mapsFromAnyList(value any) []map[string]any {
switch list := value.(type) {
case []map[string]any:
return list
case []any:
out := make([]map[string]any, 0, len(list))
for _, item := range list {
if m, ok := item.(map[string]any); ok {
out = append(out, m)
}
}
return out
default:
return nil
}
}

func strFirst(m map[string]any, keys ...string) string {
for _, key := range keys {
if v := str(m, key); v != "" {
return v
}
}
return ""
}
20 changes: 13 additions & 7 deletions cmd/memory.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,11 @@ var memoryListCmd = &cobra.Command{
if err != nil {
return err
}
path := "/v1/memory/" + args[0]
path := "/v1/agents/" + url.PathEscape(args[0]) + "/memory/documents"
if v, _ := cmd.Flags().GetString("user"); v != "" {
path += "?user_id=" + v
q := url.Values{}
q.Set("user_id", v)
path += "?" + q.Encode()
}
data, err := c.Get(path)
if err != nil {
Expand Down Expand Up @@ -54,7 +56,7 @@ var memoryGetCmd = &cobra.Command{
if err != nil {
return err
}
data, err := c.Get("/v1/memory/" + url.PathEscape(args[0]) + "/" + url.PathEscape(args[1]))
data, err := c.Get(memoryDocumentPath(args[0], args[1]))
if err != nil {
return err
}
Expand All @@ -77,7 +79,7 @@ var memoryStoreCmd = &cobra.Command{
if err != nil {
return err
}
_, err = c.Put("/v1/memory/"+url.PathEscape(args[0])+"/"+url.PathEscape(args[1]),
_, err = c.Put(memoryDocumentPath(args[0], args[1]),
map[string]any{"content": content})
if err != nil {
return err
Expand All @@ -99,7 +101,7 @@ var memoryDeleteCmd = &cobra.Command{
if err != nil {
return err
}
_, err = c.Delete("/v1/memory/" + url.PathEscape(args[0]) + "/" + url.PathEscape(args[1]))
_, err = c.Delete(memoryDocumentPath(args[0], args[1]))
if err != nil {
return err
}
Expand All @@ -120,15 +122,19 @@ var memorySearchCmd = &cobra.Command{
query, _ := cmd.Flags().GetString("query")
user, _ := cmd.Flags().GetString("user")
body := buildBody("query", query, "user_id", user)
data, err := c.Post("/v1/memory/"+args[0]+"/search", body)
data, err := c.Post("/v1/agents/"+url.PathEscape(args[0])+"/memory/search", body)
if err != nil {
return err
}
printer.Print(unmarshalList(data))
printer.Print(unmarshalMap(data))
return nil
},
}

func memoryDocumentPath(agentID, path string) string {
return "/v1/agents/" + url.PathEscape(agentID) + "/memory/documents/" + url.PathEscape(path)
}

func init() {
memoryListCmd.Flags().String("user", "", "Filter by user ID")
memoryStoreCmd.Flags().String("content", "", "Content (or @filepath)")
Expand Down
189 changes: 187 additions & 2 deletions cmd/p4_ux_polish_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"io"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
Expand Down Expand Up @@ -155,6 +156,10 @@ func TestConfigDefaultsUsesWSMethod(t *testing.T) {
func TestToolsInvokeArgsReadsFile(t *testing.T) {
defer resetTestFlag(toolsInvokeCmd, "args", "")
defer resetTestFlag(toolsInvokeCmd, "param", "")
defer resetTestFlag(toolsInvokeCmd, "agent", "")
defer resetTestFlag(toolsInvokeCmd, "action", "")
defer resetTestFlag(toolsInvokeCmd, "session", "")
defer resetTestFlag(toolsInvokeCmd, "dry-run", "false")
var body map[string]any
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/tools/invoke" {
Expand All @@ -172,15 +177,195 @@ func TestToolsInvokeArgsReadsFile(t *testing.T) {
if err := os.WriteFile(path, []byte(`{"city":"Saigon"}`), 0o600); err != nil {
t.Fatalf("write args: %v", err)
}
if err := runCmd(t, "tools", "invoke", "weather", "--args=@"+path, "--param=unit=c"); err != nil {
if err := runCmd(t, "tools", "invoke", "weather", "--args=@"+path, "--param=unit=c",
"--agent=goclaw", "--action=forecast", "--session=sess-1", "--dry-run"); err != nil {
t.Fatalf("tools invoke: %v", err)
}
params := body["parameters"].(map[string]any)
if body["tool"] != "weather" {
t.Fatalf("tool = %#v", body["tool"])
}
if body["agentId"] != "goclaw" || body["action"] != "forecast" ||
body["sessionKey"] != "sess-1" || body["dryRun"] != true {
t.Fatalf("context fields = %#v", body)
}
params := body["args"].(map[string]any)
if params["city"] != "Saigon" || params["unit"] != "c" {
t.Fatalf("params = %#v", params)
}
}

func TestToolsCustomUnsupportedDoesNotCallServer(t *testing.T) {
requests := 0
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requests++
w.WriteHeader(http.StatusInternalServerError)
}))
defer srv.Close()
t.Setenv("GOCLAW_SERVER", srv.URL)
t.Setenv("GOCLAW_TOKEN", "test-token")

err := runCmd(t, "tools", "custom", "list")
if err == nil {
t.Fatal("expected unsupported custom tools error")
}
var detail *output.ErrorDetail
if !errors.As(err, &detail) || detail.Code != "INVALID_REQUEST" {
t.Fatalf("error = %#v, want INVALID_REQUEST detail", err)
}
if requests != 0 {
t.Fatalf("requests = %d, want 0", requests)
}
}

func TestUsageTimeseriesMapsFlagsToServerContract(t *testing.T) {
defer resetTestFlag(usageTimeseriesCmd, "start", "")
defer resetTestFlag(usageTimeseriesCmd, "end", "")
defer resetTestFlag(usageTimeseriesCmd, "granularity", "day")
defer resetTestFlag(usageTimeseriesCmd, "agent", "")
var query url.Values
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/usage/timeseries" {
w.WriteHeader(http.StatusNotFound)
return
}
query = r.URL.Query()
okJSON(t, w, map[string]any{"series": []map[string]any{}})
}))
defer srv.Close()
t.Setenv("GOCLAW_SERVER", srv.URL)
t.Setenv("GOCLAW_TOKEN", "test-token")

if err := runCmd(t, "usage", "timeseries", "--start=2026-05-20", "--end=2026-05-21", "--granularity=day", "--agent=agent-1"); err != nil {
t.Fatalf("usage timeseries: %v", err)
}
if query.Get("from") != "2026-05-20T00:00:00Z" || query.Get("to") != "2026-05-21T00:00:00Z" ||
query.Get("group_by") != "day" || query.Get("agent_id") != "agent-1" {
t.Fatalf("query = %#v", query)
}
if query.Has("start") || query.Has("end") || query.Has("granularity") || query.Has("agent") {
t.Fatalf("query contains legacy keys: %#v", query)
}
}

func TestUsageBreakdownMapsFlagsToServerContract(t *testing.T) {
defer resetTestFlag(usageBreakdownCmd, "start", "")
defer resetTestFlag(usageBreakdownCmd, "end", "")
defer resetTestFlag(usageBreakdownCmd, "by", "agent")
var query url.Values
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/usage/breakdown" {
w.WriteHeader(http.StatusNotFound)
return
}
query = r.URL.Query()
okJSON(t, w, map[string]any{"groups": []map[string]any{}})
}))
defer srv.Close()
t.Setenv("GOCLAW_SERVER", srv.URL)
t.Setenv("GOCLAW_TOKEN", "test-token")

if err := runCmd(t, "usage", "breakdown", "--start=2026-05-20", "--end=2026-05-21", "--by=provider"); err != nil {
t.Fatalf("usage breakdown: %v", err)
}
if query.Get("from") != "2026-05-20T00:00:00Z" || query.Get("to") != "2026-05-21T00:00:00Z" ||
query.Get("group_by") != "provider" {
t.Fatalf("query = %#v", query)
}
if query.Has("start") || query.Has("end") || query.Has("by") {
t.Fatalf("query contains legacy keys: %#v", query)
}
}

func TestMemoryCommandsUseAgentScopedHTTPRoutes(t *testing.T) {
defer resetTestFlag(memoryListCmd, "user", "")
defer resetTestFlag(memorySearchCmd, "query", "")
defer resetTestFlag(memorySearchCmd, "user", "")
var paths []string
var searchBody map[string]any
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
paths = append(paths, r.URL.String())
switch r.URL.Path {
case "/v1/agents/goclaw/memory/documents":
okJSON(t, w, []map[string]any{{"path": "notes.md"}})
case "/v1/agents/goclaw/memory/search":
_ = json.NewDecoder(r.Body).Decode(&searchBody)
okJSON(t, w, map[string]any{"results": []map[string]any{}, "count": 0})
default:
w.WriteHeader(http.StatusNotFound)
}
}))
defer srv.Close()
t.Setenv("GOCLAW_SERVER", srv.URL)
t.Setenv("GOCLAW_TOKEN", "test-token")

if err := runCmd(t, "memory", "list", "goclaw", "--user=system"); err != nil {
t.Fatalf("memory list: %v", err)
}
if err := runCmd(t, "memory", "search", "goclaw", "--query=test"); err != nil {
t.Fatalf("memory search: %v", err)
}
if len(paths) != 2 || paths[0] != "/v1/agents/goclaw/memory/documents?user_id=system" ||
paths[1] != "/v1/agents/goclaw/memory/search" {
t.Fatalf("paths = %#v", paths)
}
if searchBody["query"] != "test" {
t.Fatalf("search body = %#v", searchBody)
}
}

func TestListCommandsAcceptObjectWrappedLists(t *testing.T) {
var paths []string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
paths = append(paths, r.URL.Path)
switch r.URL.Path {
case "/v1/agents":
okJSON(t, w, map[string]any{"agents": []map[string]any{{"id": "agent-1"}}})
case "/v1/sessions":
okJSON(t, w, map[string]any{"sessions": []map[string]any{{"session_key": "sess-1"}}})
case "/v1/tools/builtin":
okJSON(t, w, map[string]any{"tools": []map[string]any{{"name": "sessions_list"}}})
default:
w.WriteHeader(http.StatusNotFound)
}
}))
defer srv.Close()
t.Setenv("GOCLAW_SERVER", srv.URL)
t.Setenv("GOCLAW_TOKEN", "test-token")
t.Setenv("GOCLAW_OUTPUT", "json")

agentsOut, err := captureStdout(t, func() error {
return runCmd(t, "agents", "list")
})
if err != nil {
t.Fatalf("agents list: %v", err)
}
sessionsOut, err := captureStdout(t, func() error {
return runCmd(t, "sessions", "list")
})
if err != nil {
t.Fatalf("sessions list: %v", err)
}
toolsOut, err := captureStdout(t, func() error {
return runCmd(t, "tools", "builtin", "list")
})
if err != nil {
t.Fatalf("tools builtin list: %v", err)
}
if strings.Contains(agentsOut, "null") || !strings.Contains(agentsOut, "agent-1") {
t.Fatalf("agents stdout = %q", agentsOut)
}
if strings.Contains(sessionsOut, "null") || !strings.Contains(sessionsOut, "sess-1") {
t.Fatalf("sessions stdout = %q", sessionsOut)
}
if strings.Contains(toolsOut, "null") || !strings.Contains(toolsOut, "sessions_list") {
t.Fatalf("tools stdout = %q", toolsOut)
}
if len(paths) != 3 || paths[0] != "/v1/agents" || paths[1] != "/v1/sessions" ||
paths[2] != "/v1/tools/builtin" {
t.Fatalf("paths = %#v", paths)
}
}

func TestChatReplayAndResumeUseExistingWSContracts(t *testing.T) {
defer resetTestFlag(chatReplayCmd, "session", "")
defer resetTestFlag(chatReplayCmd, "before", "")
Expand Down
9 changes: 5 additions & 4 deletions cmd/sessions.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,13 +41,14 @@ var sessionsListCmd = &cobra.Command{
return err
}
if cfg.OutputFormat != "table" {
printer.Print(unmarshalList(data))
printer.Print(unmarshalNamedList(data, "sessions"))
return nil
}
tbl := output.NewTable("KEY", "AGENT", "USER", "LABEL", "INPUT_TOKENS", "OUTPUT_TOKENS")
for _, s := range unmarshalList(data) {
tbl.AddRow(str(s, "session_key"), str(s, "agent_id"), str(s, "user_id"),
str(s, "label"), str(s, "input_tokens"), str(s, "output_tokens"))
for _, s := range unmarshalNamedList(data, "sessions") {
tbl.AddRow(strFirst(s, "session_key", "key"), strFirst(s, "agent_id", "agentID", "agentName"),
strFirst(s, "user_id", "userID"), str(s, "label"),
strFirst(s, "input_tokens", "inputTokens"), strFirst(s, "output_tokens", "outputTokens"))
}
printer.Print(tbl)
return nil
Expand Down
2 changes: 1 addition & 1 deletion cmd/tools.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ var toolsBuiltinListCmd = &cobra.Command{
if err != nil {
return err
}
printer.Print(unmarshalList(data))
printer.Print(unmarshalNamedList(data, "tools"))
return nil
},
}
Expand Down
Loading
Loading