Compare commits

..
Author SHA1 Message Date
Gnanam 57ef38ebcb fix(providerio): surface ErrStreamStalled in callers + widen flaky test margins
Address review:
- All four provider stream handlers (openai, codex, anthropic, gemini) now treat
  ErrStreamStalled in the same branch as ErrStreamIdle, via a new
  providerio.StreamTimeoutMessage helper that gives the stall a distinct, accurate
  message (it reports the content window and does NOT claim the upstream stopped
  sending data — keep-alives were still arriving). Previously a stalled stream fell
  through to the generic provider-error path.
- Widen the content-watchdog test timing margins (idle 100ms, data/keepalive every
  30ms) so CI/GC jitter can't trip the idle watchdog and mask the content path.
- Add a StreamTimeoutMessage unit test.
2026-06-30 22:13:55 +05:30
Gnanam 31e57915c9 fix(providerio): bound heartbeat-but-no-output streams (content-stall watchdog)
A stalled-but-alive upstream hung the agent indefinitely: SSE keep-alives reset the
idle watchdog (intentionally — a heartbeating upstream isn't 'dead', and aborting it
killed healthy long requests), so a stream that heartbeats while producing nothing
never tripped the idle timeout and there was no other ceiling. Observed on both
chatgpt/gpt-5.x (Codex) and ollama minimax-m3 — different provider code, same shared
providerio SSE loop — so the fix belongs here, once, for every provider.

Add a second 'content' watchdog alongside the idle one: keep-alives still reset idle
(no regression for heartbeating connections), but only REAL data lines reset the
content watchdog. If no data arrives for streamContentStallFactor (2) × the idle
timeout, abort with ErrStreamStalled instead of hanging. A slow-but-producing stream
(data between heartbeats) keeps resetting content and is never killed.

Contained to internal/providers/providerio — no provider/caller changes.
2026-06-30 22:03:24 +05:30
KRATOS a0548d707b test(providers): isolate the OAuth store in the chatgpt Codex routing test (#346)
TestNewRoutesChatGPTCatalogToCodexProvider asserts the chatgpt-account-id header is
empty when no OAuth login is stored, but never redirected the token store — so on a
developer machine with a real chatgpt login it read that login and failed (it still
passed in CI, where no token exists). Point ZERO_OAUTH_TOKENS_PATH at an empty temp
path, mirroring TestNewRoutesChatGPTCatalogWithStoredAccountID, so the test is
deterministic regardless of the developer's stored credentials.
2026-06-30 21:21:44 +05:30
KRATOS 83a371d669 fix: stream Codex reasoning + drop swarm agents on their own completion (#345)
Two agent-visibility fixes surfaced diagnosing a 20-minute gpt-5.5 step and an
'agents never disappear' report.

A) Codex Responses path streamed no reasoning, so a long thinking phase produced
   zero visible output and read as a hang (the activity clock never advanced).
   Root cause: the request never asked for a reasoning summary and the parser had
   no case for reasoning deltas. Now request reasoning.summary="auto" and forward
   response.reasoning_summary_text.delta as StreamEventReasoning — the existing TUI
   handler renders it live and refreshes the activity clock.

B) A finished swarm member stayed in the AGENTS panel until the whole turn ended.
   Now it drops once ITS OWN task completes (fade over the linger window, then
   remove), independent of the overall run — matching how finished specialists
   already behave. Running members and mid-flight collect are unaffected.
2026-06-30 21:21:30 +05:30
KRATOS 84823d7be1 fix(swarm): constrain agent_type to the registered roster via a dynamic enum (#344)
* 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.

* 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:50:11 +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
35 changed files with 658 additions and 148 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"
}
+2 -2
View File
@@ -211,11 +211,11 @@ func (provider *Provider) stream(ctx context.Context, body []byte, events chan<-
err = providerio.ScanSSEDataWithContext(streamCtx, cancelStream, response.Body, provider.streamIdleTimeout, func(data string) bool {
return provider.emitPayload(ctx, data, state, events)
})
if errors.Is(err, providerio.ErrStreamIdle) {
if errors.Is(err, providerio.ErrStreamIdle) || errors.Is(err, providerio.ErrStreamStalled) {
state.closeOpen(ctx, events)
providerio.SendEvent(ctx, events, zeroruntime.StreamEvent{
Type: zeroruntime.StreamEventError,
Error: provider.redact(fmt.Sprintf("provider stream error: idle timeout after %s (upstream stopped sending data)", provider.streamIdleTimeout)),
Error: provider.redact("provider stream error: " + providerio.StreamTimeoutMessage(err, provider.streamIdleTimeout)),
})
return
}
+7
View File
@@ -264,6 +264,13 @@ func TestNewRejectsUnsupportedProviderKind(t *testing.T) {
}
func TestNewRoutesChatGPTCatalogToCodexProvider(t *testing.T) {
// Isolate the OAuth token store to an empty temp path so the factory reads no
// stored login — otherwise this test picks up the developer's real chatgpt
// OAuth token and the "want empty chatgpt-account-id" assertion fails locally
// (it still passes in CI, where no login is stored). Mirrors the isolation in
// TestNewRoutesChatGPTCatalogWithStoredAccountID.
t.Setenv("ZERO_OAUTH_TOKENS_PATH", t.TempDir()+"/tokens.json")
transport := &captureTransport{
responseBody: "data: [DONE]\n\n",
}
+2 -2
View File
@@ -158,10 +158,10 @@ func (provider *Provider) stream(ctx context.Context, body []byte, events chan<-
err = providerio.ScanSSEDataWithContext(streamCtx, cancelStream, response.Body, provider.streamIdleTimeout, func(data string) bool {
return provider.emitPayload(ctx, data, &state, events)
})
if errors.Is(err, providerio.ErrStreamIdle) {
if errors.Is(err, providerio.ErrStreamIdle) || errors.Is(err, providerio.ErrStreamStalled) {
providerio.SendEvent(ctx, events, zeroruntime.StreamEvent{
Type: zeroruntime.StreamEventError,
Error: provider.redact(fmt.Sprintf("provider stream error: idle timeout after %s (upstream stopped sending data)", provider.streamIdleTimeout)),
Error: provider.redact("provider stream error: " + providerio.StreamTimeoutMessage(err, provider.streamIdleTimeout)),
})
return
}
+21 -3
View File
@@ -59,6 +59,7 @@ 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"
@@ -86,6 +87,10 @@ 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
@@ -256,7 +261,9 @@ 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 != "" {
req.Reasoning = &responsesReasoning{Effort: 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"}
}
return req, nil
}
@@ -382,10 +389,10 @@ func (p *CodexProvider) streamResponses(
err = providerio.ScanSSEDataWithContext(streamCtx, cancelStream, response.Body, inner.streamIdleTimeout, func(data string) bool {
return p.emitResponsesEvent(ctx, data, state, events)
})
if errors.Is(err, providerio.ErrStreamIdle) {
if errors.Is(err, providerio.ErrStreamIdle) || errors.Is(err, providerio.ErrStreamStalled) {
providerio.SendEvent(ctx, events, zeroruntime.StreamEvent{
Type: zeroruntime.StreamEventError,
Error: p.redact(fmt.Sprintf("provider stream error: idle timeout after %s (upstream stopped sending data)", inner.streamIdleTimeout)),
Error: p.redact("provider stream error: " + providerio.StreamTimeoutMessage(err, inner.streamIdleTimeout)),
})
return
}
@@ -501,6 +508,17 @@ 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
+52
View File
@@ -485,6 +485,58 @@ 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) {
+16 -7
View File
@@ -218,12 +218,12 @@ func (provider *Provider) stream(ctx context.Context, body []byte, events chan<-
err = providerio.ScanSSEDataWithContext(streamCtx, cancelStream, response.Body, provider.streamIdleTimeout, func(data string) bool {
return provider.emitPayload(ctx, data, state, events)
})
if errors.Is(err, providerio.ErrStreamIdle) {
if errors.Is(err, providerio.ErrStreamIdle) || errors.Is(err, providerio.ErrStreamStalled) {
state.flushBufferedContent(events)
state.closeBufferedOpen(events)
sendEvent(ctx, events, zeroruntime.StreamEvent{
Type: zeroruntime.StreamEventError,
Error: provider.redact(fmt.Sprintf("provider stream error: idle timeout after %s (upstream stopped sending data)", provider.streamIdleTimeout)),
Error: provider.redact("provider stream error: " + providerio.StreamTimeoutMessage(err, provider.streamIdleTimeout)),
})
return
}
@@ -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"`
}
+62 -10
View File
@@ -23,6 +23,26 @@ const maxSSELineBytes = 16 * 1024 * 1024
// closing the connection. Callers surface it as an idle-timeout error.
var ErrStreamIdle = errors.New("idle timeout (upstream stopped sending data)")
// ErrStreamStalled reports that a streaming upstream kept the connection alive
// (SSE keep-alives reset the idle watchdog, so it never fired) but produced no
// actual output for streamContentStallFactor × the idle timeout. Without this an
// upstream that heartbeats-but-stalls — observed on chatgpt/gpt-5.x and ollama
// reasoning models — would hang the agent indefinitely.
var ErrStreamStalled = errors.New("stream stalled (upstream kept the connection alive but produced no output)")
// StreamTimeoutMessage returns the human-readable detail for a stream-timeout
// error (ErrStreamIdle or ErrStreamStalled), given the configured idle timeout.
// Callers prepend their own "provider stream error: " prefix. A stalled stream
// gets a distinct, actionable message — it did NOT stop sending data (keep-alives
// kept arriving); it just produced no output, which usually means the model is
// stuck or very slow.
func StreamTimeoutMessage(err error, idleTimeout time.Duration) string {
if errors.Is(err, ErrStreamStalled) {
return fmt.Sprintf("no output for %s (the model kept the connection alive but produced nothing — it may be stuck; try a faster model or lower reasoning effort)", idleTimeout*streamContentStallFactor)
}
return fmt.Sprintf("idle timeout after %s (upstream stopped sending data)", idleTimeout)
}
// DefaultStreamIdleTimeout is the single source of truth for how long every
// provider waits on a silent stream before aborting it. The watchdog only fires
// on genuine silence — SSE keep-alive comments reset it (see
@@ -33,6 +53,14 @@ var ErrStreamIdle = errors.New("idle timeout (upstream stopped sending data)")
// Override globally with ZERO_STREAM_IDLE_TIMEOUT.
const DefaultStreamIdleTimeout = 5 * time.Minute
// streamContentStallFactor bounds a heartbeat-but-no-output stream. Keep-alives
// reset the idle watchdog (a heartbeating upstream is not "dead"), but if NO real
// data line arrives for streamContentStallFactor × the idle timeout, the stream is
// treated as stalled and aborted (ErrStreamStalled). It is > 1 so a slow-but-
// producing request — one that emits data between heartbeats — is never killed; the
// content watchdog only catches a stream that heartbeats while producing nothing.
const streamContentStallFactor = 2
// streamIdleTimeoutEnv is the global override for the stream idle timeout. It
// accepts a Go duration ("5m", "300s", "90s") or a bare number of seconds
// ("300"). A value of "0", "off", "none", or "disabled" turns the watchdog off
@@ -202,21 +230,36 @@ func ScanSSEDataWithContext(
// The idle watchdog is optional. When idleTimeout <= 0 it is disabled, but we
// STILL run the goroutine + select loop so ctx cancellation is always honored
// (a nil idleC channel simply never fires in the select).
var idleC <-chan time.Time
var idleC, contentC <-chan time.Time
resetIdle := func() {}
resetContent := func() {}
if idleTimeout > 0 {
// reset drains the timer's channel if it already fired before re-arming, so
// a stale tick can't trip the watchdog after activity resumed.
reset := func(t *time.Timer, d time.Duration) func() {
return func() {
if !t.Stop() {
select {
case <-t.C:
default:
}
}
t.Reset(d)
}
}
idle := time.NewTimer(idleTimeout)
defer idle.Stop()
idleC = idle.C
resetIdle = func() {
if !idle.Stop() {
select {
case <-idle.C:
default:
}
}
idle.Reset(idleTimeout)
}
resetIdle = reset(idle, idleTimeout)
// Content watchdog: only real data lines reset it (keep-alives do not), so a
// stream that heartbeats without producing output is bounded instead of
// hanging forever.
contentTimeout := idleTimeout * streamContentStallFactor
content := time.NewTimer(contentTimeout)
defer content.Stop()
contentC = content.C
resetContent = reset(content, contentTimeout)
}
for {
@@ -232,6 +275,12 @@ func ScanSSEDataWithContext(
// a timeout instead of blocking the agent forever.
cancel()
return ErrStreamIdle
case <-contentC:
// Upstream kept heartbeating (idle watchdog never fired) but produced
// no output for the content window. Treat as stalled and abort rather
// than hanging forever.
cancel()
return ErrStreamStalled
case item, ok := <-payloads:
if !ok {
// Reader finished: deliver its terminal status (EOF -> nil,
@@ -246,8 +295,11 @@ func ScanSSEDataWithContext(
}
resetIdle()
if item.keepAlive {
// A heartbeat keeps the connection "alive" (idle watchdog) but is
// NOT output, so it must not reset the content watchdog.
continue
}
resetContent()
if !handle(item.data) {
// The provider asked to stop (e.g. it already emitted an error
// for this payload). Abort the read and end like ScanSSEData:
@@ -201,3 +201,106 @@ func TestUpstreamUnreachable(t *testing.T) {
})
}
}
// A heartbeating-but-output-less upstream must not hang forever: SSE keep-alives
// feed the idle watchdog (so it never fires), but the content watchdog
// (idleTimeout × streamContentStallFactor) aborts with ErrStreamStalled when no
// real data line arrives. This is the gpt-5.x / ollama "still generating forever"
// hang.
func TestScanSSEDataWithContextAbortsOnContentStall(t *testing.T) {
pr, pw := io.Pipe()
defer func() { _ = pw.Close() }()
stop := make(chan struct{})
defer close(stop)
go func() {
// Heartbeat every 30ms — comfortably under the 100ms idle timeout, so idle
// never fires under CI/GC jitter — but never send a data line.
for {
select {
case <-stop:
return
default:
}
if _, err := io.WriteString(pw, ": keep-alive\n\n"); err != nil {
return
}
time.Sleep(30 * time.Millisecond)
}
}()
cancelled := false
cancel := func() { cancelled = true }
done := make(chan error, 1)
go func() {
// idle 100ms → content stall at 200ms. Keep-alives reset idle but not content.
done <- ScanSSEDataWithContext(context.Background(), cancel, pr, 100*time.Millisecond, func(string) bool { return true })
}()
select {
case err := <-done:
if !errors.Is(err, ErrStreamStalled) {
t.Fatalf("err = %v, want ErrStreamStalled", err)
}
case <-time.After(3 * time.Second):
t.Fatal("ScanSSEDataWithContext hung on a heartbeat-but-no-output stream")
}
if !cancelled {
t.Fatal("content stall did not cancel the request context")
}
}
// Real data lines reset the content watchdog, so a slow-but-producing stream that
// runs longer than the content window is never aborted.
func TestScanSSEDataWithContextContentResetsOnData(t *testing.T) {
pr, pw := io.Pipe()
go func() {
// One data line every 30ms for ~300ms (well past the 200ms content window,
// with comfortable slack under the 100ms idle timeout), so the content
// watchdog keeps resetting, then close cleanly.
for i := 0; i < 10; i++ {
if _, err := io.WriteString(pw, "data: chunk\n\n"); err != nil {
return
}
time.Sleep(30 * time.Millisecond)
}
_ = pw.Close()
}()
n := 0
done := make(chan error, 1)
go func() {
done <- ScanSSEDataWithContext(context.Background(), func() {}, pr, 100*time.Millisecond, func(string) bool { n++; return true })
}()
select {
case err := <-done:
if err != nil {
t.Fatalf("err = %v, want nil (data kept the stream alive)", err)
}
case <-time.After(3 * time.Second):
t.Fatal("ScanSSEDataWithContext hung on a producing stream")
}
if n != 10 {
t.Fatalf("handled %d data lines, want 10", n)
}
}
// StreamTimeoutMessage must give a stalled stream a distinct, accurate detail —
// it reports the content window (idle × factor) and must NOT claim the upstream
// "stopped sending data" (keep-alives were still arriving).
func TestStreamTimeoutMessage(t *testing.T) {
idle := 5 * time.Minute
if msg := StreamTimeoutMessage(ErrStreamIdle, idle); !strings.Contains(msg, "idle timeout after 5m") {
t.Fatalf("idle message = %q, want it to mention the 5m idle timeout", msg)
}
stalled := StreamTimeoutMessage(ErrStreamStalled, idle)
if !strings.Contains(stalled, "no output for 10m") {
t.Fatalf("stalled message = %q, want it to report the 10m content window", stalled)
}
if strings.Contains(stalled, "stopped sending data") {
t.Fatalf("stalled message must not claim the upstream stopped sending data: %q", stalled)
}
}
+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 -7
View File
@@ -261,13 +261,11 @@ func (m model) swarmSpawnedAgents() []swarmAgent {
switch a.state {
case "done", "failed", "completed", "cancelled":
a.finishing = true
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.
// 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).
doneAt, stamped := m.swarmDoneAt[a.id]
if stamped && m.now().Sub(doneAt) >= sidebarAgentLinger {
continue // past the linger window — remove
+9 -9
View File
@@ -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 TestSwarmAgentsPersistWhileRunInFlight(t *testing.T) {
func TestSwarmAgentDropsOnOwnCompletionEvenMidRun(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
m.pending = true // run STILL going — a member must still drop on its OWN completion
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},
)
// Long past the linger window — but it must still show while pending.
m.swarmDoneAt = map[string]time.Time{"subagent-1": base.Add(-10 * sidebarAgentLinger)}
// Just finished, still within the linger window: shows briefly with a fading ✓.
m.swarmDoneAt = map[string]time.Time{"subagent-1": base.Add(-sidebarAgentLinger / 2)}
agents := m.swarmSpawnedAgents()
if len(agents) != 1 {
t.Fatalf("a finished member must stay while the run is in flight, got %d: %+v", len(agents), agents)
t.Fatalf("within the linger window a finished member should still show (fading), got %d: %+v", len(agents), agents)
}
if !agents[0].finishing {
t.Fatalf("a finished member should render done (✓), got %+v", agents[0])
}
// Once the turn ends, the long-finished member fades out and drops.
m.pending = false
// 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)}
if got := len(m.swarmSpawnedAgents()); got != 0 {
t.Fatalf("after the turn ends a long-finished member should drop, got %d", got)
t.Fatalf("a member past its linger window must drop on its own completion mid-run, got %d", got)
}
}
+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}`;