diff --git a/api/serverless/openapi.yaml b/api/serverless/openapi.yaml index 4db8a51..69874e6 100644 --- a/api/serverless/openapi.yaml +++ b/api/serverless/openapi.yaml @@ -4854,10 +4854,9 @@ components: endpointPath: $ref: '#/components/schemas/EndpointPath' output: - type: object - additionalProperties: true - nullable: true - description: Inference result payload on success; null otherwise. + description: > + Inference result payload on success; null otherwise. May be any JSON + value, including an object, array, string, number, boolean, or null. error: type: string nullable: true diff --git a/internal/api/serverless/gen/client.gen.go b/internal/api/serverless/gen/client.gen.go index a47bab7..e644d30 100644 --- a/internal/api/serverless/gen/client.gen.go +++ b/internal/api/serverless/gen/client.gen.go @@ -1551,9 +1551,9 @@ type Task struct { // Id Task identifier, supplied by the caller when the task was submitted. Id string `json:"id"` - // Output Inference result payload on success; null otherwise. - Output *map[string]interface{} `json:"output,omitempty"` - Status TaskStatus `json:"status"` + // Output Inference result payload on success; null otherwise. May be any JSON value, including an object, array, string, number, boolean, or null. + Output interface{} `json:"output,omitempty"` + Status TaskStatus `json:"status"` } // TaskId Client-generated task identifier, canonical lowercase UUID. One id is one task: resubmitting it is answered with the task it already names rather than starting a second, so a request whose response was lost can be sent again without paying for the work twice. Reusing an id for a different request returns the first task, so the id is the caller's to keep unique. diff --git a/internal/api/serverless/tasks_test.go b/internal/api/serverless/tasks_test.go index dc016e6..93c34a3 100644 --- a/internal/api/serverless/tasks_test.go +++ b/internal/api/serverless/tasks_test.go @@ -132,11 +132,42 @@ func TestInvokeSync_Completed(t *testing.T) { if err != nil { t.Fatalf("InvokeSync: %v", err) } - if task.Status != TaskStatusCompleted || task.Output == nil || (*task.Output)["ok"] != true { + output, ok := task.Output.(map[string]any) + if task.Status != TaskStatusCompleted || !ok || output["ok"] != true { t.Fatalf("unexpected task: %+v", task) } } +func TestInvokeAsync_CompletedDuplicateTaskAcceptsAnyJSONOutput(t *testing.T) { + tests := []struct { + name string + output string + }{ + {name: "scalar", output: `"done"`}, + {name: "list", output: `["done",1,false]`}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + body := `{"id":"` + testTaskID + `","status":"completed","appId":"my-app","endpointPath":"infer","createdAt":"2026-07-30T12:00:00Z","output":` + tc.output + `}` + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusAccepted) + _, _ = w.Write([]byte(body)) + })) + defer srv.Close() + + c := newClient("test-key", srv.URL, slog.Default(), srv.Client()) + task, err := c.InvokeAsync(context.Background(), testAppID, testEndpoint, testTaskID, nil) + if err != nil { + t.Fatalf("InvokeAsync: %v", err) + } + if task.Output == nil { + t.Fatal("expected output to be decoded") + } + }) + } +} + func TestInvokeAsync_GeneratesTaskID(t *testing.T) { var gotID string srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -275,6 +306,23 @@ func TestListTasks_CursorAndStatus(t *testing.T) { } } +func TestListTasks_AcceptsScalarOutput(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"data":[{"id":"` + testTaskID + `","status":"completed","appId":"my-app","endpointPath":"infer","createdAt":"2026-07-30T12:00:00Z","output":"done"}]}`)) + })) + defer srv.Close() + + c := newClient("test-key", srv.URL, slog.Default(), srv.Client()) + page, err := c.ListTasks(context.Background(), testAppID, nil) + if err != nil { + t.Fatalf("ListTasks: %v", err) + } + if len(page.Data) != 1 || page.Data[0].Output != "done" { + t.Fatalf("unexpected tasks: %+v", page.Data) + } +} + func TestListTasks_EmptyPageKeepsCursor(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") diff --git a/internal/cmd/serverless/apps_tasks_test.go b/internal/cmd/serverless/apps_tasks_test.go index 881e67e..262daa0 100644 --- a/internal/cmd/serverless/apps_tasks_test.go +++ b/internal/cmd/serverless/apps_tasks_test.go @@ -37,7 +37,7 @@ func TestTaskResult_IncludesOutputAndError(t *testing.T) { AppId: testAppID, Status: serverlessapi.TaskStatusFailed, Error: &errMsg, - Output: &output, + Output: output, CreatedAt: created, CompletedAt: &completed, } @@ -54,6 +54,17 @@ func TestTaskResult_IncludesOutputAndError(t *testing.T) { } } +func TestTaskResult_RendersScalarOutput(t *testing.T) { + r := taskResult{Output: "done"} + rows := r.Rows() + for _, row := range rows { + if row[0] == "Output" && row[1] == `"done"` { + return + } + } + t.Fatalf("scalar output missing from rows: %#v", rows) +} + func TestTasksResult_Headers(t *testing.T) { r := tasksResult{ {Id: testTaskID, Status: serverlessapi.TaskStatusPending, CreatedAt: time.Date(2026, 7, 30, 12, 0, 0, 0, time.UTC)}, diff --git a/internal/cmd/serverless/display.go b/internal/cmd/serverless/display.go index 838769b..e12cbf5 100644 --- a/internal/cmd/serverless/display.go +++ b/internal/cmd/serverless/display.go @@ -345,7 +345,7 @@ func (r taskResult) Rows() [][]any { {colError, formatOptionalString(r.Error)}, } if r.Output != nil { - rows = append(rows, []any{"Output", formatJSONValue(*r.Output)}) + rows = append(rows, []any{"Output", formatJSONValue(r.Output)}) } return rows }