Skip to content
Open
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
11 changes: 11 additions & 0 deletions .changesets/1792716065-457f42af.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
id: 1792716065-457f42af
features:
- core
- serverEvents
targets:
- cli
type: fix
bump: patch
description: show the request progress indicator while streaming-capable operations wait for their first response
author: TristanSpeakEasy
date: "2026-10-23"
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,17 @@ import (
// "Object"). The function uses reflection to call Next()/Value()/Err()/Close()
// on the stream field, handling both EventStream (Value() *T) and JsonLStream
// (Value() (T, error)) signatures.
func StreamResult(cmd *cobra.Command, res interface{}, streamFieldName string) error {
// stopProgress, when given, runs before anything is written: on the first
// event, at end of stream, or before falling back to a single JSON result.
func StreamResult(cmd *cobra.Command, res interface{}, streamFieldName string, stopProgress ...func()) error {
stop := func() {
for _, fn := range stopProgress {
if fn != nil {
fn()
}
}
}
defer stop()
if flagutil.DidDryRunRequest(cmd) {
return nil
}
Expand All @@ -43,16 +53,19 @@ func StreamResult(cmd *cobra.Command, res interface{}, streamFieldName string) e
}
if v.Kind() != reflect.Struct {
// Fallback: not a struct envelope — output as regular result
stop()
return Result(cmd, res)
}
streamVal := v.FieldByName(streamFieldName)
if !streamVal.IsValid() {
// Fallback: stream field not found — output as regular result
stop()
return Result(cmd, res)
}
// Guard IsNil: only call on nillable kinds. Fall back to Result() for
// mixed-content operations where non-stream content may still exist.
if isNillable(streamVal) && streamVal.IsNil() {
stop()
return Result(cmd, res)
}

Expand All @@ -64,6 +77,7 @@ func StreamResult(cmd *cobra.Command, res interface{}, streamFieldName string) e
rawMethod := streamVal.MethodByName("RawValue")
if !nextMethod.IsValid() || !valueMethod.IsValid() {
// Fallback: not a streaming type — output as regular result
stop()
return Result(cmd, res)
}

Expand Down Expand Up @@ -105,6 +119,7 @@ func StreamResult(cmd *cobra.Command, res interface{}, streamFieldName string) e

// Call Next() bool
nextResults := nextMethod.Call(nil)
stop()
if !nextResults[0].Bool() {
break
}
Expand Down
8 changes: 6 additions & 2 deletions templates/templates/cli/opcmd.go.stmpl
Original file line number Diff line number Diff line change
Expand Up @@ -342,11 +342,13 @@ func {{$runFuncName}}(cmd *cobra.Command, args []string) error {
// Streaming response — iterate events and output incrementally.
// Skip streaming iteration in dry-run mode (synthetic response has no stream).
if !client.IsDryRun(cmd) {
stopProgress := output.StartRequestProgress(cmd)
res, err := s{{.Local.SubSDKAccessor}}.{{sanitizeMethodName $op}}({{templateSDKCallArgs $op $reqObjName}})
if err != nil {
stopProgress()
return output.Error(cmd, err)
}
return output.StreamResult(cmd, res, "{{getStreamingFieldName $op}}")
return output.StreamResult(cmd, res, "{{getStreamingFieldName $op}}", stopProgress)
}
{{- end}}
{{- if not (operationHasDeferredBody $op)}}
Expand Down Expand Up @@ -496,11 +498,13 @@ func {{$executeFuncName}}(cmd *cobra.Command, args []string, asyncIntent bool) (
{{- end}}
{{- if hasStreamingResponse $op}}
if !client.IsDryRun(cmd) && !asyncIntent {
stopProgress := output.StartRequestProgress(cmd)
res, err := s{{.Local.SubSDKAccessor}}.{{sanitizeMethodName $op}}({{templateSDKCallArgs $op $reqObjName}})
if err != nil {
stopProgress()
return nil, output.Error(cmd, err)
}
if err := output.StreamResult(cmd, res, "{{getStreamingFieldName $op}}"); err != nil {
if err := output.StreamResult(cmd, res, "{{getStreamingFieldName $op}}", stopProgress); err != nil {
return nil, err
}
return nil, nil
Expand Down
53 changes: 53 additions & 0 deletions templates/templates/cli/request_progress_test.go.stmpl
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,59 @@ func TestRequestProgressContextCancellationClears(t *testing.T) {
require.True(t, strings.HasSuffix(got, "\r\x1b[2K"), "cancellation must leave the line cleared")
stop()
}
{{- if anyOperationHasStreaming}}

type progressTestStream struct {
items []string
next int
delay time.Duration
}

func (s *progressTestStream) Next() bool {
if s.next == 0 {
time.Sleep(s.delay)
}
if s.next >= len(s.items) {
return false
}
s.next++
return true
}

func (s *progressTestStream) Value() *string { return &s.items[s.next-1] }

func (s *progressTestStream) Close() error { return nil }

func TestRequestProgressStreamClearsBeforeFirstEvent(t *testing.T) {
cmd, master, _ := newProgressTestCommand(t, context.Background())
stop := output.StartRequestProgress(cmd)
stream := &progressTestStream{items: []string{"first-event"}, delay: 300 * time.Millisecond}
require.NoError(t, output.StreamResult(cmd, struct{ Stream *progressTestStream }{stream}, "Stream", stop))

got := readProgressOutput(master, 100*time.Millisecond)
require.Contains(t, got, "fixture request")
event := strings.Index(got, "first-event")
require.GreaterOrEqual(t, event, 0)
require.True(t, strings.HasSuffix(got[:event], "\r\x1b[2K"), "progress must be cleared immediately before the first event")
require.NotContains(t, got[event:], "fixture request")
}

func TestRequestProgressStreamFallbackClearsBeforeResult(t *testing.T) {
cmd, master, _ := newProgressTestCommand(t, context.Background())
stop := output.StartRequestProgress(cmd)
time.Sleep(300 * time.Millisecond)
require.NoError(t, output.StreamResult(cmd, struct {
Stream *progressTestStream
Value string
}{Value: "single-result"}, "Stream", stop))

got := readProgressOutput(master, 100*time.Millisecond)
require.Contains(t, got, "fixture request")
result := strings.Index(got, "single-result")
require.GreaterOrEqual(t, result, 0)
require.True(t, strings.HasSuffix(got[:result], "\r\x1b[2K"), "progress must be cleared immediately before the result")
}
{{- end}}

func TestRequestProgressSuppressionFlags(t *testing.T) {
for name, set := range map[string]func(*cobra.Command) error{
Expand Down
12 changes: 6 additions & 6 deletions zSDKs/sdk-cli/.speakeasy/gen.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion zSDKs/sdk-cli/internal/cli/chat.go
Original file line number Diff line number Diff line change
Expand Up @@ -112,11 +112,13 @@ func runChatCmd(cmd *cobra.Command, args []string) error {
// Streaming response — iterate events and output incrementally.
// Skip streaming iteration in dry-run mode (synthetic response has no stream).
if !client.IsDryRun(cmd) {
stopProgress := output.StartRequestProgress(cmd)
res, err := s.Chat(cmd.Context(), *request, sdkOpts...)
if err != nil {
stopProgress()
return output.Error(cmd, err)
}
return output.StreamResult(cmd, res, "ChatStream")
return output.StreamResult(cmd, res, "ChatStream", stopProgress)
}
res, err := s.Chat(cmd.Context(), *request, sdkOpts...)
if err != nil {
Expand Down
4 changes: 3 additions & 1 deletion zSDKs/sdk-cli/internal/cli/getasset.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,11 +71,13 @@ func runGetAssetCmd(cmd *cobra.Command, args []string) error {
// Streaming response — iterate events and output incrementally.
// Skip streaming iteration in dry-run mode (synthetic response has no stream).
if !client.IsDryRun(cmd) {
stopProgress := output.StartRequestProgress(cmd)
res, err := s.GetAsset(cmd.Context(), *req, sdkOpts...)
if err != nil {
stopProgress()
return output.Error(cmd, err)
}
return output.StreamResult(cmd, res, "AssetStatusStream")
return output.StreamResult(cmd, res, "AssetStatusStream", stopProgress)
}
res, err := s.GetAsset(cmd.Context(), *req, sdkOpts...)
if err != nil {
Expand Down
4 changes: 3 additions & 1 deletion zSDKs/sdk-cli/internal/cli/renderasset.go
Original file line number Diff line number Diff line change
Expand Up @@ -91,11 +91,13 @@ func executeRenderAssetCmd(cmd *cobra.Command, args []string, asyncIntent bool)
sdkOpts = append(sdkOpts, operations.WithSkipDeserialization())
}
if !client.IsDryRun(cmd) && !asyncIntent {
stopProgress := output.StartRequestProgress(cmd)
res, err := s.RenderAsset(cmd.Context(), *request, sdkOpts...)
if err != nil {
stopProgress()
return nil, output.Error(cmd, err)
}
if err := output.StreamResult(cmd, res, "AssetStream"); err != nil {
if err := output.StreamResult(cmd, res, "AssetStream", stopProgress); err != nil {
return nil, err
}
return nil, nil
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,11 +54,13 @@ func runStreamMetadataCmd(cmd *cobra.Command, args []string) error {
// Streaming response — iterate events and output incrementally.
// Skip streaming iteration in dry-run mode (synthetic response has no stream).
if !client.IsDryRun(cmd) {
stopProgress := output.StartRequestProgress(cmd)
res, err := s.StreamMetadata.StreamMetadata(cmd.Context(), sdkOpts...)
if err != nil {
stopProgress()
return output.Error(cmd, err)
}
return output.StreamResult(cmd, res, "Object")
return output.StreamResult(cmd, res, "Object", stopProgress)
}
res, err := s.StreamMetadata.StreamMetadata(cmd.Context(), sdkOpts...)
if err != nil {
Expand Down
17 changes: 16 additions & 1 deletion zSDKs/sdk-cli/internal/output/streaming.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,17 @@ import (
// "Object"). The function uses reflection to call Next()/Value()/Err()/Close()
// on the stream field, handling both EventStream (Value() *T) and JsonLStream
// (Value() (T, error)) signatures.
func StreamResult(cmd *cobra.Command, res interface{}, streamFieldName string) error {
// stopProgress, when given, runs before anything is written: on the first
// event, at end of stream, or before falling back to a single JSON result.
func StreamResult(cmd *cobra.Command, res interface{}, streamFieldName string, stopProgress ...func()) error {
stop := func() {
for _, fn := range stopProgress {
if fn != nil {
fn()
}
}
}
defer stop()
if flagutil.DidDryRunRequest(cmd) {
return nil
}
Expand All @@ -44,16 +54,19 @@ func StreamResult(cmd *cobra.Command, res interface{}, streamFieldName string) e
}
if v.Kind() != reflect.Struct {
// Fallback: not a struct envelope — output as regular result
stop()
return Result(cmd, res)
}
streamVal := v.FieldByName(streamFieldName)
if !streamVal.IsValid() {
// Fallback: stream field not found — output as regular result
stop()
return Result(cmd, res)
}
// Guard IsNil: only call on nillable kinds. Fall back to Result() for
// mixed-content operations where non-stream content may still exist.
if isNillable(streamVal) && streamVal.IsNil() {
stop()
return Result(cmd, res)
}

Expand All @@ -65,6 +78,7 @@ func StreamResult(cmd *cobra.Command, res interface{}, streamFieldName string) e
rawMethod := streamVal.MethodByName("RawValue")
if !nextMethod.IsValid() || !valueMethod.IsValid() {
// Fallback: not a streaming type — output as regular result
stop()
return Result(cmd, res)
}

Expand Down Expand Up @@ -106,6 +120,7 @@ func StreamResult(cmd *cobra.Command, res interface{}, streamFieldName string) e

// Call Next() bool
nextResults := nextMethod.Call(nil)
stop()
if !nextResults[0].Bool() {
break
}
Expand Down
51 changes: 51 additions & 0 deletions zSDKs/sdk-cli/tests/request_progress_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,57 @@ func TestRequestProgressContextCancellationClears(t *testing.T) {
stop()
}

type progressTestStream struct {
items []string
next int
delay time.Duration
}

func (s *progressTestStream) Next() bool {
if s.next == 0 {
time.Sleep(s.delay)
}
if s.next >= len(s.items) {
return false
}
s.next++
return true
}

func (s *progressTestStream) Value() *string { return &s.items[s.next-1] }

func (s *progressTestStream) Close() error { return nil }

func TestRequestProgressStreamClearsBeforeFirstEvent(t *testing.T) {
cmd, master, _ := newProgressTestCommand(t, context.Background())
stop := output.StartRequestProgress(cmd)
stream := &progressTestStream{items: []string{"first-event"}, delay: 300 * time.Millisecond}
require.NoError(t, output.StreamResult(cmd, struct{ Stream *progressTestStream }{stream}, "Stream", stop))

got := readProgressOutput(master, 100*time.Millisecond)
require.Contains(t, got, "fixture request")
event := strings.Index(got, "first-event")
require.GreaterOrEqual(t, event, 0)
require.True(t, strings.HasSuffix(got[:event], "\r\x1b[2K"), "progress must be cleared immediately before the first event")
require.NotContains(t, got[event:], "fixture request")
}

func TestRequestProgressStreamFallbackClearsBeforeResult(t *testing.T) {
cmd, master, _ := newProgressTestCommand(t, context.Background())
stop := output.StartRequestProgress(cmd)
time.Sleep(300 * time.Millisecond)
require.NoError(t, output.StreamResult(cmd, struct {
Stream *progressTestStream
Value string
}{Value: "single-result"}, "Stream", stop))

got := readProgressOutput(master, 100*time.Millisecond)
require.Contains(t, got, "fixture request")
result := strings.Index(got, "single-result")
require.GreaterOrEqual(t, result, 0)
require.True(t, strings.HasSuffix(got[:result], "\r\x1b[2K"), "progress must be cleared immediately before the result")
}

func TestRequestProgressSuppressionFlags(t *testing.T) {
for name, set := range map[string]func(*cobra.Command) error{

Expand Down
Loading