@@ -77,7 +77,7 @@ class Ping(HostCase):
7777 self.assertEqual(r["result"]["fmPath"], self.fm)
7878 self.assertTrue(r["result"]["fmAvailable"])
7979 self.assertTrue(r["result"]["schemasOk"])
80− self.assertEqual(r["result"]["organizeMode"], "nested")
80+ self.assertEqual(r["result"]["organizeMode"], "topics")
8181
8282 def test_ping_model_unavailable(self):
8383 self.control({"available": {"stderr": "Apple Intelligence is not enabled", "exit": 1}})
@@ -107,7 +107,7 @@ class Ping(HostCase):
107107 r = self.call("ping")
108108 self.assertTrue(r["ok"], r)
109109 self.assertTrue(r["result"]["schemasOk"])
110− self.assertEqual(sorted(f for f in os.listdir(os.path.join(self.support, "schemas")) if f != "version"), ["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", "topics.json"])
111111
112112 def test_ping_without_fm(self):
113113 self.env["DIAGONAL_FM"] = os.path.join(self.tmp, "missing-fm")
@@ -124,9 +124,12 @@ class Name(HostCase):
124124 self.assertEqual(r["result"], {"title": "Rust async runtimes", "emoji": "🦀"})
125125 self.assertGreater(r["meta"]["promptChars"], 0)
126126 argv = [a for a in self.argv_log() if a[0] == "respond"][0]
127− self.assertEqual(argv, ["respond", "--model", "system", "--no-stream", "--schema", os.path.join(self.support, "schemas", "name.json")])
128− # The prompt goes on stdin, never into argv where `ps` would show tab titles.
127+ self.assertEqual(argv[:7], ["respond", "--model", "system", "--no-stream", "--schema", os.path.join(self.support, "schemas", "name.json"), "-i"])
128+ self.assertIn("You name a browser tab group", argv[7])
129+ self.assertEqual(len(argv), 8)
130+ # Tab text goes on stdin, never into argv where `ps` would show it.
129131 self.assertIn("tokio.rs", self.stdin_log()[0])
132+ self.assertNotIn("tokio", " ".join(argv))
130133
131134 def test_json_wrapped_in_prose_and_fence(self):
132135 self.respond('Sure! Here it is:\n```json\n{"title": "Rust async", "emoji": "🦀"}\n```')
@@ -240,8 +243,8 @@ class ErrorCodes(HostCase):
240243 def test_strict_retry_appends_line(self):
241244 self.respond(json.dumps({"title": "Rust async", "emoji": "🦀"}))
242245 self.call("name", {"items": ITEMS3}, strict=True)
243− prompt = self.stdin_log()[0]
244− self.assertIn("Reply with only the object", prompt)
246+ argv = [a for a in self.argv_log() if a[0] == "respond"][0]
247+ self.assertIn("Reply with only the object", argv[argv.index("-i") + 1])
245248
246249
247250class Organize(HostCase):
@@ -249,66 +252,97 @@ class Organize(HostCase):
249252 {"title": "Hotels in Lisbon", "url": "https://booking.com/lisbon"},
250253 {"title": "Lisbon travel guide", "url": "https://lonelyplanet.com/portugal/lisbon"},
251254 {"title": "Random page", "url": "https://example.org/"},
255+ {"title": "Pasta recipe", "url": "https://cooking.example/pasta"},
256+ {"title": "Rice recipe", "url": "https://cooking.example/rice"},
252257 ]
258+ EXISTING = [{"g": 0, "title": "Lisbon trip", "samples": ["Flights to Lisbon", "Lisbon metro map"]}]
253259
254− def test_nested_path(self):
255− self.respond(json.dumps({"groups": [
256− {"title": "Rust async", "emoji": "🦀", "color": "orange", "existing": -1, "members": [0, 1, 2]},
257− {"title": "", "emoji": "", "color": "", "existing": 0, "members": [3, 4]},
258− ], "leftovers": [5]}))
259− r = self.call("organize", {"items": self.ITEMS, "existingGroups": [{"g": 0, "title": "Lisbon trip"}], "maxGroups": 2})
260+ def topics(self, pairs):
261+ self.respond(json.dumps({"tabs": [{"index": i, "topic": t} for i, t in pairs]}))
262+
263+ def test_tabs_with_one_topic_form_groups_and_join_existing_ones(self):
264+ # Indexes 8 and 9 are the existing group's example tabs (numbered after the 8 tabs to sort).
265+ self.topics([(0, "Programming"), (1, "programming"), (2, "Programming."), (3, "Travel"), (4, "travel"),
266+ (5, "Other"), (6, "Cooking"), (7, "Cooking"), (8, "Travel"), (9, "Travel")])
267+ r = self.call("organize", {"items": self.ITEMS, "existingGroups": self.EXISTING, "maxGroups": 4})
260268 self.assertTrue(r["ok"], r)
261− self.assertEqual(r["result"]["groups"][0], {"title": "", "emoji": "", "color": "", "existing": 0, "members": [3, 4]})
262− self.assertEqual(r["result"]["groups"][1]["title"], "Rust async")
269+ self.assertEqual(r["meta"]["path"], "topics")
270+ groups = r["result"]["groups"]
271+ self.assertEqual(groups[0], {"title": "", "emoji": "", "color": "", "existing": 0, "members": [3, 4]})
272+ new = sorted(g["members"] for g in groups[1:])
273+ self.assertEqual(new, [[0, 1, 2], [6, 7]])
274+ self.assertTrue(all(g["title"] == "" for g in groups[1:])) # the naming loop titles new groups
263275 self.assertEqual(r["result"]["leftovers"], [5])
264− self.assertEqual(r["meta"]["path"], "nested")
265276
266− def test_two_call_fallback(self):
267− os.remove(os.path.join(self.support, "schemas", "organize.json"))
268− for name in ("organize-labels.json", "organize-assign.json"):
269− open(os.path.join(self.support, "schemas", name), "w").write("{}")
277+ def test_new_groups_are_named_right_away(self):
270278 self.control({"respond_queue": [
271− {"stdout": json.dumps({"labels": ["Rust async"], "emojis": ["🦀"], "colors": ["orange"]})},
272− {"stdout": json.dumps({"assignment": [1, 1, 1, 0, 0, -1]})},
279+ {"stdout": json.dumps({"tabs": [{"index": i, "topic": t} for i, t in
280+ [(0, "Programming"), (1, "Programming"), (2, "Programming"), (6, "Cooking"), (7, "Cooking")]]})},
281+ {"stdout": json.dumps({"title": "Rust async runtimes", "emoji": "🦀"})},
282+ {"stderr": "Error: The model's safety guardrails were triggered.\n", "exit": 1},
283+ {"stderr": "Error: The model's safety guardrails were triggered.\n", "exit": 1},
273284 ]})
274− r = self.call("organize", {"items": self.ITEMS, "existingGroups": [{"g": 0, "title": "Lisbon trip"}], "maxGroups": 2})
285+ r = self.call("organize", {"items": self.ITEMS, "existingGroups": self.EXISTING, "maxGroups": 4})
275286 self.assertTrue(r["ok"], r)
276− self.assertEqual(r["meta"]["path"], "two-call")
277− groups = {g.get("existing", -1): g for g in r["result"]["groups"]}
278− self.assertEqual(groups[0]["members"], [3, 4])
279− self.assertEqual(groups[-1]["title"], "Rust async")
280− self.assertEqual(groups[-1]["members"], [0, 1, 2])
281− self.assertEqual(r["result"]["leftovers"], [5])
282− prompts_sent = self.stdin_log()
283− self.assertEqual(len(prompts_sent), 2)
284− self.assertIn("1 | Rust async", prompts_sent[1])
287+ by_members = {tuple(g["members"]): g for g in r["result"]["groups"]}
288+ self.assertEqual((by_members[(0, 1, 2)]["title"], by_members[(0, 1, 2)]["emoji"]), ("Rust async runtimes", "🦀"))
289+ self.assertEqual(by_members[(6, 7)]["title"], "") # naming failed: the extension's naming loop takes it
290+ name_calls = self.stdin_log()[1:]
291+ self.assertIn("Tokio", name_calls[0])
292+ self.assertIn('"Lisbon trip"', name_calls[0]) # existing titles are off limits
293+
294+ def test_a_topic_of_one_tab_is_a_leftover(self):
295+ self.topics([(0, "Programming"), (1, "Programming"), (2, "Rust"), (3, "Travel"), (4, "Travel"), (5, "Web"), (6, "Food"), (7, "Cooking")])
296+ r = self.call("organize", {"items": self.ITEMS, "maxGroups": 4})
297+ self.assertEqual(sorted(g["members"] for g in r["result"]["groups"]), [[0, 1], [3, 4]])
298+ self.assertEqual(r["result"]["leftovers"], [2, 5, 6, 7])
299+
300+ def test_prompt_rules_in_instructions_and_tabs_on_stdin(self):
301+ self.topics([(0, "Programming")])
302+ self.call("organize", {"items": self.ITEMS, "existingGroups": self.EXISTING})
303+ argv = [a for a in self.argv_log() if a[0] == "respond"][0]
304+ instructions = argv[argv.index("-i") + 1]
305+ self.assertIn("broad topics", instructions)
306+ self.assertNotIn("Lisbon", " ".join(argv)) # no tab or group text in the process list
307+ body = self.stdin_log()[0]
308+ self.assertIn("4 | Lisbon travel guide", body)
309+ self.assertIn("8 | Flights to Lisbon | - | -", body)
310+
311+ def test_no_topics_is_bad_output(self):
312+ self.respond(json.dumps({"tabs": []}))
313+ r = self.call("organize", {"items": self.ITEMS})
314+ self.assertEqual(r["error"]["code"], "BAD_MODEL_OUTPUT")
315+
316+ def test_context_overflow_suggests_a_smaller_batch(self):
317+ self.respond(stderr="Error: The session's transcript exceeded the model's context size.\n", exit=1)
318+ r = self.call("organize", {"items": self.ITEMS})
319+ self.assertEqual(r["error"]["code"], "OVER_BUDGET")
320+ self.assertEqual(r["error"]["allowedItems"], 5)
285321
286322
287323class InstallSchemas(HostCase):
288− def test_nested_supported(self):
324+ def test_writes_name_and_topics(self):
289325 for f in os.listdir(os.path.join(self.support, "schemas")):
290326 os.remove(os.path.join(self.support, "schemas", f))
291327 code, _, p = self.run_host(args=["--install-schemas"])
292328 self.assertEqual(code, 0, p.stderr)
293− self.assertEqual(sorted(f for f in os.listdir(os.path.join(self.support, "schemas")) if f != "version"), ["name.json", "organize.json"])
329+ self.assertEqual(sorted(os.listdir(os.path.join(self.support, "schemas"))), ["name.json", "topics.json", "version"])
294330
295331 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.
332+ # Real fm: `--object tabs` must be followed by `--schema <json>` for the item's own schema.
297333 for f in os.listdir(os.path.join(self.support, "schemas")):
298334 os.remove(os.path.join(self.support, "schemas", f))
299335 code, _, p = self.run_host(args=["--install-schemas"])
300336 self.assertEqual(code, 0, p.stderr)
301337 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")
309−
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.
338+ self.assertTrue(any("TabTopic" in a for a in calls))
339+ topics = next(a for a in calls if "Topics" in a)
340+ i = topics.index("--object")
341+ self.assertEqual(topics[i + 1:i + 3], ["tabs", "--schema"])
342+ self.assertIn('"args"', topics[i + 3]) # the fake fm's output for the TabTopic schema
343+ self.assertEqual(topics[i + 4], "--array")
344+
345+ def test_schemas_from_an_older_host_are_replaced(self):
312346 d = os.path.join(self.support, "schemas")
313347 for f in os.listdir(d):
314348 os.remove(os.path.join(d, f))
@@ -316,15 +350,8 @@ class InstallSchemas(HostCase):
316350 open(os.path.join(d, name), "w").write("{}")
317351 r = self.call("ping")
318352 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)
321−
322− def test_falls_back_to_two_flat_schemas(self):
323− self.control({"schema_nested": False})
324− code, _, p = self.run_host(args=["--install-schemas"])
325− self.assertEqual(code, 0, p.stderr)
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"])
327−
353+ self.assertEqual(r["result"]["organizeMode"], "topics")
354+ self.assertEqual(sorted(os.listdir(d)), ["name.json", "topics.json", "version"])
328355
329356 def test_first_use_writes_missing_schemas(self):
330357 for f in os.listdir(os.path.join(self.support, "schemas")):