Răsfoiți Sursa

fix(web): address file-reference review feedback

Dudu-0223 1 săptămână în urmă
părinte
comite
51e9e1599d

+ 2 - 2
.agents/notes/implemented/feature/2026-09-15-markdown-file-preview-links.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-15-markdown-file-preview-links.md
-2026-09-15-markdown-file-preview-links.md: 337385c2218d8b38b292094e90f12a46f0a7297c
-2026-09-15-markdown-file-preview-links.zh.md: a14974bbd078f895b2598fe18943c8a8d55cbb88
+2026-09-15-markdown-file-preview-links.md: d35f1d11cb303eb0b1e47b3d37f8e8b0aedc1937
+2026-09-15-markdown-file-preview-links.zh.md: 8eae197032abbe833a6e6655799043412c1c10b9

+ 2 - 2
.agents/notes/implemented/feature/2026-09-15-markdown-file-preview-links.md

@@ -28,7 +28,7 @@ The Web file-reference prompt asks for a link on every existing-file mention out
 
 ## Consequences
 
-Source references need no new Session event. The static Web guidance is logged through the existing system-message mechanism. Links become active when the message settles. A range selects its first line; the preview does not highlight a multi-line selection. Unit tests cover destination parsing and callback wiring; the keyless `markdown-file-links` Web snapshot covers file content, colon labels, line navigation, and tab reuse through the shipped composition. Web prompt sidecars pin the assembled guidance.
+Source references need no new Session event. The static Web guidance is logged through the existing system-message mechanism. Links become active when the message settles. A range selects its first line; the preview does not highlight a multi-line selection. Unit tests cover destination parsing and callback wiring; the keyless `markdown-file-links` Web snapshot covers file content, colon labels, line navigation, and tab reuse through the shipped composition. The package tests check the guidance in every Web prompt sidecar and keep the final colon treatment aligned with the shipped paragraph. Historical input hashes and the evaluation runner’s keyless checks run in Linux PR CI. The evaluation runner waits for live process-group members to terminate, excluding zombies that can retain a group id after exit; a PID-existence probe cannot establish running work.
 
 ## Prompt evaluation
 
@@ -44,6 +44,6 @@ The development comparison ran four read-only tasks three times per variant. E c
 | Full-path labels / named labels | 44/373 | 89/471 | 118/403 |
 | Balanced 11-answer coverage | 95.2% | 93.0% | 79.0% |
 
-The colon revision is a separate development run. Its first three answers contained 28 mixed `:start-Lend` suffixes, retained as failures. Explicitly forbidding both `#` and `L` in the suffix yielded 51 structurally valid links in one repeated explanation, but no colon labels, three missing links, and 27 unnecessary directory qualifiers. A subsequent user-accepted demo produced 18 structurally valid links, including three colon labels and no mixed suffix. Neither run establishes reliable compliance or exhaustive factual accuracy. Authentication failures, the timeout, and all first answers remain in the local experiment archive; PR #4255 carries the extended 69-row metrics and task-rubric tables.
+The colon revision is a separate development run. Its first three answers contained 28 mixed `:start-Lend` suffixes, retained as failures. Explicitly forbidding both `#` and `L` in the suffix yielded 51 structurally valid links in one repeated explanation, but no colon labels, three missing links, and 27 unnecessary directory qualifiers. A subsequent user-accepted demo produced 18 structurally valid links, including three colon labels and no mixed suffix. Neither run establishes reliable compliance or exhaustive factual accuracy. Authentication failures, the timeout, and all first answers remain in the local experiment archive; the [committed metrics](../../../../packages/client/ui-deliverables/evals/file-references/results/2026-09-16/metrics.md) carry the extended 69-row metrics and task-rubric tables.
 
 Reusable [evaluation inputs and rubrics](../../../../packages/client/ui-deliverables/evals/file-references/README.md) live beside the prompt owner, with compact historical observations and failure records. Their manual runner uses the supported headless profile and keeps generated outputs outside version control. Package ownership keeps prompt changes and their regression criteria together; the repository performance benchmark tree has different input and timing requirements.

+ 2 - 2
.agents/notes/implemented/feature/2026-09-15-markdown-file-preview-links.zh.md

@@ -28,7 +28,7 @@ Web 文件引用提示词要求链接命令、配置表达式和代码块以外
 
 ## Consequences
 
-源码引用无需新增 Session 事件。静态 Web 指导通过现有系统消息机制记录。链接在消息落定后可用。范围定位到起始行;预览不会高亮多行选区。单元测试覆盖目标解析和回调连接;无密钥的 `markdown-file-links` Web 快照通过正式组合覆盖文件内容、冒号标签、行号导航和标签复用。Web 提示词 sidecar 固定组合后的指导。
+源码引用无需新增 Session 事件。静态 Web 指导通过现有系统消息机制记录。链接在消息落定后可用。范围定位到起始行;预览不会高亮多行选区。单元测试覆盖目标解析和回调连接;无密钥的 `markdown-file-links` Web 快照通过正式组合覆盖文件内容、冒号标签、行号导航和标签复用。包测试校验每份 Web 提示词 sidecar 中的指导,并保持最终冒号评测文本与正式段落一致。历史输入哈希和评测运行器的无密钥检查在 Linux PR CI 中运行。评测运行器等待仍在运行的进程组成员终止,排除退出后仍可能保留组 id 的僵尸进程;PID 存在性检查无法确认进程仍在运行。
 
 ## Prompt evaluation
 
@@ -44,6 +44,6 @@ Web 文件引用提示词要求链接命令、配置表达式和代码块以外
 | 完整路径标签 / 名称标签 | 44/373 | 89/471 | 118/403 |
 | 平衡的 11 份回答覆盖率 | 95.2% | 93.0% | 79.0% |
 
-冒号修订是独立的开发验证。最初三份回答包含 28 个混合 `:start-Lend` 后缀,均保留为失败。明确禁止后缀包含 `#` 和 `L` 后,一次重复讲解生成 51 个结构有效的链接,但没有冒号标签,仍有三次漏链和 27 个不必要的目录限定。随后用户接受的 demo 生成 18 个结构有效的链接,其中三个使用冒号标签,没有混合后缀。这些运行都不能证明稳定遵循或完整事实正确性。认证失败、超时和所有首答仍保留在本地实验归档中;PR #4255 包含扩展的 69 行指标及任务 rubric 表。
+冒号修订是独立的开发验证。最初三份回答包含 28 个混合 `:start-Lend` 后缀,均保留为失败。明确禁止后缀包含 `#` 和 `L` 后,一次重复讲解生成 51 个结构有效的链接,但没有冒号标签,仍有三次漏链和 27 个不必要的目录限定。随后用户接受的 demo 生成 18 个结构有效的链接,其中三个使用冒号标签,没有混合后缀。这些运行都不能证明稳定遵循或完整事实正确性。认证失败、超时和所有首答仍保留在本地实验归档中;[已提交的指标](../../../../packages/client/ui-deliverables/evals/file-references/results/2026-09-16/metrics.md)包含扩展的 69 行指标及任务 rubric 表。
 
 可复用的[评测输入和 rubric](../../../../packages/client/ui-deliverables/evals/file-references/README.zh.md) 与提示词所有者放在一起,并保留精简历史观察和失败记录。人工触发的运行器使用受支持的 headless profile,生成输出不进入版本控制。包内归属让提示词变化与回归标准一起维护;仓库性能 benchmark 目录有不同的输入和计时要求。

+ 3 - 0
.github/workflows/ci.yml

@@ -475,6 +475,9 @@ jobs:
       - name: Test production blame scoring
         run: uv run --python 3.10 --with-requirements .github/review-ownership/requirements.txt python -m unittest discover -s .github/review-ownership -p 'test_*.py'
 
+      - name: Test file-reference evaluation tools
+        run: python -m unittest discover -s packages/client/ui-deliverables/evals/file-references -p 'test_*.py' -v
+
       - name: Run complete keyless Python suite
         run: uv run --python 3.10 --group test --project python/sdk pytest
 

+ 2 - 2
packages/client/ui-deliverables/evals/file-references/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/ui-deliverables/evals/file-references/README.md
-README.md: e6c5520cf92cacc80d917f600fdeb0352f6c5c7b
-README.zh.md: ea94964ec996ee6e206b6f141e6b71063e2e2a88
+README.md: d4ad2a81209ad2786d491ddf7aab3858543c5328
+README.zh.md: 7a542e2fa3d4789b323037bf3a10d362ecb83b02

+ 4 - 4
packages/client/ui-deliverables/evals/file-references/README.md

@@ -21,7 +21,7 @@ This owner-local evaluation compares file-reference guidance using four unchange
 <a id="run-a-cohort"></a>
 ## Run a cohort
 
-Requires Python 3.9+, a POSIX host, Node and repository dependencies, and a built checkout (`pnpm run build`). Run from the repository root. Supply the normal DSH environment file with `--env-file`, or inherit its environment; the runner does not read credential values. `--model` is explicit, with the DeepSeek provider and high reasoning effort. Each attempt gets fresh DSH home, agent home, Session, and an archived HEAD workspace; the executable is this checkout's built `dsh --profile headless` launcher.
+Requires Python 3.9+, a POSIX host with `ps`, Node and repository dependencies, and a built checkout (`pnpm run build`). Run from the repository root. Supply the normal DSH environment file with `--env-file`, or inherit its environment; the runner does not read credential values. `--model` is explicit, with the DeepSeek provider and high reasoning effort. Each attempt gets fresh DSH home, agent home, Session, and an archived HEAD workspace; the executable is this checkout's built `dsh --profile headless` launcher.
 
 For one real smoke, set `DSH_EVAL_ENV` to your normal environment-file path and run:
 
@@ -29,9 +29,9 @@ For one real smoke, set `DSH_EVAL_ENV` to your normal environment-file path and
 python3 packages/client/ui-deliverables/evals/file-references/run.py --variant a-colon --case plan --repetitions 1 --model deepseek-flash --env-file "$DSH_EVAL_ENV"
 ```
 
-Omit `--variant`, `--case`, and `--repetitions` for the original three variants, four tasks, and three repetitions. `--prepare-only` records inputs without calling the API. The default 480-second attempt bound can be shortened with `--timeout`. Runs execute serially and stop at the first failed attempt; no retry or resume replaces an output. The printed directory lives under ignored `.artifacts/file-reference-evals/`, retaining input hashes, source archive, outputs, workspaces, Sessions, and failures. A supplied `--output` must be new and inside `.artifacts/`.
+Omit `--variant`, `--case`, and `--repetitions` for the original three variants, four tasks, and three repetitions. `--prepare-only` records inputs without calling the API. The default 480-second attempt bound can be shortened with `--timeout`. Runs execute serially and stop at the first failed attempt; no retry or resume replaces an output. The printed directory lives under ignored `.artifacts/file-reference-evals/`, retaining input hashes, source archive, outputs, workspaces, Sessions, and failures. A supplied `--output` must be new and inside `.artifacts/`; missing parent directories are created. The runner reaps the launcher and waits for every process-group member to terminate; zombies awaiting their parent’s reap do not count as running work.
 
-The portable runner uses the normal read-only sandbox and never-approval policy. Evaluator files are removed from the task workspace, but host/runtime/network resources are shared: this is not hermetic isolation. Inspect recorded tool activity for out-of-workspace access. Its controls differ from historical R3's macOS-specific guards and parallel scheduling; compare only within the new cohort, not by pooling it with R3. The archive pins source HEAD while the launcher hash identifies the build; rebuild after runtime changes.
+The portable runner uses the normal read-only sandbox and never-approval policy. Evaluator files are removed from the task workspace, but host/runtime/network resources are shared: this is not hermetic isolation. Inspect recorded tool activity for out-of-workspace access. Its controls differ from historical R3's macOS-specific guards and parallel scheduling; compare only within the new cohort, not by pooling it with R3. The archive pins source HEAD while the launcher hash identifies the build; rebuild after runtime changes. Treatment files retain their embedded newline and omit the shipped section’s leading output reminder, so the evaluated text is not byte-identical to the shipped section and cohort numbers do not measure that full section.
 
 <a id="review-and-summarize"></a>
 ## Review and summarize
@@ -45,7 +45,7 @@ python3 packages/client/ui-deliverables/evals/file-references/summarize.py packa
 python3 -m unittest discover -s packages/client/ui-deliverables/evals/file-references -p 'test_*.py' -v
 ```
 
-It validates nonnegative counts, E = L + M + I, duplicate attempts, and fresh-answer hashes. It reports planned/attempted/answered counts, missing audits, valid-link coverage, perfect audited answers, and timeouts. It does not automatically infer missing-file mentions or judge semantic correctness. These keyless Python checks validate the evaluation tools; they are manually invoked and do not add a real-API CI job.
+It validates nonnegative counts, E = L + M + I, duplicate attempts, and fresh-answer hashes. It reports planned/attempted/answered counts, missing audits, valid-link coverage, perfect audited answers, and timeouts. It does not automatically infer missing-file mentions or judge semantic correctness. These keyless Python checks validate the evaluation tools, historical input hashes, and process cleanup in the Linux PR CI job. The package’s normal Vitest suite checks the final colon treatment against the shipped guidance and that guidance in every Web prompt sidecar. No real-API CI job is added.
 
 <a id="preserved-results"></a>
 ## Preserved results

+ 4 - 4
packages/client/ui-deliverables/evals/file-references/README.zh.md

@@ -21,7 +21,7 @@ kind: "package-reference"
 <a id="run-a-cohort"></a>
 ## 运行一组评测
 
-需要 Python 3.9+、POSIX 主机、Node 和仓库依赖,以及已构建的检出目录(`pnpm run build`)。从仓库根目录执行。通过 `--env-file` 指定正常使用的 DSH 环境文件,或继承其环境;运行器不读取凭据值。`--model` 必须显式提供,使用 DeepSeek provider 和 high 推理强度。每次尝试使用新的 DSH home、agent home、Session 和 HEAD 归档工作区;可执行程序是本检出目录构建的 `dsh --profile headless` 启动器。
+需要 Python 3.9+、提供 `ps` 的 POSIX 主机、Node 和仓库依赖,以及已构建的检出目录(`pnpm run build`)。从仓库根目录执行。通过 `--env-file` 指定正常使用的 DSH 环境文件,或继承其环境;运行器不读取凭据值。`--model` 必须显式提供,使用 DeepSeek provider 和 high 推理强度。每次尝试使用新的 DSH home、agent home、Session 和 HEAD 归档工作区;可执行程序是本检出目录构建的 `dsh --profile headless` 启动器。
 
 执行一次真实冒烟验证时,将 `DSH_EVAL_ENV` 设为正常环境文件路径,然后运行:
 
@@ -29,9 +29,9 @@ kind: "package-reference"
 python3 packages/client/ui-deliverables/evals/file-references/run.py --variant a-colon --case plan --repetitions 1 --model deepseek-flash --env-file "$DSH_EVAL_ENV"
 ```
 
-省略 `--variant`、`--case` 和 `--repetitions` 即使用原三版、四个任务和三次重复。`--prepare-only` 只记录输入,不调用 API。默认每次尝试上限为 480 秒,可用 `--timeout` 缩短。运行串行执行,遇到首次失败即停止;重试或恢复不会替换输出。打印的目录位于被忽略的 `.artifacts/file-reference-evals/` 下,保留输入哈希、源码归档、输出、工作区、Session 和失败。指定的 `--output` 必须是 `.artifacts/` 内的新目录。
+省略 `--variant`、`--case` 和 `--repetitions` 即使用原三版、四个任务和三次重复。`--prepare-only` 只记录输入,不调用 API。默认每次尝试上限为 480 秒,可用 `--timeout` 缩短。运行串行执行,遇到首次失败即停止;重试或恢复不会替换输出。打印的目录位于被忽略的 `.artifacts/file-reference-evals/` 下,保留输入哈希、源码归档、输出、工作区、Session 和失败。指定的 `--output` 必须是 `.artifacts/` 内的新目录;缺失的父目录会自动创建。运行器回收启动器,并等待进程组的所有成员终止;等待父进程回收的僵尸进程不计为仍在运行的工作。
 
-可移植运行器使用正常的只读 sandbox 和 never approval 策略。评测文件会从任务工作区移除,但宿主、运行时和网络资源仍共享,因此不是完全隔离环境。应检查记录的工具活动中是否存在工作区外访问。它的控制措施与历史 R3 的 macOS 专用防护及并行调度不同;只在新组内比较,不与 R3 合并统计。归档固定 HEAD 源码,启动器哈希标识构建;运行时代码变化后应重新构建。
+可移植运行器使用正常的只读 sandbox 和 never approval 策略。评测文件会从任务工作区移除,但宿主、运行时和网络资源仍共享,因此不是完全隔离环境。应检查记录的工具活动中是否存在工作区外访问。它的控制措施与历史 R3 的 macOS 专用防护及并行调度不同;只在新组内比较,不与 R3 合并统计。归档固定 HEAD 源码,启动器哈希标识构建;运行时代码变化后应重新构建。评测文本保留内嵌换行,并省略正式提示词段开头的产出提醒,因此评测文本与正式段落并非逐字节相同,组内指标不衡量该完整段落。
 
 <a id="review-and-summarize"></a>
 ## 评审与汇总
@@ -45,7 +45,7 @@ python3 packages/client/ui-deliverables/evals/file-references/summarize.py packa
 python3 -m unittest discover -s packages/client/ui-deliverables/evals/file-references -p 'test_*.py' -v
 ```
 
-它验证非负计数、E = L + M + I、重复尝试和新答案哈希,并报告计划数、尝试数、回答数、缺失审计、有效链接覆盖、已审计完美回答和超时。它不自动推断遗漏的文件提及,也不判断语义正确性。这些无密钥 Python 检查验证评测工具,由人工调用,不增加真实 API CI 作业。
+它验证非负计数、E = L + M + I、重复尝试和新答案哈希,并报告计划数、尝试数、回答数、缺失审计、有效链接覆盖、已审计完美回答和超时。它不自动推断遗漏的文件提及,也不判断语义正确性。这些无密钥 Python 检查在 Linux PR CI 作业中验证评测工具、历史输入哈希和进程清理。包的常规 Vitest 测试校验最终冒号评测文本与正式指导的一致性,以及每份 Web 提示词 sidecar 中的指导。不增加真实 API CI 作业。
 
 <a id="preserved-results"></a>
 ## 保留的结果

+ 10 - 7
packages/client/ui-deliverables/evals/file-references/run.py

@@ -19,8 +19,15 @@ def digest(path):
     return hashlib.sha256(path.read_bytes()).hexdigest()
 
 
+def process_group_running(pgid):
+    """Report live group members; zombies have exited and await their parent's reap."""
+    rows = subprocess.check_output(['ps', '-A', '-o', 'pgid=', '-o', 'stat='], text=True)
+    return any(int(group) == pgid and not state.startswith('Z')
+               for group, state in (row.split() for row in rows.splitlines()))
+
+
 def execute(command, workspace, task, slot, timeout):
-    """Preserve first output and reap the owned process group, including on interruption."""
+    """Preserve first output, reap the launcher, and await termination of its process group."""
     started = time.monotonic()
     timed_out = False
     interrupted = False
@@ -51,11 +58,7 @@ def execute(command, workspace, task, slot, timeout):
         pass
     deadline = time.monotonic() + 5
     teardown_error = None
-    while True:
-        try:
-            os.killpg(child.pid, 0)
-        except ProcessLookupError:
-            break
+    while process_group_running(child.pid):
         if time.monotonic() >= deadline:
             teardown_error = 'Owned process group did not finish teardown'
             break
@@ -108,7 +111,7 @@ def main():
     if args.output:
         if not output.is_relative_to(REPO / '.artifacts'):
             parser.error('--output must be inside repository .artifacts/')
-        output.mkdir(mode=0o700)
+        output.mkdir(mode=0o700, parents=True)
     output.chmod(0o700)
     env_args = ['--env-file=' + str(args.env_file.resolve())] if args.env_file else []
     revision = subprocess.check_output(['git', 'rev-parse', 'HEAD'], cwd=REPO, text=True).strip()

+ 47 - 8
packages/client/ui-deliverables/evals/file-references/test_eval.py

@@ -1,11 +1,14 @@
 """Keyless checks for retained failures and honest evaluation denominators."""
 import json
 import os
+import signal
+import subprocess
+import time
 from pathlib import Path
 import sys
 import tempfile
 import unittest
-from run import execute, digest
+from run import execute, digest, process_group_running
 from summarize import aggregate, observations
 
 
@@ -50,17 +53,53 @@ class EvaluationTests(unittest.TestCase):
             for name, value in expected.items():
                 self.assertEqual(digest(owner / folder / (name + '.txt')), value)
 
-    def test_descendant_ignoring_term_is_reaped(self):
+    def test_zombie_group_has_no_running_work(self):
+        child = subprocess.Popen([sys.executable, '-c',
+            'import time; print("ready", flush=True); time.sleep(60)'],
+            stdout=subprocess.PIPE, text=True, start_new_session=True)
+        try:
+            self.assertEqual(child.stdout.readline(), 'ready\n')
+            self.assertTrue(process_group_running(child.pid))
+            child.kill()
+            deadline = time.monotonic() + 5
+            while not subprocess.check_output(['ps', '-p', str(child.pid), '-o', 'stat='], text=True).strip().startswith('Z'):
+                if time.monotonic() >= deadline:
+                    self.fail('Killed child did not reach zombie state')
+                time.sleep(.01)
+            # Deliberately leave our child unreaped while checking the same group.
+            if sys.platform == 'linux':
+                os.killpg(child.pid, 0)
+            self.assertFalse(process_group_running(child.pid))
+        finally:
+            child.kill()
+            child.communicate()
+
+    def test_successful_leader_does_not_leave_a_term_ignoring_descendant_running(self):
         with tempfile.TemporaryDirectory() as root:
             slot = Path(root)
             task = slot / 'task.txt'
             task.write_text('task')
-            code = 'import json, subprocess, sys, time; child=subprocess.Popen([sys.executable,"-c","import signal,time; signal.signal(signal.SIGTERM,signal.SIG_IGN); time.sleep(60)"]); print(json.dumps({"pid":child.pid}),flush=True); time.sleep(60)'
-            result = execute([sys.executable, '-c', code], slot, task, slot, .3)
-            self.assertTrue(result['timedOut'])
-            pid = json.loads((slot / 'stdout.jsonl').read_text())['pid']
-            with self.assertRaises(ProcessLookupError):
-                os.kill(pid, 0)
+            descendant = 'import signal,time; signal.signal(signal.SIGTERM,signal.SIG_IGN); print("ready",flush=True); time.sleep(60)'
+            code = f"""import json, subprocess, sys
+child = subprocess.Popen([sys.executable, '-c', {descendant!r}], stdout=subprocess.PIPE, text=True)
+assert child.stdout.readline() == 'ready\\n'
+print(json.dumps({{'pid': child.pid}}), flush=True)
+print(json.dumps({{'type': 'final', 'text': 'done'}}), flush=True)
+"""
+            result = execute([sys.executable, '-c', code], slot, task, slot, 10)
+            pid = json.loads((slot / 'stdout.jsonl').read_text().splitlines()[0])['pid']
+            try:
+                self.assertFalse(result['timedOut'])
+                self.assertEqual(result['exitCode'], 0)
+                self.assertTrue(result['nonemptyFinal'])
+                self.assertIsNone(result['teardownError'])
+                state = subprocess.run(['ps', '-p', str(pid), '-o', 'stat='], capture_output=True, text=True, check=False)
+                self.assertTrue(state.returncode == 1 or state.stdout.strip().startswith('Z'))
+            finally:
+                try:
+                    os.kill(pid, signal.SIGKILL)
+                except ProcessLookupError:
+                    pass
 
     def test_historical_counts_reproduce_report(self):
         source = Path(__file__).parent / 'results/2026-09-16/observations.json'

+ 8 - 0
packages/client/ui-deliverables/tests/prompt.host.spec.ts

@@ -1,5 +1,6 @@
 /** Node-half coverage for the model guidance paired with Web file references. */
 
+import { readFile } from 'node:fs/promises'
 import { Context } from '@deepseek-ai/cordis'
 import { afterEach, describe, expect, it } from 'vitest'
 import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
@@ -30,6 +31,13 @@ describe('ui-deliverables node plugin', () => {
       .find(entry => entry.name === 'ui:deliverable-file-references')
     expect(section?.text).toMatchInlineSnapshot('"When you successfully create or modify files, mention the primary outputs in your final response. Outside commands, configuration expressions, and code blocks, link every mention of an existing file, including repeats and tables, to its full path relative to the working directory or absolute; append #L24 or #L24-L30 to the target for known lines. Use the filename or a clear alias as the label, adding only enough parent directories to distinguish files; keep full paths out of labels. Default to the name alone; when precise locations matter, append :24 or :24–30, with no # or L in the line suffix."')
 
+    const treatment = await readFile(new URL('../evals/file-references/prompts/a-colon.txt', import.meta.url), 'utf8')
+    expect(section?.text).toBe(`When you successfully create or modify files, mention the primary outputs in your final response. ${treatment.trim().split('\n').join(' ')}`)
+    for (const scenario of ['cordis-tool-round', 'fresh-round-trip', 'ptc-round', 'schedule-catalog']) {
+      const sidecar = await readFile(new URL(`../../../../snapshots/web/${scenario}/system-prompt.expected.md`, import.meta.url), 'utf8')
+      expect(sidecar, scenario).toContain(section!.text)
+    }
+
     await mounted.dispose()
     expect((await ctx.systemPrompt.assemble()).sections
       .some(entry => entry.name === 'ui:deliverable-file-references')).toBe(false)

+ 2 - 2
packages/client/ui-primitives/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/ui-primitives/README.md
-README.md: 71176026981767edb948537e98ea4567cfe2cc84
-README.zh.md: df128643dc1780716070fdbe98164e08243ee2ed
+README.md: c601550a0d2b880555347c6fa5c56caeeee5f34a
+README.zh.md: 3bcb84cc23cbd13149b25996bf05bce9ad28f966

+ 1 - 1
packages/client/ui-primitives/README.md

@@ -72,7 +72,7 @@ The catalog above lists what each export is for; this section covers the behavio
 
 ### Rendering agent output
 
-Passing `openFile` makes local Markdown links clickable after settlement. Absolute and workspace-relative paths support percent escapes and `#L24` / `#L24-L30` fragments; ranges open at their first line. The callback receives the decoded path and optional line, while the renderer preserves the label and displays a file icon. Without a callback, local links remain text. URL schemes, queries, unsupported fragments, and malformed destinations never reach the file opener.
+Passing `openFile` makes local Markdown links clickable after settlement. Absolute and workspace-relative paths support percent escapes and `#L24` / `#L24-L30` fragments; ranges open at their first line. Literal `?` and `#` in filenames must be percent-encoded. The tooltip uses the decoded path and supplies the accessible name when the label is empty. The callback receives the decoded path and optional line, while the renderer preserves the label and displays a file icon. Without a callback, local links remain text. URL schemes, queries, unsupported fragments, and malformed destinations never reach the file opener.
 
 `MarkdownText` renders untrusted GFM and TeX math, blocks unsafe links and images, and can turn resolved file mentions into explicit controls. When the owner passes a `pathImages` vocabulary, image destinations that are local media paths rewrite to displayable URLs on settled renders only (the same streaming gate as file mentions); without a vocabulary, local destinations remain inert alt text. A load or decode failure replaces the image with its authored alt text, or the original destination when alt is empty. Changing the image source permits a fresh load. While a reply streams, it freezes completed blocks, advances a top-level open fence by completed lines, and highlights that fence from saved Shiki grammar state. Completed token lines enter fixed-size React groups, so later chunks reconcile only the growing group; an unchanged fence retains that DOM when the final full parse resolves cross-document syntax. `TerminalBlock`, `ReadBlock`, `DiffBlock`, `SearchBlock`, and `WebBlock` render the matching tool-result intent with copy controls, overflow handling, and ANSI processing where applicable. `JsonTree` and `JsonBlock` inspect JSON values read-only, while `projectUserText` projects sent user text into inline plain runs and reference chips for the message bubble and queue rows. When supplied with `UserTextReferences`, file and skill references become keyboard-accessible preview buttons using the same hover and focus styling as prose file links; the first pointer click can open a preview, while subsequent clicks and existing text selections retain native selection handling. Keyboard activation opens previews even when text is selected.
 

+ 1 - 1
packages/client/ui-primitives/README.zh.md

@@ -72,7 +72,7 @@ kind: "package-library"
 
 ### 渲染 agent 输出
 
-传入 `openFile` 后,本地 Markdown 链接在落定后可点击。绝对路径和工作区相对路径支持百分号转义以及 `#L24` / `#L24-L30` 片段;范围定位到起始行。回调接收解码后的路径和可选行号,渲染器保留标签并显示文件图标。不传回调时,本地链接仍为文本。URL 协议、查询串、不支持的片段及格式错误的目标不会传给文件打开器。
+传入 `openFile` 后,本地 Markdown 链接在落定后可点击。绝对路径和工作区相对路径支持百分号转义以及 `#L24` / `#L24-L30` 片段;范围定位到起始行。文件名中的字面 `?` 和 `#` 必须百分号编码。悬停提示使用解码后的路径,并在标签为空时提供可访问名称。回调接收解码后的路径和可选行号,渲染器保留标签并显示文件图标。不传回调时,本地链接仍为文本。URL 协议、查询串、不支持的片段及格式错误的目标不会传给文件打开器。
 
 `MarkdownText` 渲染不可信的 GFM 与 TeX 公式、阻止不安全的链接与图片,并可把已解析的文件提及转换为显式控件。当 owner 传入 `pathImages` 词表时,本地媒体路径的图片目标只在落定渲染阶段重写为可展示 URL(与 file mentions 相同的流式门);不传词表时本地目标保持惰性 alt 文本。加载或解码失败后,图片替换为作者的 alt 文本;alt 为空时显示原始目标路径。图片源变化后可重新加载。回复流式输出时,它冻结已完成的块、按已完成行推进顶层未闭合 fence,并从保存的 Shiki grammar state 为该 fence 增量高亮。已完成的 token 行进入固定大小的 React 分组,后续分片只 reconcile 正在增长的分组;最终全量解析解决跨文档语法时,未变化的 fence 会保留该 DOM。`TerminalBlock`、`ReadBlock`、`DiffBlock`、`SearchBlock` 与 `WebBlock` 把对应的工具结果意图渲染为带复制控件、溢出处理及适用时 ANSI 处理的卡片。`JsonTree` 与 `JsonBlock` 以只读方式检查 JSON 值;`projectUserText` 把已发送的用户文本投影为行内普通文本段与引用 chip,供消息气泡和排队行使用。 传入 `UserTextReferences` 时,文件和 skill 引用成为支持键盘操作的预览按钮,复用正文文件链接的悬停和聚焦样式;第一次指针点击可以打开预览,后续点击和已有选区保留原生选择行为。键盘激活在存在选区时仍可打开预览。
 

+ 7 - 1
packages/client/ui-primitives/src/markdown/MarkdownText.module.css

@@ -55,7 +55,8 @@
   margin-bottom: 8px;
 }
 
-.markdown a {
+.markdown a,
+.markdown .fileLink {
   /* deepsuite markdown.css uses brand-text (blue in newDesign); this sheet
      keeps design-platform brand-text as near-black, so links use the
      dedicated link alias instead. */
@@ -315,6 +316,11 @@
   cursor: pointer;
 }
 
+.fileLink {
+  max-width: 100%;
+  text-align: inherit;
+}
+
 .fileMention:hover,
 .fileMention:focus {
   outline: none;

+ 1 - 0
packages/client/ui-primitives/src/markdown/file-link.ts

@@ -2,6 +2,7 @@
 
 /**
  * Decode a file destination and its optional GitHub-style line fragment.
+ * Literal `?` and `#` in filenames must be percent-encoded.
  * @param value - Parsed Markdown link destination.
  * @returns A local path and optional first line, or undefined for URLs,
  * fragment-only links, queries, malformed escapes, or invalid line ranges.

+ 1 - 1
packages/client/ui-primitives/src/markdown/render.tsx

@@ -556,7 +556,7 @@ function renderAnchor(url: string, children: ReactNode[], key: Key, glyph = true
       <button
         key={key}
         type="button"
-        className={css.fileMention}
+        className={clsx(css.fileMention, css.fileLink)}
         title={file.path}
         onClick={() => { openFile(file.path, file.line === undefined ? undefined : { line: file.line }) }}
       >

+ 11 - 1
packages/client/ui-primitives/tests/markdown-file-links.client.spec.tsx

@@ -18,12 +18,22 @@ describe('Markdown file links', () => {
     const openFile = vi.fn()
     const view = render(<MarkdownText text={`[source](${target})`} openFile={openFile} />)
     const link = view.getByRole('button', { name: 'source' })
-    expect(link.getAttribute('title')).toBe(target)
+    expect(link.getAttribute('title')).toBe(path)
     fireEvent.click(link)
     expect(openFile).toHaveBeenCalledWith(path, options)
     expect(view.container.querySelector('a')).toBeNull()
   })
 
+  it.each(['', '![](https://example.com/image.png)'])('names an empty label %s with the decoded path', (label) => {
+    const view = render(<MarkdownText text={`[${label}](docs/My%20Notes.md)`} openFile={vi.fn()} />)
+    expect(view.getByRole('button', { name: 'docs/My Notes.md' })).toBeTruthy()
+  })
+
+  it('preserves an image label’s alternative text as the accessible name', () => {
+    const view = render(<MarkdownText text={'[![diagram](https://example.com/image.png)](src/a.ts)'} openFile={vi.fn()} />)
+    expect(view.getByRole('button', { name: 'diagram' })).toBeTruthy()
+  })
+
   it('handles reference links and code labels without nesting file-mention buttons', () => {
     const openFile = vi.fn()
     const resolve = vi.fn()

Fișier diff suprimat deoarece este prea mare
+ 2 - 2
snapshots/web/markdown-file-links/session.v3.jsonl


+ 1 - 1
snapshots/web/schedule-catalog/system-prompt.expected.md

@@ -34,4 +34,4 @@ Use subagent in the background by default. Start independent delegations togethe
 
 Use subagent_fork in the background by default. Start independent delegations together in one assistant message and continue useful work while they run. Set `run_in_background: false` only when your next action depends on that subagent's result. When a background run settles, the runtime sends you a notice containing its outcome and any final assistant message.
 
-When you successfully create or modify files, mention the primary outputs in your final response. To make those and any other changed-file references clickable in Web, format them as Markdown inline code using the exact file-tool path, or a basename when unique among the files changed in that turn.
+When you successfully create or modify files, mention the primary outputs in your final response. Outside commands, configuration expressions, and code blocks, link every mention of an existing file, including repeats and tables, to its full path relative to the working directory or absolute; append #L24 or #L24-L30 to the target for known lines. Use the filename or a clear alias as the label, adding only enough parent directories to distinguish files; keep full paths out of labels. Default to the name alone; when precise locations matter, append :24 or :24–30, with no # or L in the line suffix.

Unele fișiere nu au fost afișate deoarece prea multe fișiere au fost modificate în acest diff