@pippit-dev/cli 1.0.52 → 1.0.53

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -466,7 +466,11 @@ pippit-tool-cli query-result \
466
466
  --download-dir "./output"
467
467
  ```
468
468
 
469
- `query-result` 会查询指定 Run 并输出 JSON。Run 成功完成后下载视频和图片产物,`completed=true`、`status="succeeded"`,`videos` 和 `images` 中各包含 `download_url` 和 `output_path`;图片扩展名取自产物 `metadata.format`,缺省时兜底 `.png`。`status` 是机读状态,共 5 种取值:`running`(运行中,对应服务端已提交、处理中、生成中、程序中断等继续轮询的状态)、`succeeded`(成功)、`failed`(失败并填充 `error_message`)、`canceled`(已取消)、`unknown`(未指定、等待补充输入、人工交互中断等无法自动判定的状态)。`completed` 只表示是否到终态(成功、失败、已取消时为 true),判断成功失败请使用 `status`;查询或下载本身失败时 `status="unknown"`、`completed=false`,具体原因见 `error_message`。
469
+ `query-result` 查询指定 Run 并输出 JSON,成功后下载视频和图片。`status` 支持 `running`、`succeeded`、`failed`、`canceled`、`input_required`、`interrupted`、`unknown`。`completed` 仅表示终态:成功、失败、取消和人工交互中断结束(state=9)为 true;等待补充输入(state=6)为 `input_required`、false,需要停止自动轮询并等待交互。程序中断/等待工具回调(state=8)仍为 `running`。
470
+
471
+ 判断优先级:无论查询接口 `ret` 为多少,只要能解析指定 Run,就使用其 `state`;失败原因优先读取 `fail_reason.message/code`。没有目标 Run 时,仅按此接口已核实的返回识别终态:`ret=5` 且 `errmsg` 为 `创作失败` 或以 `创作失败:` 开头,返回 `failed`、true;`ret=5` 且 `errmsg` 为 `创作已取消`,返回 `canceled`、true。其他查询错误(包括 `ret=5` 的 `获取会话信息失败`、`ret=1/2`、鉴权/团队权限、网络及解析错误)返回 `unknown`、false 并保留错误与可用的 LogID,不推断任务失败。当前接口的 `ret=2` 是参数错误,不代表安审失败。未指定或未知 Run 状态保持 `unknown`。已解析 Run 的非零接口返回也记录到本地错误日志。
472
+
473
+ Run 已成功但产物缺失或下载失败时,保留 `succeeded`、true,在 `error_message` 中报告错误,并保留此前已成功下载的 `videos` / `images`。媒体项包含 `download_url` 和 `output_path`;图片扩展名取自 `metadata.format`,缺省为 `.png`。完整交付仍须检查错误及所有目标文件,不能只看 `completed=true`。
470
474
 
471
475
  ## Adapter 接入边界
472
476
 
package/checksums.txt CHANGED
@@ -1,6 +1,6 @@
1
- edd2d8640d4a34b156d8c614ef5186ce91a6ea8778aee7b208d41296a840ef3f pippit-tool-cli-1.0.52-darwin-amd64.tar.gz
2
- 7b4b992088057f012851cd5c396d05239ad5fd6782bf93c3a7f9f5ae494b81fd pippit-tool-cli-1.0.52-darwin-arm64.tar.gz
3
- aab287cc80ccab5f78f0f73df95d15b61d7e9e67205fb8b5e6386c22a35dc732 pippit-tool-cli-1.0.52-linux-amd64.tar.gz
4
- a9c86c8323d9f2a4859a0280095a7cac85cdf0e7fc59aa3bc758f99332bd387e pippit-tool-cli-1.0.52-linux-arm64.tar.gz
5
- a0846db0ff0f2c61337c85f50f3b746ad9a6bd1ed2862df16399ba6dcc4257e7 pippit-tool-cli-1.0.52-windows-amd64.zip
6
- 54dd5852b9597b3ad9405be0c63ebfa670b713c169f69649647b3afe5ed89fe1 pippit-tool-cli-1.0.52-windows-arm64.zip
1
+ 08e0f157a05bdcbbb4c7a53eeeee3374d5be4bf86ec31139c5d42328c8cc781e pippit-tool-cli-1.0.53-darwin-amd64.tar.gz
2
+ fa1b79ed862a997d7ec8117895158f1914d2fcaa30792b303fd924d2856a834f pippit-tool-cli-1.0.53-darwin-arm64.tar.gz
3
+ e0d30c4edf201108587da2bd1ce97f9e7a30721e41700c50e7ddb57cc4c2474a pippit-tool-cli-1.0.53-linux-amd64.tar.gz
4
+ b60c817d9b58659838baf9954e5cc110ddc92ed2736e5512047dc2b20a9dfde8 pippit-tool-cli-1.0.53-linux-arm64.tar.gz
5
+ b890470552424b37fbd98d8a0e202d66526f7f575233cfb860599dadbc802be2 pippit-tool-cli-1.0.53-windows-amd64.zip
6
+ cf549f5b6e7d929d7b8b48b0a1a3df467ad921c23b4a93361799d7723b023bde pippit-tool-cli-1.0.53-windows-arm64.zip
@@ -26,7 +26,11 @@ func NewQueryResultCommand(stdout, stderr io.Writer, runner *common.Runner) *cob
26
26
  "run_id": strings.TrimSpace(opts.RunID),
27
27
  "download_dir": strings.TrimSpace(opts.DownloadDir),
28
28
  })
29
- result = internalgen.NewQueryResultError(err, opts)
29
+ if result == nil {
30
+ result = internalgen.NewQueryResultError(err, opts)
31
+ } else if result.ErrorMessage == "" {
32
+ result.ErrorMessage = err.Error()
33
+ }
30
34
  }
31
35
  encoder := json.NewEncoder(stdout)
32
36
  encoder.SetIndent("", " ")
@@ -434,98 +434,103 @@ func TestGenerateVideoErrorLogIncludesLogID(t *testing.T) {
434
434
  }
435
435
 
436
436
  func TestQueryResultDownloadsCompletedVideo(t *testing.T) {
437
- var requestedDownload bool
438
- server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
439
- if r.Header.Get("Authorization") != "Bearer test-token" {
440
- t.Fatalf("Authorization = %q, want test bearer token", r.Header.Get("Authorization"))
441
- }
442
- switch r.URL.Path {
443
- case "/api/biz/v1/skill/get_thread":
444
- if r.Method != http.MethodPost {
445
- t.Fatalf("get_thread method = %s, want POST", r.Method)
437
+ for _, ret := range []string{"0", "1", "2", "5", "1015", "16019", "987654"} {
438
+ t.Run("ret="+ret, func(t *testing.T) {
439
+ var requestedDownload bool
440
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
441
+ if r.Header.Get("Authorization") != "Bearer test-token" {
442
+ t.Fatalf("Authorization = %q, want test bearer token", r.Header.Get("Authorization"))
443
+ }
444
+ switch r.URL.Path {
445
+ case "/api/biz/v1/skill/get_thread":
446
+ if r.Method != http.MethodPost {
447
+ t.Fatalf("get_thread method = %s, want POST", r.Method)
448
+ }
449
+ data, err := io.ReadAll(r.Body)
450
+ if err != nil {
451
+ t.Fatalf("read body: %v", err)
452
+ }
453
+ var body map[string]any
454
+ if err := sonic.Unmarshal(data, &body); err != nil {
455
+ t.Fatalf("decode body: %v", err)
456
+ }
457
+ if body["thread_id"] != "thread_123" {
458
+ t.Fatalf("thread_id = %v, want thread_123", body["thread_id"])
459
+ }
460
+ if body["run_id"] != "run_456" {
461
+ t.Fatalf("run_id = %v, want run_456", body["run_id"])
462
+ }
463
+ if _, ok := body["version"]; ok {
464
+ t.Fatalf("version = %v, want omitted", body["version"])
465
+ }
466
+ _, _ = w.Write([]byte(`{"ret":"` + ret + `","errmsg":"","data":{"thread":{"thread_id":"thread_123","run_list":[{"run_id":"run_456","state":3,"entry_list":[{"artifact":{"content":[{"sub_type":"biz/x_data_prompt_text","data":"做个小猫视频"},{"sub_type":"biz/x_data_video","data":"{\"video\":{\"download_url\":\"` + serverURL(r) + `/video.mp4\",\"title\":\"cat_video\",\"vid\":\"cat_vid\"}}"}]}}]}]}}}`))
467
+ case "/video.mp4":
468
+ requestedDownload = true
469
+ if r.Method != http.MethodGet {
470
+ t.Fatalf("download method = %s, want GET", r.Method)
471
+ }
472
+ _, _ = w.Write([]byte("video-data"))
473
+ default:
474
+ t.Fatalf("unexpected path %s", r.URL.Path)
475
+ }
476
+ }))
477
+ defer server.Close()
478
+
479
+ downloadDir := filepath.Join(t.TempDir(), "downloads")
480
+ var stdout, stderr bytes.Buffer
481
+ root := newTestRootCommand(t, &stdout, &stderr, server.URL)
482
+ root.SetArgs([]string{
483
+ "query-result",
484
+ "--thread-id", "thread_123",
485
+ "--run-id", "run_456",
486
+ "--download-dir", downloadDir,
487
+ })
488
+
489
+ if err := root.Execute(); err != nil {
490
+ t.Fatalf("Execute() error = %v, stderr = %s", err, stderr.String())
446
491
  }
447
- data, err := io.ReadAll(r.Body)
448
- if err != nil {
449
- t.Fatalf("read body: %v", err)
492
+
493
+ outputPath := filepath.Join(downloadDir, "cat_vid.mp4")
494
+ downloadURL := server.URL + "/video.mp4"
495
+ if !requestedDownload {
496
+ t.Fatal("download endpoint was not requested")
450
497
  }
451
- var body map[string]any
452
- if err := sonic.Unmarshal(data, &body); err != nil {
453
- t.Fatalf("decode body: %v", err)
498
+ got := decodeJSON(t, stdout.Bytes())
499
+ if got["completed"] != true {
500
+ t.Fatalf("completed = %v, want true", got["completed"])
454
501
  }
455
- if body["thread_id"] != "thread_123" {
456
- t.Fatalf("thread_id = %v, want thread_123", body["thread_id"])
502
+ if got["status"] != "succeeded" {
503
+ t.Fatalf("status = %v, want succeeded", got["status"])
457
504
  }
458
- if body["run_id"] != "run_456" {
459
- t.Fatalf("run_id = %v, want run_456", body["run_id"])
505
+ if got["thread_id"] != "thread_123" || got["run_id"] != "run_456" {
506
+ t.Fatalf("ids = (%v, %v), want thread/run ids", got["thread_id"], got["run_id"])
460
507
  }
461
- if _, ok := body["version"]; ok {
462
- t.Fatalf("version = %v, want omitted", body["version"])
508
+ if _, ok := got["state"]; ok {
509
+ t.Fatalf("state should not be returned: %#v", got)
463
510
  }
464
- _, _ = w.Write([]byte(`{"ret":"0","errmsg":"","data":{"thread":{"thread_id":"thread_123","run_list":[{"run_id":"run_456","state":3,"entry_list":[{"artifact":{"content":[{"sub_type":"biz/x_data_prompt_text","data":"做个小猫视频"},{"sub_type":"biz/x_data_video","data":"{\"video\":{\"download_url\":\"` + serverURL(r) + `/video.mp4\",\"title\":\"cat_video\",\"vid\":\"cat_vid\"}}"}]}}]}]}}}`))
465
- case "/video.mp4":
466
- requestedDownload = true
467
- if r.Method != http.MethodGet {
468
- t.Fatalf("download method = %s, want GET", r.Method)
511
+ if got["error_message"] != "" {
512
+ t.Fatalf("error_message = %v, want empty", got["error_message"])
469
513
  }
470
- _, _ = w.Write([]byte("video-data"))
471
- default:
472
- t.Fatalf("unexpected path %s", r.URL.Path)
473
- }
474
- }))
475
- defer server.Close()
476
-
477
- downloadDir := filepath.Join(t.TempDir(), "downloads")
478
- var stdout, stderr bytes.Buffer
479
- root := newTestRootCommand(t, &stdout, &stderr, server.URL)
480
- root.SetArgs([]string{
481
- "query-result",
482
- "--thread-id", "thread_123",
483
- "--run-id", "run_456",
484
- "--download-dir", downloadDir,
485
- })
486
-
487
- if err := root.Execute(); err != nil {
488
- t.Fatalf("Execute() error = %v, stderr = %s", err, stderr.String())
489
- }
514
+ videos, ok := got["videos"].([]any)
515
+ if !ok || len(videos) != 1 {
516
+ t.Fatalf("videos = %#v, want one video", got["videos"])
517
+ }
518
+ video, ok := videos[0].(map[string]any)
519
+ if !ok {
520
+ t.Fatalf("video = %#v, want object", videos[0])
521
+ }
522
+ if video["download_url"] != downloadURL || video["output_path"] != outputPath {
523
+ t.Fatalf("video = %#v, want download_url/output_path", video)
524
+ }
525
+ for _, unwanted := range []string{"vid", "asset_id", "title", "draft", "draft_task_id"} {
526
+ if _, ok := video[unwanted]; ok {
527
+ t.Fatalf("video = %#v, should not contain %s", video, unwanted)
528
+ }
529
+ }
530
+ assertFileContent(t, outputPath, "video-data")
490
531
 
491
- outputPath := filepath.Join(downloadDir, "cat_vid.mp4")
492
- downloadURL := server.URL + "/video.mp4"
493
- if !requestedDownload {
494
- t.Fatal("download endpoint was not requested")
495
- }
496
- got := decodeJSON(t, stdout.Bytes())
497
- if got["completed"] != true {
498
- t.Fatalf("completed = %v, want true", got["completed"])
499
- }
500
- if got["status"] != "succeeded" {
501
- t.Fatalf("status = %v, want succeeded", got["status"])
502
- }
503
- if got["thread_id"] != "thread_123" || got["run_id"] != "run_456" {
504
- t.Fatalf("ids = (%v, %v), want thread/run ids", got["thread_id"], got["run_id"])
505
- }
506
- if _, ok := got["state"]; ok {
507
- t.Fatalf("state should not be returned: %#v", got)
508
- }
509
- if got["error_message"] != "" {
510
- t.Fatalf("error_message = %v, want empty", got["error_message"])
511
- }
512
- videos, ok := got["videos"].([]any)
513
- if !ok || len(videos) != 1 {
514
- t.Fatalf("videos = %#v, want one video", got["videos"])
515
- }
516
- video, ok := videos[0].(map[string]any)
517
- if !ok {
518
- t.Fatalf("video = %#v, want object", videos[0])
519
- }
520
- if video["download_url"] != downloadURL || video["output_path"] != outputPath {
521
- t.Fatalf("video = %#v, want download_url/output_path", video)
522
- }
523
- for _, unwanted := range []string{"vid", "asset_id", "title", "draft", "draft_task_id"} {
524
- if _, ok := video[unwanted]; ok {
525
- t.Fatalf("video = %#v, should not contain %s", video, unwanted)
526
- }
532
+ })
527
533
  }
528
- assertFileContent(t, outputPath, "video-data")
529
534
  }
530
535
 
531
536
  func TestQueryResultPreservesDraftMetadata(t *testing.T) {
@@ -601,11 +606,11 @@ func TestQueryResultIgnoresVideoDataWithoutVideoSubType(t *testing.T) {
601
606
  t.Fatalf("Execute() error = %v, stderr = %s", err, stderr.String())
602
607
  }
603
608
  got := decodeJSON(t, stdout.Bytes())
604
- if got["completed"] != false {
605
- t.Fatalf("completed = %v, want false", got["completed"])
609
+ if got["completed"] != true {
610
+ t.Fatalf("completed = %v, want true for a succeeded Run", got["completed"])
606
611
  }
607
- if got["status"] != "unknown" {
608
- t.Fatalf("status = %v, want unknown", got["status"])
612
+ if got["status"] != "succeeded" {
613
+ t.Fatalf("status = %v, want succeeded despite missing downloadable artifacts", got["status"])
609
614
  }
610
615
  if got["thread_id"] != "thread_123" || got["run_id"] != "run_456" {
611
616
  t.Fatalf("ids = (%v, %v), want thread/run ids", got["thread_id"], got["run_id"])
@@ -674,14 +679,14 @@ func TestQueryResultGetThreadBusinessErrorReturnsErrorMessage(t *testing.T) {
674
679
  t.Fatalf("Execute() error = %v, stderr = %s", err, stderr.String())
675
680
  }
676
681
  got := decodeJSON(t, stdout.Bytes())
677
- if got["completed"] != false {
678
- t.Fatalf("completed = %v, want false", got["completed"])
682
+ if got["completed"] != true {
683
+ t.Fatalf("completed = %v, want true", got["completed"])
679
684
  }
680
685
  if got["thread_id"] != "thread_123" || got["run_id"] != "run_456" {
681
686
  t.Fatalf("ids = (%v, %v), want thread/run ids", got["thread_id"], got["run_id"])
682
687
  }
683
- if got["status"] != "unknown" {
684
- t.Fatalf("status = %v, want unknown", got["status"])
688
+ if got["status"] != "failed" {
689
+ t.Fatalf("status = %v, want failed", got["status"])
685
690
  }
686
691
  if got["error_message"] != "创作失败:暂时无法生成 log_id=log_456" {
687
692
  t.Fatalf("error_message = %v, want get_thread business error", got["error_message"])
@@ -692,6 +697,46 @@ func TestQueryResultGetThreadBusinessErrorReturnsErrorMessage(t *testing.T) {
692
697
  }
693
698
  }
694
699
 
700
+ func TestQueryResultUsesRunDespiteBusinessError(t *testing.T) {
701
+ for _, tt := range []struct {
702
+ name, data, status, message string
703
+ completed bool
704
+ }{
705
+ {"structured failure", `{"thread":{"thread_id":"thread_123","run_list":[{"run_id":"run_456","state":4,"error_message":"Run 失败"}]}}`, "failed", "Run 失败", true},
706
+ {"envelope fallback", `{"thread":{"thread_id":"thread_123","run_list":[{"run_id":"run_456","state":4}]}}`, "failed", "模拟接口错误 log_id=fixture-log", true},
707
+ {"succeeded without artifacts", `{"thread":{"thread_id":"thread_123","run_list":[{"run_id":"run_456","state":3}]}}`, "succeeded", "下载失败:未找到可下载的产物", true},
708
+ {"canceled", `{"thread":{"thread_id":"thread_123","run_list":[{"run_id":"run_456","state":5}]}}`, "canceled", "", true},
709
+ {"running", `{"thread":{"thread_id":"thread_123","run_list":[{"run_id":"run_456","state":2}]}}`, "running", "", false},
710
+ {"different run", `{"thread":{"thread_id":"thread_123","run_list":[{"run_id":"other","state":4}]}}`, "unknown", "模拟接口错误 log_id=fixture-log", false},
711
+ {"missing thread", `{}`, "unknown", "模拟接口错误 log_id=fixture-log", false},
712
+ {"null data", `null`, "unknown", "模拟接口错误 log_id=fixture-log", false},
713
+ {"malformed run", `{"thread":{"run_list":"invalid"}}`, "unknown", "模拟接口错误 log_id=fixture-log", false},
714
+ } {
715
+ t.Run(tt.name, func(t *testing.T) {
716
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
717
+ if r.URL.Path != "/api/biz/v1/skill/get_thread" {
718
+ t.Fatalf("unexpected path %s", r.URL.Path)
719
+ }
720
+ fmt.Fprintf(w, `{"ret":"2","errmsg":"模拟接口错误","log_id":"fixture-log","data":%s}`, tt.data)
721
+ }))
722
+ defer server.Close()
723
+ var stdout, stderr bytes.Buffer
724
+ root := newTestRootCommand(t, &stdout, &stderr, server.URL)
725
+ root.SetArgs([]string{"query-result", "--thread-id", "thread_123", "--run-id", "run_456", "--download-dir", t.TempDir()})
726
+ if err := root.Execute(); err != nil {
727
+ t.Fatalf("Execute() error = %v, stderr = %s", err, stderr.String())
728
+ }
729
+ got := decodeJSON(t, stdout.Bytes())
730
+ if got["status"] != tt.status || got["completed"] != tt.completed || got["error_message"] != tt.message {
731
+ t.Fatalf("output = %#v, want status=%s completed=%v error_message=%q", got, tt.status, tt.completed, tt.message)
732
+ }
733
+ if got["thread_id"] != "thread_123" || got["run_id"] != "run_456" {
734
+ t.Fatalf("output = %#v, want requested thread/run IDs", got)
735
+ }
736
+ })
737
+ }
738
+ }
739
+
695
740
  func TestQueryResultFailedReturnsErrorMessage(t *testing.T) {
696
741
  server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
697
742
  switch r.URL.Path {
@@ -789,10 +834,10 @@ func TestQueryResultRunStateMapping(t *testing.T) {
789
834
  {2, "running", false},
790
835
  {4, "failed", true},
791
836
  {5, "canceled", true},
792
- {6, "unknown", false},
837
+ {6, "input_required", false},
793
838
  {7, "running", false},
794
839
  {8, "running", false},
795
- {9, "unknown", false},
840
+ {9, "interrupted", true},
796
841
  {99, "unknown", false},
797
842
  }
798
843
  for _, tt := range tests {
@@ -0,0 +1,265 @@
1
+ package cmd
2
+
3
+ import (
4
+ "bytes"
5
+ "encoding/json"
6
+ "fmt"
7
+ "net/http"
8
+ "net/http/httptest"
9
+ "os"
10
+ "path/filepath"
11
+ "strings"
12
+ "testing"
13
+ )
14
+
15
+ // This matrix follows the legacy skill query endpoint, including its middleware.
16
+ // Business error envelopes deliberately omit Run, as the real handler does.
17
+ func TestQueryResultResponseBranches(t *testing.T) {
18
+ tests := []struct {
19
+ name, ret, message, status string
20
+ httpStatus int
21
+ }{
22
+ {"empty thread id", "2", "thread id不能为空", "unknown", 200},
23
+ {"invalid version", "2", "version参数无效", "unknown", 200},
24
+ {"empty run id", "2", "run id不能为空", "unknown", 200},
25
+ {"invalid AK owner info", "2", "无法根据此Ak找到有效用户信息", "unknown", 200},
26
+ {"invalid OAuth user info", "2", "无法获取有效用户信息", "unknown", 200},
27
+ {"RPC or DB error", "5", "获取会话信息失败", "unknown", 200},
28
+ {"no runs", "1", "会话中缺少有效的Run", "unknown", 200},
29
+ {"run not found", "2", "会话中未找到RunID为run_456的Run", "unknown", 200},
30
+ {"run copy failed", "1", "解析Run信息失败", "unknown", 200},
31
+ {"failed without reason", "5", "创作失败", "failed", 200},
32
+ {"failed with reason", "5", "创作失败:视频可能不合规,请修改后重试", "failed", 200},
33
+ {"failed with empty reason", "5", "创作失败:", "failed", 200},
34
+ {"canceled", "5", "创作已取消", "canceled", 200},
35
+ {"bad bearer or AK", "1015", "invalid access key", "unknown", 401},
36
+ {"bad owner", "1015", "invalid access key owner", "unknown", 401},
37
+ {"bad team id", "1015", "invalid team id", "unknown", 401},
38
+ {"team login missing", "1015", "login required for team access", "unknown", 401},
39
+ {"permission RPC failure", "1015", "team permission check failed", "unknown", 401},
40
+ {"permission RPC rejection", "1015", "team permission denied", "unknown", 401},
41
+ {"team denied", "16019", "team permission denied", "unknown", 200},
42
+ {"member inactive", "16020", "team member is inactive", "unknown", 200},
43
+ {"role denied", "16021", "team role is not allowed", "unknown", 200},
44
+ {"team missing", "16022", "team not found", "unknown", 200},
45
+ {"team unavailable", "16023", "team is unavailable", "unknown", 200},
46
+ {"subscription inactive", "16024", "team subscription is inactive", "unknown", 200},
47
+ {"member limit", "16025", "team member limit exceeded", "unknown", 200},
48
+ {"product identity invalid", "2", "missing or unsupported business app id", "unknown", 400},
49
+ {"product identity unavailable", "5", "business app identity unavailable", "unknown", 503},
50
+ {"request binding error", "-3", "bad request", "unknown", 200},
51
+ {"panic", "-2", "system panic", "unknown", 500},
52
+ {"unknown future ret", "987654", "new server error", "unknown", 200},
53
+ {"wrong ret with failure phrase", "2", "创作失败:原因", "unknown", 200},
54
+ {"wrong ret with cancellation phrase", "1", "创作已取消", "unknown", 200},
55
+ {"unrecognized ret with failure phrase", "987654", "创作失败", "unknown", 200},
56
+ {"zero ret without run", "0", "创作失败", "unknown", 200},
57
+ {"contains failure word", "5", "查询创作失败记录失败", "unknown", 200},
58
+ {"only moderation wording", "5", "视频可能不合规", "unknown", 200},
59
+ {"wrong failure delimiter", "5", "创作失败: reason", "unknown", 200},
60
+ {"cancellation suffix", "5", "创作已取消查询失败", "unknown", 200},
61
+ }
62
+ for _, tt := range tests {
63
+ t.Run(tt.name, func(t *testing.T) {
64
+ body, err := json.Marshal(map[string]any{"ret": tt.ret, "errmsg": tt.message, "log_id": "branch-log", "data": nil})
65
+ if err != nil {
66
+ t.Fatal(err)
67
+ }
68
+ got := executeQueryResponse(t, tt.httpStatus, string(body))
69
+ completed := tt.status == "failed" || tt.status == "canceled"
70
+ if got["status"] != tt.status || got["completed"] != completed {
71
+ t.Fatalf("got %#v, want %s completed=%t", got, tt.status, completed)
72
+ }
73
+ message, _ := got["error_message"].(string)
74
+ if tt.ret != "0" && (!strings.Contains(message, tt.message) || !strings.Contains(message, "branch-log")) {
75
+ t.Fatalf("original error/LogID lost: %q", message)
76
+ }
77
+ if message == "" {
78
+ t.Fatalf("missing error: %#v", got)
79
+ }
80
+ })
81
+ }
82
+ }
83
+
84
+ func executeQueryResponse(t *testing.T, status int, body string) map[string]any {
85
+ t.Helper()
86
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
87
+ if r.URL.Path != "/api/biz/v1/skill/get_thread" {
88
+ t.Errorf("unexpected download/request: %s", r.URL.Path)
89
+ }
90
+ w.WriteHeader(status)
91
+ _, _ = w.Write([]byte(body))
92
+ }))
93
+ defer server.Close()
94
+ return executeQueryURL(t, server.URL, t.TempDir())
95
+ }
96
+
97
+ func executeQueryURL(t *testing.T, url, dir string) map[string]any {
98
+ t.Helper()
99
+ var stdout, stderr bytes.Buffer
100
+ root := newTestRootCommand(t, &stdout, &stderr, url)
101
+ root.SetArgs([]string{"query-result", "--thread-id", "thread_123", "--run-id", "run_456", "--download-dir", dir})
102
+ if err := root.Execute(); err != nil {
103
+ t.Fatalf("Execute: %v, stderr=%s", err, stderr.String())
104
+ }
105
+ got := decodeJSON(t, stdout.Bytes())
106
+ if got["thread_id"] != "thread_123" || got["run_id"] != "run_456" {
107
+ t.Fatalf("requested IDs lost: %#v", got)
108
+ }
109
+ for _, key := range []string{"videos", "images"} {
110
+ if _, ok := got[key].([]any); !ok {
111
+ t.Fatalf("%s is not an array: %#v", key, got)
112
+ }
113
+ }
114
+ return got
115
+ }
116
+
117
+ func TestQueryResultRecordedFailureResponse(t *testing.T) {
118
+ // Recorded production response; only diagnostic identifiers are sanitized.
119
+ data, err := os.ReadFile("testdata/query-result/recorded-failure.json")
120
+ if err != nil {
121
+ t.Fatal(err)
122
+ }
123
+ got := executeQueryResponse(t, 200, string(data))
124
+ if got["status"] != "failed" || got["completed"] != true || got["error_message"] != "创作失败:视频可能不合规,请修改后重试 log_id=recorded-failure-log" {
125
+ t.Fatalf("recorded failure was not recognized: %#v", got)
126
+ }
127
+ }
128
+
129
+ func TestQueryResultRunPrecedesEveryBusinessError(t *testing.T) {
130
+ states := []struct {
131
+ state int
132
+ status string
133
+ completed bool
134
+ }{
135
+ {0, "unknown", false}, {1, "running", false}, {2, "running", false},
136
+ {3, "succeeded", true}, {4, "failed", true}, {5, "canceled", true},
137
+ {6, "input_required", false}, {7, "running", false}, {8, "running", false},
138
+ {9, "interrupted", true}, {99, "unknown", false},
139
+ }
140
+ // These are compatibility envelopes, not assertions that the server currently
141
+ // returns a structured Run for every error. The recorded fixture covers that.
142
+ for _, ret := range []string{"0", "1", "2", "5", "1015", "16019", "16020", "16021", "16022", "16023", "16024", "16025", "-2", "-3", "987654"} {
143
+ for _, state := range states {
144
+ t.Run(fmt.Sprintf("ret=%s/state=%d", ret, state.state), func(t *testing.T) {
145
+ clearDailyErrorLog(t)
146
+ got := executeQueryResponse(t, 200, fmt.Sprintf(`{"ret":%q,"errmsg":"创作失败:envelope reason","log_id":"matrix-log","data":{"thread":{"run_list":[{"run_id":"other","state":4},{"run_id":"run_456","state":%d,"fail_reason":{"message":"Run reason","code":12023}}]}}}`, ret, state.state))
147
+ if got["status"] != state.status || got["completed"] != state.completed {
148
+ t.Fatalf("got %#v, want %#v", got, state)
149
+ }
150
+ if state.state == 4 && got["error_message"] != "Run reason (error_code=12023)" {
151
+ t.Fatalf("lost run reason: %#v", got)
152
+ }
153
+ if state.state != 3 && state.state != 4 && got["error_message"] != "" {
154
+ t.Fatalf("envelope overwrote Run: %#v", got)
155
+ }
156
+ if ret != "0" {
157
+ logs, err := json.Marshal(readDailyErrorLog(t))
158
+ if err != nil {
159
+ t.Fatal(err)
160
+ }
161
+ if !strings.Contains(string(logs), "ret="+ret) || !strings.Contains(string(logs), "matrix-log") {
162
+ t.Fatalf("missing diagnostics: %s", logs)
163
+ }
164
+ }
165
+ })
166
+ }
167
+ }
168
+ }
169
+
170
+ func TestQueryResultUnusableResponses(t *testing.T) {
171
+ for _, body := range []string{
172
+ ``, `<html>gateway error</html>`, `{"ret":`,
173
+ `{}`, `{"ret":"0"}`, `{"ret":"0","data":null}`,
174
+ `{"ret":"0","data":{}}`, `{"ret":"0","data":{"readable_text":"失败"}}`,
175
+ `{"ret":"0","data":{"thread":null}}`,
176
+ `{"ret":"0","data":{"thread":{"run_list":[]}}}`,
177
+ `{"ret":"0","data":{"thread":{"run_list":"invalid"}}}`,
178
+ `{"ret":"0","data":{"thread":{"run_list":[{"run_id":"other","state":4}]}}}`,
179
+ `{"ret":"0","data":{"thread":{"run_list":[{"run_id":"run_456","state":"bad"}]}}}`,
180
+ `{"ret":"","errmsg":"创作失败","data":null}`,
181
+ `{"ret":"5","errmsg":"","data":null}`,
182
+ } {
183
+ t.Run(body, func(t *testing.T) {
184
+ got := executeQueryResponse(t, 200, body)
185
+ if got["status"] != "unknown" || got["completed"] != false || got["error_message"] == "" {
186
+ t.Fatalf("got %#v", got)
187
+ }
188
+ })
189
+ }
190
+ for _, code := range []int{400, 401, 403, 429, 500, 502, 503, 504} {
191
+ t.Run(fmt.Sprint(code), func(t *testing.T) {
192
+ got := executeQueryResponse(t, code, "gateway error")
193
+ if got["status"] != "unknown" || got["completed"] != false || !strings.Contains(got["error_message"].(string), fmt.Sprint(code)) {
194
+ t.Fatalf("got %#v", got)
195
+ }
196
+ })
197
+ }
198
+ t.Run("connection refused", func(t *testing.T) {
199
+ server := httptest.NewServer(http.NotFoundHandler())
200
+ server.Close()
201
+ got := executeQueryURL(t, server.URL, t.TempDir())
202
+ if got["status"] != "unknown" || got["completed"] != false || got["error_message"] == "" {
203
+ t.Fatalf("got %#v", got)
204
+ }
205
+ })
206
+ }
207
+
208
+ func TestQueryResultRetainsPartialDownloads(t *testing.T) {
209
+ for _, mode := range []string{"second video fails", "first image fails", "second image fails", "cached video then image fails"} {
210
+ t.Run(mode, func(t *testing.T) {
211
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
212
+ if r.URL.Path == "/api/biz/v1/skill/get_thread" {
213
+ content := []any{map[string]any{"sub_type": "biz/x_data_video", "data": map[string]any{"video": map[string]any{"download_url": serverURL(r) + "/video", "vid": "first"}}}}
214
+ badVideo := map[string]any{"sub_type": "biz/x_data_video", "data": map[string]any{"video": map[string]any{"download_url": "invalid-scheme://broken"}}}
215
+ badImage := map[string]any{"sub_type": "biz/x_data_image", "data": map[string]any{"image": map[string]any{"url": "invalid-scheme://broken"}}}
216
+ if mode == "second video fails" {
217
+ content = append(content, badVideo)
218
+ } else {
219
+ if mode == "second image fails" {
220
+ content = append(content, map[string]any{"sub_type": "biz/x_data_image", "data": map[string]any{"image": map[string]any{"url": serverURL(r) + "/image", "asset_id": "first-image"}}})
221
+ }
222
+ content = append(content, badImage)
223
+ }
224
+ _ = json.NewEncoder(w).Encode(map[string]any{"ret": "0", "data": map[string]any{"thread": map[string]any{"run_list": []any{map[string]any{"run_id": "run_456", "state": 3, "entry_list": []any{map[string]any{"artifact": map[string]any{"content": content}}}}}}}})
225
+ return
226
+ }
227
+ _, _ = w.Write([]byte("media-data"))
228
+ }))
229
+ defer server.Close()
230
+ dir := t.TempDir()
231
+ if mode == "cached video then image fails" {
232
+ if err := os.WriteFile(filepath.Join(dir, "first.mp4"), []byte("media-data"), 0600); err != nil {
233
+ t.Fatal(err)
234
+ }
235
+ }
236
+ got := executeQueryURL(t, server.URL, dir)
237
+ if got["status"] != "succeeded" || got["completed"] != true || !strings.Contains(got["error_message"].(string), "下载失败") {
238
+ t.Fatalf("got %#v", got)
239
+ }
240
+ videos := got["videos"].([]any)
241
+ images := got["images"].([]any)
242
+ wantImages := 0
243
+ if mode == "second image fails" {
244
+ wantImages = 1
245
+ }
246
+ if len(videos) != 1 || len(images) != wantImages {
247
+ t.Fatalf("partial files lost: %#v", got)
248
+ }
249
+ for _, file := range append(videos, images...) {
250
+ assertFileContent(t, file.(map[string]any)["output_path"].(string), "media-data")
251
+ }
252
+ })
253
+ }
254
+ }
255
+
256
+ func TestQueryResultTerminalEnvelopeWithoutUsableRun(t *testing.T) {
257
+ for _, data := range []string{`null`, `{}`, `{"thread":null}`, `{"thread":{"run_list":[]}}`, `{"thread":{"run_list":[{"run_id":"other","state":3}]}}`, `{"thread":{"run_list":"bad"}}`} {
258
+ t.Run(data, func(t *testing.T) {
259
+ got := executeQueryResponse(t, 200, `{"ret":"5","errmsg":"创作失败:reason","data":`+data+`}`)
260
+ if got["status"] != "failed" || got["completed"] != true || got["error_message"] != "创作失败:reason" {
261
+ t.Fatalf("got %#v", got)
262
+ }
263
+ })
264
+ }
265
+ }
@@ -0,0 +1,6 @@
1
+ {
2
+ "ret": "5",
3
+ "errmsg": "创作失败:视频可能不合规,请修改后重试",
4
+ "log_id": "recorded-failure-log",
5
+ "data": null
6
+ }
@@ -1,6 +1,6 @@
1
- edd2d8640d4a34b156d8c614ef5186ce91a6ea8778aee7b208d41296a840ef3f pippit-tool-cli-1.0.52-darwin-amd64.tar.gz
2
- 7b4b992088057f012851cd5c396d05239ad5fd6782bf93c3a7f9f5ae494b81fd pippit-tool-cli-1.0.52-darwin-arm64.tar.gz
3
- aab287cc80ccab5f78f0f73df95d15b61d7e9e67205fb8b5e6386c22a35dc732 pippit-tool-cli-1.0.52-linux-amd64.tar.gz
4
- a9c86c8323d9f2a4859a0280095a7cac85cdf0e7fc59aa3bc758f99332bd387e pippit-tool-cli-1.0.52-linux-arm64.tar.gz
5
- a0846db0ff0f2c61337c85f50f3b746ad9a6bd1ed2862df16399ba6dcc4257e7 pippit-tool-cli-1.0.52-windows-amd64.zip
6
- 54dd5852b9597b3ad9405be0c63ebfa670b713c169f69649647b3afe5ed89fe1 pippit-tool-cli-1.0.52-windows-arm64.zip
1
+ 08e0f157a05bdcbbb4c7a53eeeee3374d5be4bf86ec31139c5d42328c8cc781e pippit-tool-cli-1.0.53-darwin-amd64.tar.gz
2
+ fa1b79ed862a997d7ec8117895158f1914d2fcaa30792b303fd924d2856a834f pippit-tool-cli-1.0.53-darwin-arm64.tar.gz
3
+ e0d30c4edf201108587da2bd1ce97f9e7a30721e41700c50e7ddb57cc4c2474a pippit-tool-cli-1.0.53-linux-amd64.tar.gz
4
+ b60c817d9b58659838baf9954e5cc110ddc92ed2736e5512047dc2b20a9dfde8 pippit-tool-cli-1.0.53-linux-arm64.tar.gz
5
+ b890470552424b37fbd98d8a0e202d66526f7f575233cfb860599dadbc802be2 pippit-tool-cli-1.0.53-windows-amd64.zip
6
+ cf549f5b6e7d929d7b8b48b0a1a3df467ad921c23b4a93361799d7723b023bde pippit-tool-cli-1.0.53-windows-arm64.zip
@@ -18,8 +18,13 @@ type GetThreadOptions struct {
18
18
  }
19
19
 
20
20
  // GetThreadResult is the parsed get_thread response used by `pippit-tool-cli get-thread`.
21
+ // A business error may return both a result and an error so structured callers
22
+ // can still inspect the returned Run. Callers must continue to handle the error.
21
23
  type GetThreadResult struct {
22
24
  ReadableText string `json:"readable_text"`
25
+ Ret string `json:"-"`
26
+ Errmsg string `json:"-"`
27
+ LogID string `json:"-"`
23
28
  RawData []byte `json:"-"`
24
29
  }
25
30
 
@@ -49,11 +54,12 @@ func GetThread(ctx context.Context, opts *GetThreadOptions, runner *Runner) (*Ge
49
54
  if err := runner.Client.SendRequest(ctx, getThreadPath(runner), body, &resp); err != nil {
50
55
  return nil, fmt.Errorf("获取线程请求失败: %w", err)
51
56
  }
57
+ result := &GetThreadResult{Ret: resp.Ret, Errmsg: resp.Errmsg, LogID: resp.LogID, RawData: resp.Data}
52
58
  if resp.Ret != "0" {
53
59
  if resp.Errmsg == "" {
54
60
  resp.Errmsg = "未知错误"
55
61
  }
56
- return nil, NewLogIDError(fmt.Sprintf("获取线程请求返回失败: ret=%s errmsg=%s", resp.Ret, resp.Errmsg), resp.LogID)
62
+ return result, NewLogIDError(fmt.Sprintf("获取线程请求返回失败: ret=%s errmsg=%s", resp.Ret, resp.Errmsg), resp.LogID)
57
63
  }
58
64
  if len(resp.Data) == 0 {
59
65
  return nil, fmt.Errorf("get_thread 响应缺少 data")
@@ -68,10 +74,8 @@ func GetThread(ctx context.Context, opts *GetThreadOptions, runner *Runner) (*Ge
68
74
  }
69
75
  }
70
76
 
71
- return &GetThreadResult{
72
- ReadableText: readable.ReadableText,
73
- RawData: resp.Data,
74
- }, nil
77
+ result.ReadableText = readable.ReadableText
78
+ return result, nil
75
79
  }
76
80
 
77
81
  func getThreadPath(runner *Runner) string {
@@ -57,6 +57,21 @@ func TestGetThreadRequiresRetZero(t *testing.T) {
57
57
  }
58
58
  }
59
59
 
60
+ func TestGetThreadPreservesDataOnBusinessError(t *testing.T) {
61
+ result, err := GetThread(context.Background(), &GetThreadOptions{
62
+ ThreadID: "thread_123",
63
+ RunID: "run_456",
64
+ }, &Runner{
65
+ Client: getThreadFakeClient{response: `{"ret":"2","errmsg":"模拟接口错误","log_id":"fixture-log","data":{"thread":{"thread_id":"thread_123","run_list":[{"run_id":"run_456","state":4}]}}}`},
66
+ })
67
+ if err == nil || !strings.Contains(err.Error(), "ret=2") || !strings.Contains(err.Error(), "fixture-log") {
68
+ t.Fatalf("error = %v, want business error with log ID", err)
69
+ }
70
+ if result == nil || !strings.Contains(string(result.RawData), `"run_456"`) {
71
+ t.Fatalf("result = %#v, want response data alongside business error", result)
72
+ }
73
+ }
74
+
60
75
  func TestGetThreadV2RequiresReadableText(t *testing.T) {
61
76
  _, err := GetThread(context.Background(), &GetThreadOptions{
62
77
  ThreadID: "thread_123",
@@ -72,3 +87,24 @@ func TestGetThreadV2RequiresReadableText(t *testing.T) {
72
87
  t.Fatalf("error = %q, want readable_text validation", err)
73
88
  }
74
89
  }
90
+
91
+ func TestGetThreadPreservesEnvelope(t *testing.T) {
92
+ for _, ret := range []string{"0", "1", "2", "5", "1015", "16019", "987654"} {
93
+ t.Run(ret, func(t *testing.T) {
94
+ result, err := GetThread(context.Background(), &GetThreadOptions{ThreadID: "thread", RunID: "run"}, &Runner{Client: getThreadFakeClient{response: `{"ret":"` + ret + `","errmsg":"server message","log_id":"server-log","data":null}`}})
95
+ if (err != nil) != (ret != "0") {
96
+ t.Fatalf("err=%v ret=%s", err, ret)
97
+ }
98
+ if result == nil || result.Ret != ret || result.Errmsg != "server message" || result.LogID != "server-log" || string(result.RawData) != "null" {
99
+ t.Fatalf("envelope lost: %#v", result)
100
+ }
101
+ raw, marshalErr := sonic.Marshal(result)
102
+ if marshalErr != nil {
103
+ t.Fatal(marshalErr)
104
+ }
105
+ if strings.Contains(string(raw), "server-log") || strings.Contains(string(raw), "server message") {
106
+ t.Fatalf("internal envelope leaked: %s", raw)
107
+ }
108
+ })
109
+ }
110
+ }
@@ -14,19 +14,22 @@ import (
14
14
  )
15
15
 
16
16
  const (
17
- successRunState = 3
18
- failedRunState = 4
19
- canceledRunState = 5
17
+ successRunState = 3
18
+ failedRunState = 4
19
+ canceledRunState = 5
20
+ interruptedRunState = 9
20
21
  )
21
22
 
22
23
  // Query-result status values. "completed" stays a terminal flag; "status"
23
24
  // carries the machine-readable result so integrators need not parse text.
24
25
  const (
25
- statusRunning = "running"
26
- statusSucceeded = "succeeded"
27
- statusFailed = "failed"
28
- statusCanceled = "canceled"
29
- statusUnknown = "unknown"
26
+ statusRunning = "running"
27
+ statusSucceeded = "succeeded"
28
+ statusFailed = "failed"
29
+ statusCanceled = "canceled"
30
+ statusUnknown = "unknown"
31
+ statusInputRequired = "input_required"
32
+ statusInterrupted = "interrupted"
30
33
  )
31
34
 
32
35
  // runStateStatus maps the server-side run state (0-9) to the user-facing
@@ -38,16 +41,17 @@ var runStateStatus = map[int]string{
38
41
  3: statusSucceeded,
39
42
  4: statusFailed,
40
43
  5: statusCanceled,
41
- 6: statusUnknown, // InputRequired
42
- 7: statusRunning, // Generating
43
- 8: statusRunning, // Interrupt
44
- 9: statusUnknown, // HITL_Interrupt
44
+ 6: statusInputRequired,
45
+ 7: statusRunning, // Generating
46
+ 8: statusRunning, // Interrupt
47
+ 9: statusInterrupted, // HITL_Interrupt is terminal, unlike Interrupt (8).
45
48
  }
46
49
 
47
50
  var terminalRunStates = map[int]struct{}{
48
- successRunState: {},
49
- failedRunState: {},
50
- canceledRunState: {},
51
+ successRunState: {},
52
+ failedRunState: {},
53
+ canceledRunState: {},
54
+ interruptedRunState: {},
51
55
  }
52
56
 
53
57
  func statusForRunState(state int) string {
@@ -100,12 +104,18 @@ type queryThread struct {
100
104
  }
101
105
 
102
106
  type queryRun struct {
103
- RunID string `json:"run_id"`
104
- State int `json:"state"`
105
- ErrorMessage string `json:"error_message"`
106
- ErrorMsg string `json:"error_msg"`
107
- Errmsg string `json:"errmsg"`
108
- EntryList []queryEntry `json:"entry_list"`
107
+ RunID string `json:"run_id"`
108
+ State int `json:"state"`
109
+ ErrorMessage string `json:"error_message"`
110
+ ErrorMsg string `json:"error_msg"`
111
+ Errmsg string `json:"errmsg"`
112
+ FailReason *queryFailReason `json:"fail_reason"`
113
+ EntryList []queryEntry `json:"entry_list"`
114
+ }
115
+
116
+ type queryFailReason struct {
117
+ Message string `json:"message"`
118
+ Code json.RawMessage `json:"code"`
109
119
  }
110
120
 
111
121
  type queryEntry struct {
@@ -152,34 +162,41 @@ func QueryResult(ctx context.Context, opts *QueryResultOptions, runner *common.R
152
162
  return nil, err
153
163
  }
154
164
 
155
- threadResult, err := common.GetThread(ctx, &common.GetThreadOptions{
165
+ threadResult, queryErr := common.GetThread(ctx, &common.GetThreadOptions{
156
166
  ThreadID: opts.ThreadID,
157
167
  RunID: opts.RunID,
158
168
  }, runner)
159
- if err != nil {
160
- return nil, queryResultQueryError(err)
161
- }
162
-
163
169
  thread, err := parseQueryThread(threadResult)
164
170
  if err != nil {
171
+ if queryErr != nil {
172
+ return queryResultWithoutRun(threadResult, queryErr, opts)
173
+ }
165
174
  return nil, fmt.Errorf("查询失败:%w", err)
166
175
  }
167
176
 
168
177
  run, ok := findQueryRun(thread, opts.RunID)
169
178
  if !ok {
179
+ if queryErr != nil {
180
+ return queryResultWithoutRun(threadResult, queryErr, opts)
181
+ }
170
182
  return nil, fmt.Errorf("查询失败:未找到 run_id=%s 对应的 Run", opts.RunID)
171
183
  }
184
+ logResolvedQueryError(queryErr, opts)
185
+ result := &QueryResultResult{
186
+ Status: statusForRunState(run.State),
187
+ Completed: isTerminalRunState(run.State),
188
+ ThreadID: firstNonEmpty(thread.ThreadID, opts.ThreadID),
189
+ RunID: opts.RunID,
190
+ Videos: []QueryResultVideo{},
191
+ Images: []QueryResultImage{},
192
+ }
172
193
  if run.State != successRunState {
173
- result := &QueryResultResult{
174
- Status: statusForRunState(run.State),
175
- Completed: isTerminalRunState(run.State),
176
- ThreadID: firstNonEmpty(thread.ThreadID, opts.ThreadID),
177
- RunID: opts.RunID,
178
- Videos: []QueryResultVideo{},
179
- Images: []QueryResultImage{},
180
- }
181
194
  if run.State == failedRunState {
182
- result.ErrorMessage = firstNonEmpty(extractQueryErrorMessage(run), "Run 失败")
195
+ message := extractQueryErrorMessage(run)
196
+ if message == "" && queryErr != nil {
197
+ message = queryResultQueryError(queryErr).Error()
198
+ }
199
+ result.ErrorMessage = firstNonEmpty(message, "Run 失败")
183
200
  }
184
201
  return result, nil
185
202
  }
@@ -187,20 +204,19 @@ func QueryResult(ctx context.Context, opts *QueryResultOptions, runner *common.R
187
204
  videos := extractQueryVideos(run)
188
205
  images := extractQueryImages(run)
189
206
  if len(videos) == 0 && len(images) == 0 {
190
- return nil, fmt.Errorf("下载失败:未找到可下载的产物")
207
+ return result, fmt.Errorf("下载失败:未找到可下载的产物")
191
208
  }
192
209
 
193
210
  downloadDir, err := common.ExpandPath(opts.DownloadDir)
194
211
  if err != nil {
195
- return nil, fmt.Errorf("下载失败:解析下载目录失败:%w", err)
212
+ return result, fmt.Errorf("下载失败:解析下载目录失败:%w", err)
196
213
  }
197
214
 
198
215
  usedNames := make(map[string]int, len(videos)+len(images))
199
216
 
200
- resultVideos := make([]QueryResultVideo, 0, len(videos))
201
217
  for i, video := range videos {
202
218
  if strings.TrimSpace(video.DownloadURL) == "" {
203
- return nil, fmt.Errorf("下载失败:第 %d 个视频产物 download_url 为空", i+1)
219
+ return result, fmt.Errorf("下载失败:第 %d 个视频产物 download_url 为空", i+1)
204
220
  }
205
221
  outputPath := filepath.Join(downloadDir, uniqueQueryResultFileName(videoFileName(video, i+1), usedNames))
206
222
  download, err := common.DownloadResult(ctx, common.DownloadResultOptions{
@@ -209,7 +225,7 @@ func QueryResult(ctx context.Context, opts *QueryResultOptions, runner *common.R
209
225
  Workers: 5,
210
226
  }, runner)
211
227
  if err != nil {
212
- return nil, fmt.Errorf("下载失败:%w", err)
228
+ return result, fmt.Errorf("下载失败:%w", err)
213
229
  }
214
230
  actualOutputPath := outputPath
215
231
  if len(download.Downloaded) > 0 {
@@ -217,7 +233,7 @@ func QueryResult(ctx context.Context, opts *QueryResultOptions, runner *common.R
217
233
  } else if len(download.AlreadyExist) > 0 {
218
234
  actualOutputPath = download.AlreadyExist[0]
219
235
  }
220
- resultVideos = append(resultVideos, QueryResultVideo{
236
+ result.Videos = append(result.Videos, QueryResultVideo{
221
237
  DownloadURL: video.DownloadURL,
222
238
  OutputPath: actualOutputPath,
223
239
  Draft: video.Draft,
@@ -225,10 +241,9 @@ func QueryResult(ctx context.Context, opts *QueryResultOptions, runner *common.R
225
241
  })
226
242
  }
227
243
 
228
- resultImages := make([]QueryResultImage, 0, len(images))
229
244
  for i, image := range images {
230
245
  if strings.TrimSpace(image.DownloadURL) == "" {
231
- return nil, fmt.Errorf("下载失败:第 %d 个图片产物 download_url 为空", i+1)
246
+ return result, fmt.Errorf("下载失败:第 %d 个图片产物 download_url 为空", i+1)
232
247
  }
233
248
  outputPath := filepath.Join(downloadDir, uniqueQueryResultFileName(imageFileName(image, i+1), usedNames))
234
249
  download, err := common.DownloadResult(ctx, common.DownloadResultOptions{
@@ -237,7 +252,7 @@ func QueryResult(ctx context.Context, opts *QueryResultOptions, runner *common.R
237
252
  Workers: 5,
238
253
  }, runner)
239
254
  if err != nil {
240
- return nil, fmt.Errorf("下载失败:%w", err)
255
+ return result, fmt.Errorf("下载失败:%w", err)
241
256
  }
242
257
  actualOutputPath := outputPath
243
258
  if len(download.Downloaded) > 0 {
@@ -245,25 +260,52 @@ func QueryResult(ctx context.Context, opts *QueryResultOptions, runner *common.R
245
260
  } else if len(download.AlreadyExist) > 0 {
246
261
  actualOutputPath = download.AlreadyExist[0]
247
262
  }
248
- resultImages = append(resultImages, QueryResultImage{
263
+ result.Images = append(result.Images, QueryResultImage{
249
264
  DownloadURL: image.DownloadURL,
250
265
  OutputPath: actualOutputPath,
251
266
  })
252
267
  }
253
268
 
254
- return &QueryResultResult{
255
- Status: statusSucceeded,
256
- Completed: true,
257
- ThreadID: firstNonEmpty(thread.ThreadID, opts.ThreadID),
258
- RunID: opts.RunID,
259
- Videos: resultVideos,
260
- Images: resultImages,
261
- }, nil
269
+ return result, nil
262
270
  }
263
271
 
264
- // queryResultQueryError converts a get_thread failure into a Go error while
265
- // keeping a clean, customer-facing message. The command layer maps it to
266
- // status=unknown; the technical envelope stays in the local error log.
272
+ // The legacy endpoint omits data for Failed/Canceled. Only these exact
273
+ // endpoint-specific responses prove a terminal Run; other errors (including
274
+ // ret=5 "获取会话信息失败") describe a failed query, not a failed Run.
275
+ func queryResultWithoutRun(threadResult *common.GetThreadResult, queryErr error, opts *QueryResultOptions) (*QueryResultResult, error) {
276
+ status := statusUnknown
277
+ if threadResult != nil && threadResult.Ret == "5" {
278
+ message := strings.TrimSpace(threadResult.Errmsg)
279
+ switch {
280
+ case message == "创作失败" || strings.HasPrefix(message, "创作失败:"):
281
+ status = statusFailed
282
+ case message == "创作已取消":
283
+ status = statusCanceled
284
+ }
285
+ }
286
+ if status == statusUnknown {
287
+ return nil, queryResultQueryError(queryErr)
288
+ }
289
+ logResolvedQueryError(queryErr, opts)
290
+ result := NewQueryResultError(queryResultQueryError(queryErr), opts)
291
+ result.Status = status
292
+ result.Completed = true
293
+ return result, nil
294
+ }
295
+
296
+ // The command logs returned errors. Errors consumed by a resolved Run must
297
+ // also retain their original ret/errmsg/log_id for diagnosis.
298
+ func logResolvedQueryError(err error, opts *QueryResultOptions) {
299
+ if err != nil {
300
+ _ = common.AppendDailyErrorLog("query-result", err, map[string]string{
301
+ "thread_id": opts.ThreadID,
302
+ "run_id": opts.RunID,
303
+ })
304
+ }
305
+ }
306
+
307
+ // queryResultQueryError extracts a customer-facing get_thread error. It is used
308
+ // when no target Run can be resolved, or as the failed Run's fallback reason.
267
309
  func queryResultQueryError(err error) error {
268
310
  var logErr *common.LogIDError
269
311
  if errors.As(err, &logErr) {
@@ -279,9 +321,8 @@ func queryResultQueryError(err error) error {
279
321
  return fmt.Errorf("查询失败:%w", err)
280
322
  }
281
323
 
282
- // NewQueryResultError builds the user-facing result for a failed query. A
283
- // failed call never produces a run status, so it reports unknown rather than
284
- // guessing; the concrete cause stays in error_message.
324
+ // NewQueryResultError builds the user-facing result when no Run could be
325
+ // resolved. The concrete cause stays in error_message.
285
326
  func NewQueryResultError(err error, opts *QueryResultOptions) *QueryResultResult {
286
327
  result := &QueryResultResult{
287
328
  Status: statusUnknown,
@@ -418,6 +459,16 @@ func extractQueryImages(run queryRun) []queryImage {
418
459
  }
419
460
 
420
461
  func extractQueryErrorMessage(run queryRun) string {
462
+ if run.FailReason != nil {
463
+ message := strings.TrimSpace(run.FailReason.Message)
464
+ code := rawMessageString(run.FailReason.Code)
465
+ if message != "" {
466
+ if code != "" && code != "0" {
467
+ return fmt.Sprintf("%s (error_code=%s)", message, code)
468
+ }
469
+ return message
470
+ }
471
+ }
421
472
  if message := firstNonEmpty(run.ErrorMessage, run.ErrorMsg, run.Errmsg); message != "" {
422
473
  return message
423
474
  }
@@ -435,6 +486,11 @@ func extractQueryErrorMessage(run queryRun) string {
435
486
  }
436
487
  }
437
488
  }
489
+ if run.FailReason != nil {
490
+ if code := rawMessageString(run.FailReason.Code); code != "" && code != "0" {
491
+ return "error_code=" + code
492
+ }
493
+ }
438
494
  return ""
439
495
  }
440
496
 
@@ -0,0 +1,63 @@
1
+ package generate_video
2
+
3
+ import (
4
+ "context"
5
+ "encoding/json"
6
+ "errors"
7
+ "testing"
8
+
9
+ "github.com/Pippit-dev/pippit-cli/internal/common"
10
+ )
11
+
12
+ func TestQueryFailureReasons(t *testing.T) {
13
+ for _, tt := range []struct{ name, run, want string }{
14
+ {"real fail reason", `{"fail_reason":{"message":"视频可能不合规,请修改后重试","code":12023}}`, "视频可能不合规,请修改后重试 (error_code=12023)"},
15
+ {"message only", `{"fail_reason":{"message":"reason"}}`, "reason"},
16
+ {"zero code", `{"fail_reason":{"message":"reason","code":0}}`, "reason"},
17
+ {"code only", `{"fail_reason":{"code":12023}}`, "error_code=12023"},
18
+ {"empty reason", `{"fail_reason":{"message":" ","code":0}}`, ""},
19
+ {"null reason", `{"fail_reason":null}`, ""},
20
+ {"structured reason wins", `{"fail_reason":{"message":"real reason","code":12023},"error_message":"legacy"}`, "real reason (error_code=12023)"},
21
+ {"legacy message fallback", `{"fail_reason":{"code":12023},"error_message":"legacy"}`, "legacy"},
22
+ {"error_msg fallback", `{"error_msg":"legacy"}`, "legacy"},
23
+ {"errmsg fallback", `{"errmsg":"legacy"}`, "legacy"},
24
+ {"artifact reason", `{"entry_list":[{"artifact":{"content":[{"data":{"error_message":"artifact reason","error_code":99}}]}}]}`, "artifact reason (error_code=99)"},
25
+ {"artifact code only", `{"entry_list":[{"artifact":{"content":[{"data":{"error_code":"99"}}]}}]}`, "error_code=99"},
26
+ } {
27
+ t.Run(tt.name, func(t *testing.T) {
28
+ var run queryRun
29
+ if err := json.Unmarshal([]byte(tt.run), &run); err != nil {
30
+ t.Fatal(err)
31
+ }
32
+ if got := extractQueryErrorMessage(run); got != tt.want {
33
+ t.Fatalf("got %q want %q", got, tt.want)
34
+ }
35
+ })
36
+ }
37
+ }
38
+
39
+ type queryErrorClient struct{ err error }
40
+
41
+ func (c queryErrorClient) SendRequest(context.Context, string, any, any) error { return c.err }
42
+ func (c queryErrorClient) SendRequestWithHeaders(context.Context, string, any, map[string]string, any) error {
43
+ return c.err
44
+ }
45
+ func (c queryErrorClient) SendMultipartRequest(context.Context, string, map[string]string, common.MultipartFile, any) error {
46
+ return c.err
47
+ }
48
+
49
+ func TestQueryResultTransportFailure(t *testing.T) {
50
+ for _, cause := range []error{context.DeadlineExceeded, context.Canceled, errors.New("connection reset")} {
51
+ t.Run(cause.Error(), func(t *testing.T) {
52
+ opts := &QueryResultOptions{ThreadID: "thread", RunID: "run", DownloadDir: t.TempDir()}
53
+ result, err := QueryResult(context.Background(), opts, &common.Runner{Client: queryErrorClient{cause}})
54
+ if result != nil || !errors.Is(err, cause) {
55
+ t.Fatalf("result=%#v err=%v", result, err)
56
+ }
57
+ result = NewQueryResultError(err, opts)
58
+ if result.Status != "unknown" || result.Completed || result.ErrorMessage == "" {
59
+ t.Fatalf("result=%#v", result)
60
+ }
61
+ })
62
+ }
63
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pippit-dev/cli",
3
- "version": "1.0.52",
3
+ "version": "1.0.53",
4
4
  "description": "Pippit CLI",
5
5
  "bin": {
6
6
  "pippit-tool-cli": "scripts/run.js"
@@ -20,6 +20,7 @@ stdout 是 JSON;命令可能将错误编码进 JSON 并以退出码 0 返回
20
20
 
21
21
  | 字段 | 含义 |
22
22
  | --- | --- |
23
+ | `status` | 目标 Run 状态:`running`、`succeeded`、`failed`、`canceled`、`input_required`、`interrupted`、`unknown`;不直接由查询接口 `ret` 决定 |
23
24
  | `completed` | 是否结束;失败时也可能为 `true`,不等于成功 |
24
25
  | `error_message` | 非空即错误,不能因为 `completed=false` 而忽略 |
25
26
  | `thread_id` / `run_id` | 对应的查询任务 |
@@ -31,6 +32,7 @@ stdout 是 JSON;命令可能将错误编码进 JSON 并以退出码 0 返回
31
32
 
32
33
  ```json
33
34
  {
35
+ "status": "succeeded",
34
36
  "completed": true,
35
37
  "thread_id": "THREAD_ID",
36
38
  "run_id": "RUN_ID",
@@ -40,7 +42,15 @@ stdout 是 JSON;命令可能将错误编码进 JSON 并以退出码 0 返回
40
42
  }
41
43
  ```
42
44
 
43
- 任务尚未完成时通常返回 `completed=false`、空错误、空媒体数组。命令不提供完整会话消息、用户反问或可区分的所有状态;不能仅凭这个响应断言具体进度、取消状态或等待用户输入。轮询停止条件见 [共用流程](../workflows/async-delivery.md)。
45
+ `status` 和 `completed` 优先按返回的目标 Run 判定,适用于任意 `ret`。失败原因优先读取 Run 的 `fail_reason.message/code`,再读取兼容错误字段和接口错误;非零接口返回仍保留本地诊断日志。
46
+
47
+ 没有目标 Run 时,只有已核实的接口返回可确定终态:`ret=5` 且 `errmsg` 为 `创作失败` 或以 `创作失败:` 开头时返回 `failed`、`completed=true`;`ret=5` 且 `errmsg` 为 `创作已取消` 时返回 `canceled`、`completed=true`。不要泛匹配“失败”或“不合规”。其余查询错误保持 `unknown`、`completed=false`,通过 `error_message` 报告原因及可用的 LogID。`ret=2` 是参数错误,不代表安审失败;`ret=5` 也可能只是查询会话失败。未指定或未知 Run 状态保持 `unknown`。
48
+
49
+ `input_required` 表示等待补充输入,`completed=false`;`interrupted` 表示人工交互中断结束,`completed=true`。两者均停止自动轮询并报告交互需求或中断状态。`canceled` 也直接停止,不因空产物误报下载失败。程序中断/等待工具回调仍为 `running`。
50
+
51
+ Run 已成功但产物缺失或下载失败时,保留 `succeeded`、`completed=true`,错误写入 `error_message`。此前已成功下载的文件仍保留在媒体数组中。核对这些文件后可交付已完成部分,但不能宣称全部下载或交付成功。
52
+
53
+ 只有 `running` 且无错误时继续轮询;未知状态不能自动当作运行中。命令不提供完整会话消息或用户反问,不编造具体进度。轮询停止条件见 [共用流程](../workflows/async-delivery.md)。
44
54
 
45
55
  ## 样片结果
46
56
 
@@ -54,4 +64,4 @@ stdout 是 JSON;命令可能将错误编码进 JSON 并以退出码 0 返回
54
64
 
55
65
  文件名由 CLI 根据产物信息生成,以 `output_path` 为准,不自行拼接编号或推测扩展名。同目录已有同名文件可能被复用;复用不证明内容相同,也不代表本次新下载。发现同名文件属于其他产物时,选择用户认可范围内的未冲突目录再查询,不删除已有文件。
56
66
 
57
- 找不到产物、链接缺失或下载失败时会返回错误。当前任一文件下载失败可能使整次查询只返回错误,无法据此认定其他文件都没下载或已完整交付。复查本次结果,不把输出目录里的任意旧文件当作本次产物。
67
+ 找不到产物、链接缺失或下载失败时会返回错误。下载中途失败时,媒体数组保留此前已成功下载或复用的文件;逐项核对 `output_path` 后可交付可用部分,并说明未完成项。不把输出目录里的任意旧文件当作本次产物,也不宣称已完整交付。
@@ -7,15 +7,15 @@
7
7
  1. 提交成功后保存 `thread_id`、`run_id` 和 `web_thread_link`,立即向用户展示任务链接。已有任务直接使用其标识进入查询,不再次生成。
8
8
  2. 使用同一 `cli_path` 和同一对任务 ID 调用查询命令,明确 `--download-dir`。每隔 10 秒查询一次,不在轮询中重新安装 CLI。
9
9
  3. 先检查命令是否可执行、输出是否为有效 JSON,再读取非空 `error_message`;有错误就进入下面的失败处理。
10
- 4. 无错误且 `completed=false` 时继续轮询,只说明尚未取得最终结果,不编造创作消息或完成百分比。
11
- 5. 无错误且 `completed=true` 时,要求媒体数组中有待交付产物,逐项核对 `output_path` 后进入交付。空结果或缺少必要字段时报告异常,不空轮询。
10
+ 4. `status=input_required` 时停止自动轮询,提示需要补充输入;`status=interrupted` 或 `canceled` 时停止并报告对应终态。只有无错误且 `status=running` 时继续轮询,不编造创作消息或完成百分比。`unknown` 不等于运行中,报告无法确定任务状态。
11
+ 5. 无错误且 `status=succeeded`、`completed=true` 时,要求媒体数组中有待交付产物,逐项核对 `output_path` 后进入交付。空结果或缺少必要字段时报告异常,不空轮询。
12
12
 
13
13
  ## 停止与恢复
14
14
 
15
15
  - 明确的任务失败、鉴权失败、参数错误或产物缺失:停止并报告错误及可用的任务 ID、链接、LogID,不重新提交生成。
16
16
  - 可识别的暂时网络错误或下载错误:间隔 10 秒后仅重试同一次查询一次;仍失败则报告阻塞。无法判别错误类别时停止,避免盲目重试。
17
17
  - 用户要求停止时停止本地轮询;这不等于已取消服务端任务。持续未完成时遵守用户或宿主的等待时限,最迟在连续 48 小时后停止等待,保留 ID 供后续恢复查询。宿主无法持续执行时如实说明,不承诺后台监控。
18
- - 当前查询输出无法区分所有非成功状态;若长时间未完成或任务页面显示需要交互,报告当前查询能力的限制,不代答或新建任务。
18
+ - 等待输入或人工交互中断时,查询输出不含完整反问内容;引导查看任务页面,不代答或新建任务。
19
19
  - 恢复时继续查询原任务,复用该任务已确定的输出目录,对已交付产物去重。
20
20
 
21
21
  ## 媒体交付完成标准