feat(chat): render markdown tables in answers, viewer, and thinking
GFM pipe tables in the shared renderer (TODO.md L6): a table-protection
pass in frontend/assets/markdown.js (fences -> tables -> escape order)
pulls each header+separator+body block out as a placeholder, renders
cells escape-first with the same inline transforms, and reinserts a
semantic <table class="md-table"> inside a horizontal-overflow
.md-table-wrap — so a pipe table in a chat answer, the document
viewer/modal, and the thinking block all render the same semantic
table. Fences win over tables; lone pipes stay text.
- styles.css: .md-table palette rules (PLAN §7.2 tokens, no motion);
min-width: max-content so a WIDE table keeps its natural width and
the wrapper is the real scroller (width:100% alone wrapped the wide
table's cells — proven by the new E2E).
- mock_llm.py: TABLE_TRIGGER ("show me a table") -> byte-stable
TABLE_ANSWER (3-column table, <img onerror> XSS probe line, wide
5-column table), checked before DEFLECT_MODE like SUMMARY_MODE.
- tests/fixtures/docs/homelab/tables.md: 3x3 pipe table + pipe-heavy
fenced block (viewer/fence subject); the shared fixture set grows
8 -> 9 docs, so every suite pinning the count (added/formats/
stat-docs/EXPECTED_ROWS) is updated accordingly.
- tests/e2e/test_markdown_tables.py (new, story suite): chat table
shape + non-deflection, wide-table wrapper scroll (no page
overflow), XSS probe inert, viewer modal table, fence-not-a-table,
lone pipe stays text.
- tests/e2e/test_agent_document_tools.py: fix a pre-existing flake —
the "Calling tool…" label window is ~0.4 s at the mock's 0.1 s
tool-frame pacing, and a polling expect could stride over it
(failed 3 of 5 runs on the committed baseline). The pre-submit
MutationObserver record is the deterministic source of truth; the
racy to_have_text gate is gone.
uv run pytest: 738 passed, app/ coverage 99% (TOTAL unchanged);
ruff + pyright clean; story E2E 6/6 in isolation; regression E2E
suites (chat_rag, document_viewer, document_summaries, smoke) green.
This commit is contained in:
@@ -65,6 +65,17 @@ Implements just enough of the aipi surface:
|
||||
section, or with the tool conversation not yet started and no tools
|
||||
offered — e.g. budgets 0/0) behave exactly as today. ``E2E_REAL_LLM=1``
|
||||
ignores the mock entirely (the real model does what it does).
|
||||
- user message containing ``show me a table`` (phase 44, markdown
|
||||
tables, TODO.md L6) -> the fixed table answer (``TABLE_ANSWER``):
|
||||
a 3-column service table, an ``<img onerror>`` XSS probe line, and
|
||||
a deliberately wide 5-column table — byte-stable, so the story E2E
|
||||
can assert the rendered ``<table class="md-table">`` shape, the
|
||||
escaped XSS line, and the wrapper's horizontal scroll inside the
|
||||
46rem column. Checked BEFORE the ``DEFLECT_MODE`` branch (a
|
||||
deflection prompt never carries the marker, same reasoning as
|
||||
``SUMMARY_MODE``), so a marker question always gets the table
|
||||
answer; the E2E asks it against an on-topic fixture (HIGH gate) and
|
||||
asserts non-deflection.
|
||||
|
||||
``max_tokens`` is honored deterministically (token ≈ whitespace word),
|
||||
like a real endpoint: an answer longer than the cap is truncated. This
|
||||
@@ -163,6 +174,37 @@ _DOCUMENTS_BLOCK_RE = re.compile(r"<documents>.*?</documents>", re.S)
|
||||
#: contain the phrase, so every other suite is unaffected.
|
||||
TOOLS_TRIGGER = "use your tools"
|
||||
|
||||
#: Phase 44 (markdown-tables story, TODO.md L6): a user message
|
||||
#: containing this substring (case-insensitive) gets the fixed table
|
||||
#: answer (``TABLE_ANSWER`` below) — a 3-column table, an XSS probe
|
||||
#: line, and a deliberately wide table (see the module docstring).
|
||||
#: Existing E2E questions do not contain the phrase, so every other
|
||||
#: suite is unaffected.
|
||||
TABLE_TRIGGER = "show me a table"
|
||||
|
||||
#: The fixed table answer (phase 44) — byte-stable on purpose: the story
|
||||
#: E2E asserts the rendered table shape, the escaped ``<img onerror>``
|
||||
#: line (the XSS payload must survive the mock byte-for-byte), and the
|
||||
#: wide table's ``scrollWidth > clientWidth`` inside the 46rem column.
|
||||
TABLE_ANSWER = (
|
||||
"Here's the shape, in a table:\n"
|
||||
"\n"
|
||||
"| Service | Port | Host |\n"
|
||||
"|---|---|---|\n"
|
||||
"| Caddy | 80 | homelab-gw |\n"
|
||||
"| GitLab | 8929 | homelab-git |\n"
|
||||
"| ntfy | 2087 | homelab-ntfy |\n"
|
||||
"\n"
|
||||
"<img src=x onerror=alert(1)>\n"
|
||||
"\n"
|
||||
"And the wide one:\n"
|
||||
"\n"
|
||||
"| A very long column header to force overflow | Second column with "
|
||||
"some padding text | Third column | Fourth | Fifth |\n"
|
||||
"|---|---|---|---|---|\n"
|
||||
"| value-one | value-two | value-three | value-four | value-five |"
|
||||
)
|
||||
|
||||
|
||||
#: The agent's ``read_document`` tool-result prefix (app.rag.agent
|
||||
#: ``_execute_tool``): ``"Document <source/path>:\n<content>"``.
|
||||
@@ -298,6 +340,20 @@ def compose_answer(body: dict[str, Any]) -> str:
|
||||
answer = "Knowledge base outline:\n- " + " ".join(
|
||||
TOKEN_RE.findall(user.lower())[:8]
|
||||
)
|
||||
elif TABLE_TRIGGER in user.lower():
|
||||
# Markdown tables (phase 44, TODO.md L6): the story E2E's
|
||||
# deterministic table answer — a 3-column table, the
|
||||
# <img onerror> XSS probe line (it must survive the mock
|
||||
# byte-for-byte so the E2E can prove the renderer neutralizes
|
||||
# it), and a wide 5-column table (guarantees scrollWidth >
|
||||
# clientWidth inside the 46rem column). Byte-stable. Checked
|
||||
# BEFORE the DEFLECT_MODE branch: a deflection prompt never
|
||||
# carries the marker (it lives in the user message, same
|
||||
# reasoning as SUMMARY_MODE), so a marker question always gets
|
||||
# the table answer, whatever the gate says; the E2E asks it
|
||||
# against an on-topic fixture, where the gate is HIGH, and
|
||||
# asserts non-deflection as part of the table test.
|
||||
answer = TABLE_ANSWER
|
||||
elif "DEFLECT_MODE" in system:
|
||||
answer = (
|
||||
"Ah — I haven't done anything like that, so I don't want to make stuff up! "
|
||||
|
||||
Reference in New Issue
Block a user