on master help sign in
Keep the helper within its time limit, name new groups without duplicates, finish installs

Helper: - every fm call in one request shares its deadline, so the refusal retry and naming new groups no longer run past the point the extension stops waiting - the retry after an unusable title samples instead of repeating the greedy answer, and the rejected title is listed as not allowed - a new group whose name another group in the window already has is left for the naming loop, which picks a different one - a topic two groups share equally joins neither, unless the tabs' words say which one it is - tabs that only got vague topics stay loose instead of failing the request - --selftest without fm says so instead of crashing - --register succeeds when at least one browser was set up Install: a browser that could not be set up no longer stops the installer before Apple's terms and the self-test; it ends with one line saying how to finish. Releases run one at a time in push order, so an older run can't become the latest release the cask downloads. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018SsrrFmPFG4NfNt7omzSis

commit6c3796d39ec1211867628cc37f57a966871d5b65 authorClaude <noreply@anthropic.com> dateSat 26 Sep 2026, 11:54 (2 weeks ago) parent9dba9d5
9 files changed+191−34
.github/workflows/release.yml+4−1 host/diagonal-host.py+44−25 host/tests/test_host.py+56 host/validate.py+44−4 scripts/install-manifest.sh+11−2 src/background/naming.ts+13 src/background/organize.ts+10−2 test/auto.test.ts+1 test/naming.test.ts+8
.github/workflows/release.yml+4−1
@@ -9,8 +9,11 @@ on:
99permissions:
1010 contents: write
1111
12+# One release at a time, in push order: an older run finishing last must not become the latest release,
13+# which is what the Homebrew cask downloads.
1214concurrency:
13− group: release-${{ github.sha }}
15+ group: release
16+ cancel-in-progress: false
1417
1518jobs:
1619 release:
host/diagonal-host.py+44−25
@@ -255,28 +255,41 @@ PERMISSIVE = ["--guardrails", "permissive-content-transformations"]
255255USAGE_EXIT = 64
256256
257257
258−def run_fm(prompt, schema, model, timeout_s):
259− """`prompt` is a prompts.Prompt: its fixed rules go to -i, the tab text on stdin."""
258+# Below this much time left in a request, a second fm call (the permissive retry, naming one more new
259+# group) is not started: it would only be cut off.
260+MIN_CALL_S = 5.0
261+
262+
263+def run_fm(prompt, schema, opts):
264+ """`prompt` is a prompts.Prompt: its fixed rules go to -i, the tab text on stdin. Every call in one
265+ request shares its deadline, so the extension's guard (its timeout plus 10 s) is a real upper bound."""
260266 try:
261− return _run_fm(prompt, schema, model, timeout_s, [])
267+ return _run_fm(prompt, schema, opts, [])
262268 except Fail as f:
263− if f.code != "GUARDRAIL":
269+ if f.code != "GUARDRAIL" or time_left(opts) < MIN_CALL_S:
264270 raise
265− return _run_fm(prompt, schema, model, timeout_s, PERMISSIVE)
271+ return _run_fm(prompt, schema, opts, PERMISSIVE)
272+
273+
274+def time_left(opts):
275+ return opts["deadline"] - time.time()
266276
267277
268−def _run_fm(prompt, schema, model, timeout_s, extra):
278+def _run_fm(prompt, schema, opts, extra):
269279 # The tab text goes on stdin (fm reads it there when no prompt argument is given), so it never shows up
270280 # in the process list. fm has no timeout of its own; subprocess enforces ours.
271281 # --greedy: the same tabs get the same topics and name on every run (measured on macOS 27: five runs,
272− # one answer), so groups don't get renamed or reshuffled each time Diagonal looks at them.
273− args = [FM, "respond", "--model", model, "--no-stream", "--greedy", "--schema", schema, "-i", prompt.instructions, *extra]
282+ # one answer), so groups don't get renamed or reshuffled each time Diagonal looks at them. The strict
283+ # retry after an unusable answer samples instead: greedy would give the same answer again.
284+ greedy = [] if opts.get("strict") else ["--greedy"]
285+ args = [FM, "respond", "--model", opts["model"], "--no-stream", *greedy, "--schema", schema, "-i", prompt.instructions, *extra]
286+ timeout_s = max(1.0, time_left(opts))
274287 try:
275288 p = subprocess.run(args, input=prompt.text, capture_output=True, text=True, timeout=timeout_s, env=fm_env())
276289 except FileNotFoundError:
277290 raise Fail("MODEL_UNAVAILABLE", f"fm not found at {FM} — requires macOS 27")
278291 except subprocess.TimeoutExpired:
279− raise Fail("TIMEOUT", f"fm exceeded {timeout_s:g}s", retryable=True)
292+ raise Fail("TIMEOUT", f"fm exceeded {opts['timeout_s']:g}s", retryable=True)
280293 if p.returncode != 0:
281294 code = classify(p.stderr, p.returncode)
282295 if code == "LICENSE_REQUIRED":
@@ -295,7 +308,7 @@ def organize_mode():
295308
296309def op_ping(_payload, _opts):
297310 if not os.path.exists(FM):
298− return {"hostVersion": HOST_VERSION, "fmPath": FM, "fmAvailable": False,
311+ return {"hostVersion": HOST_VERSION, "fmPath": FM, "fmAvailable": False, "licenseRequired": False,
299312 "fmMessage": f"fm not found at {FM} — requires macOS 27", "schemasOk": False, "organizeMode": organize_mode()}
300313 license_required = False
301314 try:
@@ -367,7 +380,7 @@ def op_name(payload, opts):
367380 raise Fail("SCHEMA_MISSING", f"fm could not write {os.path.join(SCHEMAS, 'name.json')}")
368381 prompt = fit(lambda cap: prompts.build_name_prompt(payload, strict=opts.get("strict", False), desc_cap=cap), len(payload["items"]))
369382 try:
370− out = run_fm(prompt, schema, opts["model"], opts["timeout_s"])
383+ out = run_fm(prompt, schema, opts)
371384 except Fail as f:
372385 raise _fit_overflow(f, len(payload["items"]))
373386 try:
@@ -383,7 +396,7 @@ def op_organize(payload, opts):
383396 raise Fail("SCHEMA_MISSING", f"fm could not write {os.path.join(SCHEMAS, 'topics.json')}")
384397 prompt = fit(lambda cap: prompts.build_topics_prompt(payload, strict=opts.get("strict", False), desc_cap=cap), len(payload["items"]))
385398 try:
386− out = run_fm(prompt, schema, opts["model"], opts["timeout_s"])
399+ out = run_fm(prompt, schema, opts)
387400 except Fail as f:
388401 raise _fit_overflow(f, len(payload["items"]))
389402 _, owner = prompts.topic_items(payload)
@@ -393,29 +406,31 @@ def op_organize(payload, opts):
393406 raise Fail("BAD_MODEL_OUTPUT", str(e), retryable=True, raw=json.dumps(out, ensure_ascii=False)[:2000])
394407 chars = prompts.size(prompt)
395408 if payload.get("nameGroups") is not False: # the extension's fit check only needs the topics
396− chars += name_new_groups(result, payload, opts, time.time() + opts["timeout_s"])
409+ chars += name_new_groups(result, payload, opts)
397410 return result, chars, "topics"
398411
399412
400−def name_new_groups(result, payload, opts, deadline):
413+def name_new_groups(result, payload, opts):
401414 """Title each new group with the name op (well under a second each on fm), so groups appear named.
402− A group that fails, or runs past the deadline, keeps "" and the extension's naming loop names it."""
403− taken = [g.get("title") for g in payload.get("existingGroups") or [] if g.get("title")]
415+ A group that fails, runs past the request's deadline, or gets a title another group in the window
416+ already has keeps "" and the extension's naming loop, which checks every sibling, names it."""
417+ taken = [t for t in [g.get("title") for g in payload.get("existingGroups") or []] + list(payload.get("siblingTitles") or []) if t]
404418 chars = 0
405419 for g in result["groups"]:
406420 if "existing" in g or g.get("title"):
407421 continue
408− left = deadline - time.time()
409− if left < 5:
422+ if time_left(opts) < MIN_CALL_S:
410423 break
411424 sub = {"items": [dict(payload["items"][i], i=n) for n, i in enumerate(g["members"])], "siblingTitles": taken[:20]}
412425 try:
413− named, n, _ = op_name(sub, {**opts, "timeout_s": min(opts["timeout_s"], left)})
426+ named, n, _ = op_name(sub, {**opts, "strict": False})
414427 except Fail:
415428 continue
429+ chars += n
430+ if any(validate.same_label(named["title"], t) for t in taken):
431+ continue
416432 g["title"], g["emoji"] = named["title"], named["emoji"]
417433 taken.append(named["title"])
418− chars += n
419434 return chars
420435
421436
@@ -429,8 +444,8 @@ def handle(req):
429444 raw_opts = req.get("opts") or {}
430445 timeout_ms = raw_opts.get("timeoutMs", 45000)
431446 timeout_ms = timeout_ms if isinstance(timeout_ms, (int, float)) and not isinstance(timeout_ms, bool) else 45000
432− opts = {"model": "system", "strict": bool(raw_opts.get("strict")),
433− "timeout_s": max(5.0, min(120.0, timeout_ms / 1000))}
447+ timeout_s = max(5.0, min(120.0, timeout_ms / 1000))
448+ opts = {"model": "system", "strict": bool(raw_opts.get("strict")), "timeout_s": timeout_s, "deadline": t0 + timeout_s}
434449 op = req["op"]
435450 if op == "ping":
436451 return {"v": 1, "id": rid, "ok": True, "result": op_ping(req["payload"], opts)}
@@ -553,14 +568,16 @@ def selftest():
553568
554569 ping = op_ping({}, {})
555570 check("fm found", os.path.exists(FM), FM)
556− if ping["licenseRequired"]:
571+ if not os.path.exists(FM):
572+ return 1 # nothing else can work without it
573+ if ping.get("licenseRequired"):
557574 check("fm terms accepted", False, f"run: {LICENSE_FIX}")
558575 else:
559576 check("model available (fm available --model system)", ping["fmAvailable"], ping["fmMessage"])
560577 for name in SCHEMA_COMMANDS:
561578 path = os.path.join(SCHEMAS, name)
562579 readable = os.path.isfile(path) and os.path.getsize(path) > 0
563− hint = f"written on their own after {LICENSE_FIX}" if ping["licenseRequired"] else "missing: run diagonal-host --install-schemas"
580+ hint = f"written on their own after {LICENSE_FIX}" if ping.get("licenseRequired") else "missing: run diagonal-host --install-schemas"
564581 check(f"schema {name}", readable, path if readable else hint)
565582 check("allowed origin pinned", "<" not in ALLOWED_ORIGIN, ALLOWED_ORIGIN)
566583 if ping["fmAvailable"] and schema_path("name.json"):
@@ -670,7 +687,9 @@ def register(host_path):
670687 f.write("\n".join(written) + "\n")
671688 except OSError:
672689 pass
673− return 1 if failures else 0
690+ # One browser that could not be written (a locked folder, a stale profile) does not undo the others:
691+ # the install goes on and the ones that failed were named above.
692+ return 0 if len(dirs) - failures > 0 else 1
674693
675694
676695def unregister():
host/tests/test_host.py+56
@@ -287,6 +287,19 @@ class ErrorCodes(HostCase):
287287 self.assertIn("Reply with only the object", argv[argv.index("-i") + 1])
288288
289289
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)
301+
302+
290303class Organize(HostCase):
291304 ITEMS = ITEMS3 + [
292305 {"title": "Hotels in Lisbon", "url": "https://booking.com/lisbon"},
@@ -331,6 +344,33 @@ class Organize(HostCase):
331344 self.assertIn("Tokio", name_calls[0])
332345 self.assertIn('"Lisbon trip"', name_calls[0]) # existing titles are off limits
333346
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+
334374 def test_two_topics_joining_one_group_make_one_join(self):
335375 # The group's two example tabs (8 and 9) got different topics; both topics still join group 0 once.
336376 self.topics([(3, "Travel"), (4, "Hotels"), (8, "Travel"), (9, "Hotels"), (0, "Programming")])
@@ -487,6 +527,15 @@ class Register(HostCase):
487527 self.assertEqual(p.returncode, 1)
488528 self.assertIn("missing or not executable", p.stderr)
489529
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+
490539 def test_no_browser_found(self):
491540 os.makedirs(self.env["DIAGONAL_APP_SUPPORT"])
492541 p = self.run_copy("--register", self.host_copy)
@@ -508,6 +557,13 @@ class SelfTest(HostCase):
508557 self.assertEqual(code, 1)
509558 self.assertIn("FAIL model available", p.stdout.decode())
510559
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+
511567 def test_selftest_names_the_license_fix(self):
512568 self.control({"license": True})
513569 code, _, p = self.run_host(args=["--selftest"])
host/validate.py+44−4
@@ -160,6 +160,10 @@ def is_valid_label(label):
160160 )
161161
162162
163+def same_label(a, b):
164+ return bool(a) and bool(b) and " ".join(a.split()).casefold() == " ".join(b.split()).casefold()
165+
166+
163167def validate_name(out, payload):
164168 if not isinstance(out, dict):
165169 raise ValidationError("reply is not an object")
@@ -265,16 +269,52 @@ def organize_from_topics(out, payload, owner):
265269 votes = group_votes.setdefault(topic, {})
266270 votes[owner[i]] = votes.get(owner[i], 0) + 1
267271 if not tab_topic:
268− raise ValidationError("no tab got a topic")
272+ if not any(isinstance(t, dict) and _in_range(t.get("index"), n_items) for t in out["tabs"]):
273+ raise ValidationError("no tab got a topic")
274+ # Every tab got only a vague topic ("Other", "Misc"): the model found nothing in common.
275+ return validate_organize({"groups": [], "leftovers": []}, payload)
269276 clusters = {}
270277 for i in sorted(tab_topic):
271278 clusters.setdefault(tab_topic[i], []).append(i)
272279 groups = []
273280 for topic, members in clusters.items():
274− votes = group_votes.get(topic)
275− if votes:
276− g = max(sorted(votes), key=lambda k: votes[k])
281+ g = _winner(group_votes.get(topic) or {}, members, payload)
282+ if g is not None:
277283 groups.append({"existing": g, "members": members})
278284 else:
279285 groups.append({"title": "", "emoji": "", "color": "", "members": members})
280286 return validate_organize({"groups": groups, "leftovers": []}, payload)
287+
288+
289+def _in_range(i, n):
290+ return isinstance(i, int) and not isinstance(i, bool) and 0 <= i < n
291+
292+
293+_WORD = re.compile(r"\w{3,}")
294+
295+
296+def _words(text):
297+ return {w.casefold() for w in _WORD.findall(text or "")}
298+
299+
300+def _winner(votes, members, payload):
301+ """The existing group a topic's tabs join: the one whose example tabs share it most. When two groups tie,
302+ the one whose title and examples share more words with these tabs; when that ties too, none (the tabs
303+ form a group of their own, or stay loose), rather than whichever group happens to be listed first."""
304+ if not votes:
305+ return None
306+ top = max(votes.values())
307+ tied = sorted(k for k, v in votes.items() if v == top)
308+ if len(tied) == 1:
309+ return tied[0]
310+ tab_words = set().union(*(_words(payload["items"][i].get("title")) for i in members))
311+ groups = {g["g"]: g for g in payload.get("existingGroups") or []}
312+
313+ def overlap(k):
314+ g = groups.get(k) or {}
315+ return len(tab_words & _words(" ".join([g.get("title") or "", *(g.get("samples") or [])])))
316+
317+ scores = {k: overlap(k) for k in tied}
318+ best = max(scores.values())
319+ leaders = [k for k in tied if scores[k] == best]
320+ return leaders[0] if len(leaders) == 1 and best > 0 else None
scripts/install-manifest.sh+11−2
@@ -53,7 +53,16 @@ echo "host: $BIN -> $SHARE/diagonal-host.py"
5353"$BIN" --version >/dev/null # the host must start with the python3 it was pinned to
5454# Writes the manifest into every Chromium browser's NativeMessagingHosts folder (Chrome, Brave and
5555# Brave Origin, Edge, Vivaldi, Arc, Opera, …) and checks each one the way the browser reads it.
56−"$BIN" --register "$BIN"
56+# A browser that was never opened has no folder yet: the rest of the install still runs, and the end
57+# says how to finish.
58+register_rc=0
59+"$BIN" --register "$BIN" || register_rc=$?
60+register_note() {
61+ if [[ $register_rc -ne 0 ]]; then
62+ echo "No browser could be set up for Diagonal yet. Open your browser once, then run: bash '$HERE/$(basename "$0")'"
63+ fi
64+}
65+trap register_note EXIT
5766
5867FM="${DIAGONAL_FM:-/usr/bin/fm}"
5968
@@ -89,7 +98,7 @@ fi
8998# Diagonal also writes these on first use; doing it now lets the self-test below cover the model.
9099schemas_out=$("$BIN" --install-schemas 2>&1) || true
91100if selftest_out=$("$BIN" --selftest 2>&1); then
92− echo "Diagonal's host is ready."
101+ [[ $register_rc -ne 0 ]] || echo "Diagonal's host is ready."
93102else
94103 echo "Diagonal's host is installed, but its self-test found a problem:"
95104 echo "$schemas_out"
src/background/naming.ts+13
@@ -312,6 +312,7 @@ export class Naming {
312312 }
313313 if (e.code === "BAD_MODEL_OUTPUT" && !retried.output) {
314314 retried.output = true;
315+ mustDifferFrom = withRejected(mustDifferFrom, e.raw);
315316 continue;
316317 }
317318 this.fail(g, e, hash);
@@ -323,6 +324,7 @@ export class Naming {
323324 const e: HostError = { code: "BAD_MODEL_OUTPUT", message: "label failed worker validation", raw: JSON.stringify(reply.result).slice(0, 500), at: this.d.now() };
324325 if (!retried.output) {
325326 retried.output = true;
327+ mustDifferFrom = withRejected(mustDifferFrom, e.raw);
326328 continue;
327329 }
328330 this.fail(g, e, hash);
@@ -412,6 +414,17 @@ export class Naming {
412414 }
413415}
414416
417+/** The retry after an unusable title lists that title as not allowed, so the model doesn't give it again. */
418+function withRejected(list: string[] | undefined, raw: string | undefined): string[] | undefined {
419+ let title: unknown;
420+ try {
421+ title = raw ? JSON.parse(raw)?.title : undefined;
422+ } catch {
423+ return list;
424+ }
425+ return typeof title === "string" && title.trim() ? [...(list ?? []), title.trim().slice(0, 60)] : list;
426+}
427+
415428/** The retry bookkeeping of earlier failures, which no longer applies. */
416429function clearFailures(g: GroupRecord): void {
417430 g.nameAttempts = 0;
src/background/organize.ts+10−2
@@ -27,6 +27,8 @@ export interface ExistingGroup {
2727export interface OrganizePayload {
2828 items: NameItem[];
2929 existingGroups: ExistingGroup[];
30+ /** Every group label in the window: a new group the host names must not repeat one. */
31+ siblingTitles?: string[];
3032 allowNew: boolean;
3133 maxGroups: number;
3234 minGroupSize: number;
@@ -177,11 +179,17 @@ const CONTENT_ERRORS = new Set<HostErrorCode>(["OVER_BUDGET", "GUARDRAIL", "BAD_
177179const MAX_TIMEOUT_SPLITS = 1;
178180
179181/** One organize call, with the retries that can rescue the same tabs: strict output, then no page text. */
180−async function askOrganize(rt: Runtime, part: Member[], existingGroups: ExistingGroup[], withDescriptions: boolean): Promise<HostReply<OrganizeResult>> {
182+async function askOrganize(rt: Runtime, part: Member[], windowId: number, existingGroups: ExistingGroup[], withDescriptions: boolean): Promise<HostReply<OrganizeResult>> {
181183 const settings = rt.settings();
184+ const s = rt.state();
182185 const payload: OrganizePayload = {
183186 items: toItems(part, settings, withDescriptions),
184187 existingGroups,
188+ siblingTitles: Object.values(s.groups)
189+ .filter((g) => g.windowId === windowId && g.origin !== "tidy")
190+ .map((g) => labelOf(g.stripTitle || g.title))
191+ .filter(Boolean)
192+ .slice(0, 20),
185193 allowNew: part.length >= 2,
186194 maxGroups: maxGroupsFor(part.length),
187195 minGroupSize: settings.organizeMinGroupSize,
@@ -224,7 +232,7 @@ export async function sortPart(rt: Runtime, part: Member[], run: SortRun, probe
224232 run.left += part.length;
225233 return; // one tab and nothing to join: no call can place it (a probe still learns whether the model takes it)
226234 }
227− const reply = await askOrganize(rt, part, existingGroups, true);
235+ const reply = await askOrganize(rt, part, run.windowId, existingGroups, true);
228236 if (!reply.ok) {
229237 const e = reply.error;
230238 const timedOut = e.code === "TIMEOUT" && part.length > 4 && run.timeoutSplits < MAX_TIMEOUT_SPLITS;
test/auto.test.ts+1
@@ -94,6 +94,7 @@ describe("auto-organize", () => {
9494 await auto.run(1);
9595 expect(calls[0].items.map((i) => i.title)).toEqual(["Tab 3", "Tab 2"]);
9696 expect(calls[0].existingGroups).toEqual([expect.objectContaining({ g: 0, title: "Rust" })]);
97+ expect(calls[0].siblingTitles).toEqual(["Rust"]);
9798 expect(live.find((t) => t.id === 3)!.groupId).toBe(50);
9899 });
99100
test/naming.test.ts+8
@@ -299,6 +299,14 @@ describe("naming queue", () => {
299299 expect(writes).toEqual([[7, "🦀 Rust crates"]]);
300300 });
301301
302+ it("the retry after a title the host rejected lists that title as not allowed", async () => {
303+ replies.push({ ok: false, error: { code: "BAD_MODEL_OUTPUT", message: "rules", raw: JSON.stringify({ title: "Python", emoji: "🐍" }) } });
304+ naming.touch(7);
305+ await settle();
306+ expect(calls).toHaveLength(2);
307+ expect(calls[1].mustDifferFrom).toEqual(["Python"]);
308+ });
309+
302310 it("a group renamed by hand while the model runs keeps the user's title", async () => {
303311 const n = new Naming({
304312 ...(naming as any).d,