修复:润色回写与粘贴共用同一条落点兜底,认不出的引用落文本而不是消失

ResourceReferenceInput 新增 mentionTokenOrText:provider 的 mentionToken,答不出来(契约被破坏)时退 contentPartText 的通用文本形态;粘贴插入、润色回写的候选扫描、整根替换的落点共用这一条
润色回写的候选不再跳过「答不出 token 的 part」:这类 part 在回包里没被提到时作为末尾孤儿补回来,而不是在翻译阶段直接消失
applyContentToRoot(初始草稿与润色回写共用)解析不出引用时落文本,不再是整条吃掉
新增两处输入区用例:provider 只答得出引用答不出 token 时润色回写仍保住引用;初始草稿里已解析不出的引用落成 @resourceId 文本。两处去掉兜底即变红
同步 AGC 聊天素材引用功能说明、引用粘贴解析实施计划与决策记录(落点兜底一条,含初始草稿行为变化的记录)
This commit is contained in:
2026-09-23 13:11:17 +08:00
parent f138f648e7
commit 28a6fd97f8
5 changed files with 87 additions and 5 deletions
@@ -237,6 +237,20 @@ function mentionTokenFromPart(
return null;
}
/**
* part 落进正文时用的文本:provider 的 `mentionToken`,它答不出来(provider 契约被破坏)时退到
* `contentPartText` 的通用文本形态。文本 part 自己就是文本,返回 `null`。
*
* 粘贴插入、润色回写的候选扫描与落点都走这一条:只要 part 不是文本,就一定有一段正常文本可落,
* 「认不出的引用走文本、绝不静默丢」在几个入口是同一份实现,不是各写一遍兜底。
*/
function mentionTokenOrText(
providers: readonly ReferenceProvider[],
part: DirectCodexUserContentPart,
): string | null {
return mentionTokenFromPart(providers, part) ?? contentPartText(part);
}
/**
* 草稿的展示文本:把每个引用 part 按注入的 provider 展开成它的 token,其余文本逐字保留。
*
@@ -267,8 +281,10 @@ function applyPolishedTextToRoot(
) {
// 候选按 canonical content 原顺序取:引用、Skill、runtime 区域与附件共用一套 token 扫描,
// 与出站给润色服务的文本口径一致,因此回包保留下来的 token 能原位换回真 part。
// provider 答不出 token 的 part 也照样进候选(token 退到 `mentionTokenOrText` 的通用文本形态):
// 它在回包里没被提到时会作为末尾孤儿补回来,而不是从这门翻译里直接消失。
const candidates = current.content.flatMap((part) => {
const token = mentionTokenFromPart(providers, part);
const token = mentionTokenOrText(providers, part);
return token ? [{ token, part }] : [];
});
applyContentToRoot(buildContentFromTextTokens(value, candidates), providers);
@@ -297,7 +313,11 @@ function applyContentToRoot(
const reference = referenceFromPart(providers, part);
if (reference) {
paragraph.append($createResourceReferenceNode(reference));
return;
}
// 解析不出引用的 part 落它的文本(与粘贴同一条兜底链),整根替换同样不许把内容吃掉。
const token = mentionTokenOrText(providers, part);
if (token) paragraph.append($createTextNode(token));
});
}
@@ -361,9 +381,7 @@ function $insertContentAtSelection(
return;
}
// 解析不出的 part 已经在上游被摘掉了 token,这里必须把文本补回去,否则这段内容会静默消失。
// provider 连 token 都答不出来(契约被破坏)时落它的通用文本形态,绝不留空。
const token =
mentionTokenFromPart(providers, part) ?? contentPartText(part);
const token = mentionTokenOrText(providers, part);
if (token) {
$selectionOrRootEnd()?.insertText(token);
inserted = true;
@@ -864,6 +864,68 @@ describe('ResourceReferenceInput', () => {
]);
});
test('润色回写时 provider 答不出 token 的 part 仍留在草稿里,不在这门翻译里消失', async () => {
const onChange = vi.fn<(draft: ChatComposerDraft) => void>();
const reference = resourceReferenceFromAsset(assets[0]!, 'asset-picker');
// provider 契约被破坏:解析得出引用,但答不出 token。它仍然必须活过这一轮润色回写。
const tokenlessProvider: ReferenceProvider = {
trigger: '@',
fuzzy_lookup: () => [],
lookup: () => [reference],
toReference: (part) =>
part.type === 'agc_resource_reference' ? reference : null,
refresh: (part) => part,
mentionToken: () => null,
};
const composerRef = createRef<ResourceReferenceInputHandle>();
render(
<>
<button
type="button"
onClick={() => composerRef.current?.replaceText('润色后的需求')}
>
模拟润色
</button>
<ResourceReferenceInput
ref={composerRef}
providers={[tokenlessProvider]}
initialContent={[chatReferenceToContentPart(reference)]}
projectPath="C:/project"
ariaLabel="聊天"
onChange={onChange}
/>
</>,
);
await settleComposer();
fireEvent.click(screen.getByRole('button', { name: '模拟润色' }));
await settleComposer();
expect(draftResourceIds(composerRef.current?.getDraft())).toEqual(['hero']);
expect(draftText(composerRef.current?.getDraft())).toBe(
'润色后的需求 @hero ',
);
});
test('初始草稿里解析不出的引用落文本形态,不从草稿里消失', async () => {
const composerRef = createRef<ResourceReferenceInputHandle>();
render(
<ComposerHost
ref={composerRef}
initialContent={[
{ type: 'agc_resource_reference', resourceId: 'missing-asset' },
]}
assets={assets}
projectPath="C:/project"
ariaLabel="聊天"
onChange={vi.fn()}
/>,
);
await settleComposer();
expect(composerRef.current?.getDraft().content).toEqual([
{ type: 'input_text', text: '@missing-asset' },
]);
});
test('引用浮层打开时 Enter 不提交表单,关掉后恢复提交', async () => {
const onSubmit = vi.fn();
const user = userEvent.setup();