1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
// Held-route model-pin survival regressions for `aid run` dispatch.
// Exports: pin survival across group holds, served-list misses, and the
// broader agent-config/budget model case that falls out of switch_agent
// clearing args.model on a substituted route.
// Deps: run resolver, rate_limit, model validation mock, agent_config, Store.
use super::*;
/// The fallback that *is* picked must not run a model from the exhausted group.
/// With only gemini held, agy's claude allowance still serves — but its default
/// model is a gemini one, so `effective_model = None` hands the task right back
/// to the spent family. Substitution must pin the fallback to a healthy group.
#[test]
fn substituted_fallback_is_pinned_to_a_group_that_can_serve() {
let dir = tempfile::tempdir().expect("tempdir");
let _guard = AidHomeGuard::set(dir.path());
// opencode is held on its own provider group — the substitution trigger.
crate::rate_limit::mark_group_rate_limited(
&AgentKind::OpenCode,
None,
"opencode",
"Insufficient balance.",
);
// agy's gemini allowance is exhausted; claude still serves.
crate::rate_limit::mark_group_rate_limited(
&AgentKind::Antigravity,
None,
"gemini",
"Individual quota reached. Please upgrade your subscription to increase your limits. Resets in 59m21s.",
);
let store = Arc::new(Store::open_memory().expect("store"));
let mut args = RunArgs {
agent_name: "opencode".to_string(),
prompt: "Add unit tests".to_string(),
model: Some("opencode/deepseek-v4-pro".to_string()),
cascade: vec!["agy".to_string()],
..Default::default()
};
let setup = resolve_agent_setup(&store, &mut args).expect("should substitute opencode to agy");
assert_eq!(setup.agent_kind, AgentKind::Antigravity);
let model = setup.effective_model.expect("substitution must pin a healthy model");
assert_ne!(
crate::agent::model_group::model_group(AgentKind::Antigravity, Some(&model)),
Some("gemini"),
"the exhausted gemini family must not be dispatched"
);
assert!(setup.substituted_from.is_some());
}
/// The pin from `substituted_fallback_is_pinned_to_a_group_that_can_serve` is
/// `ModelSource::AidResolved`, and `validate_model_for_agent` drops an
/// aid-resolved model that is not in the served list — setting
/// `effective_model = None`, after which agy runs its own default, a gemini
/// model: the exact family the substitution just avoided. The served-list
/// snapshot can lag the catalog (a partial probe, or a cache written before the
/// claude family was discovered), so this must reproduce through the
/// substitution path, not by calling the validator directly.
#[test]
fn substituted_aid_resolved_pin_survives_served_list_miss() {
let dir = tempfile::tempdir().expect("tempdir");
let _guard = AidHomeGuard::set(dir.path());
crate::rate_limit::mark_group_rate_limited(
&AgentKind::OpenCode,
None,
"opencode",
"Insufficient balance.",
);
crate::rate_limit::mark_group_rate_limited(
&AgentKind::Antigravity,
None,
"gemini",
"Individual quota reached. Please upgrade your subscription to increase your limits. Resets in 59m21s.",
);
// agy's served-list snapshot predates the claude family — the pinned
// claude model is absent. This is the cache shape that trips the drop.
let _served = crate::agent::model_validation::MockServedModelsGuard::set(
AgentKind::Antigravity,
Some(vec![
"gemini-3.1-pro-high".to_string(),
"gemini-3.6-flash-high".to_string(),
"gemini-3.6-flash-low".to_string(),
]),
);
let store = Arc::new(Store::open_memory().expect("store"));
let mut args = RunArgs {
agent_name: "opencode".to_string(),
prompt: "Add unit tests".to_string(),
model: Some("opencode/deepseek-v4-pro".to_string()),
cascade: vec!["agy".to_string()],
..Default::default()
};
let setup = resolve_agent_setup(&store, &mut args).expect("should substitute opencode to agy");
assert_eq!(setup.agent_kind, AgentKind::Antigravity);
let model = setup
.effective_model
.expect("aid-resolved pin must survive a served-list miss on a substituted route");
assert_ne!(
crate::agent::model_group::model_group(AgentKind::Antigravity, Some(&model)),
Some("gemini"),
"the exhausted gemini family must not be dispatched"
);
assert!(setup.substituted_from.is_some());
}
/// The `keep_aid_resolved_pin` guard is deliberately wider than the family pin
/// from `healthy_model_for`. Because `switch_agent` clears `args.model`, every
/// `effective_model` on a substituted route is `ModelSource::AidResolved` —
/// including a model read from `~/.aid/agent_config.toml` for the fallback
/// agent. That model survives a served-list miss too: the miss is cache lag,
/// not proof the CLI will reject the model, and the alternative is the
/// fallback's own default, which for a family-metered agent can re-enter the
/// exhausted group. This pins the breadth as a decision with a test behind it,
/// not a side effect of `switch_agent` clearing `args.model`. Contrast
/// `aid_selected_default_is_persisted_as_aid_resolved` in model_source_tests,
/// where the same config model IS dropped because the route was not
/// substituted.
#[test]
fn substituted_route_keeps_agent_config_model_despite_served_list_miss() {
let dir = tempfile::tempdir().expect("tempdir");
let _guard = AidHomeGuard::set(dir.path());
// grok is agent-level held — the agent-level substitution trigger.
crate::rate_limit::mark_rate_limited(
&AgentKind::Grok,
None,
&format!(
"Individual quota reached. Please upgrade your subscription. try again at {}.",
crate::rate_limit::test_future_recovery_time()
),
);
// The fallback (codex) has a configured default model that is NOT in its
// served-list snapshot — the cache shape that drops an aid-resolved model
// on a non-substituted route.
crate::agent_config::save_agent_default_model("codex", Some("codex-stale-config-model"))
.expect("save codex default");
let _served = crate::agent::model_validation::MockServedModelsGuard::set(
AgentKind::Codex,
Some(vec!["gpt-5.4".to_string()]),
);
let store = Arc::new(Store::open_memory().expect("store"));
let mut args = RunArgs {
agent_name: "grok".to_string(),
prompt: "Add unit tests".to_string(),
cascade: vec!["codex".to_string()],
..Default::default()
};
let setup = resolve_agent_setup(&store, &mut args).expect("should substitute grok to codex");
assert_eq!(setup.agent_kind, AgentKind::Codex);
assert_eq!(
setup.effective_model.as_deref(),
Some("codex-stale-config-model"),
"an agent-config model on a substituted route must survive a served-list miss"
);
assert!(setup.substituted_from.is_some());
}