@@ -124,9 +124,9 @@ 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[: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)
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)
130130 # Tab text goes on stdin, never into argv where `ps` would show it.
131131 self.assertIn("tokio.rs", self.stdin_log()[0])
132132 self.assertNotIn("tokio", " ".join(argv))
@@ -210,9 +210,39 @@ class ErrorCodes(HostCase):
210210 r = self.call("name", {"items": items})
211211 self.assertTrue(r["ok"], r)
212212 body = self.stdin_log()[0]
213− self.assertLessEqual(len(body), host.CHAR_BUDGET)
213+ self.assertLessEqual(len(body) // 4, host.TOKEN_BUDGET)
214214 self.assertIn("word", body)
215215
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+
216246 def test_over_budget_precheck_reports_allowed_items(self):
217247 big = [{"title": "t" * 120, "url": "https://example.com/" + "p" * 280, "description": "d" * 300} for _ in range(120)]
218248 r = self.call("name", {"items": big})
@@ -301,6 +331,18 @@ class Organize(HostCase):
301331 self.assertIn("Tokio", name_calls[0])
302332 self.assertIn('"Lisbon trip"', name_calls[0]) # existing titles are off limits
303333
334+ def test_two_topics_joining_one_group_make_one_join(self):
335+ # The group's two example tabs (8 and 9) got different topics; both topics still join group 0 once.
336+ self.topics([(3, "Travel"), (4, "Hotels"), (8, "Travel"), (9, "Hotels"), (0, "Programming")])
337+ r = self.call("organize", {"items": self.ITEMS, "existingGroups": self.EXISTING})
338+ joins = [g for g in r["result"]["groups"] if "existing" in g]
339+ self.assertEqual(joins, [{"title": "", "emoji": "", "color": "", "existing": 0, "members": [3, 4]}])
340+
341+ def test_fm_answers_greedily(self):
342+ self.topics([(0, "Programming")])
343+ self.call("organize", {"items": self.ITEMS})
344+ self.assertIn("--greedy", [a for a in self.argv_log() if a[0] == "respond"][0])
345+
304346 def test_a_topic_of_one_tab_is_a_leftover(self):
305347 self.topics([(0, "Programming"), (1, "Programming"), (2, "Rust"), (3, "Travel"), (4, "Travel"), (5, "Web"), (6, "Food"), (7, "Cooking")])
306348 r = self.call("organize", {"items": self.ITEMS, "maxGroups": 4})