Skip to content

Commit 143624d

Browse files
authored
fix(cli): align live API command contracts (#15)
1 parent ce74e5e commit 143624d

8 files changed

Lines changed: 310 additions & 132 deletions

File tree

cmd/agents.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,11 +42,11 @@ var agentsListCmd = &cobra.Command{
4242
return err
4343
}
4444
if cfg.OutputFormat != "table" {
45-
printer.Print(unmarshalList(data))
45+
printer.Print(unmarshalNamedList(data, "agents"))
4646
return nil
4747
}
4848
tbl := output.NewTable("ID", "KEY", "NAME", "PROVIDER", "MODEL", "STATUS", "TYPE")
49-
for _, a := range unmarshalList(data) {
49+
for _, a := range unmarshalNamedList(data, "agents") {
5050
tbl.AddRow(str(a, "id"), str(a, "agent_key"), str(a, "display_name"),
5151
str(a, "provider"), str(a, "model"), str(a, "status"), str(a, "agent_type"))
5252
}

cmd/list_response_helpers.go

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
package cmd
2+
3+
import "encoding/json"
4+
5+
// unmarshalNamedList handles endpoints that wrap arrays in an object envelope.
6+
func unmarshalNamedList(data json.RawMessage, key string) []map[string]any {
7+
if list := unmarshalList(data); list != nil {
8+
return list
9+
}
10+
var envelope map[string]any
11+
if err := json.Unmarshal(data, &envelope); err == nil {
12+
return mapsFromAnyList(envelope[key])
13+
}
14+
return nil
15+
}
16+
17+
func mapsFromAnyList(value any) []map[string]any {
18+
switch list := value.(type) {
19+
case []map[string]any:
20+
return list
21+
case []any:
22+
out := make([]map[string]any, 0, len(list))
23+
for _, item := range list {
24+
if m, ok := item.(map[string]any); ok {
25+
out = append(out, m)
26+
}
27+
}
28+
return out
29+
default:
30+
return nil
31+
}
32+
}
33+
34+
func strFirst(m map[string]any, keys ...string) string {
35+
for _, key := range keys {
36+
if v := str(m, key); v != "" {
37+
return v
38+
}
39+
}
40+
return ""
41+
}

cmd/memory.go

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,11 @@ var memoryListCmd = &cobra.Command{
2424
if err != nil {
2525
return err
2626
}
27-
path := "/v1/memory/" + args[0]
27+
path := "/v1/agents/" + url.PathEscape(args[0]) + "/memory/documents"
2828
if v, _ := cmd.Flags().GetString("user"); v != "" {
29-
path += "?user_id=" + v
29+
q := url.Values{}
30+
q.Set("user_id", v)
31+
path += "?" + q.Encode()
3032
}
3133
data, err := c.Get(path)
3234
if err != nil {
@@ -54,7 +56,7 @@ var memoryGetCmd = &cobra.Command{
5456
if err != nil {
5557
return err
5658
}
57-
data, err := c.Get("/v1/memory/" + url.PathEscape(args[0]) + "/" + url.PathEscape(args[1]))
59+
data, err := c.Get(memoryDocumentPath(args[0], args[1]))
5860
if err != nil {
5961
return err
6062
}
@@ -77,7 +79,7 @@ var memoryStoreCmd = &cobra.Command{
7779
if err != nil {
7880
return err
7981
}
80-
_, err = c.Put("/v1/memory/"+url.PathEscape(args[0])+"/"+url.PathEscape(args[1]),
82+
_, err = c.Put(memoryDocumentPath(args[0], args[1]),
8183
map[string]any{"content": content})
8284
if err != nil {
8385
return err
@@ -99,7 +101,7 @@ var memoryDeleteCmd = &cobra.Command{
99101
if err != nil {
100102
return err
101103
}
102-
_, err = c.Delete("/v1/memory/" + url.PathEscape(args[0]) + "/" + url.PathEscape(args[1]))
104+
_, err = c.Delete(memoryDocumentPath(args[0], args[1]))
103105
if err != nil {
104106
return err
105107
}
@@ -120,15 +122,19 @@ var memorySearchCmd = &cobra.Command{
120122
query, _ := cmd.Flags().GetString("query")
121123
user, _ := cmd.Flags().GetString("user")
122124
body := buildBody("query", query, "user_id", user)
123-
data, err := c.Post("/v1/memory/"+args[0]+"/search", body)
125+
data, err := c.Post("/v1/agents/"+url.PathEscape(args[0])+"/memory/search", body)
124126
if err != nil {
125127
return err
126128
}
127-
printer.Print(unmarshalList(data))
129+
printer.Print(unmarshalMap(data))
128130
return nil
129131
},
130132
}
131133

134+
func memoryDocumentPath(agentID, path string) string {
135+
return "/v1/agents/" + url.PathEscape(agentID) + "/memory/documents/" + url.PathEscape(path)
136+
}
137+
132138
func init() {
133139
memoryListCmd.Flags().String("user", "", "Filter by user ID")
134140
memoryStoreCmd.Flags().String("content", "", "Content (or @filepath)")

cmd/p4_ux_polish_test.go

Lines changed: 187 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"io"
77
"net/http"
88
"net/http/httptest"
9+
"net/url"
910
"os"
1011
"path/filepath"
1112
"strings"
@@ -155,6 +156,10 @@ func TestConfigDefaultsUsesWSMethod(t *testing.T) {
155156
func TestToolsInvokeArgsReadsFile(t *testing.T) {
156157
defer resetTestFlag(toolsInvokeCmd, "args", "")
157158
defer resetTestFlag(toolsInvokeCmd, "param", "")
159+
defer resetTestFlag(toolsInvokeCmd, "agent", "")
160+
defer resetTestFlag(toolsInvokeCmd, "action", "")
161+
defer resetTestFlag(toolsInvokeCmd, "session", "")
162+
defer resetTestFlag(toolsInvokeCmd, "dry-run", "false")
158163
var body map[string]any
159164
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
160165
if r.URL.Path != "/v1/tools/invoke" {
@@ -172,15 +177,195 @@ func TestToolsInvokeArgsReadsFile(t *testing.T) {
172177
if err := os.WriteFile(path, []byte(`{"city":"Saigon"}`), 0o600); err != nil {
173178
t.Fatalf("write args: %v", err)
174179
}
175-
if err := runCmd(t, "tools", "invoke", "weather", "--args=@"+path, "--param=unit=c"); err != nil {
180+
if err := runCmd(t, "tools", "invoke", "weather", "--args=@"+path, "--param=unit=c",
181+
"--agent=goclaw", "--action=forecast", "--session=sess-1", "--dry-run"); err != nil {
176182
t.Fatalf("tools invoke: %v", err)
177183
}
178-
params := body["parameters"].(map[string]any)
184+
if body["tool"] != "weather" {
185+
t.Fatalf("tool = %#v", body["tool"])
186+
}
187+
if body["agentId"] != "goclaw" || body["action"] != "forecast" ||
188+
body["sessionKey"] != "sess-1" || body["dryRun"] != true {
189+
t.Fatalf("context fields = %#v", body)
190+
}
191+
params := body["args"].(map[string]any)
179192
if params["city"] != "Saigon" || params["unit"] != "c" {
180193
t.Fatalf("params = %#v", params)
181194
}
182195
}
183196

197+
func TestToolsCustomUnsupportedDoesNotCallServer(t *testing.T) {
198+
requests := 0
199+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
200+
requests++
201+
w.WriteHeader(http.StatusInternalServerError)
202+
}))
203+
defer srv.Close()
204+
t.Setenv("GOCLAW_SERVER", srv.URL)
205+
t.Setenv("GOCLAW_TOKEN", "test-token")
206+
207+
err := runCmd(t, "tools", "custom", "list")
208+
if err == nil {
209+
t.Fatal("expected unsupported custom tools error")
210+
}
211+
var detail *output.ErrorDetail
212+
if !errors.As(err, &detail) || detail.Code != "INVALID_REQUEST" {
213+
t.Fatalf("error = %#v, want INVALID_REQUEST detail", err)
214+
}
215+
if requests != 0 {
216+
t.Fatalf("requests = %d, want 0", requests)
217+
}
218+
}
219+
220+
func TestUsageTimeseriesMapsFlagsToServerContract(t *testing.T) {
221+
defer resetTestFlag(usageTimeseriesCmd, "start", "")
222+
defer resetTestFlag(usageTimeseriesCmd, "end", "")
223+
defer resetTestFlag(usageTimeseriesCmd, "granularity", "day")
224+
defer resetTestFlag(usageTimeseriesCmd, "agent", "")
225+
var query url.Values
226+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
227+
if r.URL.Path != "/v1/usage/timeseries" {
228+
w.WriteHeader(http.StatusNotFound)
229+
return
230+
}
231+
query = r.URL.Query()
232+
okJSON(t, w, map[string]any{"series": []map[string]any{}})
233+
}))
234+
defer srv.Close()
235+
t.Setenv("GOCLAW_SERVER", srv.URL)
236+
t.Setenv("GOCLAW_TOKEN", "test-token")
237+
238+
if err := runCmd(t, "usage", "timeseries", "--start=2026-05-20", "--end=2026-05-21", "--granularity=day", "--agent=agent-1"); err != nil {
239+
t.Fatalf("usage timeseries: %v", err)
240+
}
241+
if query.Get("from") != "2026-05-20T00:00:00Z" || query.Get("to") != "2026-05-21T00:00:00Z" ||
242+
query.Get("group_by") != "day" || query.Get("agent_id") != "agent-1" {
243+
t.Fatalf("query = %#v", query)
244+
}
245+
if query.Has("start") || query.Has("end") || query.Has("granularity") || query.Has("agent") {
246+
t.Fatalf("query contains legacy keys: %#v", query)
247+
}
248+
}
249+
250+
func TestUsageBreakdownMapsFlagsToServerContract(t *testing.T) {
251+
defer resetTestFlag(usageBreakdownCmd, "start", "")
252+
defer resetTestFlag(usageBreakdownCmd, "end", "")
253+
defer resetTestFlag(usageBreakdownCmd, "by", "agent")
254+
var query url.Values
255+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
256+
if r.URL.Path != "/v1/usage/breakdown" {
257+
w.WriteHeader(http.StatusNotFound)
258+
return
259+
}
260+
query = r.URL.Query()
261+
okJSON(t, w, map[string]any{"groups": []map[string]any{}})
262+
}))
263+
defer srv.Close()
264+
t.Setenv("GOCLAW_SERVER", srv.URL)
265+
t.Setenv("GOCLAW_TOKEN", "test-token")
266+
267+
if err := runCmd(t, "usage", "breakdown", "--start=2026-05-20", "--end=2026-05-21", "--by=provider"); err != nil {
268+
t.Fatalf("usage breakdown: %v", err)
269+
}
270+
if query.Get("from") != "2026-05-20T00:00:00Z" || query.Get("to") != "2026-05-21T00:00:00Z" ||
271+
query.Get("group_by") != "provider" {
272+
t.Fatalf("query = %#v", query)
273+
}
274+
if query.Has("start") || query.Has("end") || query.Has("by") {
275+
t.Fatalf("query contains legacy keys: %#v", query)
276+
}
277+
}
278+
279+
func TestMemoryCommandsUseAgentScopedHTTPRoutes(t *testing.T) {
280+
defer resetTestFlag(memoryListCmd, "user", "")
281+
defer resetTestFlag(memorySearchCmd, "query", "")
282+
defer resetTestFlag(memorySearchCmd, "user", "")
283+
var paths []string
284+
var searchBody map[string]any
285+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
286+
paths = append(paths, r.URL.String())
287+
switch r.URL.Path {
288+
case "/v1/agents/goclaw/memory/documents":
289+
okJSON(t, w, []map[string]any{{"path": "notes.md"}})
290+
case "/v1/agents/goclaw/memory/search":
291+
_ = json.NewDecoder(r.Body).Decode(&searchBody)
292+
okJSON(t, w, map[string]any{"results": []map[string]any{}, "count": 0})
293+
default:
294+
w.WriteHeader(http.StatusNotFound)
295+
}
296+
}))
297+
defer srv.Close()
298+
t.Setenv("GOCLAW_SERVER", srv.URL)
299+
t.Setenv("GOCLAW_TOKEN", "test-token")
300+
301+
if err := runCmd(t, "memory", "list", "goclaw", "--user=system"); err != nil {
302+
t.Fatalf("memory list: %v", err)
303+
}
304+
if err := runCmd(t, "memory", "search", "goclaw", "--query=test"); err != nil {
305+
t.Fatalf("memory search: %v", err)
306+
}
307+
if len(paths) != 2 || paths[0] != "/v1/agents/goclaw/memory/documents?user_id=system" ||
308+
paths[1] != "/v1/agents/goclaw/memory/search" {
309+
t.Fatalf("paths = %#v", paths)
310+
}
311+
if searchBody["query"] != "test" {
312+
t.Fatalf("search body = %#v", searchBody)
313+
}
314+
}
315+
316+
func TestListCommandsAcceptObjectWrappedLists(t *testing.T) {
317+
var paths []string
318+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
319+
paths = append(paths, r.URL.Path)
320+
switch r.URL.Path {
321+
case "/v1/agents":
322+
okJSON(t, w, map[string]any{"agents": []map[string]any{{"id": "agent-1"}}})
323+
case "/v1/sessions":
324+
okJSON(t, w, map[string]any{"sessions": []map[string]any{{"session_key": "sess-1"}}})
325+
case "/v1/tools/builtin":
326+
okJSON(t, w, map[string]any{"tools": []map[string]any{{"name": "sessions_list"}}})
327+
default:
328+
w.WriteHeader(http.StatusNotFound)
329+
}
330+
}))
331+
defer srv.Close()
332+
t.Setenv("GOCLAW_SERVER", srv.URL)
333+
t.Setenv("GOCLAW_TOKEN", "test-token")
334+
t.Setenv("GOCLAW_OUTPUT", "json")
335+
336+
agentsOut, err := captureStdout(t, func() error {
337+
return runCmd(t, "agents", "list")
338+
})
339+
if err != nil {
340+
t.Fatalf("agents list: %v", err)
341+
}
342+
sessionsOut, err := captureStdout(t, func() error {
343+
return runCmd(t, "sessions", "list")
344+
})
345+
if err != nil {
346+
t.Fatalf("sessions list: %v", err)
347+
}
348+
toolsOut, err := captureStdout(t, func() error {
349+
return runCmd(t, "tools", "builtin", "list")
350+
})
351+
if err != nil {
352+
t.Fatalf("tools builtin list: %v", err)
353+
}
354+
if strings.Contains(agentsOut, "null") || !strings.Contains(agentsOut, "agent-1") {
355+
t.Fatalf("agents stdout = %q", agentsOut)
356+
}
357+
if strings.Contains(sessionsOut, "null") || !strings.Contains(sessionsOut, "sess-1") {
358+
t.Fatalf("sessions stdout = %q", sessionsOut)
359+
}
360+
if strings.Contains(toolsOut, "null") || !strings.Contains(toolsOut, "sessions_list") {
361+
t.Fatalf("tools stdout = %q", toolsOut)
362+
}
363+
if len(paths) != 3 || paths[0] != "/v1/agents" || paths[1] != "/v1/sessions" ||
364+
paths[2] != "/v1/tools/builtin" {
365+
t.Fatalf("paths = %#v", paths)
366+
}
367+
}
368+
184369
func TestChatReplayAndResumeUseExistingWSContracts(t *testing.T) {
185370
defer resetTestFlag(chatReplayCmd, "session", "")
186371
defer resetTestFlag(chatReplayCmd, "before", "")

cmd/sessions.go

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -41,13 +41,14 @@ var sessionsListCmd = &cobra.Command{
4141
return err
4242
}
4343
if cfg.OutputFormat != "table" {
44-
printer.Print(unmarshalList(data))
44+
printer.Print(unmarshalNamedList(data, "sessions"))
4545
return nil
4646
}
4747
tbl := output.NewTable("KEY", "AGENT", "USER", "LABEL", "INPUT_TOKENS", "OUTPUT_TOKENS")
48-
for _, s := range unmarshalList(data) {
49-
tbl.AddRow(str(s, "session_key"), str(s, "agent_id"), str(s, "user_id"),
50-
str(s, "label"), str(s, "input_tokens"), str(s, "output_tokens"))
48+
for _, s := range unmarshalNamedList(data, "sessions") {
49+
tbl.AddRow(strFirst(s, "session_key", "key"), strFirst(s, "agent_id", "agentID", "agentName"),
50+
strFirst(s, "user_id", "userID"), str(s, "label"),
51+
strFirst(s, "input_tokens", "inputTokens"), strFirst(s, "output_tokens", "outputTokens"))
5152
}
5253
printer.Print(tbl)
5354
return nil

cmd/tools.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ var toolsBuiltinListCmd = &cobra.Command{
2323
if err != nil {
2424
return err
2525
}
26-
printer.Print(unmarshalList(data))
26+
printer.Print(unmarshalNamedList(data, "tools"))
2727
return nil
2828
},
2929
}

0 commit comments

Comments
 (0)