User1342 commited on
Commit
04e4eec
·
1 Parent(s): 41c2320

Adjust a ragged row and an unreadable colour rather than throwing the file away, and notice a tool call that ran out of room

Browse files
distinct_agent/harness.py CHANGED
@@ -163,6 +163,8 @@ class StructuredToolHarness:
163
  artifacts: list[Mapping[str, Any]] = []
164
  model_usages: list[Mapping[str, Any]] = []
165
  nudged = False
 
 
166
 
167
  # Two spare turns beyond the tool budget: one for the final answer, and
168
  # one for the single correction below. The loop still cannot run away,
@@ -193,6 +195,14 @@ class StructuredToolHarness:
193
  )
194
  model_usages.append(dict(inference.usage))
195
  action = _parse_action(inference.text, self.max_action_characters)
 
 
 
 
 
 
 
 
196
  answered_without_acting = action is None or action["type"] == "final"
197
  if answered_without_acting and makers and not events and not nudged:
198
  # Finished on the first move, when a tool here makes files.
@@ -208,7 +218,15 @@ class StructuredToolHarness:
208
  if action is None:
209
  progress(1.0, "Local inference complete")
210
  return HarnessResult(
211
- text=inference.text,
 
 
 
 
 
 
 
 
212
  usage=_usage(self.name, model_usages, events),
213
  tool_events=tuple(events),
214
  artifacts=tuple(artifacts),
@@ -1085,6 +1103,42 @@ def _initial_tool_prompt(prompt: str, manifest: tuple[Mapping[str, Any], ...]) -
1085
  )
1086
 
1087
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1088
  def _nudge_prompt(previous: str, makers: tuple[str, ...]) -> str:
1089
  """Asked for a file, wrote prose. Say so once, and ask again.
1090
 
 
163
  artifacts: list[Mapping[str, Any]] = []
164
  model_usages: list[Mapping[str, Any]] = []
165
  nudged = False
166
+ shortened = False
167
+ ran_out_of_room = False
168
 
169
  # Two spare turns beyond the tool budget: one for the final answer, and
170
  # one for the single correction below. The loop still cannot run away,
 
195
  )
196
  model_usages.append(dict(inference.usage))
197
  action = _parse_action(inference.text, self.max_action_characters)
198
+ if action is None and was_truncated(inference.usage):
199
+ # Not prose: a call that ran past the output cap. Say so, and
200
+ # ask for a smaller one rather than accepting the fragment.
201
+ ran_out_of_room = True
202
+ if not shortened:
203
+ shortened = True
204
+ current_prompt = _too_long_prompt(current_prompt)
205
+ continue
206
  answered_without_acting = action is None or action["type"] == "final"
207
  if answered_without_acting and makers and not events and not nudged:
208
  # Finished on the first move, when a tool here makes files.
 
218
  if action is None:
219
  progress(1.0, "Local inference complete")
220
  return HarnessResult(
221
+ text=(
222
+ inference.text
223
+ + "\n\n[This run's last tool call was cut off before it "
224
+ "finished, so nothing was produced by it. The request "
225
+ "needs more output room than this model has, or fewer "
226
+ "things asked for at once.]"
227
+ if ran_out_of_room
228
+ else inference.text
229
+ ),
230
  usage=_usage(self.name, model_usages, events),
231
  tool_events=tuple(events),
232
  artifacts=tuple(artifacts),
 
1103
  )
1104
 
1105
 
1106
+ #: What llama-server calls a reply that ran out of room rather than finishing.
1107
+ _RAN_OUT_OF_ROOM = {"length", "limit", "max_tokens"}
1108
+
1109
+
1110
+ def was_truncated(usage: Mapping[str, Any]) -> bool:
1111
+ """Whether the model stopped because it hit the output cap, not because it finished."""
1112
+
1113
+ return str(usage.get("stop_reason") or "").casefold() in _RAN_OUT_OF_ROOM
1114
+
1115
+
1116
+ def _too_long_prompt(previous: str) -> str:
1117
+ """The last action was cut off mid-JSON. Ask for a smaller one.
1118
+
1119
+ A TRUNCATED TOOL CALL LOOKS EXACTLY LIKE PROSE, AND THAT COST A WORKLOAD.
1120
+
1121
+ Held to the action schema, the model emitted a perfectly correct call --
1122
+ and the call was a whole recipe book in one argument, so it ran past the
1123
+ output cap and stopped mid-string. What arrived was not valid JSON, so it
1124
+ parsed as prose, so the run finished with a paragraph and no file, and the
1125
+ result said "produced 0 files" as though the model had ignored the tool.
1126
+ The two failures need opposite fixes and looked identical.
1127
+
1128
+ The reply itself says which happened -- ``finish_reason`` is ``length``
1129
+ rather than ``stop`` -- so it is used, and the model is told the thing it
1130
+ can act on: make a smaller call.
1131
+ """
1132
+
1133
+ return (
1134
+ f"{previous}\n\n"
1135
+ "YOUR LAST ACTION WAS CUT OFF because it was too long to finish, so it\n"
1136
+ "did nothing. Make a smaller call: put less in each argument, and if the\n"
1137
+ "request needs a long document, produce it in several calls rather than\n"
1138
+ "one. Reply with ONE JSON object and nothing else."
1139
+ )
1140
+
1141
+
1142
  def _nudge_prompt(previous: str, makers: tuple[str, ...]) -> str:
1143
  """Asked for a file, wrote prose. Say so once, and ask again.
1144
 
distinct_skills/handlers.py CHANGED
@@ -330,15 +330,12 @@ def csv_table(arguments: Mapping[str, object], skill: SkillDefinition) -> dict[s
330
  guarded_header.append(safe)
331
 
332
  rows: list[list[str]] = []
 
333
  for number, line in lines[1:]:
334
  cells = [cell.strip() for cell in line.split(separator)]
335
- if len(cells) != len(header):
336
- # Padding the row would invent data and silently change what the
337
- # user is told their table contains. Refusing names the line, so
338
- # the caller can fix the one line rather than guess.
339
- raise SkillInputError(
340
- f"line {number} has {len(cells)} fields but the header has {len(header)}"
341
- )
342
  row: list[str] = []
343
  for cell in cells:
344
  safe, changed = _guard(cell)
@@ -357,6 +354,9 @@ def csv_table(arguments: Mapping[str, object], skill: SkillDefinition) -> dict[s
357
  # Reported rather than hidden: a guarded cell is a cell whose text was
358
  # changed, and the user is entitled to know their data was edited.
359
  "guarded_cells": guarded,
 
 
 
360
  "artifact": text_artifact(
361
  _filename(arguments.get("filename"), "table.csv", ".csv"), "text/csv", text
362
  ),
@@ -368,6 +368,44 @@ def csv_table(arguments: Mapping[str, object], skill: SkillDefinition) -> dict[s
368
  # --------------------------------------------------------------------------
369
 
370
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
371
  def _multiline(arguments: Mapping[str, object], name: str, *, limit: int) -> str:
372
  """Like ``_line`` but newlines are structure here, so they survive.
373
 
@@ -432,7 +470,7 @@ def _zip_bytes(parts: Mapping[str, str]) -> bytes:
432
  _SEPARATORS: Mapping[str, str] = {"pipe": "|", "tab": "\t", "semicolon": ";"}
433
 
434
 
435
- def _rows_from_text(arguments: Mapping[str, object]) -> list[list[str]]:
436
  """Header-then-rows lines, split on the named separator, shape-checked.
437
 
438
  The same input convention as ``csv_table`` and for the same reason: a
@@ -450,13 +488,12 @@ def _rows_from_text(arguments: Mapping[str, object]) -> list[list[str]]:
450
  raise SkillInputError("text needs a header line and at least one row")
451
  rows = [[cell.strip() for cell in line.split(separator)] for line in lines]
452
  width = len(rows[0])
453
- for index, row in enumerate(rows[1:], start=2):
454
- if len(row) != width:
455
- raise SkillInputError(
456
- f"line {index} has {len(row)} fields where the header has {width}. "
457
- "Fix the row rather than letting a column silently shift"
458
- )
459
- return rows
460
 
461
 
462
  _DOCX_CONTENT_TYPES = (
@@ -607,7 +644,7 @@ def spreadsheet_xlsx(arguments: Mapping[str, object], skill: SkillDefinition) ->
607
 
608
  del skill
609
  _known(arguments, frozenset({"text", "separator", "filename"}))
610
- rows = _rows_from_text(arguments)
611
  body: list[str] = []
612
  for row_index, row in enumerate(rows, start=1):
613
  cells = []
@@ -631,11 +668,14 @@ def spreadsheet_xlsx(arguments: Mapping[str, object], skill: SkillDefinition) ->
631
  }
632
  )
633
  return {
 
 
 
634
  "artifact": bytes_artifact(
635
  _filename(arguments.get("filename"), "table.xlsx", ".xlsx"),
636
  "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
637
  payload,
638
- )
639
  }
640
 
641
 
@@ -931,21 +971,42 @@ def theme_css(arguments: Mapping[str, object], skill: SkillDefinition) -> dict[s
931
  colors_text = _multiline(arguments, "colors", limit=1200)
932
  heading = str(arguments.get("heading_font") or "Georgia, serif").strip()[:80]
933
  body_font = str(arguments.get("body_font") or "Arial, sans-serif").strip()[:80]
 
 
 
 
 
 
 
 
 
934
  variables = []
 
935
  for line in colors_text.splitlines():
936
  line = line.strip()
937
  if not line:
938
  continue
939
  key, _, value = line.partition(":")
940
- key = key.strip().lower()
941
  value = value.strip()
 
 
 
 
 
 
942
  if not re.fullmatch(r"[a-z][a-z0-9-]{0,30}", key):
943
- raise SkillInputError(f"colour name {key!r} must be lowercase-with-hyphens")
 
944
  if not re.fullmatch(r"#[0-9A-Fa-f]{3,8}", value):
945
- raise SkillInputError(f"colour {key!r} needs a #hex value, got {value!r}")
 
946
  variables.append(f" --{key}: {value};")
947
  if not variables:
948
- raise SkillInputError("colors needs at least one 'name: #hex' line")
 
 
 
949
  sheet = (
950
  f"/* Theme: {theme_name} — generated by distinct */\n"
951
  ":root {\n" + "\n".join(variables) + "\n"
@@ -956,9 +1017,12 @@ def theme_css(arguments: Mapping[str, object], skill: SkillDefinition) -> dict[s
956
  "h1, h2, h3, h4 { font-family: var(--font-heading); }\n"
957
  )
958
  return {
 
 
 
959
  "artifact": text_artifact(
960
  _filename(arguments.get("filename"), "theme.css", ".css"), "text/css", sheet
961
- )
962
  }
963
 
964
 
 
330
  guarded_header.append(safe)
331
 
332
  rows: list[list[str]] = []
333
+ adjusted: list[str] = []
334
  for number, line in lines[1:]:
335
  cells = [cell.strip() for cell in line.split(separator)]
336
+ cells, note = fit_row(cells, len(header), number)
337
+ if note:
338
+ adjusted.append(note)
 
 
 
 
339
  row: list[str] = []
340
  for cell in cells:
341
  safe, changed = _guard(cell)
 
354
  # Reported rather than hidden: a guarded cell is a cell whose text was
355
  # changed, and the user is entitled to know their data was edited.
356
  "guarded_cells": guarded,
357
+ # Same reasoning, same place: a row that did not fit the header was
358
+ # made to fit, and every one of them is named here.
359
+ "adjusted_rows": adjusted,
360
  "artifact": text_artifact(
361
  _filename(arguments.get("filename"), "table.csv", ".csv"), "text/csv", text
362
  ),
 
368
  # --------------------------------------------------------------------------
369
 
370
 
371
+ def fit_row(cells: list[str], width: int, number: int) -> tuple[list[str], str]:
372
+ """Make one row the header's width, and say what was done to it.
373
+
374
+ REFUSING THE WHOLE TABLE OVER ONE RAGGED LINE MEANT NO TABLE AT ALL.
375
+
376
+ The rule here was: a row whose field count does not match the header is an
377
+ error, because padding it would invent data and silently change what the
378
+ user is told their table contains. The instinct is right and the second
379
+ half is the important part -- *silently*. What it produced in practice,
380
+ with a model doing the typing, was a run that called the tool, got a
381
+ refusal about line seven, and ended with no spreadsheet: two of twenty
382
+ benchmark workloads, both of them asking for a table.
383
+
384
+ Nothing is invented and nothing is hidden. A short row is padded with
385
+ empty cells; a long one keeps every field, with the surplus joined into
386
+ the last column so no text is dropped. Both come back with a sentence
387
+ naming the line and what happened to it, which the caller reports beside
388
+ the table, so a person reading it can go and look at line seven exactly as
389
+ they could before -- with the difference that they also have their table.
390
+ """
391
+
392
+ if len(cells) == width:
393
+ return cells, ""
394
+ if len(cells) < width:
395
+ missing = width - len(cells)
396
+ return (
397
+ cells + [""] * missing,
398
+ f"line {number} had {len(cells)} fields where the header has {width}; "
399
+ f"{missing} empty cell(s) were added at the end",
400
+ )
401
+ kept = cells[: width - 1] + [" ".join(cells[width - 1 :])]
402
+ return (
403
+ kept,
404
+ f"line {number} had {len(cells)} fields where the header has {width}; "
405
+ "the surplus was joined into the last column",
406
+ )
407
+
408
+
409
  def _multiline(arguments: Mapping[str, object], name: str, *, limit: int) -> str:
410
  """Like ``_line`` but newlines are structure here, so they survive.
411
 
 
470
  _SEPARATORS: Mapping[str, str] = {"pipe": "|", "tab": "\t", "semicolon": ";"}
471
 
472
 
473
+ def _rows_from_text(arguments: Mapping[str, object]) -> tuple[list[list[str]], list[str]]:
474
  """Header-then-rows lines, split on the named separator, shape-checked.
475
 
476
  The same input convention as ``csv_table`` and for the same reason: a
 
488
  raise SkillInputError("text needs a header line and at least one row")
489
  rows = [[cell.strip() for cell in line.split(separator)] for line in lines]
490
  width = len(rows[0])
491
+ adjusted: list[str] = []
492
+ for index in range(1, len(rows)):
493
+ rows[index], note = fit_row(rows[index], width, index + 1)
494
+ if note:
495
+ adjusted.append(note)
496
+ return rows, adjusted
 
497
 
498
 
499
  _DOCX_CONTENT_TYPES = (
 
644
 
645
  del skill
646
  _known(arguments, frozenset({"text", "separator", "filename"}))
647
+ rows, adjusted = _rows_from_text(arguments)
648
  body: list[str] = []
649
  for row_index, row in enumerate(rows, start=1):
650
  cells = []
 
668
  }
669
  )
670
  return {
671
+ # Named, never silent: a row that did not fit the header was made to
672
+ # fit, and the caller is told which and how.
673
+ "adjusted_rows": adjusted,
674
  "artifact": bytes_artifact(
675
  _filename(arguments.get("filename"), "table.xlsx", ".xlsx"),
676
  "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
677
  payload,
678
+ ),
679
  }
680
 
681
 
 
971
  colors_text = _multiline(arguments, "colors", limit=1200)
972
  heading = str(arguments.get("heading_font") or "Georgia, serif").strip()[:80]
973
  body_font = str(arguments.get("body_font") or "Arial, sans-serif").strip()[:80]
974
+ # ONE BAD COLOUR LINE USED TO COST THE WHOLE THEME.
975
+ #
976
+ # "Primary: #1a1a2e" has a capital P and was refused; so was
977
+ # "background: rgb(20,20,30)". Either one ended the run with no stylesheet
978
+ # at all, which is how a benchmark workload asking for a theme file failed
979
+ # three calls in a row. A name that only needs normalising is normalised,
980
+ # a value that cannot be read is skipped and named, and the sheet is only
981
+ # refused when there is nothing left to put in it. Same rule as the ragged
982
+ # table rows: adjust, never silently.
983
  variables = []
984
+ skipped: list[str] = []
985
  for line in colors_text.splitlines():
986
  line = line.strip()
987
  if not line:
988
  continue
989
  key, _, value = line.partition(":")
990
+ key = re.sub(r"[\s_]+", "-", key.strip().lower()).strip("-")
991
  value = value.strip()
992
+ if not re.fullmatch(r"#[0-9A-Fa-f]{3,8}", value) and re.fullmatch(
993
+ r"[0-9A-Fa-f]{6}|[0-9A-Fa-f]{3}", value
994
+ ):
995
+ # A hex value with the hash left off is the commonest near-miss and
996
+ # is unambiguous, so it is completed rather than discarded.
997
+ value = f"#{value}"
998
  if not re.fullmatch(r"[a-z][a-z0-9-]{0,30}", key):
999
+ skipped.append(f"{key or line!r}: not a usable colour name")
1000
+ continue
1001
  if not re.fullmatch(r"#[0-9A-Fa-f]{3,8}", value):
1002
+ skipped.append(f"{key}: {value!r} is not a #hex value")
1003
+ continue
1004
  variables.append(f" --{key}: {value};")
1005
  if not variables:
1006
+ raise SkillInputError(
1007
+ "colors needs at least one 'name: #hex' line"
1008
+ + (f"; none was usable ({'; '.join(skipped[:4])})" if skipped else "")
1009
+ )
1010
  sheet = (
1011
  f"/* Theme: {theme_name} — generated by distinct */\n"
1012
  ":root {\n" + "\n".join(variables) + "\n"
 
1017
  "h1, h2, h3, h4 { font-family: var(--font-heading); }\n"
1018
  )
1019
  return {
1020
+ # Named, never silent: a colour line that could not be read was left
1021
+ # out, and the caller is told which and why.
1022
+ "skipped_colours": skipped,
1023
  "artifact": text_artifact(
1024
  _filename(arguments.get("filename"), "theme.css", ".css"), "text/css", sheet
1025
+ ),
1026
  }
1027
 
1028
 
tests/test_harness_makes_files.py CHANGED
@@ -493,3 +493,72 @@ def test_a_follow_up_question_is_not_forced_to_make_a_file(tmp_path) -> None:
493
  progress=lambda fraction, message: None,
494
  )
495
  assert "anyOf" in schemas[0]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
493
  progress=lambda fraction, message: None,
494
  )
495
  assert "anyOf" in schemas[0]
496
+
497
+
498
+ # -- a call that ran out of room ---------------------------------------------
499
+
500
+
501
+ class _Truncating:
502
+ """A model whose first action is cut off mid-JSON, as a real one is."""
503
+
504
+ def __init__(self, *outputs: str) -> None:
505
+ self._outputs = list(outputs)
506
+ self.prompts: list[str] = []
507
+
508
+ def run(self, model, prompt, *, cancel_event=None, progress=None, limits=None):
509
+ self.prompts.append(prompt)
510
+ text = self._outputs.pop(0) if self._outputs else '{"type":"final","answer":"done"}'
511
+ cut = text.startswith("{") and not text.rstrip().endswith("}")
512
+ from distinct_agent.runners import InferenceResult
513
+
514
+ return InferenceResult(
515
+ text=text,
516
+ usage={"runner": "fake", "stop_reason": "length" if cut else "stop"},
517
+ )
518
+
519
+
520
+ def test_a_cut_off_call_is_recognised_as_one() -> None:
521
+ from distinct_agent.harness import was_truncated
522
+
523
+ assert was_truncated({"stop_reason": "length"})
524
+ assert not was_truncated({"stop_reason": "stop"})
525
+ assert not was_truncated({})
526
+
527
+
528
+ def test_a_cut_off_call_is_asked_for_again_smaller(tmp_path) -> None:
529
+ """It looks exactly like prose, and the two need opposite fixes."""
530
+
531
+ runner = _Truncating(
532
+ '{"type":"tool","tool":"create-deck@1","arguments":{"slides":"a very long ',
533
+ json.dumps({"type": "tool", "tool": "create-deck@1", "arguments": {"slides": "A :: b"}}),
534
+ json.dumps({"type": "final", "answer": "deck built"}),
535
+ )
536
+ broker = _Broker((DECK,))
537
+ result = _run(StructuredToolHarness(), _job(), runner, broker, tmp_path)
538
+ assert "CUT OFF" in runner.prompts[1]
539
+ assert broker.calls and result.text == "deck built"
540
+
541
+
542
+ def test_a_run_that_kept_running_out_of_room_says_so(tmp_path) -> None:
543
+ """"produced 0 files" hid the reason; the answer now carries it."""
544
+
545
+ fragment = '{"type":"tool","tool":"create-deck@1","arguments":{"slides":"still going '
546
+ result = _run(
547
+ StructuredToolHarness(),
548
+ _job(),
549
+ _Truncating(fragment, fragment, fragment),
550
+ _Broker((DECK,)),
551
+ tmp_path,
552
+ )
553
+ assert "cut off" in result.text
554
+
555
+
556
+ def test_a_finished_answer_is_not_treated_as_cut_off(tmp_path) -> None:
557
+ result = _run(
558
+ StructuredToolHarness(),
559
+ _job(),
560
+ _Truncating(json.dumps({"type": "final", "answer": "all done"})),
561
+ _Broker((CALCULATOR,)),
562
+ tmp_path,
563
+ )
564
+ assert result.text == "all done"
tests/test_ragged_rows_still_make_a_table.py ADDED
@@ -0,0 +1,173 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """One bad line used to cost the whole spreadsheet, and it cost two workloads.
2
+
3
+ The rule was: a row whose field count does not match the header is an error,
4
+ because padding it would invent data and silently change what the user is told
5
+ their table contains. The instinct is right; the load-bearing word is
6
+ *silently*. With a model doing the typing, what it produced was a run that
7
+ called the tool, got a refusal naming line seven, and finished with no file at
8
+ all -- twice in twenty benchmark workloads, both of them asking for a table.
9
+
10
+ So the row is made to fit and every adjustment is named in the result. Nothing
11
+ is invented, nothing is dropped, nothing is hidden, and the person gets their
12
+ table.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import base64
18
+
19
+ import pytest
20
+
21
+ from distinct_skills.handlers import csv_table, fit_row, spreadsheet_xlsx
22
+ from distinct_skills.loader import SkillInputError
23
+
24
+ HEADER = "name|qty|aisle"
25
+
26
+
27
+ def _csv(text: str) -> tuple[str, list[str]]:
28
+ result = csv_table({"text": text}, None)
29
+ return base64.b64decode(result["artifact"]["base64"]).decode(), result["adjusted_rows"]
30
+
31
+
32
+ # -- the adjustment itself ---------------------------------------------------
33
+
34
+
35
+ def test_a_row_that_already_fits_is_left_exactly_as_it_is() -> None:
36
+ assert fit_row(["a", "b", "c"], 3, 2) == (["a", "b", "c"], "")
37
+
38
+
39
+ def test_a_short_row_is_padded_and_the_padding_is_named() -> None:
40
+ cells, note = fit_row(["a", "b"], 3, 7)
41
+ assert cells == ["a", "b", ""]
42
+ assert "line 7" in note and "empty cell" in note
43
+
44
+
45
+ def test_a_long_row_keeps_every_field_it_was_given() -> None:
46
+ """Dropping the surplus would lose the user's text, which is worse."""
47
+
48
+ cells, note = fit_row(["a", "b", "c", "d"], 3, 4)
49
+ assert cells == ["a", "b", "c d"]
50
+ assert "line 4" in note and "surplus" in note
51
+
52
+
53
+ def test_nothing_is_ever_invented() -> None:
54
+ """Padding adds empty cells. It never guesses a value."""
55
+
56
+ cells, _note = fit_row(["a"], 4, 2)
57
+ assert cells == ["a", "", "", ""]
58
+
59
+
60
+ # -- through the tools -------------------------------------------------------
61
+
62
+
63
+ def test_a_ragged_line_no_longer_costs_the_whole_csv() -> None:
64
+ text, adjusted = _csv(f"{HEADER}\nflour|500 g|baking\nsalt|10 g\nsaffron|1 g|spices|extra")
65
+ assert "flour,500 g,baking" in text
66
+ assert "salt,10 g," in text
67
+ assert "saffron,1 g,spices extra" in text
68
+ assert len(adjusted) == 2
69
+
70
+
71
+ def test_every_adjustment_names_the_line_it_happened_on() -> None:
72
+ """So a person can go and look at line seven exactly as they could before."""
73
+
74
+ _text, adjusted = _csv(f"{HEADER}\na|b|c\nd|e")
75
+ assert adjusted and adjusted[0].startswith("line 3 ")
76
+
77
+
78
+ def test_a_table_that_needed_no_adjustment_reports_none() -> None:
79
+ _text, adjusted = _csv(f"{HEADER}\na|b|c\nd|e|f")
80
+ assert adjusted == []
81
+
82
+
83
+ def test_the_spreadsheet_reports_its_adjustments_too() -> None:
84
+ result = spreadsheet_xlsx({"text": f"{HEADER}\na|b|c\nd|e"}, None)
85
+ assert len(result["adjusted_rows"]) == 1
86
+ assert result["artifact"]["size_bytes"] > 0
87
+
88
+
89
+ # -- what is still refused ---------------------------------------------------
90
+
91
+
92
+ def test_a_table_with_no_rows_is_still_refused() -> None:
93
+ """There is no row to make fit, so there is nothing to produce."""
94
+
95
+ with pytest.raises(SkillInputError):
96
+ csv_table({"text": HEADER}, None)
97
+
98
+
99
+ def test_a_header_with_no_separator_is_still_refused() -> None:
100
+ """A one-column table is almost always a caller who used the wrong separator."""
101
+
102
+ with pytest.raises(SkillInputError):
103
+ csv_table({"text": "name qty aisle\na b c"}, None)
104
+
105
+
106
+ def test_a_cell_that_would_run_as_a_formula_is_still_guarded() -> None:
107
+ """Making rows fit must not have loosened the thing that stops a cell running."""
108
+
109
+ text, _adjusted = _csv(f"{HEADER}\n=cmd()|1|x")
110
+ assert "'=cmd()" in text
111
+
112
+
113
+ def test_the_joined_cell_is_guarded_because_joining_happens_first() -> None:
114
+ """Order matters: rows are made to fit, and *then* every cell is checked."""
115
+
116
+ text, adjusted = _csv(f"{HEADER}\na|b|=cmd()|tail")
117
+ assert adjusted
118
+ assert "'=cmd() tail" in text
119
+
120
+
121
+ # -- the same rule, applied to a palette -------------------------------------
122
+
123
+
124
+ def _theme(colors: str):
125
+ from distinct_skills.handlers import theme_css
126
+
127
+ result = theme_css({"name": "Night Desk", "colors": colors}, None)
128
+ return base64.b64decode(result["artifact"]["base64"]).decode(), result["skipped_colours"]
129
+
130
+
131
+ def test_a_capitalised_colour_name_is_normalised_rather_than_refused() -> None:
132
+ """"Primary: #1a1a2e" used to end the run with no stylesheet."""
133
+
134
+ sheet, skipped = _theme("Primary: #1a1a2e")
135
+ assert "--primary: #1a1a2e;" in sheet
136
+ assert skipped == []
137
+
138
+
139
+ def test_spaces_and_underscores_in_a_name_become_hyphens() -> None:
140
+ sheet, _skipped = _theme("Background Deep: #16213e\nmuted_grey: #888")
141
+ assert "--background-deep: #16213e;" in sheet
142
+ assert "--muted-grey: #888;" in sheet
143
+
144
+
145
+ def test_a_hex_value_with_the_hash_left_off_is_completed() -> None:
146
+ """The commonest near-miss, and unambiguous."""
147
+
148
+ sheet, skipped = _theme("primary: 1a1a2e")
149
+ assert "--primary: #1a1a2e;" in sheet
150
+ assert skipped == []
151
+
152
+
153
+ def test_a_colour_that_cannot_be_read_is_skipped_and_named() -> None:
154
+ sheet, skipped = _theme("primary: #1a1a2e\naccent: rgb(20,20,30)")
155
+ assert "--primary: #1a1a2e;" in sheet
156
+ assert "--accent" not in sheet
157
+ assert skipped and "accent" in skipped[0]
158
+
159
+
160
+ def test_a_palette_with_nothing_usable_is_still_refused() -> None:
161
+ """There is no stylesheet to make, so saying so is the honest answer."""
162
+
163
+ from distinct_skills.handlers import theme_css
164
+
165
+ with pytest.raises(SkillInputError):
166
+ theme_css({"name": "x", "colors": "accent: rgb(1,2,3)"}, None)
167
+
168
+
169
+ def test_the_refusal_says_which_lines_it_could_not_read() -> None:
170
+ from distinct_skills.handlers import theme_css
171
+
172
+ with pytest.raises(SkillInputError, match="rgb"):
173
+ theme_css({"name": "x", "colors": "accent: rgb(1,2,3)"}, None)