Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
278 changes: 278 additions & 0 deletions packages/cli/src/acp-integration/session/Session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import { MessageType } from '../../ui/types.js';

const debugLoggerWarnSpy = vi.hoisted(() => vi.fn());
const debugLoggerDebugSpy = vi.hoisted(() => vi.fn());
const runVisionBridgeSpy = vi.hoisted(() => vi.fn());
// Records every LoopTickResolver construction's deps so a test can assert what
// Session computed (e.g. the home confinement root) without a private-field peek.
const loopTickResolverDepsSpy = vi.hoisted(() => vi.fn());
Expand All @@ -58,6 +59,7 @@ vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
}),
generatePromptSuggestion: vi.fn(),
logPromptSuggestion: vi.fn(),
runVisionBridge: runVisionBridgeSpy,
// Transparent recording wrapper: records the constructor deps, then behaves
// exactly like the real resolver (subclass → instanceof + methods preserved).
LoopTickResolver: class extends actual.LoopTickResolver {
Expand Down Expand Up @@ -344,6 +346,7 @@ describe('Session', () => {
}

beforeEach(() => {
runVisionBridgeSpy.mockReset();
currentModel = 'qwen3-code-plus';
currentAuthType = AuthType.USE_OPENAI;
switchModelSpy = vi
Expand Down Expand Up @@ -2212,6 +2215,281 @@ describe('Session', () => {
}
});

it('routes ACP image prompts through the vision bridge for text-only primary models', async () => {
mockConfig.getEffectiveInputModalities = vi.fn().mockReturnValue({});
Comment thread
yiliang114 marked this conversation as resolved.
mockConfig.getDefaultVisionBridgeModel = vi.fn().mockReturnValue({
id: 'qwen3.7-plus',
});
runVisionBridgeSpy.mockResolvedValue({
applied: true,
status: 'ok',
parts: [{ text: 'look at this' }, { text: '[transcribed image]' }],
transcript: '[transcribed image]',
convertedCount: 1,
omittedCount: 0,
modelId: 'qwen3.7-plus',
});
mockChat.sendMessageStream = vi
.fn()
.mockResolvedValue(createEmptyStream());

await session.prompt({
sessionId: 'test-session-id',
prompt: [
{ type: 'text', text: 'look at this' },
{
type: 'image',
mimeType: 'image/png',
data: 'iVBORw0KGgo=',
},
],
});

expect(runVisionBridgeSpy).toHaveBeenCalledWith(
expect.objectContaining({
config: mockConfig,
signal: expect.any(AbortSignal),
}),
);
const sent = firstSentMessage();
expect(textParts(sent)).toContain('[transcribed image]');
expect(sent.some((part) => 'inlineData' in part)).toBe(false);
});

it('strips image parts when the vision bridge is cancelled before applying', async () => {
mockConfig.getEffectiveInputModalities = vi.fn().mockReturnValue({});
mockConfig.getDefaultVisionBridgeModel = vi.fn().mockReturnValue({
id: 'qwen3.7-plus',
});
runVisionBridgeSpy.mockResolvedValue({
applied: false,
status: 'skipped',
convertedCount: 0,
omittedCount: 0,
modelId: 'qwen3.7-plus',
egressOccurred: true,
});
mockChat.sendMessageStream = vi
.fn()
.mockResolvedValue(createEmptyStream());

await session.prompt({
sessionId: 'test-session-id',
prompt: [
{ type: 'text', text: 'look at this' },
{
type: 'image',
mimeType: 'image/png',
data: 'iVBORw0KGgo=',
},
],
});

const sent = firstSentMessage();
expect(sent.some((part) => 'inlineData' in part)).toBe(false);
expect(
textParts(sent).some((t: string) => t.includes('look at this')),
).toBe(true);
});

it('preserves oversized inline images for the vision bridge', async () => {
const ENV_KEY = 'QWEN_CODE_MAX_INLINE_MEDIA_BYTES';
const original = process.env[ENV_KEY];
process.env[ENV_KEY] = '8';
try {
mockConfig.getEffectiveInputModalities = vi.fn().mockReturnValue({});
mockConfig.getDefaultVisionBridgeModel = vi.fn().mockReturnValue({
id: 'qwen3.7-plus',
});
runVisionBridgeSpy.mockResolvedValue({
applied: true,
status: 'ok',
parts: [{ text: 'look at this' }, { text: '[large image]' }],
transcript: '[large image]',
convertedCount: 1,
omittedCount: 0,
modelId: 'qwen3.7-plus',
});
mockChat.sendMessageStream = vi
.fn()
.mockResolvedValue(createEmptyStream());

await session.prompt({
sessionId: 'test-session-id',
prompt: [
{ type: 'text', text: 'look at this' },
{
type: 'image',
mimeType: 'image/png',
data: 'QUJDREVGR0hJSktMTU5PUFFSU1Q=',
},
],
});

const bridgeParts = runVisionBridgeSpy.mock.calls[0]?.[0]
?.parts as Part[];
expect(bridgeParts.some((part) => 'inlineData' in part)).toBe(true);
expect(textParts(firstSentMessage())).toContain('[large image]');
} finally {
if (original === undefined) delete process.env[ENV_KEY];
else process.env[ENV_KEY] = original;
}
});

it('falls back to text-only parts when the vision bridge throws', async () => {
mockConfig.getEffectiveInputModalities = vi.fn().mockReturnValue({});
mockConfig.getDefaultVisionBridgeModel = vi.fn().mockReturnValue({
id: 'qwen3.7-plus',
});
runVisionBridgeSpy.mockRejectedValue(new Error('provider unavailable'));
mockChat.sendMessageStream = vi
.fn()
.mockResolvedValue(createEmptyStream());

await session.prompt({
sessionId: 'test-session-id',
prompt: [
{ type: 'text', text: 'look at this' },
{
type: 'image',
mimeType: 'image/png',
data: 'iVBORw0KGgo=',
},
],
});

const sent = firstSentMessage();
expect(sent.some((part) => 'inlineData' in part)).toBe(false);
expect(
textParts(sent).some((t: string) => t.includes('look at this')),
).toBe(true);
expect(debugLoggerDebugSpy).toHaveBeenCalledWith(
expect.stringContaining('provider unavailable'),
);
});

it('forwards failed bridge replacement parts to the primary model', async () => {
mockConfig.getEffectiveInputModalities = vi.fn().mockReturnValue({});
mockConfig.getDefaultVisionBridgeModel = vi.fn().mockReturnValue({
id: 'qwen3.7-plus',
});
runVisionBridgeSpy.mockResolvedValue({
applied: true,
status: 'failed',
parts: [{ text: 'look at this' }, { text: '[bridge failed]' }],
convertedCount: 0,
omittedCount: 1,
modelId: 'qwen3.7-plus',
egressOccurred: true,
error: 'quota exceeded',
});
mockChat.sendMessageStream = vi
.fn()
.mockResolvedValue(createEmptyStream());

await session.prompt({
sessionId: 'test-session-id',
prompt: [
{ type: 'text', text: 'look at this' },
{
type: 'image',
mimeType: 'image/png',
data: 'iVBORw0KGgo=',
},
],
});

const sent = firstSentMessage();
expect(textParts(sent)).toContain('[bridge failed]');
expect(sent.some((part) => 'inlineData' in part)).toBe(false);
expect(debugLoggerDebugSpy).toHaveBeenCalledWith(
expect.stringContaining('error=quota exceeded'),
);
});

it('does not run the vision bridge when the primary model supports images', async () => {
mockConfig.getEffectiveInputModalities = vi
.fn()
.mockReturnValue({ image: true });
mockConfig.getDefaultVisionBridgeModel = vi.fn().mockReturnValue({
id: 'qwen3.7-plus',
});
mockChat.sendMessageStream = vi
.fn()
.mockResolvedValue(createEmptyStream());

await session.prompt({
sessionId: 'test-session-id',
prompt: [
{ type: 'text', text: 'look at this' },
{
type: 'image',
mimeType: 'image/png',
data: 'iVBORw0KGgo=',
},
],
});

expect(runVisionBridgeSpy).not.toHaveBeenCalled();
expect(firstSentMessage().some((part) => 'inlineData' in part)).toBe(
true,
);
});

it('preserves unsupported image @ files for the vision bridge', async () => {
mockConfig.getEffectiveInputModalities = vi.fn().mockReturnValue({});
mockConfig.getDefaultVisionBridgeModel = vi.fn().mockReturnValue({
id: 'qwen3.7-plus',
});
const readManyFilesSpy = vi
.spyOn(core, 'readManyFiles')
.mockResolvedValue({
contentParts: {
inlineData: { mimeType: 'image/png', data: 'iVBORw0KGgo=' },
},
} as Awaited<ReturnType<typeof core.readManyFiles>>);
runVisionBridgeSpy.mockResolvedValue({
applied: true,
status: 'ok',
parts: [{ text: 'look at this' }, { text: '[file image]' }],
transcript: '[file image]',
convertedCount: 1,
omittedCount: 0,
modelId: 'qwen3.7-plus',
});
mockChat.sendMessageStream = vi
.fn()
.mockResolvedValue(createEmptyStream());

try {
await session.prompt({
sessionId: 'test-session-id',
prompt: [
{ type: 'text', text: 'look at this' },
{
type: 'resource_link',
uri: 'file:///tmp/image.png',
mimeType: 'image/png',
name: 'image.png',
},
],
});

expect(readManyFilesSpy).toHaveBeenCalledWith(
mockConfig,
expect.objectContaining({
preserveUnsupportedImageForBridge: true,
}),
);
const bridgeParts = runVisionBridgeSpy.mock.calls[0]?.[0]
?.parts as Part[];
expect(bridgeParts.some((part) => 'inlineData' in part)).toBe(true);
expect(textParts(firstSentMessage())).toContain('[file image]');
} finally {
readManyFilesSpy.mockRestore();
}
});

describe('conversation_finished telemetry (#4602 review)', () => {
it('emits conversation_finished once when a turn completes normally', async () => {
const finishedSpy = vi
Expand Down
Loading
Loading