From 8add424b8973b45f0f2e9f68fa1e3fd9088e0247 Mon Sep 17 00:00:00 2001 From: EMRG Evolution Date: Thu, 29 Oct 2026 16:41:15 +0800 Subject: [PATCH 1/2] emrg: the GUI renders a local image from a path, and the prompt says so --- Agent.md | 2 +- emrg/gui/renderer/src/lib/markdown.test.ts | 53 ++++- emrg/gui/renderer/src/lib/markdown.ts | 90 ++++++++- .../renderer/src/lib/vendorMarkdown.test.ts | 36 ++++ emrg/gui/renderer/src/shell.css | 8 + emrg/gui/renderer/src/vendor.d.ts | 9 +- emrg/server/prompts/system.j2 | 6 + tests/test_image_in_reply_prompt_reach.py | 182 ++++++++++++++++++ 8 files changed, 380 insertions(+), 6 deletions(-) create mode 100644 tests/test_image_in_reply_prompt_reach.py diff --git a/Agent.md b/Agent.md index e6f742a4..421c9e19 100644 --- a/Agent.md +++ b/Agent.md @@ -55,7 +55,7 @@ One line per command: what it answers. Run the daemon in the foreground with `py - Python: `uv run pytest tests/ -v` — the number is **measured, never stored**: `uv run --no-sync python3 scripts/check-doc-count.py --measure`. Import check: `uv run python -c "from emrg.client.app import run_client"` GUI: `cd emrg/gui && npm test` (168: 100 daemon_client + 20 conn-manager + 7 integration + 7 nav-policy + 7 gui-state + 9 build-config + 11 boot-contract + 3 preload-api + 2 theme-guard + 2 image-hash) — syntax: `node --check main.js preload.js daemon_client.js` -Renderer: `cd emrg/gui/renderer && npm run typecheck && npm test` (740: 5 snapshot-store + 13 utils + 3 ErrorBoundary + 2 App smoke + 11 commands + 4 copywriting + 11 i18n + 13 markdown + 29 transcript + 28 TranscriptView + 22 history + 25 historyReplay + 40 composer + 75 Composer + 6 LinkDialog + 16 sidebar + 18 Sidebar + 9 fileTree + 9 FileTree + 17 resultPanel + 8 ResultPanel + 27 workspaceView + 13 WorkspaceView + 10 dialog + 6 Dialog + 9 ConfirmDialog + 9 RenameDialog + 12 dialogLists + 3 HelpDialog + 9 MemoryDialog + 8 SkillsDialog + 8 openSession + 6 WelcomeDialog + 9 OpenSessionDialog + 7 NewSessionDialog + 7 rewind + 8 RewindDialog + 7 GithubDeviceDialog + 59 daemonBridge + 9 DaemonBridgeProvider + 50 Shell + 25 DialogHost + 26 SettingsPanel + 18 TaskFormDialog + 5 RantDialog + 4 vendorMarkdown + 6 ApprovalDialog + 16 SandboxRootsDialog) +Renderer: `cd emrg/gui/renderer && npm run typecheck && npm test` (752: 5 snapshot-store + 13 utils + 3 ErrorBoundary + 2 App smoke + 11 commands + 4 copywriting + 11 i18n + 21 markdown + 29 transcript + 28 TranscriptView + 22 history + 25 historyReplay + 40 composer + 75 Composer + 6 LinkDialog + 16 sidebar + 18 Sidebar + 9 fileTree + 9 FileTree + 17 resultPanel + 8 ResultPanel + 27 workspaceView + 13 WorkspaceView + 10 dialog + 6 Dialog + 9 ConfirmDialog + 9 RenameDialog + 12 dialogLists + 3 HelpDialog + 9 MemoryDialog + 8 SkillsDialog + 8 openSession + 6 WelcomeDialog + 9 OpenSessionDialog + 7 NewSessionDialog + 7 rewind + 8 RewindDialog + 7 GithubDeviceDialog + 59 daemonBridge + 9 DaemonBridgeProvider + 50 Shell + 25 DialogHost + 26 SettingsPanel + 18 TaskFormDialog + 5 RantDialog + 8 vendorMarkdown + 6 ApprovalDialog + 16 SandboxRootsDialog) - CI: pytest on ubuntu **and windows-2025**, the GUI suites, and an actionlint gate over `.github/workflows/`; re-trigger a dropped push with `scripts/re-trigger-ci.sh `, never an empty commit - Merge gates, run before merging — each asks what no other gate asks: `uv run --no-sync python3 scripts/check-vote-count.py` (approvals still valid) · `scripts/check-pr-base.py` (base reaches master) · `scripts/check-merge-freshness.py` (that green CI is about the landing tree) · `scripts/check-merge-order.py` (dirt and what it lands) · `scripts/check-merge-landing-diff.py` (what the landing changes) · `scripts/check-merge-pairs.py` / `scripts/check-merge-sequence.py` (a pair, or a whole order) · `scripts/check-merge-tree-health.py` (the merged tree passes one guard) · `scripts/check-merge-plan-suite.py` (the plan's final tree passes the suite) — all under `uv run --no-sync python3` - Post-merge audit: `uv run --no-sync python3 scripts/check-merge-landed.py ` (did the merge land a tree its reviews named) diff --git a/emrg/gui/renderer/src/lib/markdown.test.ts b/emrg/gui/renderer/src/lib/markdown.test.ts index 83b992e3..2206a7fa 100644 --- a/emrg/gui/renderer/src/lib/markdown.test.ts +++ b/emrg/gui/renderer/src/lib/markdown.test.ts @@ -1,5 +1,12 @@ import { describe, expect, it } from "vitest"; -import { FENCE_END_RE, isFenceClosed, createMarkdownRenderer, type MarkedLike } from "./markdown"; +import { + FENCE_END_RE, + isFenceClosed, + createMarkdownRenderer, + normalizeImageSrc, + isLocalImageSrc, + type MarkedLike, +} from "./markdown"; /** * markdown.test.ts — marked/DOMPurify/highlight 封装纯逻辑测试(Batch 1 remainder)。 @@ -269,3 +276,47 @@ describe("块投影:live 代码围栏 未闭合→纯文本 / 闭合→完整 expect(body.querySelectorAll(".md-block:not(.live)")).toHaveLength(1); }); }); + +describe("normalizeImageSrc:本地图片两种写法归一(issue #2638)", () => { + it("根绝对路径 → file:///…(origin 根不是宿主文件系统)", () => { + expect(normalizeImageSrc("/Users/me/shot.png")).toBe("file:///Users/me/shot.png"); + }); + + it("已是 file: 的原样返回(不重复加 scheme)", () => { + expect(normalizeImageSrc("file:///Users/me/shot.png")).toBe("file:///Users/me/shot.png"); + }); + + it("其它 scheme / 协议相对 / 相对路径原样返回", () => { + expect(normalizeImageSrc("https://x.dev/a.png")).toBe("https://x.dev/a.png"); + expect(normalizeImageSrc("data:image/png;base64,AAA")).toBe("data:image/png;base64,AAA"); + expect(normalizeImageSrc("//cdn.example.com/a.png")).toBe("//cdn.example.com/a.png"); + expect(normalizeImageSrc("shots/a.png")).toBe("shots/a.png"); + }); + + it("Windows 盘符路径 → file:///(单字母不是 scheme)", () => { + expect(normalizeImageSrc("C:\\shots\\a.png")).toBe("file:///C:/shots/a.png"); + expect(normalizeImageSrc("C:/shots/a.png")).toBe("file:///C:/shots/a.png"); + }); + + it("空值原样(不造出 file:// 空路径)", () => { + expect(normalizeImageSrc("")).toBe(""); + expect(normalizeImageSrc(undefined)).toBe(""); + }); +}); + +describe("isLocalImageSrc:放行条件只认 img[src] 的 file:(issue #2638)", () => { + it("img + src + file: → true(大小写不敏感)", () => { + expect(isLocalImageSrc("img", "src", "file:///a.png")).toBe(true); + expect(isLocalImageSrc("IMG", "SRC", "FILE:///a.png")).toBe(true); + }); + + it("同一 URL 挂在别的元素/属性上 → false(放行范围不扩)", () => { + expect(isLocalImageSrc("a", "href", "file:///a.png")).toBe(false); + expect(isLocalImageSrc("img", "onerror", "file:///a.png")).toBe(false); + expect(isLocalImageSrc("iframe", "src", "file:///a.png")).toBe(false); + }); + + it("img[src] 但不是 file: → false(javascript:/data: 走默认策略)", () => { + expect(isLocalImageSrc("img", "src", "javascript:alert(1)")).toBe(false); + }); +}); diff --git a/emrg/gui/renderer/src/lib/markdown.ts b/emrg/gui/renderer/src/lib/markdown.ts index 002f5253..f4b4c36a 100644 --- a/emrg/gui/renderer/src/lib/markdown.ts +++ b/emrg/gui/renderer/src/lib/markdown.ts @@ -24,17 +24,69 @@ export interface MarkedLike { parse(text: string, opts?: Record): string | Promise; lexer(text: string): Array>; parser(tokens: Array>): string; - use(opts: { renderer?: { code?: (code: string, infostring: string, escaped: boolean) => string } }): unknown; + use(opts: { + renderer?: { + code?: (code: string, infostring: string, escaped: boolean) => string; + image?: (href: string, title: string | null, text: string) => string; + }; + }): unknown; } export interface DomPurifyLike { sanitize(html: string): string; + /** 属性级钩子的注册入口(DOMPurify addHook);没有它的替身只能走默认策略,file: 不会被放行 */ + addHook?(name: string, fn: SanitizeAttributeHook): void; } + +/** DOMPurify `uponSanitizeAttribute` 钩子的入参(只声明这里用到的字段) */ +export interface SanitizeAttributeHookArg { + attrName?: unknown; + attrValue?: unknown; + forceKeepAttr?: unknown; +} +/** 钩子签名:node 是被消毒的元素,data 是当前属性的可改写描述 */ +export type SanitizeAttributeHook = ( + node: { nodeName?: unknown }, + data: SanitizeAttributeHookArg, +) => void; export interface HljsLike { getLanguage(lang: string): boolean; highlight(code: string, opts: { language: string; ignoreIllegals: boolean }): { value: string }; highlightAuto(code: string): { value: string }; } +/** + * 本地图片路径归一(issue #2638)。 + * + * 渲染进程打包后是 `file://` 文档,`/Users/me/shot.png` 这种根绝对路径会被当成 + * **origin 根**解析(应用自己的 dist 目录),不是宿主文件系统 —— 所以在进 DOM 前 + * 改写成 `file:///Users/me/shot.png`。已有 scheme(`file:` / `http:` / `data:`…)、 + * 协议相对(`//host/a.png`)与相对路径原样返回,归一只管根绝对写法。 + */ +export function normalizeImageSrc(href: unknown): string { + const src = String(href ?? "").trim(); + if (!src) return src; + if (src.startsWith("//")) return src; // 协议相对:保留原意,不猜 scheme + // 盘符路径 C:\x 或 C:/x —— 单字母"scheme"不是 scheme + if (/^[a-zA-Z]:[\\/]/.test(src)) return `file:///${src.replace(/\\/g, "/")}`; + if (/^[a-zA-Z][a-zA-Z0-9+.\-]*:/.test(src)) return src; + if (src.startsWith("/")) return `file://${src}`; + return src; +} + +/** + * `file:` 是否只该在这一处放行:`img[src]`。 + * + * 判断条件收在一个函数里,钩子与测试读同一份 —— 放行范围一旦扩出去(`a[href]`、 + * `iframe[src]`、任何事件属性),这里就是唯一会红的地方。 + */ +export function isLocalImageSrc(tagName: unknown, attrName: unknown, value: unknown): boolean { + return ( + String(tagName ?? "").toLowerCase() === "img" && + String(attrName ?? "").toLowerCase() === "src" && + /^file:/i.test(String(value ?? "")) + ); +} + /** 代码块复制按钮文案(默认英文;与 vanilla "复制代码"/"Copy code" 对齐) */ type Translate = (key: string) => string; @@ -86,11 +138,43 @@ export function createMarkdownRenderer(deps: { return `
${codeHtml}
`; } - /** marked.use 自定义 renderer 只配置一次 */ + /** + * marked image renderer:src 先归一再转义(`/abs.png` → `file:///abs.png`)。 + * alt/title 一并转义 —— 默认 renderer 把 text 直接拼进属性,靠消毒器兜底; + * 这里自己转义,属性边界不由 HTML 的宽容解析决定。 + */ + function imageRenderer(href: string, title: string | null, text: string): string { + const src = escapeHtml(normalizeImageSrc(href)); + const alt = escapeHtml(text ?? ""); + const titleAttr = title ? ` title="${escapeHtml(title)}"` : ""; + return `${alt}`; + } + + /** + * 放行 `img[src]` 的 `file:`(issue #2638)。 + * + * DOMPurify 的默认 URI 策略不含 `file:`,会把 `src` 整个剥掉,只剩一个带 alt 的 + * 破图 —— 本地图片于是只有 base64 一条路。这里的钩子只在 isLocalImageSrc 成立时 + * 置 `forceKeepAttr`:DOMPurify 3.x 里它只绕过 URI/白名单检查,`SAFE_FOR_XML` 与 + * `attributename` 检查仍在;`javascript:` 与其它元素/属性因此逐字走默认策略。 + * 没有 addHook 的替身不做任何放行 —— 默认策略就是它的全部行为。 + */ + function installFileImageHook(): void { + if (!DOMPurify || typeof DOMPurify.addHook !== "function") return; + DOMPurify.addHook("uponSanitizeAttribute", (node, data) => { + if (!isLocalImageSrc(node && node.nodeName, data && data.attrName, data && data.attrValue)) { + return; + } + data.forceKeepAttr = true; + }); + } + + /** marked.use 自定义 renderer / 消毒钩子只配置一次 */ let configured = false; function ensureConfigured(): void { if (!marked || configured) return; - marked.use({ renderer: { code: codeRenderer } }); + marked.use({ renderer: { code: codeRenderer, image: imageRenderer } }); + installFileImageHook(); configured = true; } diff --git a/emrg/gui/renderer/src/lib/vendorMarkdown.test.ts b/emrg/gui/renderer/src/lib/vendorMarkdown.test.ts index 52cea9a7..cb064a41 100644 --- a/emrg/gui/renderer/src/lib/vendorMarkdown.test.ts +++ b/emrg/gui/renderer/src/lib/vendorMarkdown.test.ts @@ -41,3 +41,39 @@ describe("vendorMarkdown", () => { expect(html).toContain("ok"); }); }); + +/** + * 本地图片(issue #2638):`file:` 只对 `img[src]` 放行。 + * + * 这一组必须用**真实 vendor** DOMPurify 断言 —— 断言的对象是它 3.4.13 的策略, + * 不是本仓库对策略的建模。撤掉 markdown.ts 里的钩子,前两条会退化成只剩 alt 的破图 + * (`shot`),第四条会跟着变绿;这正是它们要拦住的方向。 + */ +describe("本地图片:file: 只对 img[src] 放行(issue #2638)", () => { + it("file:/// 写法 → 带 src 的 img(不是只剩 alt 的破图)", async () => { + const md = createProdMarkdownRenderer(); + const html = await md.renderMarkdown("![shot](file:///Users/me/shot.png)"); + expect(html).toContain(" { + const md = createProdMarkdownRenderer(); + const html = await md.renderMarkdown("![shot](/Users/me/shot.png)"); + expect(html).toContain('src="file:///Users/me/shot.png"'); + }); + + it("javascript: 仍被剥(img 与 a 都不放行)", async () => { + const md = createProdMarkdownRenderer(); + const html = await md.renderMarkdown( + '\n\nx', + ); + expect(html).not.toContain("javascript:"); + }); + + it("同一个 file: URL 挂到 a[href] 上仍被剥(放行范围只有 img[src])", async () => { + const md = createProdMarkdownRenderer(); + const html = await md.renderMarkdown("[open](file:///Users/me/shot.png)"); + expect(html).not.toContain('href="file:'); + }); +}); diff --git a/emrg/gui/renderer/src/shell.css b/emrg/gui/renderer/src/shell.css index 4895ccf7..8805db59 100644 --- a/emrg/gui/renderer/src/shell.css +++ b/emrg/gui/renderer/src/shell.css @@ -343,3 +343,11 @@ .error-boundary-actions button:hover { background: var(--bg-soft); } + +/* 聊天区本地图片(issue #2638):本地截图动辄几千像素宽,只收宽不放高—— + 小于容器的图保持原始尺寸。 */ +.assistant-html img, +.md-stream img { + max-width: 100%; + height: auto; +} diff --git a/emrg/gui/renderer/src/vendor.d.ts b/emrg/gui/renderer/src/vendor.d.ts index fddaa797..948b12b2 100644 --- a/emrg/gui/renderer/src/vendor.d.ts +++ b/emrg/gui/renderer/src/vendor.d.ts @@ -8,7 +8,12 @@ declare module "*/vendor/marked.min.js" { parse(text: string, opts?: Record): string | Promise; lexer(text: string): Array>; parser(tokens: Array>): string; - use(opts: { renderer?: { code?: (code: string, infostring: string, escaped: boolean) => string } }): unknown; + use(opts: { + renderer?: { + code?: (code: string, infostring: string, escaped: boolean) => string; + image?: (href: string, title: string | null, text: string) => string; + }; + }): unknown; } const marked: MarkedLike; export default marked; @@ -17,6 +22,8 @@ declare module "*/vendor/marked.min.js" { declare module "*/vendor/dompurify.min.js" { interface DomPurifyLike { sanitize(html: string): string; + /** DOMPurify addHook —— 放行 img[src] 的 file: 只有这一条路(issue #2638) */ + addHook?(name: string, fn: (node: { nodeName?: unknown }, data: { attrName?: unknown; attrValue?: unknown; forceKeepAttr?: unknown }) => void): void; } const DOMPurify: DomPurifyLike; export default DOMPurify; diff --git a/emrg/server/prompts/system.j2 b/emrg/server/prompts/system.j2 index b78e8871..a5d9aa68 100644 --- a/emrg/server/prompts/system.j2 +++ b/emrg/server/prompts/system.j2 @@ -88,6 +88,12 @@ Session `{{ session.id }}` lives in `{{ session.dir_path }}/`. - **Outward-facing GitHub text is English**, whatever language the request, rant or conversation used: PR titles and bodies, review comments, issue replies, commit messages, blog posts, upstream issues. A quotation from another language is carried in English with its source named; the original script does not appear in the artifact. - **Internal records keep their own language**: memory files, session notes, cycle records, run logs, closing summaries. +### Images in a reply + +The GUI chat renders a local image from a path and from nothing else. Write it as Markdown image syntax with an absolute path — `![the login form](/Users/me/shot.png)` or `![the login form](file:///Users/me/shot.png)`; both forms render. The TUI has no image surface, so there the same line reads as text, and the alt text must carry what the picture shows. + +Never inline base64 or any other `data:` URI. One screenshot becomes hundreds of thousands of characters, a single broken newline ends the string, and the rest of the payload then floods the transcript as body text — which is what the host ends up reading instead of the picture. + ## Rants A rant (吐槽) is the host's feedback — a complaint, a bug, a feature request, a suggestion about EMRG or about any registered agent. It arrives as ordinary conversation ("this is bad", "there's a bug", "it should…", "why not…"), so there is no mode to enter and no prefix to wait for. diff --git a/tests/test_image_in_reply_prompt_reach.py b/tests/test_image_in_reply_prompt_reach.py new file mode 100644 index 00000000..602cd15f --- /dev/null +++ b/tests/test_image_in_reply_prompt_reach.py @@ -0,0 +1,182 @@ +"""The one way to show a picture in a reply must be stated in the prompt every session reads. + +Why this file exists +-------------------- +The GUI chat renders a local image from a path, and the TUI has no image surface at all. Until this +block existed, `emrg/server/prompts/system.j2` said nothing about images — no occurrence of +``image``, of the Chinese word for it, or of ``base64`` anywhere in the template — so every session +rediscovered the question from zero, and the only technique that actually worked was inlining the +picture as base64: a single screenshot becomes hundreds of thousands of characters in one message, +and one broken newline ends the string so the rest of the payload floods the transcript as body +text. + +The rendering half of that defect is guarded where it lives (`emrg/gui/renderer/src/lib/ +vendorMarkdown.test.ts`, driven through the real vendored DOMPurify). This file guards the other +half: the prompt's silence, which is what made each session pay for the discovery again. + +What is asserted +---------------- +Two levels, two questions — the same pair `tests/test_plain_language_rule_reach.py` asks of the +plain-language rule, for the same reason. The file leg answers "does the source that ships state +it?"; the render leg answers "is this what a session receives?". Neither implies the other: +`system.j2` is a Jinja2 template, so a `{# … #}` or `{% if false %}` can remove the block from the +prompt while the file keeps every character of it. The render leg compares the block **verbatim** +rather than term by term, so a construct that reflowed the wording is visible too. + +The prohibition and its alternative are pinned together. "Never inline base64" without the two +path forms is a rule that forbids the only working technique; the two path forms without the +prohibition leaves the expensive habit legal. A rewrite that keeps one half must fail here. + +Named limit +----------- +This pins the rule's *presence* and its wording in the artifact a session receives. It cannot read +a message and decide whether the rule was followed: a session that inlines base64 under a prompt +that forbids it is a model-behaviour failure, not one this file can see. +""" + +from __future__ import annotations + +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parent.parent +SYSTEM_PROMPT = REPO_ROOT / "emrg" / "server" / "prompts" / "system.j2" + +#: The block's identity in `system.j2`. It sits inside `## Writing`, the one section that already +#: owns how a reply reads (language policy, lead-with-the-point), rather than in a section of its +#: own — a second copy appended anywhere in the template is visible here as a count of 2. +IMAGE_HEADING = "### Images in a reply" + +#: Load-bearing terms, each a verbatim substring of the shipped wording, so this list cannot drift +#: away from the sentence it checks without this file saying so. They cover: the path-only rule, +#: both accepted forms, the half that differs per client, and the prohibition with its reason. +IMAGE_TERMS = ( + "renders a local image from a path and from nothing else", # the rule itself + "Markdown image syntax with an absolute path", # how to write it + "both forms render", # /abs and file:/// alike + "the alt text must carry what the picture shows", # the TUI half + "Never inline base64 or any other `data:` URI", # the prohibition + "floods the transcript as body text", # why it is forbidden +) + + +def _block_after(text: str, heading: str) -> str: + """The block starting at ``heading``, up to the next `## ` or `### ` heading, or EOF. + + Returns ``""`` when the heading is absent — the caller asserts, so a missing block reads as a + failure to measure rather than as a pass. + """ + start = text.find(heading) + if start == -1: + return "" + rest = text[start + len(heading) :] + ends = [pos for pos in (rest.find("\n## "), rest.find("\n### ")) if pos != -1] + end = min(ends) if ends else -1 + return rest if end == -1 else rest[:end] + + +def _missing_terms(text: str, terms: tuple[str, ...]) -> list[str]: + """The terms this text does not carry. Empty means the rule is stated.""" + return [term for term in terms if term not in text] + + +def _render_system_prompt() -> str: + """The prompt a session receives, rendered by the daemon's own environment. + + `_get_jinja_env` is what `_build_system_prompt` uses — a fresh `jinja2.Environment` could differ + in `trim_blocks` / `lstrip_blocks` or in the loader path, and the prompt under test would then + be a prompt nobody receives. The context is deliberately minimal: the block sits outside every + conditional, so no context key can remove it. + """ + from emrg.server.daemon import _get_jinja_env # noqa: PLC0415 + + return _get_jinja_env().get_template("system.j2").render( + os_name="test", config_dir="/nonexistent" + ) + + +def test_the_shared_prompt_states_the_image_rule() -> None: + text = SYSTEM_PROMPT.read_text(encoding="utf-8") + block = _block_after(text, IMAGE_HEADING) + assert block, ( + "emrg/server/prompts/system.j2 must carry the images-in-a-reply block: it is the only " + "prompt every session is rendered under, and its silence on images is what left each " + "session with base64 as the only way to show a picture" + ) + missing = _missing_terms(block, IMAGE_TERMS) + assert not missing, ( + f"the images-in-a-reply block must state the path rule, both forms, the TUI half and the " + f"base64 prohibition with its reason; missing: {missing}" + ) + assert text.count(IMAGE_HEADING) == 1, ( + f"the rule must be stated once; found {text.count(IMAGE_HEADING)} copies — a duplication " + "costs prompt and leaves two copies free to drift apart" + ) + + +def test_the_rendered_prompt_carries_the_image_rule_verbatim() -> None: + """The artifact the reader gets, measured where `system.j2` is a *template*.""" + rendered = _render_system_prompt() + template_block = _block_after(SYSTEM_PROMPT.read_text(encoding="utf-8"), IMAGE_HEADING) + render_block = _block_after(rendered, IMAGE_HEADING) + assert render_block, ( + "the rendered session prompt must carry the images-in-a-reply block: it is present in " + "system.j2 but no render a session receives contains it" + ) + assert template_block in rendered, ( + "the rendered prompt carries a *different* images-in-a-reply block than the template does " + "— something in the render path edited it, and the wording a session reads is then not " + "the wording this repository reviewed" + ) + missing = _missing_terms(render_block, IMAGE_TERMS) + assert not missing, f"the rendered images-in-a-reply block is missing terms: {missing}" + assert rendered.count(IMAGE_HEADING) == 1, ( + "the rendered prompt must state the rule once; found " + f"{rendered.count(IMAGE_HEADING)} copies" + ) + + +def test_the_prohibition_and_the_way_round_it_are_pinned_together() -> None: + """Both halves, because either one alone is a defect in the other direction. + + The prohibition without the two path forms tells a session not to inline base64 while naming + nothing that works instead, which is the state this block exists to end. The path forms without + the prohibition leave the habit that floods a transcript legal. Asserting the pair means a + rewrite that keeps the pleasant half fails here with the reason rather than passing as a + wording change. + """ + block = _block_after(SYSTEM_PROMPT.read_text(encoding="utf-8"), IMAGE_HEADING) + assert "Never inline base64" in block, ( + "the block no longer forbids inlining base64 — the habit that turns one screenshot into " + "hundreds of thousands of characters" + ) + assert "/Users/me/shot.png" in block and "file:///Users/me/shot.png" in block, ( + "the block must show both accepted spellings; a prohibition whose alternative is unstated " + "leaves the reader with nothing that works" + ) + assert "The TUI has no image surface" in block, ( + "the block no longer says the TUI renders no image — a session that assumes otherwise " + "writes a reply whose only content is invisible to half its readers" + ) + + +def test_the_scan_reports_absence() -> None: + """The instrument's control: text without the block must read as missing. + + A check that reports the healthy answer whatever it is given is not a check. This feeds the + extractors a prompt with no such block and requires the absence to be visible, a prompt with + the heading twice and requires the duplication to be visible, and a prompt carrying one term + and requires exactly the other terms to be reported missing. + """ + sample = "# A prompt with no images block\n\n- Must push\n" + assert _block_after(sample, IMAGE_HEADING) == "" + assert _missing_terms(sample, IMAGE_TERMS) == list(IMAGE_TERMS) + + doubled = f"{IMAGE_HEADING}\n\n{IMAGE_HEADING}\n\n- Must push\n" + assert doubled.count(IMAGE_HEADING) == 2 + + partial = f"{IMAGE_HEADING}\n\nNever inline base64 or any other `data:` URI\n" + assert _block_after(partial, IMAGE_HEADING).strip().startswith("Never inline base64") + assert "Never inline base64 or any other `data:` URI" not in _missing_terms( + partial, IMAGE_TERMS + ) + assert len(_missing_terms(partial, IMAGE_TERMS)) == len(IMAGE_TERMS) - 1 From fd225e42f38f979fb1615ea388adf96e6caa3e25 Mon Sep 17 00:00:00 2001 From: EMRG Evolution Date: Thu, 29 Oct 2026 16:45:21 +0800 Subject: [PATCH 2/2] emrg: the prompt states the image rule without a literal digit --- emrg/server/prompts/system.j2 | 2 +- tests/test_image_in_reply_prompt_reach.py | 19 ++++++++++--------- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/emrg/server/prompts/system.j2 b/emrg/server/prompts/system.j2 index a5d9aa68..ca672a75 100644 --- a/emrg/server/prompts/system.j2 +++ b/emrg/server/prompts/system.j2 @@ -92,7 +92,7 @@ Session `{{ session.id }}` lives in `{{ session.dir_path }}/`. The GUI chat renders a local image from a path and from nothing else. Write it as Markdown image syntax with an absolute path — `![the login form](/Users/me/shot.png)` or `![the login form](file:///Users/me/shot.png)`; both forms render. The TUI has no image surface, so there the same line reads as text, and the alt text must carry what the picture shows. -Never inline base64 or any other `data:` URI. One screenshot becomes hundreds of thousands of characters, a single broken newline ends the string, and the rest of the payload then floods the transcript as body text — which is what the host ends up reading instead of the picture. +Never inline the picture as a `data:` URI, and never paste an encoded blob in place of a path. One screenshot then runs to hundreds of thousands of characters, a single broken newline ends the string, and the rest of the payload floods the transcript as body text — which is what the host ends up reading instead of the picture. ## Rants diff --git a/tests/test_image_in_reply_prompt_reach.py b/tests/test_image_in_reply_prompt_reach.py index 602cd15f..3a3df5de 100644 --- a/tests/test_image_in_reply_prompt_reach.py +++ b/tests/test_image_in_reply_prompt_reach.py @@ -54,7 +54,8 @@ "Markdown image syntax with an absolute path", # how to write it "both forms render", # /abs and file:/// alike "the alt text must carry what the picture shows", # the TUI half - "Never inline base64 or any other `data:` URI", # the prohibition + "Never inline the picture as a `data:` URI", # the prohibition + "never paste an encoded blob in place of a path", # the habit it forbids "floods the transcript as body text", # why it is forbidden ) @@ -145,9 +146,11 @@ def test_the_prohibition_and_the_way_round_it_are_pinned_together() -> None: wording change. """ block = _block_after(SYSTEM_PROMPT.read_text(encoding="utf-8"), IMAGE_HEADING) - assert "Never inline base64" in block, ( - "the block no longer forbids inlining base64 — the habit that turns one screenshot into " - "hundreds of thousands of characters" + assert "Never inline the picture as a `data:` URI" in block, ( + "the block no longer forbids inlining the picture as a `data:` URI — the habit that turns " + "one screenshot into hundreds of thousands of characters. It names the `data:` form rather " + "than the encoding by name because `tests/test_system_prompt_structure.py` holds the " + "template to zero digits, and the encoding's name carries two of them" ) assert "/Users/me/shot.png" in block and "file:///Users/me/shot.png" in block, ( "the block must show both accepted spellings; a prohibition whose alternative is unstated " @@ -174,9 +177,7 @@ def test_the_scan_reports_absence() -> None: doubled = f"{IMAGE_HEADING}\n\n{IMAGE_HEADING}\n\n- Must push\n" assert doubled.count(IMAGE_HEADING) == 2 - partial = f"{IMAGE_HEADING}\n\nNever inline base64 or any other `data:` URI\n" - assert _block_after(partial, IMAGE_HEADING).strip().startswith("Never inline base64") - assert "Never inline base64 or any other `data:` URI" not in _missing_terms( - partial, IMAGE_TERMS - ) + partial = f"{IMAGE_HEADING}\n\nNever inline the picture as a `data:` URI\n" + assert _block_after(partial, IMAGE_HEADING).strip().startswith("Never inline the picture") + assert "Never inline the picture as a `data:` URI" not in _missing_terms(partial, IMAGE_TERMS) assert len(_missing_terms(partial, IMAGE_TERMS)) == len(IMAGE_TERMS) - 1