Compare commits

..
Author SHA1 Message Date
Gnanam c169b6119e test(swarm): also assert handoff to_agent_type enum picks up a custom roster type
Address review: the custom-type recheck only re-read spawnTool, so a regression
leaving handoffTool on the built-in list would have passed.
2026-06-30 20:05:17 +05:30
Gnanam a555b584fe fix(swarm): constrain spawn/handoff agent_type to the registered roster via enum
swarm_spawn and swarm_handoff described agent_type / to_agent_type as a free-form
string with only an 'e.g. teammate, subagent' hint, so a model could pass an
unregistered type (e.g. 'worker') and only discover it was invalid from the runtime
'unknown agent type' error — wasting tool calls, especially on weaker models.

Set the parameter's enum dynamically from the live roster (Registry.AgentTypes()),
so the schema advertises the valid set (built-ins plus any user-registered agents)
up front and an invalid type can't be sent. Falls back to a free-form string when
the roster is unavailable, so the tool never advertises an empty, un-satisfiable
enum. The runtime 'available agent types: …' error is kept as a backstop.
2026-06-30 19:28:44 +05:30
KRATOS cfd065b565 fix(openai): always send message content so strict OpenAI-compatible servers accept it (#343)
Some OpenAI-compatible endpoints (e.g. glm-* on Ollama-cloud) reject a request whose
message 'content' is absent or null with HTTP 400 'invalid message content type:
<nil>'. The chat-completions mapper relied on 'content,omitempty', so a message with
empty text (a tool result, an assistant turn that only makes tool calls, or a
sub-agent that failed with no output) serialized with no content field at all.

- Drop omitempty on chatMessage.Content and always set it (to "" when there is no
  text), so contentless messages serialize as "content":"" instead of being
  dropped. Standard OpenAI/xAI/groq already tolerated the omission; this makes the
  strict servers work too.
- Skip a degenerate assistant turn that has neither text nor tool calls, mirroring
  the Anthropic/Gemini mappers (which already drop empty turns).

Fixes the failure seen on glm-5.2:cloud after a sub-agent failed with empty output.
2026-06-30 11:19:06 +05:30
Anandan fb3a0bff44 Document source-build sandbox helpers (#342)
Clarify that Linux source builds need the zero-linux-sandbox helper on PATH for native sandboxing, while macOS does not need an extra helper and Windows can self-dispatch from zero.exe.

Also document optional release-style helper builds for Linux and Windows.

Tested: git diff --check
2026-06-30 11:16:52 +05:30
072f3877b5 fix(opengateway): correct gateway host, make it the recommended default (#341)
OpenGateway pointed at gateway.gitlawb.com, which does not resolve
(NXDOMAIN), so every request failed with "no such host". The live gateway
is opengateway.gitlawb.com — a flat OpenAI-compatible endpoint
(/v1/chat/completions, Authorization: Bearer ogw_live_…) that smart-routes
by model id across its upstreams (xiaomi-mimo, minimax, qwen, google,
nvidia, z-ai).

- catalog: fix base URL to https://opengateway.gitlawb.com/v1, default model
  to mimo-v2.5-pro; add a Recommended descriptor field and hoist OpenGateway
  to the top of the catalog so it is the first/badged pick everywhere.
- models: replace the curated model list with the gateway's real upstream
  models; fix the stale model-discovery fallback host.
- providers catalog: sort recommended-first (CLI + snapshot) and render the
  ★ … (recommended) badge; same badge in the TUI /provider wizard and the
  setup onboarding list.
- providerhealth: OpenGateway's flat /v1/models 404s by design, so probe its
  public /health endpoint for connectivity instead of reporting a false fail.
- tests: update catalog order golden + transport/discovery expectations; add
  coverage for the recommended provider and the health-endpoint override.

Co-authored-by: Kevin Codex <[email protected]>
Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
2026-06-30 10:14:01 +08:00
Vasanth T 1dd884d6e9 chore(launch): MIT license, windows-arm64 install guard, TUI TTY guard (#340)
* chore(launch): MIT license field, windows-arm64 install guard, TUI TTY guard

Three launch-readiness fixes the docs cleanup doesn't cover:

- package.json: set "license": "MIT" (was the placeholder "SEE LICENSE IN
  LICENSE"), matching the LICENSE file and the README badge.
- scripts/postinstall.mjs: skip gracefully on windows-arm64. (win32, arm64)
  resolves to a valid platform/arch but the release matrix ships no
  windows-arm64 artifact, so the installer would download a 404 and hard-fail
  npm install. Treat it like any unsupported target (warnSkip, exit 0) and point
  to the x64 build (which runs under emulation on Windows on ARM) or source.
- internal/tui/run.go: fail fast when stdin is not a terminal. The interactive
  shell otherwise blocks forever in the Bubble Tea event loop on piped/redirected
  input (e.g. `echo "" | zero`); now it prints guidance toward `zero exec` and
  exits non-zero. Dependency-free ModeCharDevice check (no x/term added).

Tests: windows-arm64 skips with exit 0; the interactive shell returns non-zero
(not a hang) on non-TTY stdin.

* fix(tui): use term.IsTerminal for the stdin guard; pin exit code in test

Address review on the no-TTY guard:
- Replace the os.Stdin.Stat()/ModeCharDevice heuristic with term.IsTerminal
  (github.com/charmbracelet/x/term, already in the module graph via bubbletea).
  It is a true TTY check — it rejects pipes, regular files, and non-terminal
  char devices like /dev/null — and fails closed: anything not a verified
  terminal blocks the interactive shell.
- The test now asserts the documented exit code 2 from the guard, not just a
  non-zero result, so it proves the stdin guard fired.
2026-06-28 17:13:34 +05:30
Anandan 726f1512a3 Clean up public release docs (#339)
Rewrite the README around the initial public-release user flow, add the logo asset, and refresh install/update documentation.

Remove internal planning, audit, design, and work-split documents from the public docs tree.

Update stale documentation references and workspace seed expectations to point at public docs.

Tested: env GOCACHE=/tmp/zero-go-cache go test ./internal/workspaceseed

Tested: git diff --check
2026-06-28 17:13:21 +05:30
26 changed files with 393 additions and 113 deletions

No files matched your search

+1 -1
View File
@@ -13,6 +13,7 @@ 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
@@ -21,7 +22,6 @@ 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
+12 -2
View File
@@ -277,6 +277,9 @@ 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
@@ -354,10 +357,17 @@ 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(" id=%s name=%s",
fmt.Sprintf("%s%s name=%s",
prefix,
formatProviderCatalogValue(provider.ID, "unknown"),
formatProviderCatalogValue(provider.Name, "unknown"),
name,
),
fmt.Sprintf(" transport=%s defaultModel=%s",
formatProviderCatalogValue(provider.Transport, "unknown"),
+1
View File
@@ -285,6 +285,7 @@ func setupProviderOptions() []tui.SetupProviderOption {
EnvVar: setupProviderEnvVar(descriptor),
RequiresAuth: descriptor.RequiresAuth,
Local: descriptor.Local,
Recommended: descriptor.Recommended,
})
}
return options
+20
View File
@@ -140,6 +140,26 @@ 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 {
+17 -1
View File
@@ -58,6 +58,11 @@ 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 {
@@ -82,6 +87,11 @@ 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"),
@@ -124,7 +134,6 @@ 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
@@ -266,6 +275,13 @@ 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 {
+37 -2
View File
@@ -8,6 +8,7 @@ import (
)
var expectedCatalogIDs = []string{
"gitlawb-opengateway",
"openai",
"anthropic",
"google",
@@ -33,7 +34,6 @@ var expectedCatalogIDs = []string{
"xiaomi-mimo",
"bankr",
"zai",
"gitlawb-opengateway",
"atomic-chat",
"chatgpt-proxy",
"custom-openai-compatible",
@@ -61,6 +61,41 @@ 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 == "" {
@@ -229,7 +264,7 @@ func TestListByTransportPreservesCatalogOrder(t *testing.T) {
TransportBedrock: {"bedrock"},
TransportVertex: {"vertex"},
TransportAnthropicCompat: {"minimax", "custom-anthropic-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"},
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"},
}
for transport, wantIDs := range cases {
+19
View File
@@ -326,6 +326,9 @@ 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)
@@ -592,6 +595,22 @@ 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,3 +170,20 @@ 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")
}
}
+11 -3
View File
@@ -129,10 +129,18 @@ 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: "gpt-4.1", Description: "catalog default"},
{ID: "claude-sonnet-4.5", Description: "coding model"},
{ID: "gemini-2.5-pro", Description: "long-context model"},
{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"},
},
"atomic-chat": {
{ID: "gpt-4.1", Description: "catalog default"},
+1 -1
View File
@@ -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://gateway.gitlawb.com/zero/models.json" {
if got := defaultedOpenGatewayURL(providercatalog.Descriptor{DefaultBaseURL: "::not a url"}, ""); got != "https://opengateway.gitlawb.com/zero/models.json" {
t.Fatalf("fallback = %q", got)
}
}
+1 -1
View File
@@ -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://gateway.gitlawb.com/zero/models.json"
return "https://opengateway.gitlawb.com/zero/models.json"
}
return parsed.Scheme + "://" + parsed.Host + "/zero/models.json"
}
+14 -5
View File
@@ -385,6 +385,14 @@ 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))
}
@@ -445,11 +453,12 @@ 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 {
// 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
}
// 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
} else {
parts := make([]contentPart, 0, len(message.Images)+1)
if message.Content != "" {
+65 -85
View File
@@ -1000,57 +1000,6 @@ 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"},
@@ -1076,38 +1025,6 @@ 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,
@@ -1165,8 +1082,8 @@ func TestMapMessageTextOnlyKeepsStringContent(t *testing.T) {
t.Fatalf("Content = %#v, want string \"hi\"", msg.Content)
}
empty := mapMessage(zeroruntime.Message{Role: zeroruntime.MessageRoleAssistant, Content: ""})
if empty.Content != nil {
t.Fatalf("empty text content = %#v, want nil so omitempty drops it", empty.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)
}
}
@@ -1246,3 +1163,66 @@ 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])
}
}
+7 -2
View File
@@ -18,8 +18,13 @@ type streamOptions struct {
}
type chatMessage struct {
Role string `json:"role"`
Content any `json:"content,omitempty"`
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"`
ToolCalls []requestToolCall `json:"tool_calls,omitempty"`
ToolCallID string `json:"tool_call_id,omitempty"`
}
+21 -2
View File
@@ -103,6 +103,25 @@ func errResult(format string, a ...any) tools.Result {
return tools.Result{Status: tools.StatusError, Output: "Error: " + fmt.Sprintf(format, a...)}
}
// agentTypeProperty builds the schema for an agent-type parameter, constraining it
// to the roster's currently registered types (the built-ins plus any user-registered
// agents) via an enum so a model can't spawn or hand off to an unknown type — the
// schema teaches the valid set up front instead of the model learning it from a
// failed call. Falls back to a free-form string if the roster is unavailable or
// empty (so the tool never advertises an empty, un-satisfiable enum).
func agentTypeProperty(sw *Swarm, description string) tools.PropertySchema {
prop := tools.PropertySchema{Type: "string", Description: description}
if sw == nil {
return prop
}
if registry := sw.Registry(); registry != nil {
if types := registry.AgentTypes(); len(types) > 0 {
prop.Enum = types
}
}
return prop
}
// ---- swarm_spawn -----------------------------------------------------------
type spawnTool struct {
@@ -118,7 +137,7 @@ func (t *spawnTool) Parameters() tools.Schema {
return tools.Schema{
Type: "object",
Properties: map[string]tools.PropertySchema{
"agent_type": {Type: "string", Description: "Roster agent type to spawn (e.g. teammate, subagent)."},
"agent_type": agentTypeProperty(t.sw, "Roster agent type to spawn."),
"task": {Type: "string", Description: "The focused task/briefing for the member."},
"team": {Type: "string", Description: "Team to spawn into. Defaults to \"default\"."},
},
@@ -353,7 +372,7 @@ func (t *handoffTool) Parameters() tools.Schema {
Type: "object",
Properties: map[string]tools.PropertySchema{
"task_id": {Type: "string", Description: "Task id to hand off."},
"to_agent_type": {Type: "string", Description: "Roster agent type to take over."},
"to_agent_type": agentTypeProperty(t.sw, "Roster agent type to take over."),
"note": {Type: "string", Description: "Optional handoff note delivered to the new member's inbox."},
"team": {Type: "string", Description: "Team the task belongs to. Defaults to \"default\"."},
},
+41
View File
@@ -2,6 +2,7 @@ package swarm
import (
"context"
"slices"
"strings"
"testing"
"time"
@@ -10,6 +11,46 @@ import (
"github.com/Gitlawb/zero/internal/tools"
)
func TestSpawnAndHandoffAgentTypeEnumReflectsRoster(t *testing.T) {
sw := newSwarmFor(t, newLauncher(okFor))
want := sw.Registry().AgentTypes() // sorted built-ins: subagent, teammate
spawnEnum := (&spawnTool{sw: sw}).Parameters().Properties["agent_type"].Enum
if !slices.Equal(spawnEnum, want) {
t.Fatalf("spawn agent_type enum = %v, want roster %v", spawnEnum, want)
}
if !slices.Contains(spawnEnum, "subagent") || !slices.Contains(spawnEnum, "teammate") {
t.Fatalf("enum missing built-in roster types: %v", spawnEnum)
}
handoffEnum := (&handoffTool{sw: sw}).Parameters().Properties["to_agent_type"].Enum
if !slices.Equal(handoffEnum, want) {
t.Fatalf("handoff to_agent_type enum = %v, want roster %v", handoffEnum, want)
}
// A user-registered custom type must extend the enum (not a hardcoded list).
if err := sw.Registry().Register(Definition{AgentType: "researcher"}); err != nil {
t.Fatalf("register custom agent: %v", err)
}
spawnEnum = (&spawnTool{sw: sw}).Parameters().Properties["agent_type"].Enum
if !slices.Contains(spawnEnum, "researcher") {
t.Fatalf("custom agent type not reflected in spawn enum: %v", spawnEnum)
}
handoffEnum = (&handoffTool{sw: sw}).Parameters().Properties["to_agent_type"].Enum
if !slices.Contains(handoffEnum, "researcher") {
t.Fatalf("custom agent type not reflected in handoff enum: %v", handoffEnum)
}
}
func TestAgentTypePropertyFallsBackWithoutRoster(t *testing.T) {
prop := agentTypeProperty(nil, "desc")
if prop.Type != "string" || prop.Description != "desc" {
t.Fatalf("unexpected property: %+v", prop)
}
if prop.Enum != nil {
t.Fatalf("nil swarm must not advertise an (empty) enum, got %v", prop.Enum)
}
}
func TestCollapseRuneSafeTruncation(t *testing.T) {
// 300 multi-byte runes: truncation must not split a rune (no invalid UTF-8).
got := collapse(strings.Repeat("🚀", 300))
+8 -1
View File
@@ -1495,7 +1495,14 @@ func (m model) setupProviderLines(width int, height int) []string {
marker = "❯ "
style = zeroTheme.accent.Bold(true)
}
line := marker + style.Render(displayValue(option.Name, option.ID))
label := displayValue(option.Name, option.ID)
if option.Recommended {
label = "★ " + label
}
line := marker + style.Render(label)
if option.Recommended {
line += zeroTheme.faint.Render(" (recommended)")
}
lines = append(lines, padSetupLine(line, rowWidth))
}
// A failed OAuth login drops back here with oauthPending cleared, so the
+1
View File
@@ -99,6 +99,7 @@ type SetupProviderOption struct {
EnvVar string
RequiresAuth bool
Local bool
Recommended bool
}
// SetupSelection is the user's setup choice.
+15 -2
View File
@@ -1240,13 +1240,26 @@ func (wizard *providerWizardState) renderSelectableProvider(width int, index int
surface = zeroTheme.onSel
marker = surface(zeroTheme.accent).Render("❯ ")
}
left := marker + surface(zeroTheme.ink).Render(provider.Name)
if badge := providerWizardOAuthBadge(provider); badge != "" {
name := provider.Name
if provider.Recommended {
name = "★ " + name
}
left := marker + surface(zeroTheme.ink).Render(name)
if badge := providerWizardBadge(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 {
+13 -2
View File
@@ -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", "OpenAI", "Anthropic", "Google", "Groq", "OpenRouter", "Ollama"} {
for _, want := range []string{"Choose provider", "GitLawb OpenGateway", "OpenAI", "Anthropic", "Google", "OpenRouter", "Ollama"} {
assertContains(t, listView, want)
}
}
@@ -172,8 +172,12 @@ 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)
}
@@ -309,8 +313,15 @@ func TestProviderWizardRightAllowsExistingCredentialEnv(t *testing.T) {
m := newModel(context.Background(), Options{})
m = openProviderWizardForTest(t, m)
updated, _ := m.Update(testKey(tea.KeyEnter))
// 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))
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)
}
+12
View File
@@ -7,10 +7,22 @@ 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) {
+34 -1
View File
@@ -1,6 +1,11 @@
package tui
import "testing"
import (
"context"
"os"
"testing"
"time"
)
func TestUseAltScreenForInteractiveChat(t *testing.T) {
if !useAltScreen(Options{}) {
@@ -10,3 +15,31 @@ 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")
}
}
+5
View File
@@ -86,6 +86,7 @@ type ProviderCatalogSnapshot struct {
Local bool `json:"local"`
RuntimeSupported bool `json:"runtimeSupported"`
RuntimeUnsupportedReason string `json:"runtimeUnsupportedReason,omitempty"`
Recommended bool `json:"recommended,omitempty"`
}
type SessionSnapshot struct {
@@ -218,6 +219,9 @@ 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
@@ -244,6 +248,7 @@ func ProviderCatalogSnapshotFromDescriptor(descriptor providercatalog.Descriptor
Local: descriptor.Local,
RuntimeSupported: providercatalog.RuntimeSupported(descriptor),
RuntimeUnsupportedReason: providercatalog.RuntimeUnsupportedReason(descriptor),
Recommended: descriptor.Recommended,
}
}
+6 -1
View File
@@ -164,11 +164,16 @@ 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)
}
if index > 0 && snapshots[index-1].ID > snapshot.ID {
// 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 {
t.Fatalf("provider catalog snapshots are not sorted by id: %#v", snapshots)
}
}
+1 -1
View File
@@ -38,5 +38,5 @@
"bugs": {
"url": "https://github.com/Gitlawb/zero/issues"
},
"license": "SEE LICENSE IN LICENSE"
"license": "MIT"
}
+13
View File
@@ -102,6 +102,19 @@ 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}`;