Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
157 changes: 137 additions & 20 deletions main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3752,36 +3752,59 @@ func TestApplyCatalogGrowsLaneDial(t *testing.T) {
}
}

func TestProviderAvailabilityFiltersDisconnectedLanes(t *testing.T) {
m := model{
generated: map[string][]string{
"gpt-only_fast_low_nosp_nofa": {" default gpt:low"},
"gpt-led_fast_low_nosp_nofa": {" default gpt:low"},
"mixed_fast_low_nosp_nofa": {" default gpt:low"},
"claude-only_fast_low_nosp_nofa": {" default claude:low"},
"ox-only_fast_low_nosp_nofa": {" default ox:low"},
},
facets: facetDefs(defaultGlyphs()),
sel: defaultSel(),
}
// TestProviderAvailabilityMarksDisconnectedLanes: credentials never shrink
// the dial — every catalog lane stays listed; the connected set decides which
// are usable (pickable) and the selection moves off an unusable lane. A
// missing optional login blocks exactly its own lanes, never the blends
// between connected providers.
func TestProviderAvailabilityMarksDisconnectedLanes(t *testing.T) {
catalog := map[string][]string{
"gpt-only_fast_low_nosp_nofa": {" default gpt:low"},
"gpt-led_fast_low_nosp_nofa": {" default gpt:low"},
"mixed_fast_low_nosp_nofa": {" default gpt:low"},
"claude-only_fast_low_nosp_nofa": {" default claude:low"},
"ox-only_fast_low_nosp_nofa": {" default ox:low"},
}
m := model{generated: catalog, facets: facetDefs(defaultGlyphs()), sel: defaultSel()}
m.applyCatalog()
m.applyProviderAvailability(map[string]bool{"R": true})
served := m.laneValues()

var lanes []string
for _, f := range m.facets {
if f.key == "lane" {
lanes = f.values
// The user's regression: O+A logged in, the catalog's optional R is not.
// Only the ox lane goes dark; every O/A lane and blend stays usable.
m.applyProviderAvailability(map[string]bool{"O": true, "A": true})
if !reflect.DeepEqual(m.laneValues(), served) {
t.Fatalf("availability trimmed the dial: %v, want %v", m.laneValues(), served)
}
for _, lane := range []string{"gpt-only", "gpt-led", "mixed", "claude-only"} {
if !m.laneUsable(lane) {
t.Fatalf("connected-provider lane %q went unusable", lane)
}
}
if !reflect.DeepEqual(lanes, []string{"ox-only"}) {
t.Fatalf("OpenRouter-only lanes = %v, want [ox-only]", lanes)
if m.laneUsable("ox-only") {
t.Fatal("ox-only usable without an OpenRouter credential")
}
if m.sel["lane"] != "mixed" || m.noProviders {
t.Fatalf("selection = %q noProviders=%v, want mixed/false", m.sel["lane"], m.noProviders)
}
if note := m.disconnectedLeads([]string{"mixed", "gpt", "claude", "ox"}); note != "OpenRouter" {
t.Fatalf("lead note = %q, want OpenRouter", note)
}

// The inverse setup: only OpenRouter is connected. The dial still shows
// everything; the selection lands on the one usable lane.
m.applyProviderAvailability(map[string]bool{"R": true})
if !reflect.DeepEqual(m.laneValues(), served) {
t.Fatalf("availability trimmed the dial: %v", m.laneValues())
}
if m.sel["lane"] != "ox-only" || m.noProviders {
t.Fatalf("OpenRouter-only selection = %q noProviders=%v", m.sel["lane"], m.noProviders)
}
if m.laneUsable("mixed") || m.laneUsable("gpt-only") {
t.Fatal("required-pool lanes usable without their credentials")
}
for _, f := range m.visibleFacets() {
if f.key == "blend" {
t.Fatalf("single available lane exposed an unavailable blend: %v", f.values)
t.Fatalf("single served blend exposed a blend dial: %v", f.values)
}
}

Expand All @@ -3798,6 +3821,100 @@ func TestProviderAvailabilityFiltersDisconnectedLanes(t *testing.T) {
}
}

// TestCycleFacetSkipsUnusableLeads: an unconnected provider's lead is visible
// but not a stop — cycling steps past it onto the next usable lead, and stops
// at the edge when everything beyond is unusable.
func TestCycleFacetSkipsUnusableLeads(t *testing.T) {
catalog := map[string][]string{
"gpt-only_fast_low_nosp_nofa": {" default gpt:low"},
"gpt-led_fast_low_nosp_nofa": {" default gpt:low"},
"mixed_fast_low_nosp_nofa": {" default gpt:low"},
"claude-led_fast_low_nosp_nofa": {" default claude:low"},
"claude-only_fast_low_nosp_nofa": {" default claude:low"},
"ds-led_fast_low_nosp_nofa": {" default ds:low"},
"ox-led_fast_low_nosp_nofa": {" default ox:low"},
}
m := model{generated: catalog, facets: facetDefs(defaultGlyphs()), sel: defaultSel()}
m.applyCatalog()
m.applyProviderAvailability(map[string]bool{"O": true, "A": true})
m.sel["lane"] = "claude-led"
m.visibleFacets() // sync lead/blend from lane
m.fcur = 0

// lead order: mixed gpt claude ds ox — right from claude must stop at
// claude (ds and ox are struck), not land on a dead lane.
m.cycleFacet(1)
if m.sel["lane"] != "claude-led" {
t.Fatalf("cycle onto unusable leads moved the lane: %q", m.sel["lane"])
}
m.cycleFacet(-1) // back toward gpt: usable, normal stop
if m.sel["lane"] != "gpt-led" {
t.Fatalf("cycle to a usable lead = %q, want gpt-led", m.sel["lane"])
}
}

// TestLaneLeanBlend: ox-lean is a first-class blend — laneSplit/laneJoin
// round-trip it, the blend dial lists it between led and only, and a lead
// switch to a pool without a lean lane lands on that pool's led lane.
func TestLaneLeanBlend(t *testing.T) {
if lead, blend := laneSplit("ox-lean"); lead != "ox" || blend != "lean" {
t.Fatalf("laneSplit(ox-lean) = %q %q", lead, blend)
}
if lane := laneJoin("ox", "lean"); lane != "ox-lean" {
t.Fatalf("laneJoin(ox, lean) = %q", lane)
}
catalog := map[string][]string{
"gpt-led_fast_low_nosp_nofa": {" default gpt:low"},
"gpt-only_fast_low_nosp_nofa": {" default gpt:low"},
"mixed_fast_low_nosp_nofa": {" default gpt:low"},
"ox-led_fast_low_nosp_nofa": {" default ox:low"},
"ox-lean_fast_low_nosp_nofa": {" default gpt:low"},
"ox-only_fast_low_nosp_nofa": {" default ox:low"},
}
m := model{generated: catalog, facets: facetDefs(defaultGlyphs()), sel: defaultSel()}
m.applyCatalog()
m.sel["lane"] = "ox-lean"
var blends []string
for _, f := range m.visibleFacets() {
if f.key == "blend" {
blends = f.values
}
}
if !reflect.DeepEqual(blends, []string{"led", "lean", "only"}) {
t.Fatalf("ox blends = %v, want [led lean only]", blends)
}
// A lead switch from ox-lean to gpt: gpt has no lean lane — land on led.
if lane := m.composeLane("gpt", "lean"); lane != "gpt-led" {
t.Fatalf("composeLane(gpt, lean) = %q, want gpt-led", lane)
}
}

// TestFilterRowsDropsDisconnectedRungs: fallback rungs on pools nobody logged
// into vanish from routing rows (never the lead), so the preview and overlay
// only name models OMP can route.
func TestFilterRowsDropsDisconnectedRungs(t *testing.T) {
m := model{facts: map[string]modelFact{
"gpt-5.6-terra": {pool: "O"},
"claude-sonnet-5": {pool: "A"},
"deepseek-v4": {pool: "D"},
}}
m.providersResolved = true
m.connected = map[string]bool{"O": true, "A": true}
rows := []string{" ● task gpt-5.6-terra:medium → claude-sonnet-5:medium → deepseek-v4:medium"}
got := m.filterRows(rows)[0]
if strings.Contains(got, "deepseek") {
t.Fatalf("disconnected rung survived: %q", got)
}
if !strings.Contains(got, "gpt-5.6-terra:medium → claude-sonnet-5:medium") {
t.Fatalf("connected rungs mangled: %q", got)
}
// Before discovery resolves, rows pass through untouched.
m.providersResolved = false
if got := m.filterRows(rows)[0]; !strings.Contains(got, "deepseek-v4:medium") {
t.Fatalf("unresolved discovery must not filter: %q", got)
}
}

func TestDirectProviderProbeUsesOMPToken(t *testing.T) {
dir := t.TempDir()
script := filepath.Join(dir, "omp")
Expand Down
3 changes: 3 additions & 0 deletions model.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@ type model struct {
hasRelief bool // _rel_/_norel combos exist — show the relief dial
providersResolved bool // connected-provider discovery completed
noProviders bool // discovery found no provider usable by this catalog
// connected maps pool letters to "OMP holds a usable credential" — the
// probe result behind laneUsable and the struck lane rendering.
connected map[string]bool

depth int // 0 lead · 1 full
collapse bool // p: hide the Routing section
Expand Down
56 changes: 49 additions & 7 deletions providers.go
Original file line number Diff line number Diff line change
Expand Up @@ -249,22 +249,28 @@ func laneReliefApplies(lane string) bool {
return true
}

// laneBlends is the blend dial's canonical order: led (the lead pool drives
// everything), lean (the lead pool keeps the deliberative work and drains the
// rest elsewhere), only (pure). A lead offers the subset its catalog serves.
var laneBlends = []string{"led", "lean", "only"}

// laneSplit decomposes a lane into the two dials the TUI renders: the lead
// (a provider's lane segment, or "mixed") and the blend ("led" | "only").
// mixed has no blend of its own; it reports "led" so a later lead change
// lands on the -led lane.
// (a provider's lane segment, or "mixed") and the blend ("led" | "lean" |
// "only" — the lane's suffix). mixed has no blend of its own; it reports
// "led" so a later lead change lands on the -led lane.
func laneSplit(lane string) (lead, blend string) {
if lane == "mixed" {
return "mixed", "led"
}
lead = lane
blend = "led"
if lanePure(lane) {
blend = "only"
if seg, suffix, ok := strings.Cut(lane, "-"); ok {
lead, blend = seg, suffix
}
if p := providerByLane(lane); p != nil {
return p.Lane, blend
lead = p.Lane
}
return lane, blend
return lead, blend
}

// laneJoin is laneSplit's inverse: the canonical lane a lead+blend pair names.
Expand All @@ -275,6 +281,42 @@ func laneJoin(lead, blend string) string {
return lead + "-" + blend
}

// laneAvailable reports whether the connected credentials can serve the lane:
// a pure lane needs only its own provider, every blend needs the pools its
// policy leads roles on plus the Required pools its chains cross into. An
// optional pool nobody logged into therefore blocks exactly its own lanes —
// never the blends between the providers that are connected.
func laneAvailable(lane string, connected map[string]bool) bool {
pol, known := genLanePolicies[lane]
if !known {
// A lane this binary predates: require its own provider (if named)
// and the Required pools — the conservative reading of a blend.
if p := providerByLane(lane); p != nil {
if lanePure(lane) {
return connected[p.Pool]
}
if !connected[p.Pool] {
return false
}
}
} else {
if pol.pure {
return connected[pol.primary]
}
for _, pool := range []string{pol.primary, pol.delib, pol.util, pol.vision, pol.visionSmart} {
if pool != "" && !connected[pool] {
return false
}
}
}
for i := range providerRegistry {
if providerRegistry[i].Required && !connected[providerRegistry[i].Pool] {
return false
}
}
return true
}

// laneHostsSpecial reports whether a special-tier facet ("spark", "fable") can
// be on for the lane: its provider's pool must be in the lane's pool-set.
func laneHostsSpecial(lane, facet string) bool {
Expand Down
2 changes: 1 addition & 1 deletion render.go
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,7 @@ func (m *model) syncPreviewAt(yoff int) {
b.WriteString(stDim.Render("no connected OMP providers") + "\n")
} else if base, ok := m.generated[id]; ok {
_, roles := splitMeta(base)
roles = m.applyAdvisor(roles, m.sel["advisor"])
roles = m.filterRows(m.applyAdvisor(roles, m.sel["advisor"]))
b.WriteString(m.renderRoute(roles, m.depth, m.selectedLaunchAvailability(), rw))
} else {
b.WriteString(stDim.Render("no profile for this combination") + "\n")
Expand Down
Loading
Loading