@@ -107,7 +107,7 @@ class Ping(HostCase):
107 r = self.call("ping")
107 r = self.call("ping")
108 self.assertTrue(r["ok"], r)
108 self.assertTrue(r["ok"], r)
109 self.assertTrue(r["result"]["schemasOk"])
109 self.assertTrue(r["result"]["schemasOk"])
110− self.assertEqual(sorted(os.listdir(os.path.join(self.support, "schemas"))), ["name.json", "organize.json"])
110+ self.assertEqual(sorted(f for f in os.listdir(os.path.join(self.support, "schemas")) if f != "version"), ["name.json", "organize.json"])
112 def test_ping_without_fm(self):
112 def test_ping_without_fm(self):
113 self.env["DIAGONAL_FM"] = os.path.join(self.tmp, "missing-fm")
113 self.env["DIAGONAL_FM"] = os.path.join(self.tmp, "missing-fm")
@@ -124,10 +124,9 @@ class Name(HostCase):
124 self.assertEqual(r["result"], {"title": "Rust async runtimes", "emoji": "🦀"})
124 self.assertEqual(r["result"], {"title": "Rust async runtimes", "emoji": "🦀"})
125 self.assertGreater(r["meta"]["promptChars"], 0)
125 self.assertGreater(r["meta"]["promptChars"], 0)
126 argv = [a for a in self.argv_log() if a[0] == "respond"][0]
126 argv = [a for a in self.argv_log() if a[0] == "respond"][0]
127− self.assertEqual(argv[:6], ["respond", "--model", "system", "--no-stream", "--schema", os.path.join(self.support, "schemas", "name.json")])
127+ self.assertEqual(argv, ["respond", "--model", "system", "--no-stream", "--schema", os.path.join(self.support, "schemas", "name.json")])
128− self.assertEqual(argv[6], "--")
128+ # The prompt goes on stdin, never into argv where `ps` would show tab titles.
129− self.assertIn("tokio.rs", argv[7])
129+ self.assertIn("tokio.rs", self.stdin_log()[0])
130− self.assertEqual(len(argv), 8)
132 def test_json_wrapped_in_prose_and_fence(self):
131 def test_json_wrapped_in_prose_and_fence(self):
133 self.respond('Sure! Here it is:\n```json\n{"title": "Rust async", "emoji": "🦀"}\n```')
132 self.respond('Sure! Here it is:\n```json\n{"title": "Rust async", "emoji": "🦀"}\n```')
@@ -140,16 +139,34 @@ class Name(HostCase):
140 r = self.call("name", {"items": items})
139 r = self.call("name", {"items": items})
141 self.assertEqual(r["result"]["emoji"], "💻")
140 self.assertEqual(r["result"]["emoji"], "💻")
143− def test_pcc_only_when_asked(self):
142+ def test_pcc_falls_back_to_the_on_device_model(self):
143+ # fm on macOS 27 rejects --model pcc; an older setting must not break naming.
144 self.respond(json.dumps({"title": "Rust async", "emoji": "🦀"}))
144 self.respond(json.dumps({"title": "Rust async", "emoji": "🦀"}))
145− self.call("name", {"items": ITEMS3}, model="pcc")
145+ r = self.call("name", {"items": ITEMS3}, model="pcc")
146+ self.assertTrue(r["ok"], r)
146 argv = [a for a in self.argv_log() if a[0] == "respond"][0]
147 argv = [a for a in self.argv_log() if a[0] == "respond"][0]
147− self.assertEqual(argv[1:3], ["--model", "pcc"])
148+ self.assertEqual(argv[1:3], ["--model", "system"])
150+ def test_guardrail_refusal_is_retried_permissively_once(self):
151+ self.control({"respond_queue": [
152+ {"stderr": "Error: The model's safety guardrails were triggered.\n", "exit": 1},
153+ {"stdout": json.dumps({"title": "Rust async", "emoji": "🦀"})},
155+ r = self.call("name", {"items": ITEMS3})
156+ self.assertTrue(r["ok"], r)
157+ calls = [a for a in self.argv_log() if a[0] == "respond"]
158+ self.assertEqual(len(calls), 2)
159+ self.assertNotIn("--guardrails", calls[0])
160+ self.assertEqual(calls[1][-2:], ["--guardrails", "permissive-content-transformations"])
162+ def test_context_overflow_is_over_budget(self):
163+ self.respond(stderr="Error: The session's transcript exceeded the model's context size.\n", exit=1)
164+ self.assertEqual(self.call("name", {"items": ITEMS3})["error"]["code"], "OVER_BUDGET")
149 def test_prompt_never_contains_tab_ids(self):
166 def test_prompt_never_contains_tab_ids(self):
150 self.respond(json.dumps({"title": "Rust async", "emoji": "🦀"}))
167 self.respond(json.dumps({"title": "Rust async", "emoji": "🦀"}))
151 self.call("name", {"items": [dict(it, id=987654) for it in ITEMS3]})
168 self.call("name", {"items": [dict(it, id=987654) for it in ITEMS3]})
152− prompt = [a for a in self.argv_log() if a[0] == "respond"][0][-1]
169+ prompt = self.stdin_log()[0]
153 self.assertNotIn("987654", prompt)
170 self.assertNotIn("987654", prompt)
@@ -184,11 +201,11 @@ class ErrorCodes(HostCase):
184 self.expect("OVER_BUDGET", stderr="The prompt exceeds the context window", exit=1)
201 self.expect("OVER_BUDGET", stderr="The prompt exceeds the context window", exit=1)
186 def test_over_budget_precheck_reports_allowed_items(self):
203 def test_over_budget_precheck_reports_allowed_items(self):
187− big = [{"title": "t" * 120, "url": "https://example.com/" + "p" * 280, "description": "d" * 300} for _ in range(30)]
204+ big = [{"title": "t" * 120, "url": "https://example.com/" + "p" * 280, "description": "d" * 300} for _ in range(60)]
188 r = self.call("name", {"items": big})
205 r = self.call("name", {"items": big})
189 self.assertEqual(r["error"]["code"], "OVER_BUDGET")
206 self.assertEqual(r["error"]["code"], "OVER_BUDGET")
190 self.assertGreaterEqual(r["error"]["allowedItems"], 1)
207 self.assertGreaterEqual(r["error"]["allowedItems"], 1)
191− self.assertLess(r["error"]["allowedItems"], 30)
208+ self.assertLess(r["error"]["allowedItems"], 60)
192 self.assertEqual([a for a in self.argv_log() if a[0] == "respond"], [])
209 self.assertEqual([a for a in self.argv_log() if a[0] == "respond"], [])
194 def test_guardrail(self):
211 def test_guardrail(self):
@@ -223,7 +240,7 @@ class ErrorCodes(HostCase):
223 def test_strict_retry_appends_line(self):
240 def test_strict_retry_appends_line(self):
224 self.respond(json.dumps({"title": "Rust async", "emoji": "🦀"}))
241 self.respond(json.dumps({"title": "Rust async", "emoji": "🦀"}))
225 self.call("name", {"items": ITEMS3}, strict=True)
242 self.call("name", {"items": ITEMS3}, strict=True)
226− prompt = [a for a in self.argv_log() if a[0] == "respond"][0][-1]
243+ prompt = self.stdin_log()[0]
227 self.assertIn("Reply with only the object", prompt)
244 self.assertIn("Reply with only the object", prompt)
@@ -262,7 +279,7 @@ class Organize(HostCase):
262 self.assertEqual(groups[-1]["title"], "Rust async")
279 self.assertEqual(groups[-1]["title"], "Rust async")
263 self.assertEqual(groups[-1]["members"], [0, 1, 2])
280 self.assertEqual(groups[-1]["members"], [0, 1, 2])
264 self.assertEqual(r["result"]["leftovers"], [5])
281 self.assertEqual(r["result"]["leftovers"], [5])
265− prompts_sent = [a[-1] for a in self.argv_log() if a[0] == "respond"]
282+ prompts_sent = self.stdin_log()
266 self.assertEqual(len(prompts_sent), 2)
283 self.assertEqual(len(prompts_sent), 2)
267 self.assertIn("1 | Rust async", prompts_sent[1])
284 self.assertIn("1 | Rust async", prompts_sent[1])
@@ -273,13 +290,40 @@ class InstallSchemas(HostCase):
273 os.remove(os.path.join(self.support, "schemas", f))
290 os.remove(os.path.join(self.support, "schemas", f))
274 code, _, p = self.run_host(args=["--install-schemas"])
291 code, _, p = self.run_host(args=["--install-schemas"])
275 self.assertEqual(code, 0, p.stderr)
292 self.assertEqual(code, 0, p.stderr)
276− self.assertEqual(sorted(os.listdir(os.path.join(self.support, "schemas"))), ["name.json", "organize.json"])
293+ self.assertEqual(sorted(f for f in os.listdir(os.path.join(self.support, "schemas")) if f != "version"), ["name.json", "organize.json"])
295+ def test_nested_object_gets_its_schema_from_fm_first(self):
296+ # Real fm: `--object groups` must be followed by `--schema <json>` for the group's own schema.
297+ for f in os.listdir(os.path.join(self.support, "schemas")):
298+ os.remove(os.path.join(self.support, "schemas", f))
299+ code, _, p = self.run_host(args=["--install-schemas"])
300+ self.assertEqual(code, 0, p.stderr)
301+ calls = [a for a in self.argv_log() if a[0] == "schema"]
302+ group = next(a for a in calls if "Group" in a)
303+ self.assertIn("--optional", group) # "existing" is left out for new groups
304+ organize = next(a for a in calls if "Organized" in a)
305+ i = organize.index("--object")
306+ self.assertEqual(organize[i + 1:i + 3], ["groups", "--schema"])
307+ self.assertIn('"args"', organize[i + 3]) # the fake fm's output for the Group schema
308+ self.assertEqual(organize[i + 4], "--array")
310+ def test_schemas_from_an_older_host_are_rewritten(self):
311+ # Hosts before schema version 2 could not build the nested schema on real fm and fell back to two calls.
312+ d = os.path.join(self.support, "schemas")
313+ for f in os.listdir(d):
314+ os.remove(os.path.join(d, f))
315+ for name in ("name.json", "organize-labels.json", "organize-assign.json"):
316+ open(os.path.join(d, name), "w").write("{}")
317+ r = self.call("ping")
318+ self.assertTrue(r["result"]["schemasOk"], r)
319+ self.assertEqual(r["result"]["organizeMode"], "nested")
320+ self.assertEqual(open(os.path.join(d, "version")).read().strip(), host.SCHEMA_VERSION)
278 def test_falls_back_to_two_flat_schemas(self):
322 def test_falls_back_to_two_flat_schemas(self):
279 self.control({"schema_nested": False})
323 self.control({"schema_nested": False})
280 code, _, p = self.run_host(args=["--install-schemas"])
324 code, _, p = self.run_host(args=["--install-schemas"])
281 self.assertEqual(code, 0, p.stderr)
325 self.assertEqual(code, 0, p.stderr)
282− self.assertEqual(sorted(os.listdir(os.path.join(self.support, "schemas"))), ["name.json", "organize-assign.json", "organize-labels.json"])
326+ self.assertEqual(sorted(f for f in os.listdir(os.path.join(self.support, "schemas")) if f != "version"), ["name.json", "organize-assign.json", "organize-labels.json"])
285 def test_first_use_writes_missing_schemas(self):
329 def test_first_use_writes_missing_schemas(self):