feat(review): add model attribution to PR review summary

Add {{model}} template variable support in BundledSkillLoader. When a
skill body contains {{model}}, it is replaced with the runtime model ID
from config.getModel(). Only skills that use the variable are affected.

The /review skill now appends a model attribution footer to PR review
summaries: "Reviewed by {model} via Qwen Code /review"

This enables cross-model review workflows (e.g., develop with model A,
review with model B) with accurate attribution in PR comments.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
wenshao 2026-04-06 23:21:44 +08:00
parent fe3d596d72
commit fab4dc5949
3 changed files with 72 additions and 6 deletions

View file

@ -127,6 +127,65 @@ describe('BundledSkillLoader', () => {
expect(commands.map((c) => c.name)).toEqual(['review', 'deploy']);
});
it('should resolve {{model}} template variable in skill body', async () => {
const skill = makeSkill({
body: 'Review by {{model}} via Qwen Code',
});
mockSkillManager.listSkills.mockResolvedValue([skill]);
(mockConfig as Record<string, unknown>).getModel = vi
.fn()
.mockReturnValue('qwen3-coder');
const loader = new BundledSkillLoader(mockConfig);
const commands = await loader.loadCommands(signal);
const result = await commands[0].action!(
{ invocation: { raw: '/review', args: '' } } as never,
'',
);
expect(result).toEqual({
type: 'submit_prompt',
content: [{ text: 'Review by qwen3-coder via Qwen Code' }],
});
});
it('should use "unknown" when model is not available for {{model}}', async () => {
const skill = makeSkill({
body: 'Review by {{model}}',
});
mockSkillManager.listSkills.mockResolvedValue([skill]);
// No getModel on config
const loader = new BundledSkillLoader(mockConfig);
const commands = await loader.loadCommands(signal);
const result = await commands[0].action!(
{ invocation: { raw: '/review', args: '' } } as never,
'',
);
expect(result).toEqual({
type: 'submit_prompt',
content: [{ text: 'Review by unknown' }],
});
});
it('should not modify skill body without {{model}} template', async () => {
const skill = makeSkill({ body: 'No template here' });
mockSkillManager.listSkills.mockResolvedValue([skill]);
const loader = new BundledSkillLoader(mockConfig);
const commands = await loader.loadCommands(signal);
const result = await commands[0].action!(
{ invocation: { raw: '/review', args: '' } } as never,
'',
);
expect(result).toEqual({
type: 'submit_prompt',
content: [{ text: 'No template here' }],
});
});
it('should hide skills with cron allowedTools when cron is disabled', async () => {
const skills = [
makeSkill({ name: 'review', description: 'Review code' }),

View file

@ -59,12 +59,19 @@ export class BundledSkillLoader implements ICommandLoader {
description: skill.description,
kind: CommandKind.SKILL,
action: async (context, _args): Promise<SlashCommandActionReturn> => {
// Resolve template variables in skill body (e.g., {{model}})
let body = skill.body;
if (body.includes('{{model}}')) {
const modelId =
(typeof this.config?.getModel === 'function'
? this.config.getModel()
: undefined) ?? 'unknown';
body = body.replaceAll('{{model}}', modelId);
}
const content = context.invocation?.args
? appendToLastTextPart(
[{ text: skill.body }],
context.invocation.raw,
)
: [{ text: skill.body }];
? appendToLastTextPart([{ text: body }], context.invocation.raw)
: [{ text: body }];
return {
type: 'submit_prompt',