mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-08 09:49:34 -10:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
16405c489b |
No files matched your search
@@ -13,7 +13,6 @@ require (
|
||||
github.com/aymanbagabas/go-udiff v0.4.1
|
||||
github.com/charmbracelet/colorprofile v0.4.3
|
||||
github.com/charmbracelet/x/ansi v0.11.7
|
||||
github.com/charmbracelet/x/term v0.2.2
|
||||
github.com/ledongthuc/pdf v0.0.0-20250511090121-5959a4027728
|
||||
golang.org/x/sys v0.46.0
|
||||
mvdan.cc/sh/v3 v3.13.1
|
||||
@@ -22,6 +21,7 @@ require (
|
||||
require (
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20260615092913-2399af76d5b1 // indirect
|
||||
github.com/charmbracelet/x/exp/golden v0.0.0-20260615092313-b57e5e6d29bb // indirect
|
||||
github.com/charmbracelet/x/term v0.2.2 // indirect
|
||||
github.com/charmbracelet/x/termios v0.1.1 // indirect
|
||||
github.com/charmbracelet/x/windows v0.2.2 // indirect
|
||||
github.com/clipperhouse/displaywidth v0.11.0 // indirect
|
||||
|
||||
@@ -277,9 +277,6 @@ func listProviderCatalogSummaries(options commandCenterOptions) ([]providerCatal
|
||||
return nil, err
|
||||
}
|
||||
sort.SliceStable(summaries, func(i int, j int) bool {
|
||||
if summaries[i].Recommended != summaries[j].Recommended {
|
||||
return summaries[i].Recommended
|
||||
}
|
||||
return summaries[i].ID < summaries[j].ID
|
||||
})
|
||||
return summaries, nil
|
||||
@@ -357,17 +354,10 @@ func formatProviderCatalogSummaries(providers []providerCatalogSummary) string {
|
||||
}
|
||||
|
||||
func formatProviderCatalogLine(provider providerCatalogSummary) string {
|
||||
name := formatProviderCatalogValue(provider.Name, "unknown")
|
||||
prefix := " id="
|
||||
if provider.Recommended {
|
||||
prefix = " ★ id="
|
||||
name += " (recommended)"
|
||||
}
|
||||
lines := []string{
|
||||
fmt.Sprintf("%s%s name=%s",
|
||||
prefix,
|
||||
fmt.Sprintf(" id=%s name=%s",
|
||||
formatProviderCatalogValue(provider.ID, "unknown"),
|
||||
name,
|
||||
formatProviderCatalogValue(provider.Name, "unknown"),
|
||||
),
|
||||
fmt.Sprintf(" transport=%s defaultModel=%s",
|
||||
formatProviderCatalogValue(provider.Transport, "unknown"),
|
||||
|
||||
@@ -285,7 +285,6 @@ func setupProviderOptions() []tui.SetupProviderOption {
|
||||
EnvVar: setupProviderEnvVar(descriptor),
|
||||
RequiresAuth: descriptor.RequiresAuth,
|
||||
Local: descriptor.Local,
|
||||
Recommended: descriptor.Recommended,
|
||||
})
|
||||
}
|
||||
return options
|
||||
|
||||
@@ -140,26 +140,6 @@ func TestPostinstallSkipsUnsupportedPlatform(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostinstallSkipsWindowsArm64(t *testing.T) {
|
||||
// (win32, arm64) resolves to a valid platform/arch but the release matrix has
|
||||
// no windows-arm64 artifact, so the install must skip gracefully (exit 0)
|
||||
// rather than hard-fail on a 404 download.
|
||||
stdout, stderr, err := runPostinstall(t,
|
||||
"ZERO_INSTALL_DRY_RUN=1",
|
||||
"ZERO_INSTALL_PLATFORM=win32",
|
||||
"ZERO_INSTALL_ARCH=arm64",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("windows-arm64 should exit 0, got err=%v stderr=%s", err, stderr)
|
||||
}
|
||||
if strings.TrimSpace(stdout) != "" {
|
||||
t.Fatalf("windows-arm64 should not print a plan, got %q", stdout)
|
||||
}
|
||||
if !strings.Contains(stderr, "no prebuilt binary for windows-arm64") {
|
||||
t.Fatalf("stderr=%q, want the windows-arm64 skip message", stderr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostinstallHonorsSkipEnv(t *testing.T) {
|
||||
stdout, stderr, err := runPostinstall(t, "ZERO_SKIP_DOWNLOAD=1")
|
||||
if err != nil {
|
||||
|
||||
@@ -58,11 +58,6 @@ type Descriptor struct {
|
||||
// OAuthDeviceFlow reports that RFC 8628 device-code login is supported (for
|
||||
// headless / SSH use) in addition to the browser flow.
|
||||
OAuthDeviceFlow bool
|
||||
|
||||
// Recommended marks the provider that should be surfaced first and badged
|
||||
// (★ … (recommended)) in every catalog-ordered list and picker. At most one
|
||||
// descriptor should set this.
|
||||
Recommended bool
|
||||
}
|
||||
|
||||
func RuntimeSupported(descriptor Descriptor) bool {
|
||||
@@ -87,11 +82,6 @@ func RuntimeUnsupportedReason(descriptor Descriptor) string {
|
||||
}
|
||||
|
||||
var descriptors = []Descriptor{
|
||||
// GitLawb OpenGateway — the recommended default. An OpenAI-compatible gateway
|
||||
// that smart-routes by model id across upstream providers (xiaomi-mimo,
|
||||
// minimax, qwen, google, nvidia, z-ai). Flat /v1/chat/completions with a
|
||||
// Bearer ogw_live_… key; listed first and badged in every picker.
|
||||
recommended(openAICompat("gitlawb-opengateway", "GitLawb OpenGateway", "https://opengateway.gitlawb.com/v1", "mimo-v2.5-pro", []string{"GITLAWB_OPENGATEWAY_API_KEY"}, "gitlawb opengateway", "opengateway")),
|
||||
openAI("openai", "OpenAI", "https://api.openai.com/v1", "gpt-4.1", []string{"OPENAI_API_KEY"}),
|
||||
anthropic("anthropic", "Anthropic", "https://api.anthropic.com", "claude-sonnet-4.5", []string{"ANTHROPIC_API_KEY"}),
|
||||
google("google", "Google", "https://generativelanguage.googleapis.com", "gemini-2.5-pro", []string{"GEMINI_API_KEY", "GOOGLE_API_KEY"}, "gemini"),
|
||||
@@ -134,6 +124,7 @@ var descriptors = []Descriptor{
|
||||
openAICompat("xiaomi-mimo", "Xiaomi MiMo", "https://api.mimo.xiaomi.com/openai/v1", "mimo-vl", []string{"MIMO_API_KEY", "XIAOMI_API_KEY"}, "xiaomi mimo"),
|
||||
openAICompat("bankr", "Bankr", "https://api.bankr.bot/v1", "bankr-large", []string{"BANKR_API_KEY"}),
|
||||
openAICompat("zai", "Z.ai", "https://open.bigmodel.cn/api/paas/v4", "glm-4.5", []string{"ZAI_API_KEY", "ZHIPU_API_KEY"}),
|
||||
openAICompat("gitlawb-opengateway", "GitLawb OpenGateway", "https://gateway.gitlawb.com/v1", "gpt-4.1", []string{"GITLAWB_OPENGATEWAY_API_KEY"}, "gitlawb opengateway"),
|
||||
openAICompat("atomic-chat", "Atomic Chat", "https://api.atomic.chat/v1", "gpt-4.1", []string{"ATOMIC_CHAT_API_KEY"}),
|
||||
// ChatGPT subscription via a local OAuth proxy. A ChatGPT (Plus/Pro) OAuth
|
||||
// token only works against ChatGPT's own backend (which is Cloudflare-gated to
|
||||
@@ -275,13 +266,6 @@ func google(id string, name string, baseURL string, model string, env []string,
|
||||
}
|
||||
}
|
||||
|
||||
// recommended marks a descriptor as the recommended default so list/picker
|
||||
// surfaces sort it first and render the ★ … (recommended) badge.
|
||||
func recommended(descriptor Descriptor) Descriptor {
|
||||
descriptor.Recommended = true
|
||||
return descriptor
|
||||
}
|
||||
|
||||
// oauthProvider marks a descriptor as OAuth-capable. mintsKey => the flow returns
|
||||
// an API key (OpenRouter); deviceFlow => RFC 8628 device code is also supported.
|
||||
func oauthProvider(descriptor Descriptor, mintsKey bool, deviceFlow bool) Descriptor {
|
||||
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
)
|
||||
|
||||
var expectedCatalogIDs = []string{
|
||||
"gitlawb-opengateway",
|
||||
"openai",
|
||||
"anthropic",
|
||||
"google",
|
||||
@@ -34,6 +33,7 @@ var expectedCatalogIDs = []string{
|
||||
"xiaomi-mimo",
|
||||
"bankr",
|
||||
"zai",
|
||||
"gitlawb-opengateway",
|
||||
"atomic-chat",
|
||||
"chatgpt-proxy",
|
||||
"custom-openai-compatible",
|
||||
@@ -61,41 +61,6 @@ func TestAllHasStableUniqueIDs(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecommendedProviderIsFirstAndUnique(t *testing.T) {
|
||||
descriptors := All()
|
||||
if len(descriptors) == 0 {
|
||||
t.Fatal("All() returned no descriptors")
|
||||
}
|
||||
if !descriptors[0].Recommended {
|
||||
t.Fatalf("All()[0] = %q, want it to be the recommended provider", descriptors[0].ID)
|
||||
}
|
||||
if descriptors[0].ID != "gitlawb-opengateway" {
|
||||
t.Fatalf("recommended provider = %q, want %q", descriptors[0].ID, "gitlawb-opengateway")
|
||||
}
|
||||
recommended := 0
|
||||
for _, descriptor := range descriptors {
|
||||
if descriptor.Recommended {
|
||||
recommended++
|
||||
}
|
||||
}
|
||||
if recommended != 1 {
|
||||
t.Fatalf("recommended descriptor count = %d, want exactly 1", recommended)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecommendedProviderEndpoint(t *testing.T) {
|
||||
descriptor, ok := Get("gitlawb-opengateway")
|
||||
if !ok {
|
||||
t.Fatal("gitlawb-opengateway not found in catalog")
|
||||
}
|
||||
if descriptor.DefaultBaseURL != "https://opengateway.gitlawb.com/v1" {
|
||||
t.Fatalf("OpenGateway base URL = %q, want %q", descriptor.DefaultBaseURL, "https://opengateway.gitlawb.com/v1")
|
||||
}
|
||||
if descriptor.Transport != TransportOpenAICompatible {
|
||||
t.Fatalf("OpenGateway transport = %q, want %q", descriptor.Transport, TransportOpenAICompatible)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCatalogDescriptorsExposeRequiredDefaults(t *testing.T) {
|
||||
for _, descriptor := range All() {
|
||||
if descriptor.ID == "" {
|
||||
@@ -264,7 +229,7 @@ func TestListByTransportPreservesCatalogOrder(t *testing.T) {
|
||||
TransportBedrock: {"bedrock"},
|
||||
TransportVertex: {"vertex"},
|
||||
TransportAnthropicCompat: {"minimax", "custom-anthropic-compatible"},
|
||||
TransportOpenAICompat: {"gitlawb-opengateway", "ollama-cloud", "ollama", "lmstudio", "openrouter", "huggingface", "chatgpt", "groq", "deepseek", "together", "dashscope", "moonshot", "nvidia-nim", "mistral", "github", "xai", "venice", "xiaomi-mimo", "bankr", "zai", "atomic-chat", "chatgpt-proxy", "custom-openai-compatible"},
|
||||
TransportOpenAICompat: {"ollama-cloud", "ollama", "lmstudio", "openrouter", "huggingface", "chatgpt", "groq", "deepseek", "together", "dashscope", "moonshot", "nvidia-nim", "mistral", "github", "xai", "venice", "xiaomi-mimo", "bankr", "zai", "gitlawb-opengateway", "atomic-chat", "chatgpt-proxy", "custom-openai-compatible"},
|
||||
}
|
||||
|
||||
for transport, wantIDs := range cases {
|
||||
|
||||
@@ -326,9 +326,6 @@ func healthRequest(ctx context.Context, profile config.ProviderProfile, kind con
|
||||
return nil, false, err
|
||||
}
|
||||
endpoint := baseURL + healthPath(kind)
|
||||
if override, ok := overrideHealthEndpoint(profile, baseURL); ok {
|
||||
endpoint = override
|
||||
}
|
||||
// Loopback is permitted only when the user's OWN configured base_url is loopback
|
||||
// (a local provider like Ollama/LM Studio) — never for a redirect target. This is
|
||||
// the flag returned to the caller so the dial path applies the same policy. (AUDIT-H1)
|
||||
@@ -595,22 +592,6 @@ func blockedAddrReason(addr netip.Addr) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// overrideHealthEndpoint returns a provider-specific connectivity probe URL when
|
||||
// the default {baseURL}+/models path does not exist. GitLawb OpenGateway is a
|
||||
// smart-routing gateway whose flat /v1/models endpoint 404s by design ("Use
|
||||
// /v1/<provider>/<path>"), so probe its public /health endpoint at the host root
|
||||
// instead, which reports real reachability without a per-model call.
|
||||
func overrideHealthEndpoint(profile config.ProviderProfile, baseURL string) (string, bool) {
|
||||
if profile.CatalogID != "gitlawb-opengateway" {
|
||||
return "", false
|
||||
}
|
||||
parsed, err := url.Parse(baseURL)
|
||||
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
|
||||
return "", false
|
||||
}
|
||||
return parsed.Scheme + "://" + parsed.Host + "/health", true
|
||||
}
|
||||
|
||||
func healthPath(kind config.ProviderKind) string {
|
||||
switch kind {
|
||||
case config.ProviderKindAnthropic, config.ProviderKindAnthropicCompat:
|
||||
|
||||
@@ -170,20 +170,3 @@ func TestProbeConnectivityRedactsProviderErrorDetails(t *testing.T) {
|
||||
t.Fatalf("custom header secret leaked in connectivity check: %s", rendered)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOverrideHealthEndpointOpenGateway(t *testing.T) {
|
||||
got, ok := overrideHealthEndpoint(config.ProviderProfile{CatalogID: "gitlawb-opengateway"}, "https://opengateway.gitlawb.com/v1")
|
||||
if !ok {
|
||||
t.Fatal("expected OpenGateway to override the health endpoint")
|
||||
}
|
||||
if got != "https://opengateway.gitlawb.com/health" {
|
||||
t.Fatalf("OpenGateway health endpoint = %q, want host-root /health", got)
|
||||
}
|
||||
|
||||
if _, ok := overrideHealthEndpoint(config.ProviderProfile{CatalogID: "openai"}, "https://api.openai.com/v1"); ok {
|
||||
t.Fatal("non-OpenGateway providers must not override the health endpoint")
|
||||
}
|
||||
if _, ok := overrideHealthEndpoint(config.ProviderProfile{CatalogID: "gitlawb-opengateway"}, "::not a url"); ok {
|
||||
t.Fatal("an unparseable base URL must not produce an override")
|
||||
}
|
||||
}
|
||||
@@ -129,18 +129,10 @@ var curatedModels = map[string][]Model{
|
||||
{ID: "glm-4.6", Description: "latest general model"},
|
||||
{ID: "glm-z1-air", Description: "reasoning model"},
|
||||
},
|
||||
// OpenGateway smart-routes by model id across its upstream providers
|
||||
// (see /health: xiaomi-mimo, minimax, qwen, google, nvidia, z-ai). These are
|
||||
// the curated coding defaults; the gateway accepts any model its upstreams
|
||||
// expose, so users can also type an id the picker doesn't list.
|
||||
"gitlawb-opengateway": {
|
||||
{ID: "mimo-v2.5-pro", Description: "catalog default (Xiaomi MiMo)"},
|
||||
{ID: "mimo-v2.5-pro-ultraspeed", Description: "fast model (Xiaomi MiMo)"},
|
||||
{ID: "MiniMax-M3", Description: "MiniMax model"},
|
||||
{ID: "qwen-plus", Description: "Qwen model"},
|
||||
{ID: "gemini-2.5-pro", Description: "long-context model (Google)"},
|
||||
{ID: "glm-4.6", Description: "Z.ai model"},
|
||||
{ID: "nvidia/llama-3.1-nemotron-70b-instruct", Description: "NVIDIA NIM model"},
|
||||
{ID: "gpt-4.1", Description: "catalog default"},
|
||||
{ID: "claude-sonnet-4.5", Description: "coding model"},
|
||||
{ID: "gemini-2.5-pro", Description: "long-context model"},
|
||||
},
|
||||
"atomic-chat": {
|
||||
{ID: "gpt-4.1", Description: "catalog default"},
|
||||
|
||||
@@ -54,7 +54,7 @@ func TestDefaultedOpenGatewayURL(t *testing.T) {
|
||||
if got := defaultedOpenGatewayURL(providercatalog.Descriptor{DefaultBaseURL: "https://gw.example.com/v1"}, ""); got != "https://gw.example.com/zero/models.json" {
|
||||
t.Fatalf("derived = %q", got)
|
||||
}
|
||||
if got := defaultedOpenGatewayURL(providercatalog.Descriptor{DefaultBaseURL: "::not a url"}, ""); got != "https://opengateway.gitlawb.com/zero/models.json" {
|
||||
if got := defaultedOpenGatewayURL(providercatalog.Descriptor{DefaultBaseURL: "::not a url"}, ""); got != "https://gateway.gitlawb.com/zero/models.json" {
|
||||
t.Fatalf("fallback = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -223,7 +223,7 @@ func defaultedOpenGatewayURL(provider providercatalog.Descriptor, override strin
|
||||
}
|
||||
parsed, err := url.Parse(provider.DefaultBaseURL)
|
||||
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
|
||||
return "https://opengateway.gitlawb.com/zero/models.json"
|
||||
return "https://gateway.gitlawb.com/zero/models.json"
|
||||
}
|
||||
return parsed.Scheme + "://" + parsed.Host + "/zero/models.json"
|
||||
}
|
||||
|
||||
@@ -59,7 +59,6 @@ const (
|
||||
responsesEventOutputItemAdded = "response.output_item.added"
|
||||
responsesEventContentPartAdded = "response.content_part.added"
|
||||
responsesEventOutputTextDelta = "response.output_text.delta"
|
||||
responsesEventReasoningDelta = "response.reasoning_summary_text.delta"
|
||||
responsesEventOutputTextDone = "response.output_text.done"
|
||||
responsesEventFunctionArgsDelta = "response.function_call_arguments.delta"
|
||||
responsesEventContentPartDone = "response.content_part.done"
|
||||
@@ -87,10 +86,6 @@ type responsesRequest struct {
|
||||
// here; omitted entirely when the caller requests no (or an unsupported) effort.
|
||||
type responsesReasoning struct {
|
||||
Effort string `json:"effort,omitempty"`
|
||||
// Summary requests a streamed reasoning summary ("auto" lets the API pick a
|
||||
// level). Without it the backend emits no reasoning events, so a long thinking
|
||||
// phase produces zero visible output and reads as a hang in the UI.
|
||||
Summary string `json:"summary,omitempty"`
|
||||
}
|
||||
|
||||
// inputItem is one element of the Responses `input` array. The Type field
|
||||
@@ -261,9 +256,7 @@ func (p *CodexProvider) buildResponsesRequest(request zeroruntime.CompletionRequ
|
||||
// and an empty or unsupported effort simply omits the field — without this the
|
||||
// caller's chosen effort was silently dropped for every Codex model.
|
||||
if effort := openAIReasoningEffort(request.ReasoningEffort); effort != "" {
|
||||
// Summary "auto" makes the backend stream reasoning_summary_text deltas so a
|
||||
// long thinking phase shows live progress instead of looking hung.
|
||||
req.Reasoning = &responsesReasoning{Effort: effort, Summary: "auto"}
|
||||
req.Reasoning = &responsesReasoning{Effort: effort}
|
||||
}
|
||||
return req, nil
|
||||
}
|
||||
@@ -508,17 +501,6 @@ func (p *CodexProvider) emitResponsesEvent(
|
||||
})
|
||||
}
|
||||
return true
|
||||
case responsesEventReasoningDelta:
|
||||
// Reasoning summary deltas: surface as live "thinking" so a long reasoning
|
||||
// phase shows progress (and keeps the activity clock fresh) instead of
|
||||
// looking like a hang. Requested via reasoning.summary="auto".
|
||||
if event.Delta != "" {
|
||||
providerio.SendEvent(ctx, events, zeroruntime.StreamEvent{
|
||||
Type: zeroruntime.StreamEventReasoning,
|
||||
Content: event.Delta,
|
||||
})
|
||||
}
|
||||
return true
|
||||
case responsesEventFunctionArgsDelta:
|
||||
p.handleFunctionArgsDelta(ctx, &event, state, events)
|
||||
return true
|
||||
|
||||
@@ -485,58 +485,6 @@ func TestCodexProviderForwardsReasoningEffort(t *testing.T) {
|
||||
if reasoning["effort"] != "high" {
|
||||
t.Fatalf("body.reasoning.effort = %#v, want high", reasoning["effort"])
|
||||
}
|
||||
// A reasoning summary must be requested so the backend streams
|
||||
// reasoning_summary_text deltas — otherwise a long think shows no live output.
|
||||
if reasoning["summary"] != "auto" {
|
||||
t.Fatalf("body.reasoning.summary = %#v, want auto", reasoning["summary"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodexProviderStreamsReasoningSummaryDeltas(t *testing.T) {
|
||||
// reasoning_summary_text deltas must surface as StreamEventReasoning (live
|
||||
// "thinking"), in order, alongside the normal text output. Without this a long
|
||||
// reasoning phase produces zero visible output and reads as a hang.
|
||||
var rec codexRequest
|
||||
srv := newCodexResponsesServer(t, &rec,
|
||||
`{"type":"response.created","response":{"id":"resp-1","status":"in_progress"}}`,
|
||||
`{"type":"response.reasoning_summary_text.delta","delta":"Thinking. "}`,
|
||||
`{"type":"response.reasoning_summary_text.delta","delta":"Planning."}`,
|
||||
`{"type":"response.output_text.delta","delta":"done"}`,
|
||||
`{"type":"response.completed","response":{"id":"resp-1","status":"completed"}}`,
|
||||
)
|
||||
defer srv.Close()
|
||||
|
||||
provider, err := NewCodexProvider(CodexOptions{
|
||||
Options: Options{APIKey: "sk-test", BaseURL: srv.URL, Model: "gpt-5"},
|
||||
AccountID: "acc-x",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewCodexProvider: %v", err)
|
||||
}
|
||||
stream, err := provider.StreamCompletion(context.Background(), zeroruntime.CompletionRequest{
|
||||
Messages: []zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "solve it"}},
|
||||
ReasoningEffort: "high",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("StreamCompletion: %v", err)
|
||||
}
|
||||
var reasoning, text []string
|
||||
for ev := range stream {
|
||||
switch ev.Type {
|
||||
case zeroruntime.StreamEventReasoning:
|
||||
reasoning = append(reasoning, ev.Content)
|
||||
case zeroruntime.StreamEventText:
|
||||
text = append(text, ev.Content)
|
||||
case zeroruntime.StreamEventError:
|
||||
t.Fatalf("unexpected error event: %q", ev.Error)
|
||||
}
|
||||
}
|
||||
if got := strings.Join(reasoning, ""); got != "Thinking. Planning." {
|
||||
t.Fatalf("reasoning deltas = %q, want %q", got, "Thinking. Planning.")
|
||||
}
|
||||
if got := strings.Join(text, ""); got != "done" {
|
||||
t.Fatalf("text deltas = %q, want %q", got, "done")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodexProviderOmitsReasoningWhenUnset(t *testing.T) {
|
||||
|
||||
@@ -385,14 +385,6 @@ func sendBufferedEvent(events chan<- zeroruntime.StreamEvent, event zeroruntime.
|
||||
func (provider *Provider) openAIRequest(request zeroruntime.CompletionRequest) chatCompletionRequest {
|
||||
messages := make([]chatMessage, 0, len(request.Messages))
|
||||
for _, message := range request.Messages {
|
||||
// Drop a degenerate assistant turn that carries neither text nor tool calls
|
||||
// (e.g. a sub-agent that failed with no output). The Anthropic/Gemini mappers
|
||||
// already skip empty turns; without this, the contentless message reaches
|
||||
// strict OpenAI-compatible servers and is rejected.
|
||||
if message.Role == zeroruntime.MessageRoleAssistant &&
|
||||
strings.TrimSpace(message.Content) == "" && len(message.ToolCalls) == 0 {
|
||||
continue
|
||||
}
|
||||
messages = append(messages, mapMessage(message))
|
||||
}
|
||||
|
||||
@@ -453,12 +445,11 @@ func mapMessage(message zeroruntime.Message) chatMessage {
|
||||
// message that happens to carry Images keeps the plain string/nil content
|
||||
// path (its images are simply not serialized).
|
||||
if len(message.Images) == 0 || message.Role != zeroruntime.MessageRoleUser {
|
||||
// Always set content (to "" when empty) so it serializes as `"content":""`
|
||||
// rather than being dropped. Strict OpenAI-compatible servers reject a
|
||||
// message with no content field; tool results and assistant-with-tool-calls
|
||||
// turns must still carry an (empty) content. Truly empty assistant turns are
|
||||
// dropped upstream in openAIRequest.
|
||||
mapped.Content = message.Content
|
||||
// Preserve the legacy behavior: only non-empty text is serialized, so
|
||||
// empty content is omitted by the `omitempty` tag.
|
||||
if message.Content != "" {
|
||||
mapped.Content = message.Content
|
||||
}
|
||||
} else {
|
||||
parts := make([]contentPart, 0, len(message.Images)+1)
|
||||
if message.Content != "" {
|
||||
|
||||
@@ -1000,6 +1000,57 @@ func TestStreamCompletionEmitsDroppedOnNamelessToolCall(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAIRequestTextOnlyOmitsEmptyContent(t *testing.T) {
|
||||
provider, err := New(Options{Model: "gpt-test"})
|
||||
if err != nil {
|
||||
t.Fatalf("New returned error: %v", err)
|
||||
}
|
||||
|
||||
got, err := json.Marshal(provider.openAIRequest(zeroruntime.CompletionRequest{
|
||||
Messages: []zeroruntime.Message{
|
||||
{Role: zeroruntime.MessageRoleSystem, Content: "system"},
|
||||
{Role: zeroruntime.MessageRoleUser, Content: "hello"},
|
||||
{
|
||||
Role: zeroruntime.MessageRoleAssistant,
|
||||
Content: "", // assistant turn that is *only* a tool call -> empty content must drop
|
||||
ToolCalls: []zeroruntime.ToolCall{{
|
||||
ID: "call_1",
|
||||
Name: "read_file",
|
||||
Arguments: `{"path":"README.md"}`,
|
||||
}},
|
||||
},
|
||||
{Role: zeroruntime.MessageRoleTool, Content: "contents", ToolCallID: "call_1"},
|
||||
},
|
||||
}))
|
||||
if err != nil {
|
||||
t.Fatalf("marshal request: %v", err)
|
||||
}
|
||||
|
||||
// String content round-trips as a JSON string, and empty content is omitted.
|
||||
if !strings.Contains(string(got), `"content":"hello"`) {
|
||||
t.Fatalf("user content not serialized as string: %s", got)
|
||||
}
|
||||
if strings.Contains(string(got), `"content":""`) {
|
||||
t.Fatalf("empty assistant content must be omitted, got: %s", got)
|
||||
}
|
||||
|
||||
// Decode the assistant message and assert no content key at all.
|
||||
var decoded chatCompletionRequest
|
||||
var raw struct {
|
||||
Messages []map[string]any `json:"messages"`
|
||||
}
|
||||
if err := json.Unmarshal(got, &raw); err != nil {
|
||||
t.Fatalf("unmarshal request: %v", err)
|
||||
}
|
||||
_ = decoded
|
||||
if _, present := raw.Messages[2]["content"]; present {
|
||||
t.Fatalf("assistant (tool-only) message must omit content key, got: %#v", raw.Messages[2])
|
||||
}
|
||||
if raw.Messages[1]["content"] != "hello" {
|
||||
t.Fatalf("user content = %#v, want \"hello\"", raw.Messages[1]["content"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestContentPartImageURLMarshalsDataURI(t *testing.T) {
|
||||
parts := []contentPart{
|
||||
{Type: "text", Text: "look"},
|
||||
@@ -1025,6 +1076,38 @@ func TestContentPartImageURLMarshalsDataURI(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAIRequestEmptyContentStaysOmittedAfterAnyRefactor(t *testing.T) {
|
||||
provider, err := New(Options{Model: "gpt-test"})
|
||||
if err != nil {
|
||||
t.Fatalf("New returned error: %v", err)
|
||||
}
|
||||
got, err := json.Marshal(provider.openAIRequest(zeroruntime.CompletionRequest{
|
||||
Messages: []zeroruntime.Message{
|
||||
{
|
||||
Role: zeroruntime.MessageRoleAssistant,
|
||||
Content: "",
|
||||
ToolCalls: []zeroruntime.ToolCall{{
|
||||
ID: "call_1",
|
||||
Name: "read_file",
|
||||
Arguments: `{}`,
|
||||
}},
|
||||
},
|
||||
},
|
||||
}))
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
var raw struct {
|
||||
Messages []map[string]any `json:"messages"`
|
||||
}
|
||||
if err := json.Unmarshal(got, &raw); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if _, present := raw.Messages[0]["content"]; present {
|
||||
t.Fatalf("empty content boxed in any must stay omitted, got: %#v", raw.Messages[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestMapMessageBuildsImageURLContentParts(t *testing.T) {
|
||||
msg := mapMessage(zeroruntime.Message{
|
||||
Role: zeroruntime.MessageRoleUser,
|
||||
@@ -1082,8 +1165,8 @@ func TestMapMessageTextOnlyKeepsStringContent(t *testing.T) {
|
||||
t.Fatalf("Content = %#v, want string \"hi\"", msg.Content)
|
||||
}
|
||||
empty := mapMessage(zeroruntime.Message{Role: zeroruntime.MessageRoleAssistant, Content: ""})
|
||||
if got, ok := empty.Content.(string); !ok || got != "" {
|
||||
t.Fatalf("empty text content = %#v, want \"\" so it serializes as content:\"\" (strict servers reject a missing/null content)", empty.Content)
|
||||
if empty.Content != nil {
|
||||
t.Fatalf("empty text content = %#v, want nil so omitempty drops it", empty.Content)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1163,66 +1246,3 @@ func TestStreamCompletionSerializesImageContentParts(t *testing.T) {
|
||||
t.Fatalf("image url = %#v, want data:image/png;base64,QUJD", imageURL["url"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestOpenAIRequestEmptyContentHandling locks in the fix for strict OpenAI-
|
||||
// compatible servers (e.g. glm-* on Ollama-cloud) that reject a message whose
|
||||
// `content` is absent/null with "invalid message content type: <nil>": a
|
||||
// contentless assistant turn with no tool calls is dropped, and every other
|
||||
// message serializes an explicit `"content"` field (empty string when there is
|
||||
// no text) instead of omitting it.
|
||||
func TestOpenAIRequestEmptyContentHandling(t *testing.T) {
|
||||
provider, err := New(Options{Model: "gpt-test"})
|
||||
if err != nil {
|
||||
t.Fatalf("New returned error: %v", err)
|
||||
}
|
||||
|
||||
req := provider.openAIRequest(zeroruntime.CompletionRequest{
|
||||
Messages: []zeroruntime.Message{
|
||||
{Role: zeroruntime.MessageRoleUser, Content: "hi"},
|
||||
// Degenerate empty assistant turn (e.g. a sub-agent that failed with no
|
||||
// output): must be dropped, not sent.
|
||||
{Role: zeroruntime.MessageRoleAssistant, Content: " "},
|
||||
// Assistant with tool calls but no text: kept, content present as "".
|
||||
{Role: zeroruntime.MessageRoleAssistant, Content: "", ToolCalls: []zeroruntime.ToolCall{{
|
||||
ID: "call_1", Name: "read_file", Arguments: "{}",
|
||||
}}},
|
||||
// Tool result with empty content: kept, content present as "".
|
||||
{Role: zeroruntime.MessageRoleTool, Content: "", ToolCallID: "call_1"},
|
||||
},
|
||||
})
|
||||
|
||||
if len(req.Messages) != 3 {
|
||||
t.Fatalf("empty assistant turn should be dropped; got %d messages: %#v", len(req.Messages), req.Messages)
|
||||
}
|
||||
|
||||
// No message may serialize with an absent/null content field.
|
||||
for i, message := range req.Messages {
|
||||
if message.Content == nil {
|
||||
t.Fatalf("message %d has nil content (would serialize as null/omitted): %#v", i, message)
|
||||
}
|
||||
data, err := json.Marshal(message)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal message %d: %v", i, err)
|
||||
}
|
||||
if !strings.Contains(string(data), `"content":`) {
|
||||
t.Fatalf("message %d omits the content field: %s", i, data)
|
||||
}
|
||||
}
|
||||
|
||||
if req.Messages[0].Content != "hi" {
|
||||
t.Fatalf("user content = %#v, want \"hi\"", req.Messages[0].Content)
|
||||
}
|
||||
// The kept-but-textless messages must send content as an explicit empty string.
|
||||
for _, idx := range []int{1, 2} {
|
||||
data, err := json.Marshal(req.Messages[idx])
|
||||
if err != nil {
|
||||
t.Fatalf("marshal message %d: %v", idx, err)
|
||||
}
|
||||
if !strings.Contains(string(data), `"content":""`) {
|
||||
t.Fatalf("message %d should send content:\"\", got %s", idx, data)
|
||||
}
|
||||
}
|
||||
if len(req.Messages[1].ToolCalls) != 1 {
|
||||
t.Fatalf("assistant tool calls dropped: %#v", req.Messages[1])
|
||||
}
|
||||
}
|
||||
@@ -18,13 +18,8 @@ type streamOptions struct {
|
||||
}
|
||||
|
||||
type chatMessage struct {
|
||||
Role string `json:"role"`
|
||||
// Content has no omitempty: strict OpenAI-compatible servers (e.g. some
|
||||
// Ollama-cloud models like glm-*) reject a message whose `content` is absent
|
||||
// or null with "invalid message content type: <nil>". mapMessage always sets
|
||||
// this (to "" when there's no text), so a contentless message serializes as
|
||||
// `"content":""` rather than being dropped.
|
||||
Content any `json:"content"`
|
||||
Role string `json:"role"`
|
||||
Content any `json:"content,omitempty"`
|
||||
ToolCalls []requestToolCall `json:"tool_calls,omitempty"`
|
||||
ToolCallID string `json:"tool_call_id,omitempty"`
|
||||
}
|
||||
|
||||
@@ -1495,14 +1495,7 @@ func (m model) setupProviderLines(width int, height int) []string {
|
||||
marker = "❯ "
|
||||
style = zeroTheme.accent.Bold(true)
|
||||
}
|
||||
label := displayValue(option.Name, option.ID)
|
||||
if option.Recommended {
|
||||
label = "★ " + label
|
||||
}
|
||||
line := marker + style.Render(label)
|
||||
if option.Recommended {
|
||||
line += zeroTheme.faint.Render(" (recommended)")
|
||||
}
|
||||
line := marker + style.Render(displayValue(option.Name, option.ID))
|
||||
lines = append(lines, padSetupLine(line, rowWidth))
|
||||
}
|
||||
// A failed OAuth login drops back here with oauthPending cleared, so the
|
||||
|
||||
@@ -99,7 +99,6 @@ type SetupProviderOption struct {
|
||||
EnvVar string
|
||||
RequiresAuth bool
|
||||
Local bool
|
||||
Recommended bool
|
||||
}
|
||||
|
||||
// SetupSelection is the user's setup choice.
|
||||
|
||||
@@ -1240,26 +1240,13 @@ func (wizard *providerWizardState) renderSelectableProvider(width int, index int
|
||||
surface = zeroTheme.onSel
|
||||
marker = surface(zeroTheme.accent).Render("❯ ")
|
||||
}
|
||||
name := provider.Name
|
||||
if provider.Recommended {
|
||||
name = "★ " + name
|
||||
}
|
||||
left := marker + surface(zeroTheme.ink).Render(name)
|
||||
if badge := providerWizardBadge(provider); badge != "" {
|
||||
left := marker + surface(zeroTheme.ink).Render(provider.Name)
|
||||
if badge := providerWizardOAuthBadge(provider); badge != "" {
|
||||
left += surface(zeroTheme.faint).Render(" " + badge)
|
||||
}
|
||||
return fitStyledLine(left, width)
|
||||
}
|
||||
|
||||
// providerWizardBadge is the faint hint shown next to a provider row. The
|
||||
// recommended provider takes precedence over the OAuth hint.
|
||||
func providerWizardBadge(provider providercatalog.Descriptor) string {
|
||||
if provider.Recommended {
|
||||
return "(recommended)"
|
||||
}
|
||||
return providerWizardOAuthBadge(provider)
|
||||
}
|
||||
|
||||
// providerWizardOAuthBadge is the faint mode hint shown next to OAuth providers.
|
||||
func providerWizardOAuthBadge(provider providercatalog.Descriptor) string {
|
||||
if !provider.OAuth {
|
||||
|
||||
@@ -53,7 +53,7 @@ func TestProviderCommandOpensOnboardingWizard(t *testing.T) {
|
||||
updated, _ = next.Update(testKey(tea.KeyEnter))
|
||||
next = updated.(model)
|
||||
listView := plainRender(t, next.View())
|
||||
for _, want := range []string{"Choose provider", "GitLawb OpenGateway", "OpenAI", "Anthropic", "Google", "OpenRouter", "Ollama"} {
|
||||
for _, want := range []string{"Choose provider", "OpenAI", "Anthropic", "Google", "Groq", "OpenRouter", "Ollama"} {
|
||||
assertContains(t, listView, want)
|
||||
}
|
||||
}
|
||||
@@ -172,12 +172,8 @@ func TestProviderWizardAdvancesProviderAPIKeyAndModelSteps(t *testing.T) {
|
||||
m := newModel(context.Background(), Options{})
|
||||
m = openProviderWizardForTest(t, m)
|
||||
|
||||
// Index 0 is the recommended OpenGateway entry, so two downs land on anthropic
|
||||
// (opengateway → openai → anthropic).
|
||||
updated, _ := m.Update(testKey(tea.KeyDown))
|
||||
next := updated.(model)
|
||||
updated, _ = next.Update(testKey(tea.KeyDown))
|
||||
next = updated.(model)
|
||||
if got := next.providerWizard.currentProvider().ID; got != "anthropic" {
|
||||
t.Fatalf("after down, selected provider = %q, want anthropic", got)
|
||||
}
|
||||
@@ -313,15 +309,8 @@ func TestProviderWizardRightAllowsExistingCredentialEnv(t *testing.T) {
|
||||
m := newModel(context.Background(), Options{})
|
||||
m = openProviderWizardForTest(t, m)
|
||||
|
||||
// Move off the recommended OpenGateway entry (index 0) onto openai so the
|
||||
// OPENAI_API_KEY env credential is the one in play.
|
||||
updated, _ := m.Update(testKey(tea.KeyDown))
|
||||
updated, _ := m.Update(testKey(tea.KeyEnter))
|
||||
next := updated.(model)
|
||||
if got := next.providerWizard.currentProvider().ID; got != "openai" {
|
||||
t.Fatalf("after down, selected provider = %q, want openai", got)
|
||||
}
|
||||
updated, _ = next.Update(testKey(tea.KeyEnter))
|
||||
next = updated.(model)
|
||||
if next.providerWizard.step != providerWizardStepCredential {
|
||||
t.Fatalf("enter from provider step = %v, want credential", next.providerWizard.step)
|
||||
}
|
||||
|
||||
@@ -7,22 +7,10 @@ import (
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"github.com/charmbracelet/colorprofile"
|
||||
"github.com/charmbracelet/x/term"
|
||||
)
|
||||
|
||||
// Run starts the Zero Bubble Tea shell and returns a process-style exit code.
|
||||
func Run(ctx context.Context, options Options) int {
|
||||
// The interactive shell needs a real terminal on stdin: with piped or
|
||||
// redirected input Bubble Tea blocks forever waiting for events that never
|
||||
// arrive (e.g. `echo "" | zero`). Fail fast with guidance toward the headless
|
||||
// path instead of hanging. term.IsTerminal is a true TTY check (it rejects
|
||||
// pipes, regular files, and non-terminal char devices like /dev/null) and
|
||||
// fails closed — anything that is not a verified terminal blocks the shell.
|
||||
if !term.IsTerminal(os.Stdin.Fd()) {
|
||||
fmt.Fprintln(os.Stderr, "zero: the interactive shell needs a terminal (stdin is not a TTY). For non-interactive use, run: zero exec \"<prompt>\"")
|
||||
return 2
|
||||
}
|
||||
|
||||
externalSink := options.RuntimeMessageSink
|
||||
var program *tea.Program
|
||||
options.RuntimeMessageSink = func(msg tea.Msg) {
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
import "testing"
|
||||
|
||||
func TestUseAltScreenForInteractiveChat(t *testing.T) {
|
||||
if !useAltScreen(Options{}) {
|
||||
@@ -15,31 +10,3 @@ func TestUseAltScreenForInteractiveChat(t *testing.T) {
|
||||
t.Fatal("setup takeover should also use the alternate screen")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunRejectsNonTTYStdin pins that the interactive shell fails fast with a
|
||||
// non-zero code when stdin is not a terminal, instead of blocking forever in the
|
||||
// Bubble Tea event loop (e.g. `echo "" | zero`). The guard runs before any model
|
||||
// construction, so empty Options are fine.
|
||||
func TestRunRejectsNonTTYStdin(t *testing.T) {
|
||||
r, w, err := os.Pipe()
|
||||
if err != nil {
|
||||
t.Fatalf("os.Pipe: %v", err)
|
||||
}
|
||||
w.Close() // a pipe read-end is not a character device
|
||||
|
||||
old := os.Stdin
|
||||
os.Stdin = r
|
||||
defer func() { os.Stdin = old; r.Close() }()
|
||||
|
||||
done := make(chan int, 1)
|
||||
go func() { done <- Run(context.Background(), Options{}) }()
|
||||
|
||||
select {
|
||||
case code := <-done:
|
||||
if code != 2 {
|
||||
t.Fatalf("Run with non-TTY stdin returned %d; want exit code 2 from the stdin TTY guard", code)
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("Run blocked on non-TTY stdin instead of failing fast")
|
||||
}
|
||||
}
|
||||
@@ -261,11 +261,13 @@ func (m model) swarmSpawnedAgents() []swarmAgent {
|
||||
switch a.state {
|
||||
case "done", "failed", "completed", "cancelled":
|
||||
a.finishing = true
|
||||
// A member drops once ITS OWN task completes — not when the whole turn
|
||||
// ends: fade out over the linger window from when it was first seen
|
||||
// finished (stamped each tick by stampSwarmDone), then remove. This
|
||||
// holds whether or not the overall run is still in flight, mirroring
|
||||
// how finished specialists drop (sidebarSpecialists).
|
||||
if m.pending {
|
||||
// Run still going: keep it visible and clickable, no fade.
|
||||
a.finishedAt = time.Time{}
|
||||
live = append(live, a)
|
||||
continue
|
||||
}
|
||||
// Turn ended: fade out over the linger window, then drop.
|
||||
doneAt, stamped := m.swarmDoneAt[a.id]
|
||||
if stamped && m.now().Sub(doneAt) >= sidebarAgentLinger {
|
||||
continue // past the linger window — remove
|
||||
|
||||
@@ -391,32 +391,32 @@ func TestSidebarShowsSwarmSpawnedAgents(t *testing.T) {
|
||||
// row (e.g. a resumed transcript that dropped the call): the member is still
|
||||
// A finished member stays visible (and clickable) while the run is still in
|
||||
// flight, so the user can inspect it; only once the turn ends does it drop.
|
||||
func TestSwarmAgentDropsOnOwnCompletionEvenMidRun(t *testing.T) {
|
||||
func TestSwarmAgentsPersistWhileRunInFlight(t *testing.T) {
|
||||
base := time.Date(2026, 6, 24, 12, 0, 0, 0, time.UTC)
|
||||
m := sidebarTestModel()
|
||||
m.now = func() time.Time { return base }
|
||||
m.pending = true // run STILL going — a member must still drop on its OWN completion
|
||||
m.pending = true // run still going
|
||||
m.activeRunID = 7 // exercise the run-scoped filter with a non-zero id
|
||||
m.transcript = append(m.transcript,
|
||||
transcriptRow{kind: rowToolCall, tool: "swarm_spawn", detail: "build homepage", runID: 7},
|
||||
transcriptRow{kind: rowToolResult, tool: "swarm_spawn", detail: "Spawned subagent as task subagent-1 on team default.", runID: 7},
|
||||
transcriptRow{kind: rowToolResult, tool: "swarm_collect", detail: "Results: 1 task(s)\n- subagent-1 [done] build homepage", runID: 7},
|
||||
)
|
||||
// Just finished, still within the linger window: shows briefly with a fading ✓.
|
||||
m.swarmDoneAt = map[string]time.Time{"subagent-1": base.Add(-sidebarAgentLinger / 2)}
|
||||
// Long past the linger window — but it must still show while pending.
|
||||
m.swarmDoneAt = map[string]time.Time{"subagent-1": base.Add(-10 * sidebarAgentLinger)}
|
||||
|
||||
agents := m.swarmSpawnedAgents()
|
||||
if len(agents) != 1 {
|
||||
t.Fatalf("within the linger window a finished member should still show (fading), got %d: %+v", len(agents), agents)
|
||||
t.Fatalf("a finished member must stay while the run is in flight, got %d: %+v", len(agents), agents)
|
||||
}
|
||||
if !agents[0].finishing {
|
||||
t.Fatalf("a finished member should render done (✓), got %+v", agents[0])
|
||||
}
|
||||
|
||||
// Past the linger window: it drops — even though the overall run is still in
|
||||
// flight (previously a finished member lingered until the whole turn ended).
|
||||
m.swarmDoneAt = map[string]time.Time{"subagent-1": base.Add(-2 * sidebarAgentLinger)}
|
||||
// Once the turn ends, the long-finished member fades out and drops.
|
||||
m.pending = false
|
||||
if got := len(m.swarmSpawnedAgents()); got != 0 {
|
||||
t.Fatalf("a member past its linger window must drop on its own completion mid-run, got %d", got)
|
||||
t.Fatalf("after the turn ends a long-finished member should drop, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -86,7 +86,6 @@ type ProviderCatalogSnapshot struct {
|
||||
Local bool `json:"local"`
|
||||
RuntimeSupported bool `json:"runtimeSupported"`
|
||||
RuntimeUnsupportedReason string `json:"runtimeUnsupportedReason,omitempty"`
|
||||
Recommended bool `json:"recommended,omitempty"`
|
||||
}
|
||||
|
||||
type SessionSnapshot struct {
|
||||
@@ -219,9 +218,6 @@ func ProviderCatalogSnapshots(options ProviderCatalogSnapshotOptions) ([]Provide
|
||||
snapshots = append(snapshots, ProviderCatalogSnapshotFromDescriptor(descriptor))
|
||||
}
|
||||
sort.SliceStable(snapshots, func(i int, j int) bool {
|
||||
if snapshots[i].Recommended != snapshots[j].Recommended {
|
||||
return snapshots[i].Recommended
|
||||
}
|
||||
return snapshots[i].ID < snapshots[j].ID
|
||||
})
|
||||
return snapshots, nil
|
||||
@@ -248,7 +244,6 @@ func ProviderCatalogSnapshotFromDescriptor(descriptor providercatalog.Descriptor
|
||||
Local: descriptor.Local,
|
||||
RuntimeSupported: providercatalog.RuntimeSupported(descriptor),
|
||||
RuntimeUnsupportedReason: providercatalog.RuntimeUnsupportedReason(descriptor),
|
||||
Recommended: descriptor.Recommended,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -164,16 +164,11 @@ func TestProviderCatalogSnapshotsExposeStableDescriptors(t *testing.T) {
|
||||
if len(snapshots) < 4 {
|
||||
t.Fatalf("expected provider catalog descriptors, got %#v", snapshots)
|
||||
}
|
||||
if !snapshots[0].Recommended {
|
||||
t.Fatalf("expected the recommended provider first, got %#v", snapshots[0])
|
||||
}
|
||||
for index, snapshot := range snapshots {
|
||||
if snapshot.ID == "" || snapshot.Name == "" || snapshot.Transport == "" {
|
||||
t.Fatalf("catalog snapshot missing identity fields: %#v", snapshot)
|
||||
}
|
||||
// The recommended provider is hoisted to the front; everything after it is
|
||||
// sorted by id.
|
||||
if index > 0 && !snapshots[index-1].Recommended && snapshots[index-1].ID > snapshot.ID {
|
||||
if index > 0 && snapshots[index-1].ID > snapshot.ID {
|
||||
t.Fatalf("provider catalog snapshots are not sorted by id: %#v", snapshots)
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -38,5 +38,5 @@
|
||||
"bugs": {
|
||||
"url": "https://github.com/Gitlawb/zero/issues"
|
||||
},
|
||||
"license": "MIT"
|
||||
"license": "SEE LICENSE IN LICENSE"
|
||||
}
|
||||
@@ -102,19 +102,6 @@ if (!platform || !arch) {
|
||||
);
|
||||
}
|
||||
|
||||
// The release matrix builds linux/macos {x64,arm64} and windows-x64 — there is no
|
||||
// windows-arm64 artifact. (win32, arm64) otherwise resolves to a valid
|
||||
// platform/arch, so it would proceed to download a non-existent asset and hard
|
||||
// -fail npm install on a 404. Guard it explicitly to skip gracefully; Windows on
|
||||
// ARM runs the x64 build under emulation, so the x64 package is the fallback.
|
||||
if (platform === 'windows' && arch === 'arm64') {
|
||||
warnSkip(
|
||||
`no prebuilt binary for windows-arm64 (the windows-x64 build runs under ` +
|
||||
`emulation on Windows on ARM). Build from source or install the x64 package: ` +
|
||||
`https://github.com/${REPO} (go run ./cmd/zero).`,
|
||||
);
|
||||
}
|
||||
|
||||
const ext = platform === 'windows' ? 'zip' : 'tar.gz';
|
||||
const binaryName = platform === 'windows' ? 'zero.exe' : 'zero';
|
||||
const tag = `v${VERSION}`;
|
||||
|
||||
Reference in new issue
Block a user