@@ -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[:8], ["respond", "--model", "system", "--no-stream", "--greedy", "--schema", os.path.join(self.support, "schemas", "name.json"), "-i"])
128+ self.assertIn("You name a browser tab group", argv[8])
129+ self.assertEqual(len(argv), 9)
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```')
@@ -200,12 +203,52 @@ class ErrorCodes(HostCase):
200203 def test_over_budget_from_fm(self):
201204 self.expect("OVER_BUDGET", stderr="The prompt exceeds the context window", exit=1)
202205
206+ def test_page_text_is_shortened_before_giving_up(self):
207+ # 40 tabs with long page text don't fit at 500 chars each, but do once the text is shortened.
208+ items = [{"title": f"Page {n}", "url": f"https://example.com/{n}", "description": "word " * 100} for n in range(40)]
209+ self.respond(json.dumps({"title": "Reading list", "emoji": "📚"}))
210+ r = self.call("name", {"items": items})
211+ self.assertTrue(r["ok"], r)
212+ body = self.stdin_log()[0]
213+ self.assertLessEqual(len(body) // 4, host.TOKEN_BUDGET)
214+ self.assertIn("word", body)
215+
216+ def test_chinese_pages_are_measured_in_tokens(self):
217+ # 30 Chinese tabs: few characters, but about twice the tokens per character of English. fm's own
218+ # count says it's too long, so the page text is shortened.
219+ items = [{"title": f"页面 {n}", "url": f"https://example.cn/{n}", "description": "中文内容" * 100} for n in range(30)]
220+ self.control({"count_tokens": {"stdout": "9000\n", "exit": 0},
221+ "respond": {"stdout": json.dumps({"title": "阅读清单", "emoji": "📚"}), "exit": 0}})
222+ r = self.call("name", {"items": items})
223+ self.assertTrue(r["ok"], r)
224+ self.assertIn("count-tokens", [a[0] for a in self.argv_log()])
225+ body = self.stdin_log()[0]
226+ self.assertIn("中文内容", body)
227+ self.assertNotIn("中文内容" * 60, body) # 500 characters didn't fit; 200 did
228+
229+ def test_over_budget_by_fms_count_reports_allowed_items(self):
230+ items = [{"title": "中文标题" * 30, "url": f"https://example.cn/{n}"} for n in range(100)]
231+ self.control({"count_tokens": {"stdout": "14000\n", "exit": 0}})
232+ r = self.call("name", {"items": items})
233+ self.assertEqual(r["error"]["code"], "OVER_BUDGET")
234+ self.assertEqual(r["error"]["allowedItems"], 50)
235+
236+ def test_short_prompts_skip_the_exact_count(self):
237+ self.respond(json.dumps({"title": "Rust async", "emoji": "🦀"}))
238+ self.call("name", {"items": ITEMS3})
239+ self.assertNotIn("count-tokens", [a[0] for a in self.argv_log()])
240+
241+ def test_estimate_covers_the_measured_counts(self):
242+ # Measured with fm count-tokens on macOS 27 (tab lists): the estimate must never be lower.
243+ for text, measured in (("a" * 3072, 810), ("я" * 1274, 400), ("中" * 476, 255), ("の" * 384, 195)):
244+ self.assertGreaterEqual(host.estimate_tokens(host.prompts.Prompt("", text)), measured, text[0])
245+
203246 def test_over_budget_precheck_reports_allowed_items(self):
204− big = [{"title": "t" * 120, "url": "https://example.com/" + "p" * 280, "description": "d" * 300} for _ in range(60)]
247+ big = [{"title": "t" * 120, "url": "https://example.com/" + "p" * 280, "description": "d" * 300} for _ in range(120)]
205248 r = self.call("name", {"items": big})
206249 self.assertEqual(r["error"]["code"], "OVER_BUDGET")
207250 self.assertGreaterEqual(r["error"]["allowedItems"], 1)
208− self.assertLess(r["error"]["allowedItems"], 60)
251+ self.assertLess(r["error"]["allowedItems"], 120)
209252 self.assertEqual([a for a in self.argv_log() if a[0] == "respond"], [])
210253
211254 def test_guardrail(self):
@@ -240,8 +283,21 @@ class ErrorCodes(HostCase):
240283 def test_strict_retry_appends_line(self):
241284 self.respond(json.dumps({"title": "Rust async", "emoji": "🦀"}))
242285 self.call("name", {"items": ITEMS3}, strict=True)
243− prompt = self.stdin_log()[0]
244− self.assertIn("Reply with only the object", prompt)
286+ argv = [a for a in self.argv_log() if a[0] == "respond"][0]
287+ self.assertIn("Reply with only the object", argv[argv.index("-i") + 1])
288+
289+
290+ def test_strict_retry_samples_instead_of_repeating_the_greedy_answer(self):
291+ self.respond(json.dumps({"title": "Rust async", "emoji": "🦀"}))
292+ self.call("name", {"items": ITEMS3}, strict=True)
293+ self.assertNotIn("--greedy", [a for a in self.argv_log() if a[0] == "respond"][0])
294+
295+ def test_permissive_retry_shares_the_request_deadline(self):
296+ # The first call uses up the request's time: no second call runs past what the extension waits for.
297+ self.control({"respond_queue": [{"stderr": "Error: The model's safety guardrails were triggered.\n", "exit": 1, "sleep": 2.5}]})
298+ r = self.call("name", {"items": ITEMS3}, timeoutMs=5000)
299+ self.assertEqual(r["error"]["code"], "GUARDRAIL")
300+ self.assertEqual(len([a for a in self.argv_log() if a[0] == "respond"]), 1)
245301
246302
247303class Organize(HostCase):
@@ -249,66 +305,142 @@ class Organize(HostCase):
249305 {"title": "Hotels in Lisbon", "url": "https://booking.com/lisbon"},
250306 {"title": "Lisbon travel guide", "url": "https://lonelyplanet.com/portugal/lisbon"},
251307 {"title": "Random page", "url": "https://example.org/"},
308+ {"title": "Pasta recipe", "url": "https://cooking.example/pasta"},
309+ {"title": "Rice recipe", "url": "https://cooking.example/rice"},
252310 ]
311+ EXISTING = [{"g": 0, "title": "Lisbon trip", "samples": ["Flights to Lisbon", "Lisbon metro map"]}]
312+
313+ def topics(self, pairs):
314+ self.respond(json.dumps({"tabs": [{"index": i, "topic": t} for i, t in pairs]}))
253315
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})
316+ def test_tabs_with_one_topic_form_groups_and_join_existing_ones(self):
317+ # Indexes 8 and 9 are the existing group's example tabs (numbered after the 8 tabs to sort).
318+ self.topics([(0, "Programming"), (1, "programming"), (2, "Programming."), (3, "Travel"), (4, "travel"),
319+ (5, "Other"), (6, "Cooking"), (7, "Cooking"), (8, "Travel"), (9, "Travel")])
320+ r = self.call("organize", {"items": self.ITEMS, "existingGroups": self.EXISTING, "maxGroups": 4})
260321 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")
322+ self.assertEqual(r["meta"]["path"], "topics")
323+ groups = r["result"]["groups"]
324+ self.assertEqual(groups[0], {"title": "", "emoji": "", "color": "", "existing": 0, "members": [3, 4]})
325+ new = sorted(g["members"] for g in groups[1:])
326+ self.assertEqual(new, [[0, 1, 2], [6, 7]])
327+ self.assertTrue(all(g["title"] == "" for g in groups[1:])) # the naming loop titles new groups
263328 self.assertEqual(r["result"]["leftovers"], [5])
264− self.assertEqual(r["meta"]["path"], "nested")
265329
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("{}")
330+ def test_new_groups_are_named_right_away(self):
270331 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]})},
332+ {"stdout": json.dumps({"tabs": [{"index": i, "topic": t} for i, t in
333+ [(0, "Programming"), (1, "Programming"), (2, "Programming"), (6, "Cooking"), (7, "Cooking")]]})},
334+ {"stdout": json.dumps({"title": "Rust async runtimes", "emoji": "🦀"})},
335+ {"stderr": "Error: The model's safety guardrails were triggered.\n", "exit": 1},
336+ {"stderr": "Error: The model's safety guardrails were triggered.\n", "exit": 1},
273337 ]})
274− r = self.call("organize", {"items": self.ITEMS, "existingGroups": [{"g": 0, "title": "Lisbon trip"}], "maxGroups": 2})
338+ r = self.call("organize", {"items": self.ITEMS, "existingGroups": self.EXISTING, "maxGroups": 4})
275339 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])
340+ by_members = {tuple(g["members"]): g for g in r["result"]["groups"]}
341+ self.assertEqual((by_members[(0, 1, 2)]["title"], by_members[(0, 1, 2)]["emoji"]), ("Rust async runtimes", "🦀"))
342+ self.assertEqual(by_members[(6, 7)]["title"], "") # naming failed: the extension's naming loop takes it
343+ name_calls = self.stdin_log()[1:]
344+ self.assertIn("Tokio", name_calls[0])
345+ self.assertIn('"Lisbon trip"', name_calls[0]) # existing titles are off limits
346+
347+ def test_a_new_group_named_like_another_group_is_left_to_the_naming_loop(self):
348+ self.control({"respond_queue": [
349+ {"stdout": json.dumps({"tabs": [{"index": i, "topic": "Programming"} for i in (0, 1, 2)]})},
350+ {"stdout": json.dumps({"title": "Rust async", "emoji": "🦀"})},
351+ ]})
352+ r = self.call("organize", {"items": self.ITEMS, "siblingTitles": ["rust async"]})
353+ self.assertEqual(r["result"]["groups"][0]["title"], "")
354+ self.assertIn('"rust async"', self.stdin_log()[1]) # the name call was told, too
355+
356+ def test_a_topic_two_groups_share_equally_joins_neither_unless_the_words_say_which(self):
357+ existing = [{"g": 0, "title": "Lisbon trip", "samples": ["Flights to Lisbon"]},
358+ {"g": 1, "title": "Porto trip", "samples": ["Porto restaurants"]}]
359+ # Examples 8 (group 0) and 9 (group 1) both got Travel: a tie.
360+ self.topics([(3, "Travel"), (4, "Travel"), (8, "Travel"), (9, "Travel")])
361+ r = self.call("organize", {"items": self.ITEMS, "existingGroups": existing})
362+ self.assertEqual([g.get("existing") for g in r["result"]["groups"]], [0]) # "Lisbon" is in both tabs' titles
363+ items = [{"title": "Beach day", "url": "https://a.example/"}, {"title": "City walk", "url": "https://b.example/"}]
364+ self.topics([(0, "Travel"), (1, "Travel"), (2, "Travel"), (3, "Travel")])
365+ r = self.call("organize", {"items": items, "existingGroups": existing})
366+ self.assertNotIn("existing", r["result"]["groups"][0])
367+
368+ def test_only_vague_topics_leave_every_tab_loose(self):
369+ self.topics([(0, "Other"), (1, "Misc"), (2, "General")])
370+ r = self.call("organize", {"items": ITEMS3})
371+ self.assertTrue(r["ok"], r)
372+ self.assertEqual(r["result"], {"groups": [], "leftovers": [0, 1, 2]})
373+
374+ def test_two_topics_joining_one_group_make_one_join(self):
375+ # The group's two example tabs (8 and 9) got different topics; both topics still join group 0 once.
376+ self.topics([(3, "Travel"), (4, "Hotels"), (8, "Travel"), (9, "Hotels"), (0, "Programming")])
377+ r = self.call("organize", {"items": self.ITEMS, "existingGroups": self.EXISTING})
378+ joins = [g for g in r["result"]["groups"] if "existing" in g]
379+ self.assertEqual(joins, [{"title": "", "emoji": "", "color": "", "existing": 0, "members": [3, 4]}])
380+
381+ def test_fm_answers_greedily(self):
382+ self.topics([(0, "Programming")])
383+ self.call("organize", {"items": self.ITEMS})
384+ self.assertIn("--greedy", [a for a in self.argv_log() if a[0] == "respond"][0])
385+
386+ def test_topics_only_when_asked_not_to_name(self):
387+ self.topics([(0, "Programming"), (1, "Programming"), (2, "Travel")])
388+ r = self.call("organize", {"items": self.ITEMS, "nameGroups": False})
389+ self.assertTrue(r["ok"], r)
390+ self.assertEqual(len([a for a in self.argv_log() if a[0] == "respond"]), 1)
391+
392+ def test_a_topic_of_one_tab_is_a_leftover(self):
393+ self.topics([(0, "Programming"), (1, "Programming"), (2, "Rust"), (3, "Travel"), (4, "Travel"), (5, "Web"), (6, "Food"), (7, "Cooking")])
394+ r = self.call("organize", {"items": self.ITEMS, "maxGroups": 4})
395+ self.assertEqual(sorted(g["members"] for g in r["result"]["groups"]), [[0, 1], [3, 4]])
396+ self.assertEqual(r["result"]["leftovers"], [2, 5, 6, 7])
397+
398+ def test_prompt_rules_in_instructions_and_tabs_on_stdin(self):
399+ self.topics([(0, "Programming")])
400+ self.call("organize", {"items": self.ITEMS, "existingGroups": self.EXISTING})
401+ argv = [a for a in self.argv_log() if a[0] == "respond"][0]
402+ instructions = argv[argv.index("-i") + 1]
403+ self.assertIn("broad topics", instructions)
404+ self.assertNotIn("Lisbon", " ".join(argv)) # no tab or group text in the process list
405+ body = self.stdin_log()[0]
406+ self.assertIn("4 | Lisbon travel guide", body)
407+ self.assertIn("8 | Flights to Lisbon | - | -", body)
408+
409+ def test_no_topics_is_bad_output(self):
410+ self.respond(json.dumps({"tabs": []}))
411+ r = self.call("organize", {"items": self.ITEMS})
412+ self.assertEqual(r["error"]["code"], "BAD_MODEL_OUTPUT")
413+
414+ def test_context_overflow_suggests_a_smaller_batch(self):
415+ self.respond(stderr="Error: The session's transcript exceeded the model's context size.\n", exit=1)
416+ r = self.call("organize", {"items": self.ITEMS})
417+ self.assertEqual(r["error"]["code"], "OVER_BUDGET")
418+ self.assertEqual(r["error"]["allowedItems"], 5)
285419
286420
287421class InstallSchemas(HostCase):
288− def test_nested_supported(self):
422+ def test_writes_name_and_topics(self):
289423 for f in os.listdir(os.path.join(self.support, "schemas")):
290424 os.remove(os.path.join(self.support, "schemas", f))
291425 code, _, p = self.run_host(args=["--install-schemas"])
292426 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"])
427+ self.assertEqual(sorted(os.listdir(os.path.join(self.support, "schemas"))), ["name.json", "topics.json", "version"])
294428
295429 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.
430+ # Real fm: `--object tabs` must be followed by `--schema <json>` for the item's own schema.
297431 for f in os.listdir(os.path.join(self.support, "schemas")):
298432 os.remove(os.path.join(self.support, "schemas", f))
299433 code, _, p = self.run_host(args=["--install-schemas"])
300434 self.assertEqual(code, 0, p.stderr)
301435 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.
436+ self.assertTrue(any("TabTopic" in a for a in calls))
437+ topics = next(a for a in calls if "Topics" in a)
438+ i = topics.index("--object")
439+ self.assertEqual(topics[i + 1:i + 3], ["tabs", "--schema"])
440+ self.assertIn('"args"', topics[i + 3]) # the fake fm's output for the TabTopic schema
441+ self.assertEqual(topics[i + 4], "--array")
442+
443+ def test_schemas_from_an_older_host_are_replaced(self):
312444 d = os.path.join(self.support, "schemas")
313445 for f in os.listdir(d):
314446 os.remove(os.path.join(d, f))
@@ -316,15 +448,8 @@ class InstallSchemas(HostCase):
316448 open(os.path.join(d, name), "w").write("{}")
317449 r = self.call("ping")
318450 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−
451+ self.assertEqual(r["result"]["organizeMode"], "topics")
452+ self.assertEqual(sorted(os.listdir(d)), ["name.json", "topics.json", "version"])
328453
329454 def test_first_use_writes_missing_schemas(self):
330455 for f in os.listdir(os.path.join(self.support, "schemas")):
@@ -402,6 +527,15 @@ class Register(HostCase):
402527 self.assertEqual(p.returncode, 1)
403528 self.assertIn("missing or not executable", p.stderr)
404529
530+ def test_one_browser_that_cannot_be_written_does_not_fail_the_rest(self):
531+ chrome = self.make_profile("Google/Chrome")
532+ brave = self.make_profile("BraveSoftware/Brave-Browser")
533+ open(os.path.join(brave, "NativeMessagingHosts"), "w").close() # a file where the folder should be
534+ p = self.run_copy("--register", self.host_copy)
535+ self.assertEqual(p.returncode, 0, p.stderr)
536+ self.assertIn("BraveSoftware/Brave-Browser: could not write", p.stderr)
537+ self.assertTrue(os.path.exists(os.path.join(chrome, "NativeMessagingHosts", "io.diagonal.host.json")))
538+
405539 def test_no_browser_found(self):
406540 os.makedirs(self.env["DIAGONAL_APP_SUPPORT"])
407541 p = self.run_copy("--register", self.host_copy)
@@ -423,6 +557,13 @@ class SelfTest(HostCase):
423557 self.assertEqual(code, 1)
424558 self.assertIn("FAIL model available", p.stdout.decode())
425559
560+ def test_selftest_without_fm_says_so_and_stops(self):
561+ self.env["DIAGONAL_FM"] = os.path.join(self.tmp, "no-such-fm")
562+ code, _, p = self.run_host(args=["--selftest"])
563+ self.assertEqual(code, 1)
564+ self.assertIn("FAIL fm found", p.stdout.decode())
565+ self.assertNotIn("Traceback", p.stderr.decode())
566+
426567 def test_selftest_names_the_license_fix(self):
427568 self.control({"license": True})
428569 code, _, p = self.run_host(args=["--selftest"])