mirror of
https://github.com/NanmiCoder/claude-code-haha.git
synced 2026-10-10 11:53:10 +08:00
54a77db95d
The prose scan excludes CJK so a verb is not swallowed into a path, which cut an unquoted `测试文档1.docx` to `1.docx` and `D:/资料/测试文档1.docx` to `1.docx`. A match that is recognisably the tail of a CJK name is now widened to the whole token. Names the text cannot bound are settled against what exists: `报告v2.docx`, names glued together without a space, names with spaces or full-width brackets, and names made only of CJK plus an extension. The extractor offers the other readings; a changed file settles them, otherwise one cached workspace listing per folder does, longest existing name first. CJK-only names read like prose about formats (`后缀为.docx的文件`), so they are never linked and appear as cards or images only once confirmed. Fixes #1423
675 lines
25 KiB
TypeScript
675 lines
25 KiB
TypeScript
import { describe, expect, it } from 'vitest'
|
||
import { extractAssistantOutputTargets } from './assistantOutputTargets'
|
||
|
||
const workDir = '/Users/nanmi/project/demo'
|
||
|
||
describe('extractAssistantOutputTargets', () => {
|
||
it('extracts markdown links for workspace html, markdown, and images', () => {
|
||
const content = [
|
||
'已完成:',
|
||
'- [index.html](/Users/nanmi/project/demo/index.html)',
|
||
'- [notes](docs/result.md)',
|
||
'- [preview](assets/hero.png)',
|
||
].join('\n')
|
||
|
||
const targets = extractAssistantOutputTargets(content, { workDir })
|
||
|
||
expect(targets.map((target) => [target.kind, target.href, target.normalizedPath])).toEqual([
|
||
['local-html', '/Users/nanmi/project/demo/index.html', 'index.html'],
|
||
['markdown', 'docs/result.md', 'docs/result.md'],
|
||
['image', 'assets/hero.png', 'assets/hero.png'],
|
||
])
|
||
})
|
||
|
||
it('extracts Markdown images with empty alt text or a destination title', () => {
|
||
const targets = extractAssistantOutputTargets(
|
||
' ',
|
||
{ workDir },
|
||
)
|
||
|
||
expect(targets).toMatchObject([
|
||
{
|
||
kind: 'image',
|
||
title: 'empty-alt.png',
|
||
href: 'outputs/empty-alt.png',
|
||
normalizedPath: 'outputs/empty-alt.png',
|
||
},
|
||
{
|
||
kind: 'image',
|
||
title: 'preview',
|
||
href: 'outputs/titled.png',
|
||
normalizedPath: 'outputs/titled.png',
|
||
},
|
||
])
|
||
})
|
||
|
||
it('detects a naked relative video path as a video target', () => {
|
||
const targets = extractAssistantOutputTargets('渲染完成,见 outputs/clip.mp4 。', { workDir })
|
||
|
||
expect(targets).toMatchObject([
|
||
{
|
||
kind: 'video',
|
||
href: 'outputs/clip.mp4',
|
||
normalizedPath: 'outputs/clip.mp4',
|
||
source: 'plain-path',
|
||
},
|
||
])
|
||
})
|
||
|
||
it('detects a markdown link to a video as a video target', () => {
|
||
const targets = extractAssistantOutputTargets('[v](demo.webm)', { workDir })
|
||
|
||
expect(targets).toMatchObject([
|
||
{
|
||
kind: 'video',
|
||
href: 'demo.webm',
|
||
normalizedPath: 'demo.webm',
|
||
source: 'markdown-link',
|
||
},
|
||
])
|
||
})
|
||
|
||
it('extracts office documents as resources while source references stay inline', () => {
|
||
const targets = extractAssistantOutputTargets(
|
||
'交付物见 [合同](reports/brief.docx),实现位于 src/main.ts。',
|
||
{ workDir },
|
||
)
|
||
|
||
expect(targets).toMatchObject([
|
||
{ kind: 'file', href: 'reports/brief.docx', normalizedPath: 'reports/brief.docx' },
|
||
])
|
||
expect(targets.some((target) => target.normalizedPath === 'src/main.ts')).toBe(false)
|
||
})
|
||
|
||
it('preserves explicit video identity outside the workspace for the backend route', () => {
|
||
const targets = extractAssistantOutputTargets('[bad](/etc/x.mp4)', { workDir })
|
||
|
||
expect(targets).toMatchObject([{ href: '/etc/x.mp4', normalizedPath: '/etc/x.mp4' }])
|
||
})
|
||
|
||
it('normalizes markdown destinations with angle brackets, spaces, and line suffixes', () => {
|
||
const content = [
|
||
'[html](</Users/nanmi/project/demo/My Page/index.html>)',
|
||
'[lined](/Users/nanmi/project/demo/index.html:12)',
|
||
].join('\n')
|
||
|
||
const targets = extractAssistantOutputTargets(content, { workDir })
|
||
|
||
expect(targets.map((target) => [target.href, target.normalizedPath])).toEqual([
|
||
['/Users/nanmi/project/demo/My Page/index.html', 'My Page/index.html'],
|
||
['/Users/nanmi/project/demo/index.html', 'index.html'],
|
||
])
|
||
})
|
||
|
||
it('accepts safe Windows workspace paths with case-insensitive segments', () => {
|
||
const targets = extractAssistantOutputTargets(
|
||
'[Preview](C:/users/nanmi/project/demo/out/index.html)',
|
||
{ workDir: 'C:/Users/nanmi/project/demo' },
|
||
)
|
||
|
||
expect(targets).toMatchObject([
|
||
{
|
||
kind: 'local-html',
|
||
href: 'C:/users/nanmi/project/demo/out/index.html',
|
||
normalizedPath: 'out/index.html',
|
||
},
|
||
])
|
||
})
|
||
|
||
it('accepts absolute paths when the workdir is filesystem root', () => {
|
||
const targets = extractAssistantOutputTargets(
|
||
'[Preview](/tmp/demo/index.html)',
|
||
{ workDir: '/' },
|
||
)
|
||
|
||
expect(targets).toMatchObject([
|
||
{
|
||
kind: 'local-html',
|
||
href: '/tmp/demo/index.html',
|
||
normalizedPath: 'tmp/demo/index.html',
|
||
},
|
||
])
|
||
})
|
||
|
||
it('extracts naked localhost and loopback URLs', () => {
|
||
const targets = extractAssistantOutputTargets(
|
||
'Open http://localhost:5173 and http://127.0.0.1:3000/app and http://[::1]:4173/app now.',
|
||
{ workDir },
|
||
)
|
||
|
||
expect(targets).toMatchObject([
|
||
{ kind: 'localhost-url', href: 'http://localhost:5173' },
|
||
{ kind: 'localhost-url', href: 'http://127.0.0.1:3000/app' },
|
||
{ kind: 'localhost-url', href: 'http://[::1]:4173/app' },
|
||
])
|
||
})
|
||
|
||
it('trims markdown/code punctuation around naked localhost URLs', () => {
|
||
const targets = extractAssistantOutputTargets(
|
||
'地址:`http://localhost:9527/`,备用:http://127.0.0.1:3000/app)。',
|
||
{ workDir },
|
||
)
|
||
|
||
expect(targets.map((target) => target.href)).toEqual([
|
||
'http://localhost:9527/',
|
||
'http://127.0.0.1:3000/app',
|
||
])
|
||
})
|
||
|
||
it('ignores localhost URLs printed inside fenced log output', () => {
|
||
const targets = extractAssistantOutputTargets(
|
||
[
|
||
'日志前 50 行:',
|
||
'```log',
|
||
'[08:29:36][INFO] 代理服务已启动: 127.0.0.1:15721',
|
||
'[08:29:36][INFO] Claude Live 配置已接管,代理地址: http://127.0.0.1:15721',
|
||
'```',
|
||
].join('\n'),
|
||
{ workDir },
|
||
)
|
||
|
||
expect(targets).toEqual([])
|
||
})
|
||
|
||
it('ignores markdown links printed inside fenced code blocks', () => {
|
||
const targets = extractAssistantOutputTargets(
|
||
['```md', '调试输出: [preview](http://localhost:5173/) [page](index.html)', '```'].join('\n'),
|
||
{ workDir },
|
||
)
|
||
|
||
expect(targets).toEqual([])
|
||
})
|
||
|
||
it('keeps markdown localhost links as markdown-link targets with authored labels', () => {
|
||
const targets = extractAssistantOutputTargets(
|
||
'[Preview](http://localhost:4173) then http://localhost:4173',
|
||
{ workDir },
|
||
)
|
||
|
||
expect(targets).toMatchObject([
|
||
{
|
||
kind: 'localhost-url',
|
||
href: 'http://localhost:4173',
|
||
title: 'Preview',
|
||
source: 'markdown-link',
|
||
},
|
||
])
|
||
expect(targets).toHaveLength(1)
|
||
})
|
||
|
||
it('preserves paths outside the active workspace', () => {
|
||
const targets = extractAssistantOutputTargets(
|
||
'[secret](/Users/nanmi/private/secret.html) [ok](/Users/nanmi/project/demo/public/index.html)',
|
||
{ workDir },
|
||
)
|
||
|
||
expect(targets).toHaveLength(2)
|
||
expect(targets[0]?.normalizedPath).toBe('/Users/nanmi/private/secret.html')
|
||
expect(targets[1]).toMatchObject({
|
||
kind: 'local-html',
|
||
normalizedPath: 'public/index.html',
|
||
})
|
||
})
|
||
|
||
it('deduplicates repeated targets while preserving order', () => {
|
||
const content = [
|
||
'[index](index.html)',
|
||
'Again: http://localhost:5173',
|
||
'[index copy](./index.html)',
|
||
'Again: http://localhost:5173',
|
||
].join('\n')
|
||
|
||
const targets = extractAssistantOutputTargets(content, { workDir })
|
||
|
||
expect(targets.map((target) => target.href)).toEqual(['index.html', 'http://localhost:5173'])
|
||
})
|
||
|
||
it('extracts files from absolute-root directory trees inside code blocks', () => {
|
||
const targets = extractAssistantOutputTargets(
|
||
[
|
||
'目录结构',
|
||
'```',
|
||
'/Users/nanmi/project/demo/generated/',
|
||
'├── README.md # Markdown 说明文件',
|
||
'├── index.html # 静态页面',
|
||
'└── todo-app/',
|
||
' └── index.html',
|
||
'```',
|
||
].join('\n'),
|
||
{ workDir },
|
||
)
|
||
|
||
expect(targets.map((target) => [target.href, target.normalizedPath])).toEqual([
|
||
['/Users/nanmi/project/demo/generated/README.md', 'generated/README.md'],
|
||
['/Users/nanmi/project/demo/generated/index.html', 'generated/index.html'],
|
||
['/Users/nanmi/project/demo/generated/todo-app/index.html', 'generated/todo-app/index.html'],
|
||
])
|
||
})
|
||
|
||
it('ignores orphan preview file names inside code blocks', () => {
|
||
const targets = extractAssistantOutputTargets(
|
||
['```', 'index.html', 'README.md', '```'].join('\n'),
|
||
{ workDir },
|
||
)
|
||
|
||
expect(targets).toEqual([])
|
||
})
|
||
|
||
it('preserves first-seen order across mixed target types', () => {
|
||
const targets = extractAssistantOutputTargets(
|
||
'Open http://localhost:5173 first, then [index](index.html), then docs/guide.md.',
|
||
{ workDir },
|
||
)
|
||
|
||
expect(targets.map((target) => target.href)).toEqual([
|
||
'http://localhost:5173',
|
||
'index.html',
|
||
'docs/guide.md',
|
||
])
|
||
})
|
||
|
||
it('keeps explicit generated document paths while ignoring remote and file URLs', () => {
|
||
const targets = extractAssistantOutputTargets(
|
||
'Read https://example.com and maybe file:///etc/passwd, but use report.pdf only externally.',
|
||
{ workDir },
|
||
)
|
||
|
||
expect(targets).toMatchObject([
|
||
{ kind: 'file', href: 'report.pdf', normalizedPath: 'report.pdf' },
|
||
])
|
||
})
|
||
|
||
it('caps results at 6 by default', () => {
|
||
const targets = extractAssistantOutputTargets(
|
||
[
|
||
'[one](one.html)',
|
||
'[two](two.html)',
|
||
'[three](three.html)',
|
||
'[four](four.html)',
|
||
'[five](five.html)',
|
||
'[six](six.html)',
|
||
'[seven](seven.html)',
|
||
].join('\n'),
|
||
{ workDir },
|
||
)
|
||
|
||
expect(targets.map((target) => target.href)).toEqual([
|
||
'one.html',
|
||
'two.html',
|
||
'three.html',
|
||
'four.html',
|
||
'five.html',
|
||
'six.html',
|
||
])
|
||
})
|
||
|
||
it('respects an explicit limit override', () => {
|
||
const targets = extractAssistantOutputTargets(
|
||
'[one](one.html) [two](two.html) [three](three.html)',
|
||
{ workDir, limit: 2 },
|
||
)
|
||
|
||
expect(targets.map((target) => target.href)).toEqual(['one.html', 'two.html'])
|
||
})
|
||
})
|
||
|
||
describe('extractAssistantOutputTargets with changedFiles reconciliation', () => {
|
||
it('surfaces a generated office artifact even when the assistant forgot to name it', () => {
|
||
const targets = extractAssistantOutputTargets('已经生成完成。', {
|
||
workDir: '/work',
|
||
changedFiles: ['/work/reports/brief.docx', '/work/src/main.ts'],
|
||
})
|
||
|
||
expect(targets).toMatchObject([
|
||
{
|
||
kind: 'file',
|
||
href: 'reports/brief.docx',
|
||
normalizedPath: 'reports/brief.docx',
|
||
source: 'changed-file',
|
||
},
|
||
])
|
||
expect(targets.some((target) => target.normalizedPath === 'src/main.ts')).toBe(false)
|
||
})
|
||
|
||
it('reconciles mentioned paths without sweeping unmentioned artifacts for a non-owner reply', () => {
|
||
const targets = extractAssistantOutputTargets('正在处理 `index.html`。', {
|
||
workDir: '/work',
|
||
changedFiles: ['/work/app/index.html', '/work/reports/brief.docx'],
|
||
includeChangedFileFallback: false,
|
||
})
|
||
|
||
expect(targets).toMatchObject([
|
||
{
|
||
kind: 'local-html',
|
||
href: 'app/index.html',
|
||
normalizedPath: 'app/index.html',
|
||
},
|
||
])
|
||
expect(targets.some((target) => target.normalizedPath === 'reports/brief.docx')).toBe(false)
|
||
})
|
||
|
||
it('corrects a bare mention to the real changed path in a subfolder', () => {
|
||
// The reported bug: the model writes /private/tmp/todo-app/index.html but the
|
||
// prose only says `index.html`, so the chip used to point at the (missing)
|
||
// workdir-root index.html. With the turn's real changed files it is corrected.
|
||
const targets = extractAssistantOutputTargets('已创建 `index.html`,直接用浏览器打开。', {
|
||
workDir: '/private/tmp',
|
||
changedFiles: [
|
||
'/private/tmp/todo-app/index.html',
|
||
'/private/tmp/todo-app/style.css',
|
||
'/private/tmp/todo-app/app.js',
|
||
],
|
||
})
|
||
|
||
expect(targets).toHaveLength(1)
|
||
expect(targets[0]).toMatchObject({
|
||
kind: 'local-html',
|
||
href: 'todo-app/index.html',
|
||
normalizedPath: 'todo-app/index.html',
|
||
subtitle: 'todo-app/index.html',
|
||
})
|
||
})
|
||
|
||
it('drops a mentioned file that the turn never changed', () => {
|
||
const targets = extractAssistantOutputTargets('参考旧文件 old-report.html 和新结果 result.html', {
|
||
workDir: '/work',
|
||
changedFiles: ['/work/out/result.html'],
|
||
})
|
||
|
||
expect(targets.map((target) => target.normalizedPath)).toEqual(['out/result.html'])
|
||
})
|
||
|
||
it('keeps localhost url chips untouched while reconciling files', () => {
|
||
const targets = extractAssistantOutputTargets('启动后访问 http://localhost:5173/ ,源码见 index.html', {
|
||
workDir: '/work',
|
||
changedFiles: ['/work/app/index.html'],
|
||
})
|
||
|
||
const byKind = new Map(targets.map((target) => [target.kind, target]))
|
||
expect(byKind.get('localhost-url')?.href).toBe('http://localhost:5173/')
|
||
expect(byKind.get('local-html')?.normalizedPath).toBe('app/index.html')
|
||
})
|
||
|
||
it('applies the limit after dropping stale file mentions', () => {
|
||
const targets = extractAssistantOutputTargets('旧文件 old.html,服务在 http://localhost:5173/', {
|
||
workDir: '/work',
|
||
changedFiles: [],
|
||
limit: 1,
|
||
})
|
||
|
||
expect(targets).toMatchObject([
|
||
{ kind: 'localhost-url', href: 'http://localhost:5173/' },
|
||
])
|
||
})
|
||
|
||
it('rewrites a changed file outside the workdir to its absolute posix path', () => {
|
||
const targets = extractAssistantOutputTargets('已创建 todo.html', {
|
||
workDir: 'C:/Users/me/tmp/session',
|
||
changedFiles: ['D:\\workspace\\demo\\todo.html'],
|
||
})
|
||
|
||
expect(targets).toHaveLength(1)
|
||
expect(targets[0]).toMatchObject({
|
||
kind: 'local-html',
|
||
href: 'D:/workspace/demo/todo.html',
|
||
normalizedPath: 'D:/workspace/demo/todo.html',
|
||
})
|
||
})
|
||
|
||
it('keeps a generated document the turn wrote through a shell command', () => {
|
||
// `Write plan.md` plus `python make_report.py`: the checkpoint records only
|
||
// the first, so reconciling the mention of the second against it used to drop
|
||
// the actual deliverable.
|
||
const targets = extractAssistantOutputTargets(
|
||
'计划见 plan.md,报告已生成:out/report.docx',
|
||
{ workDir: '/w', changedFiles: ['/w/plan.md'] },
|
||
)
|
||
|
||
expect(targets.map((target) => target.normalizedPath))
|
||
.toEqual(['plan.md', 'out/report.docx'])
|
||
})
|
||
|
||
describe('a file named in an inline code span', () => {
|
||
// The span, not the sentence, bounds the name: a CJK basename inside backticks
|
||
// is one file. Scanning it as prose (CJK excluded on purpose) cut
|
||
// `开题报告2.docx` down to `2.docx` — a card that opens a file that is not there.
|
||
it('keeps the whole CJK basename', () => {
|
||
const targets = extractAssistantOutputTargets(
|
||
'- `开题报告2.docx` — 9/10\n- `开题报告3.docx` — 9/14\n- `开题报告_v2.docx` — 9/17',
|
||
{ workDir: '/w', changedFiles: [] },
|
||
)
|
||
|
||
expect(targets.map((target) => target.normalizedPath))
|
||
.toEqual(['开题报告2.docx', '开题报告3.docx', '开题报告_v2.docx'])
|
||
})
|
||
|
||
describe('a name the text alone cannot bound', () => {
|
||
const names = (content: string, changedFiles: string[]) =>
|
||
extractAssistantOutputTargets(content, {
|
||
workDir: '/w',
|
||
changedFiles,
|
||
includeChangedFileFallback: false,
|
||
includeUnconfirmedNames: true,
|
||
})
|
||
.map((target) => [target.title, target.normalizedPath])
|
||
|
||
it('takes the longer reading the turn really wrote', () => {
|
||
expect(names('已生成报告v2.docx', ['/w/报告v2.docx'])).toEqual([['报告v2.docx', '报告v2.docx']])
|
||
})
|
||
|
||
it('splits names glued together without a space', () => {
|
||
expect(names('已找到测试文档1.docx和测试文档2.docx', ['/w/测试文档1.docx', '/w/测试文档2.docx']))
|
||
.toEqual([['测试文档1.docx', '测试文档1.docx'], ['测试文档2.docx', '测试文档2.docx']])
|
||
})
|
||
|
||
it('recovers a name with spaces and full-width brackets', () => {
|
||
expect(names('已生成 毕业设计(论文)任务书 张三.docx', ['/w/毕业设计(论文)任务书 张三.docx']))
|
||
.toEqual([['毕业设计(论文)任务书 张三.docx', '毕业设计(论文)任务书 张三.docx']])
|
||
})
|
||
|
||
it('prefers the mention over a shorter name that also exists', () => {
|
||
expect(names('已找到 测试文档1.docx', ['/w/1.docx', '/w/测试文档1.docx']))
|
||
.toEqual([['测试文档1.docx', '测试文档1.docx']])
|
||
})
|
||
|
||
describe('a name made only of CJK and an extension', () => {
|
||
it('is not guessed at by default, as prose about formats looks the same', () => {
|
||
for (const content of ['已生成 开题报告.docx', '只支持后缀为.docx的文件']) {
|
||
expect(extractAssistantOutputTargets(content, { workDir: '/w', changedFiles: [] })).toEqual([])
|
||
}
|
||
})
|
||
|
||
it('is offered for confirmation when asked, and settled by a changed file', () => {
|
||
const unconfirmed = extractAssistantOutputTargets('已生成 开题报告.docx', {
|
||
workDir: '/w', changedFiles: [], includeUnconfirmedNames: true,
|
||
})
|
||
expect(unconfirmed).toMatchObject([{ normalizedPath: '开题报告.docx', awaitsConfirmation: true }])
|
||
|
||
const written = extractAssistantOutputTargets('已生成 开题报告.docx', {
|
||
workDir: '/w', changedFiles: ['/w/开题报告.docx'], includeUnconfirmedNames: true,
|
||
})
|
||
expect(written).toHaveLength(1)
|
||
expect(written[0]).toMatchObject({ title: '开题报告.docx', normalizedPath: '开题报告.docx' })
|
||
expect(written[0]!.awaitsConfirmation).toBeUndefined()
|
||
})
|
||
})
|
||
|
||
it('leaves the readings open for the disk when no changed file settles them', () => {
|
||
const [target] = extractAssistantOutputTargets('已生成报告v2.docx', { workDir: '/w', changedFiles: [] })
|
||
expect(target).toMatchObject({ normalizedPath: 'v2.docx' })
|
||
expect(target!.nameCandidates).toEqual(expect.arrayContaining(['已生成报告v2.docx', '报告v2.docx']))
|
||
})
|
||
})
|
||
|
||
it('keeps the whole CJK name when the prose does not quote it (#1423)', () => {
|
||
const targets = extractAssistantOutputTargets(
|
||
'已找到 测试文档1.docx 和 测试文档2.docx,另一份在 C:\\Users\\a\\Desktop\\资料\\测试文档3.docx',
|
||
{ workDir: 'C:\\Users\\a\\Desktop', changedFiles: [] },
|
||
)
|
||
|
||
expect(targets.map((target) => [target.title, target.normalizedPath])).toEqual([
|
||
['测试文档1.docx', '测试文档1.docx'],
|
||
['测试文档2.docx', '测试文档2.docx'],
|
||
['测试文档3.docx', 'C:/Users/a/Desktop/资料/测试文档3.docx'],
|
||
])
|
||
})
|
||
|
||
it('keeps a CJK directory', () => {
|
||
const targets = extractAssistantOutputTargets(
|
||
'见 `论文/开题报告终稿.docx`',
|
||
{ workDir: '/w', changedFiles: [] },
|
||
)
|
||
|
||
expect(targets.map((target) => target.normalizedPath)).toEqual(['论文/开题报告终稿.docx'])
|
||
})
|
||
|
||
it('does not turn a command into a file card', () => {
|
||
const targets = extractAssistantOutputTargets('运行 `open 开题报告.docx` 即可', {
|
||
workDir: '/w',
|
||
changedFiles: [],
|
||
})
|
||
|
||
expect(targets.map((target) => target.normalizedPath)).not.toContain('开题报告.docx')
|
||
})
|
||
|
||
it('still stops prose at the Chinese verb flush against an ASCII path', () => {
|
||
const targets = extractAssistantOutputTargets('生成了out/report.docx', { workDir: '/w', changedFiles: [] })
|
||
|
||
expect(targets.map((target) => target.normalizedPath)).toEqual(['out/report.docx'])
|
||
})
|
||
})
|
||
|
||
it('places a bare deliverable name in the directory the turn actually wrote into', () => {
|
||
// The real shape of a "generate three documents" turn: the prose gives the
|
||
// directory once and then lists basenames. Resolved against the work dir those
|
||
// point nowhere, and the card renders but cannot be opened.
|
||
const targets = extractAssistantOutputTargets(
|
||
'三个文档已完成,都在 /private/tmp/three_docs/:`sales_data.xlsx`、`sales_report.docx`',
|
||
{
|
||
workDir: '/private/tmp',
|
||
changedFiles: ['/private/tmp/three_docs/make_xlsx.py', '/private/tmp/three_docs/make_docx.js'],
|
||
},
|
||
)
|
||
|
||
expect(targets.map((target) => target.normalizedPath))
|
||
.toEqual(['three_docs/sales_data.xlsx', 'three_docs/sales_report.docx'])
|
||
})
|
||
|
||
it('leaves a bare name alone when the turn wrote into more than one directory', () => {
|
||
// Two candidate directories is no evidence at all, and picking one would give
|
||
// the same dead card less predictably.
|
||
const targets = extractAssistantOutputTargets(
|
||
'产出:`report.docx`',
|
||
{ workDir: '/w', changedFiles: ['/w/a/make.py', '/w/b/other.py'] },
|
||
)
|
||
|
||
expect(targets.map((target) => target.normalizedPath)).toEqual(['report.docx'])
|
||
})
|
||
|
||
it('does not re-anchor a deliverable that already carries a directory', () => {
|
||
const targets = extractAssistantOutputTargets(
|
||
'产出:`out/report.docx`',
|
||
{ workDir: '/w', changedFiles: ['/w/scripts/make.py'] },
|
||
)
|
||
|
||
expect(targets.map((target) => target.normalizedPath)).toEqual(['out/report.docx'])
|
||
})
|
||
|
||
it('still drops an unmatched markdown mention, which is as often a file being read', () => {
|
||
// Markdown is a deliverable *and* the format this product reads all day, so a
|
||
// mention of one carries no evidence that the turn produced it.
|
||
const targets = extractAssistantOutputTargets(
|
||
'我正准备查看 test123.md',
|
||
{ workDir: '/w', changedFiles: ['/w/src/first.ts'] },
|
||
)
|
||
|
||
expect(targets).toEqual([])
|
||
})
|
||
|
||
it('still drops an unmatched source file, so reconciliation keeps doing its job', () => {
|
||
const targets = extractAssistantOutputTargets(
|
||
'改了 plan.md,也看了 src/helper.ts',
|
||
{ workDir: '/w', changedFiles: ['/w/plan.md'] },
|
||
)
|
||
|
||
expect(targets.map((target) => target.normalizedPath)).toEqual(['plan.md'])
|
||
})
|
||
|
||
it('does not list a generated document twice when it is also a changed file', () => {
|
||
// The mention and the changed-file sweep must share one seen-set, or the
|
||
// document gets a card from each.
|
||
const targets = extractAssistantOutputTargets(
|
||
'报告已生成:report.docx',
|
||
{ workDir: '/w', changedFiles: ['/w/report.docx'] },
|
||
)
|
||
|
||
expect(targets.map((target) => target.normalizedPath)).toEqual(['report.docx'])
|
||
})
|
||
|
||
it('drops file mentions when changedFiles explicitly confirms no files changed', () => {
|
||
const targets = extractAssistantOutputTargets(
|
||
'我正准备查看 test123.md,服务地址是 http://localhost:5173/',
|
||
{
|
||
workDir: '/private/tmp',
|
||
changedFiles: [],
|
||
},
|
||
)
|
||
|
||
expect(targets).toHaveLength(1)
|
||
expect(targets).toMatchObject([
|
||
{ kind: 'localhost-url', href: 'http://localhost:5173/' },
|
||
])
|
||
})
|
||
|
||
it('falls back to text-only behavior when changedFiles is unavailable', () => {
|
||
const targets = extractAssistantOutputTargets('已创建 `index.html`', {
|
||
workDir: '/private/tmp',
|
||
})
|
||
|
||
expect(targets).toMatchObject([{ kind: 'local-html', normalizedPath: 'index.html' }])
|
||
})
|
||
|
||
it('does not correct when the basename is ambiguous across changed files', () => {
|
||
const targets = extractAssistantOutputTargets('见 index.html', {
|
||
workDir: '/work',
|
||
changedFiles: ['/work/a/index.html', '/work/b/index.html'],
|
||
})
|
||
|
||
// Ambiguous basename match → no unique target, mention dropped rather than guessed.
|
||
expect(targets).toHaveLength(0)
|
||
})
|
||
})
|
||
|
||
describe('explicit output path identity', () => {
|
||
const root = '/Users/nanmi/workspace/myself_code/cchaha-promo'
|
||
const content = `\`${root}/out/cc-haha-promo.mp4\`
|
||
顺带,项目根目录是 \`${root}/\`:
|
||
- \`out/cc-haha-promo.mp4\` — 成片
|
||
- \`public/audio/track.wav\` — 合成音轨
|
||
- \`src/lib/shots.ts\` — 分镜
|
||
- \`README.md\` — 说明`
|
||
|
||
it.each([undefined, [], ['/session/README.md']])('preserves screenshot deliverables with checkpoint %j', (changedFiles) => {
|
||
const targets = extractAssistantOutputTargets(content, { workDir: '/session', changedFiles })
|
||
expect(targets.map((target) => target.href)).toEqual([
|
||
`${root}/out/cc-haha-promo.mp4`, `${root}/public/audio/track.wav`, `${root}/README.md`,
|
||
...(changedFiles?.length ? ['README.md'] : []),
|
||
])
|
||
})
|
||
|
||
it('does not replace an explicit absolute file with another same-named checkpoint file', () => {
|
||
const targets = extractAssistantOutputTargets('报告 `/external/report.pdf`', {
|
||
workDir: '/session', changedFiles: ['/session/report.pdf'], includeChangedFileFallback: false,
|
||
})
|
||
expect(targets.map((target) => target.href)).toEqual(['/external/report.pdf'])
|
||
})
|
||
})
|
||
|
||
describe('canonical output deduplication', () => {
|
||
it.each([undefined, [], ['/work/report.pdf']])('deduplicates absolute and relative identity with checkpoint %j', (changedFiles) => {
|
||
const targets = extractAssistantOutputTargets('`/work/report.pdf` and `report.pdf`', { workDir: '/work', changedFiles })
|
||
expect(targets).toHaveLength(1)
|
||
expect(targets[0]?.href).toBe('/work/report.pdf')
|
||
})
|
||
})
|