mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-11-07 06:19:43 -10:00
fix(web-shell): preserve slash command attachments (#10730)
* fix(web-shell): preserve slash command attachments * fix(web-shell): align attachment command resolution * fix(web-shell): preserve attachments for slash-like text --------- Co-authored-by: 钉萁 <[email protected]>
This commit is contained in:
1 parent
95aac6f7e7
commit
ceb5b007ef
8 files changed
+462
-15
No files matched your search
@@ -22183,6 +22183,73 @@ describe('Session', () => {
|
||||
expect(mockChatRecordingService.recordUserMessage).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['skill', CommandKind.SKILL],
|
||||
['custom command', CommandKind.FILE],
|
||||
])(
|
||||
'keeps attachments when a %s expands into a model prompt',
|
||||
async (_, kind) => {
|
||||
vi.mocked(
|
||||
nonInteractiveCliCommands.handleSlashCommand,
|
||||
).mockResolvedValueOnce({
|
||||
type: 'submit_prompt',
|
||||
content: [{ text: 'Expanded skill prompt' }],
|
||||
resolvedCommand: {
|
||||
name: 'price-sheet',
|
||||
kind,
|
||||
},
|
||||
});
|
||||
|
||||
await session.prompt({
|
||||
sessionId: 'test-session-id',
|
||||
prompt: [
|
||||
{ type: 'text', text: '/price-sheet update these prices' },
|
||||
{ type: 'image', mimeType: 'image/png', data: 'QUJD' },
|
||||
{
|
||||
type: 'resource',
|
||||
resource: {
|
||||
uri: 'attachment:///notes.txt',
|
||||
mimeType: 'text/plain',
|
||||
text: 'hello',
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(firstSentMessage()).toEqual([
|
||||
{ text: '@attachment:///notes.txt' },
|
||||
{ inlineData: { mimeType: 'image/png', data: 'QUJD' } },
|
||||
{ text: 'File: attachment:///notes.txt\nhello' },
|
||||
{ text: 'Expanded skill prompt' },
|
||||
]);
|
||||
},
|
||||
);
|
||||
|
||||
it('does not forward attachments from built-in commands', async () => {
|
||||
vi.mocked(
|
||||
nonInteractiveCliCommands.handleSlashCommand,
|
||||
).mockResolvedValueOnce({
|
||||
type: 'submit_prompt',
|
||||
content: [{ text: 'Expanded built-in prompt' }],
|
||||
resolvedCommand: {
|
||||
name: 'remember',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
},
|
||||
});
|
||||
|
||||
await session.prompt({
|
||||
sessionId: 'test-session-id',
|
||||
prompt: [
|
||||
{ type: 'text', text: '/remember this' },
|
||||
{ type: 'image', mimeType: 'image/png', data: 'QUJD' },
|
||||
],
|
||||
});
|
||||
|
||||
expect(firstSentMessage()).toEqual([
|
||||
{ text: 'Expanded built-in prompt' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('preserves an expanded slash prompt cancelled before model send', async () => {
|
||||
const finishedSpy = vi
|
||||
.spyOn(core, 'logConversationFinishedEvent')
|
||||
|
||||
@@ -13510,14 +13510,24 @@ export class Session implements SessionContext {
|
||||
: undefined;
|
||||
|
||||
switch (result.type) {
|
||||
case 'submit_prompt':
|
||||
// Command wants to submit a prompt to the model
|
||||
// Convert PartListUnion to Part[]
|
||||
case 'submit_prompt': {
|
||||
const expandedPrompt = normalizePartList(result.content);
|
||||
const attachmentBlocks =
|
||||
result.resolvedCommand?.kind === CommandKind.BUILT_IN
|
||||
? []
|
||||
: originalPrompt.filter((block) => block.type !== 'text');
|
||||
const attachmentParts =
|
||||
attachmentBlocks.length === 0
|
||||
? []
|
||||
: await this.#resolvePrompt(attachmentBlocks, abortSignal, {
|
||||
deferBridgeConversions: true,
|
||||
});
|
||||
return this.#applyBridgeConversionsIfNeeded(
|
||||
normalizePartList(result.content),
|
||||
[...attachmentParts, ...expandedPrompt],
|
||||
abortSignal,
|
||||
onFullTurnModel,
|
||||
);
|
||||
}
|
||||
|
||||
case 'message': {
|
||||
if (result.messageType === 'error') {
|
||||
|
||||
@@ -2290,6 +2290,7 @@ describe('createDaemonSessionActions', () => {
|
||||
connection: {
|
||||
status: 'connected',
|
||||
workspaceCwd: '/workspace',
|
||||
commands: [commandInfo('help', 'builtin-command')],
|
||||
capabilities: {
|
||||
v: 1,
|
||||
mode: 'http-bridge',
|
||||
@@ -2316,6 +2317,319 @@ describe('createDaemonSessionActions', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('does not upload attachments for built-in command aliases', async () => {
|
||||
const session = createMockSession('session-a');
|
||||
const { actions } = createActionsHarness({
|
||||
session,
|
||||
connection: {
|
||||
status: 'connected',
|
||||
workspaceCwd: '/workspace',
|
||||
commands: [commandInfo('compress', 'builtin-command', ['summarize'])],
|
||||
capabilities: {
|
||||
v: 1,
|
||||
mode: 'http-bridge',
|
||||
features: ['session_attachments'],
|
||||
modelServices: [],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await actions.submitPrompt('/summarize', {
|
||||
images: [{ data: 'AQID', mimeType: 'image/png' }],
|
||||
});
|
||||
|
||||
expect(session.uploadAttachment).not.toHaveBeenCalled();
|
||||
expect(session.submitPrompt).toHaveBeenCalledWith({
|
||||
prompt: [{ type: 'text', text: '/summarize' }],
|
||||
});
|
||||
});
|
||||
|
||||
it.each(['/Compress', '/SUMMARIZE'])(
|
||||
'keeps attachments for unresolved wrong-case command %s',
|
||||
async (text) => {
|
||||
const session = createMockSession('session-a');
|
||||
const { actions } = createActionsHarness({
|
||||
session,
|
||||
connection: {
|
||||
status: 'connected',
|
||||
workspaceCwd: '/workspace',
|
||||
commands: [commandInfo('compress', 'builtin-command', ['summarize'])],
|
||||
capabilities: {
|
||||
v: 1,
|
||||
mode: 'http-bridge',
|
||||
features: ['session_attachments'],
|
||||
modelServices: [],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await actions.submitPrompt(text, {
|
||||
images: [{ data: 'AQID', mimeType: 'image/png' }],
|
||||
});
|
||||
|
||||
expect(session.uploadAttachment).toHaveBeenCalledOnce();
|
||||
},
|
||||
);
|
||||
|
||||
it('prefers a primary command name over another command alias', async () => {
|
||||
const session = createMockSession('session-a');
|
||||
const { actions } = createActionsHarness({
|
||||
session,
|
||||
connection: {
|
||||
status: 'connected',
|
||||
workspaceCwd: '/workspace',
|
||||
commands: [
|
||||
commandInfo('compress', 'builtin-command', ['summarize']),
|
||||
commandInfo('summarize', 'skill-dir-command'),
|
||||
],
|
||||
capabilities: {
|
||||
v: 1,
|
||||
mode: 'http-bridge',
|
||||
features: ['session_attachments'],
|
||||
modelServices: [],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await actions.submitPrompt('/summarize', {
|
||||
images: [{ data: 'AQID', mimeType: 'image/png' }],
|
||||
});
|
||||
|
||||
expect(session.uploadAttachment).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('recognizes built-in commands with whitespace after the slash', async () => {
|
||||
const session = createMockSession('session-a');
|
||||
const { actions } = createActionsHarness({
|
||||
session,
|
||||
connection: {
|
||||
status: 'connected',
|
||||
workspaceCwd: '/workspace',
|
||||
commands: [commandInfo('compress', 'builtin-command')],
|
||||
capabilities: {
|
||||
v: 1,
|
||||
mode: 'http-bridge',
|
||||
features: ['session_attachments'],
|
||||
modelServices: [],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await actions.submitPrompt('/ compress', {
|
||||
images: [{ data: 'AQID', mimeType: 'image/png' }],
|
||||
});
|
||||
|
||||
expect(session.uploadAttachment).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not upload an unknown slash command before command metadata loads', async () => {
|
||||
const session = createMockSession('session-a');
|
||||
const { actions } = createActionsHarness({
|
||||
session,
|
||||
connection: {
|
||||
status: 'connected',
|
||||
workspaceCwd: '/workspace',
|
||||
commands: [commandInfo('price-sheet', 'skill-dir-command')],
|
||||
capabilities: {
|
||||
v: 1,
|
||||
mode: 'http-bridge',
|
||||
features: ['session_attachments'],
|
||||
modelServices: [],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await actions.submitPrompt('/remember this API shape', {
|
||||
images: [{ data: 'AQID', mimeType: 'image/png' }],
|
||||
});
|
||||
|
||||
expect(session.uploadAttachment).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not upload a slash command before command metadata loads', async () => {
|
||||
const session = createMockSession('session-a');
|
||||
const { actions } = createActionsHarness({
|
||||
session,
|
||||
connection: {
|
||||
status: 'connected',
|
||||
workspaceCwd: '/workspace',
|
||||
capabilities: {
|
||||
v: 1,
|
||||
mode: 'http-bridge',
|
||||
features: ['session_attachments'],
|
||||
modelServices: [],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await actions.submitPrompt('/remember this API shape', {
|
||||
images: [{ data: 'AQID', mimeType: 'image/png' }],
|
||||
});
|
||||
|
||||
expect(session.uploadAttachment).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
'// stack trace from prod',
|
||||
'/* crash note */',
|
||||
'/var/log/app.log shows the crash',
|
||||
'/var\\log\\app.log shows the crash',
|
||||
])('uploads attachments for non-command slash input %s', async (text) => {
|
||||
const session = createMockSession('session-a');
|
||||
const { actions } = createActionsHarness({
|
||||
session,
|
||||
connection: {
|
||||
status: 'connected',
|
||||
workspaceCwd: '/workspace',
|
||||
capabilities: {
|
||||
v: 1,
|
||||
mode: 'http-bridge',
|
||||
features: ['session_attachments'],
|
||||
modelServices: [],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await actions.submitPrompt(text, {
|
||||
images: [{ data: 'AQID', mimeType: 'image/png' }],
|
||||
});
|
||||
|
||||
expect(session.uploadAttachment).toHaveBeenCalledOnce();
|
||||
expect(session.submitPrompt).toHaveBeenCalledWith({
|
||||
prompt: [
|
||||
{ type: 'text', text },
|
||||
{
|
||||
type: 'image',
|
||||
attachmentId: 'image.png',
|
||||
mimeType: 'image/png',
|
||||
size: 3,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('uploads attachments for unknown commands after metadata loads', async () => {
|
||||
const session = createMockSession('session-a');
|
||||
const { actions } = createActionsHarness({
|
||||
session,
|
||||
connection: {
|
||||
status: 'connected',
|
||||
workspaceCwd: '/workspace',
|
||||
commands: [commandInfo('compress', 'builtin-command')],
|
||||
capabilities: {
|
||||
v: 1,
|
||||
mode: 'http-bridge',
|
||||
features: ['session_attachments'],
|
||||
modelServices: [],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await actions.submitPrompt('/unlisted update', {
|
||||
images: [{ data: 'AQID', mimeType: 'image/png' }],
|
||||
});
|
||||
|
||||
expect(session.uploadAttachment).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('uploads attachments used by skill slash commands', async () => {
|
||||
const session = createMockSession('session-a');
|
||||
const { actions, store } = createActionsHarness({
|
||||
session,
|
||||
connection: {
|
||||
status: 'connected',
|
||||
workspaceCwd: '/workspace',
|
||||
commands: [commandInfo('price-sheet', 'skill-dir-command')],
|
||||
capabilities: {
|
||||
v: 1,
|
||||
mode: 'http-bridge',
|
||||
features: ['session_attachments'],
|
||||
modelServices: [],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await actions.submitPrompt('/price-sheet update these prices', {
|
||||
images: [{ data: 'AQID', mimeType: 'image/png' }],
|
||||
});
|
||||
|
||||
expect(session.uploadAttachment).toHaveBeenCalledWith(
|
||||
expect.any(Blob),
|
||||
'image.png',
|
||||
'image/png',
|
||||
undefined,
|
||||
);
|
||||
expect(session.submitPrompt).toHaveBeenCalledWith({
|
||||
prompt: [
|
||||
{ type: 'text', text: '/price-sheet update these prices' },
|
||||
{
|
||||
type: 'image',
|
||||
attachmentId: 'image.png',
|
||||
mimeType: 'image/png',
|
||||
size: 3,
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(store.appendLocalUserMessage).toHaveBeenCalledWith(
|
||||
'/price-sheet update these prices',
|
||||
[{ data: 'AQID', mimeType: 'image/png' }],
|
||||
undefined,
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['skill-dir-command', true],
|
||||
['builtin-command', false],
|
||||
] as const)(
|
||||
'classifies %s command attachments on the sendPrompt path',
|
||||
async (source, shouldUpload) => {
|
||||
const session = createMockSession('session-a');
|
||||
const { actions } = createActionsHarness({
|
||||
session,
|
||||
connection: {
|
||||
status: 'connected',
|
||||
workspaceCwd: '/workspace',
|
||||
commands: [commandInfo('price-sheet', source)],
|
||||
capabilities: {
|
||||
v: 1,
|
||||
mode: 'http-bridge',
|
||||
features: ['session_attachments'],
|
||||
modelServices: [],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const prompt = actions.sendPrompt('/price-sheet update', {
|
||||
images: [{ data: 'AQID', mimeType: 'image/png' }],
|
||||
});
|
||||
await vi.waitFor(() => expect(session.submitPrompt).toHaveBeenCalled());
|
||||
|
||||
expect(session.uploadAttachment).toHaveBeenCalledTimes(
|
||||
shouldUpload ? 1 : 0,
|
||||
);
|
||||
expect(session.submitPrompt).toHaveBeenCalledWith(
|
||||
{
|
||||
prompt: shouldUpload
|
||||
? [
|
||||
{ type: 'text', text: '/price-sheet update' },
|
||||
{
|
||||
type: 'image',
|
||||
attachmentId: 'image.png',
|
||||
mimeType: 'image/png',
|
||||
size: 3,
|
||||
},
|
||||
]
|
||||
: [{ type: 'text', text: '/price-sheet update' }],
|
||||
},
|
||||
expect.any(AbortSignal),
|
||||
);
|
||||
|
||||
await actions.cancel();
|
||||
await expect(prompt).resolves.toEqual({ stopReason: 'cancelled' });
|
||||
},
|
||||
);
|
||||
|
||||
it('uploads text attachments and submits attachment references', async () => {
|
||||
const session = createMockSession('session-a');
|
||||
const { actions, store } = createActionsHarness({
|
||||
@@ -3912,11 +4226,13 @@ function createDeferred<T>() {
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
function commandInfo(name: string) {
|
||||
function commandInfo(name: string, source?: string, altNames?: string[]) {
|
||||
const raw = commandRaw(name);
|
||||
return {
|
||||
name,
|
||||
description: '',
|
||||
...(source ? { source } : {}),
|
||||
...(altNames ? { altNames } : {}),
|
||||
raw,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -416,6 +416,28 @@ export function createDaemonSessionActions({
|
||||
);
|
||||
}
|
||||
|
||||
function discardsSlashCommandAttachments(text: string): boolean {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed.startsWith('/')) return false;
|
||||
if (trimmed.startsWith('//') || trimmed.startsWith('/*')) return false;
|
||||
const name = trimmed.slice(1).trim().split(/\s+/, 1)[0];
|
||||
if (!name) return false;
|
||||
if (name.includes('/') || name.includes('\\')) return false;
|
||||
// Keep this lexical gate and two-pass lookup aligned with the daemon's
|
||||
// isSlashCommand/parseSlashCommand behavior. A built-in entry marks a
|
||||
// fully loaded command snapshot; until then unresolved commands fail closed.
|
||||
const connection = getConnection();
|
||||
const command =
|
||||
connection.commands?.find((candidate) => candidate.name === name) ??
|
||||
connection.commands?.find((candidate) =>
|
||||
candidate.altNames?.includes(name),
|
||||
);
|
||||
if (command) return command.source === 'builtin-command';
|
||||
return !connection.commands?.some(
|
||||
(candidate) => candidate.source === 'builtin-command',
|
||||
);
|
||||
}
|
||||
|
||||
async function promptContentWithUploadedAttachments(
|
||||
session: DaemonSessionClient,
|
||||
text: string,
|
||||
@@ -432,7 +454,7 @@ export function createDaemonSessionActions({
|
||||
references: DaemonSessionAttachmentReference[];
|
||||
fileReferences: DaemonSessionAttachmentReference[];
|
||||
}> {
|
||||
if (text.trimStart().startsWith('/')) {
|
||||
if (discardsSlashCommandAttachments(text)) {
|
||||
return {
|
||||
content: toDaemonPromptContent(text),
|
||||
references: [],
|
||||
@@ -857,9 +879,9 @@ export function createDaemonSessionActions({
|
||||
img.mimeType || img.mediaType || img.media_type || 'image/*',
|
||||
}));
|
||||
const normalizedFiles = normalizePromptFiles(options?.files);
|
||||
const slashCommand = text.trimStart().startsWith('/');
|
||||
const displayedImages = slashCommand ? [] : normalizedImages;
|
||||
const displayedFiles = slashCommand ? [] : normalizedFiles;
|
||||
const discardAttachments = discardsSlashCommandAttachments(text);
|
||||
const displayedImages = discardAttachments ? [] : normalizedImages;
|
||||
const displayedFiles = discardAttachments ? [] : normalizedFiles;
|
||||
const inputAnnotations =
|
||||
options?.inputAnnotations && options.inputAnnotations.length > 0
|
||||
? options.inputAnnotations
|
||||
@@ -1019,9 +1041,9 @@ export function createDaemonSessionActions({
|
||||
mimeType: img.mimeType || img.mediaType || img.media_type || 'image/*',
|
||||
}));
|
||||
const normalizedFiles = normalizePromptFiles(options?.files);
|
||||
const slashCommand = text.trimStart().startsWith('/');
|
||||
const displayedImages = slashCommand ? [] : normalizedImages;
|
||||
const displayedFiles = slashCommand ? [] : normalizedFiles;
|
||||
const discardAttachments = discardsSlashCommandAttachments(text);
|
||||
const displayedImages = discardAttachments ? [] : normalizedImages;
|
||||
const displayedFiles = discardAttachments ? [] : normalizedFiles;
|
||||
const inputAnnotations =
|
||||
options?.inputAnnotations && options.inputAnnotations.length > 0
|
||||
? options.inputAnnotations
|
||||
|
||||
@@ -1176,6 +1176,31 @@ describe('updateConnectionFromDaemonEvent', () => {
|
||||
expect(next.skills).toEqual(['review']);
|
||||
});
|
||||
|
||||
it('maps command aliases from available_commands_update metadata', () => {
|
||||
const next = applyEvent(
|
||||
{ status: 'connected', workspaceCwd: '/workspace' },
|
||||
availableCommandsEvent(
|
||||
[
|
||||
{
|
||||
name: 'compress',
|
||||
description: 'Compress context',
|
||||
input: null,
|
||||
_meta: { source: 'builtin-command', altNames: ['summarize'] },
|
||||
},
|
||||
],
|
||||
[],
|
||||
),
|
||||
);
|
||||
|
||||
expect(next.commands).toEqual([
|
||||
expect.objectContaining({
|
||||
name: 'compress',
|
||||
source: 'builtin-command',
|
||||
altNames: ['summarize'],
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('reads nested availableSkills from the daemon wire shape', () => {
|
||||
const next = applyEvent(
|
||||
{ status: 'connected', workspaceCwd: '/workspace' },
|
||||
|
||||
@@ -849,11 +849,17 @@ function mapAvailableCommandsUpdate(
|
||||
|
||||
function mapCommandMeta(
|
||||
meta: Record<string, unknown> | null | undefined,
|
||||
): Pick<DaemonCommandInfo, 'source'> {
|
||||
): Pick<DaemonCommandInfo, 'source' | 'altNames'> {
|
||||
const record = meta ?? undefined;
|
||||
const source = getString(record, 'source');
|
||||
const altNames = Array.isArray(record?.['altNames'])
|
||||
? record['altNames'].filter(
|
||||
(name): name is string => typeof name === 'string',
|
||||
)
|
||||
: [];
|
||||
return {
|
||||
...(source ? { source } : {}),
|
||||
...(altNames.length > 0 ? { altNames } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -44,8 +44,8 @@ export function toDaemonPromptContent(
|
||||
files: readonly DaemonPromptFile[] = [],
|
||||
): PromptContentBlock[] {
|
||||
// Token lines keep a visible trace in the daemon-recorded transcript
|
||||
// (which stores text blocks only). Skipped for slash commands: the
|
||||
// daemon's slash path drops attachment blocks, so a token would dangle.
|
||||
// (which stores text blocks only). Slash commands resolve attachment blocks
|
||||
// separately from their expanded text, so they do not need token lines.
|
||||
const withTokens = withAttachmentTokens(
|
||||
text,
|
||||
files.map((file) => attachmentUriForName(file.name)),
|
||||
|
||||
@@ -322,6 +322,7 @@ export interface DaemonCommandInfo {
|
||||
argumentHint?: string;
|
||||
autoSubmit?: boolean;
|
||||
source?: string;
|
||||
altNames?: string[];
|
||||
raw: DaemonAvailableCommand;
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user