mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-01-25 10:16:22 +00:00
Compare commits
1 Commits
feat/vscod
...
mingholy/f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f578ff07a2 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -12,7 +12,7 @@
|
||||
!.gemini/config.yaml
|
||||
!.gemini/commands/
|
||||
|
||||
# Note: .qwen-code-clipboard/ is NOT in gitignore so Qwen Code can access pasted images
|
||||
# Note: .gemini-clipboard/ is NOT in gitignore so Gemini can access pasted images
|
||||
|
||||
# Dependency directory
|
||||
node_modules
|
||||
|
||||
@@ -11,8 +11,10 @@ import {
|
||||
AbortError,
|
||||
isAbortError,
|
||||
isSDKAssistantMessage,
|
||||
isSDKResultMessage,
|
||||
type TextBlock,
|
||||
type ContentBlock,
|
||||
type SDKUserMessage,
|
||||
} from '@qwen-code/sdk';
|
||||
import { SDKTestHelper, createSharedTestOptions } from './test-helper.js';
|
||||
|
||||
@@ -250,6 +252,161 @@ describe('AbortController and Process Lifecycle (E2E)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Closed stdin behavior (asyncGenerator prompt)', () => {
|
||||
it('should reject control requests after stdin closes', async () => {
|
||||
async function* createPrompt(): AsyncIterable<SDKUserMessage> {
|
||||
yield {
|
||||
type: 'user',
|
||||
session_id: crypto.randomUUID(),
|
||||
message: {
|
||||
role: 'user',
|
||||
content: 'Say "OK".',
|
||||
},
|
||||
parent_tool_use_id: null,
|
||||
};
|
||||
}
|
||||
|
||||
const q = query({
|
||||
prompt: createPrompt(),
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
cwd: testDir,
|
||||
debug: false,
|
||||
},
|
||||
});
|
||||
|
||||
let firstResultReceived = false;
|
||||
|
||||
try {
|
||||
for await (const message of q) {
|
||||
if (isSDKResultMessage(message)) {
|
||||
firstResultReceived = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
expect(firstResultReceived).toBe(true);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
await expect(q.setPermissionMode('default')).rejects.toThrow(
|
||||
'Input stream closed',
|
||||
);
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle control responses when stdin closes before replies', async () => {
|
||||
await helper.createFile('test.txt', 'original content');
|
||||
|
||||
let canUseToolCalledResolve: () => void = () => {};
|
||||
const canUseToolCalledPromise = new Promise<void>((resolve, reject) => {
|
||||
canUseToolCalledResolve = resolve;
|
||||
setTimeout(() => {
|
||||
reject(new Error('canUseTool callback not called'));
|
||||
}, 15000);
|
||||
});
|
||||
|
||||
let inputStreamDoneResolve: () => void = () => {};
|
||||
const inputStreamDonePromise = new Promise<void>((resolve, reject) => {
|
||||
inputStreamDoneResolve = resolve;
|
||||
setTimeout(() => {
|
||||
reject(new Error('inputStreamDonePromise timeout'));
|
||||
}, 15000);
|
||||
});
|
||||
|
||||
let firstResultResolve: () => void = () => {};
|
||||
const firstResultPromise = new Promise<void>((resolve) => {
|
||||
firstResultResolve = resolve;
|
||||
});
|
||||
|
||||
let secondResultResolve: () => void = () => {};
|
||||
const secondResultPromise = new Promise<void>((resolve, reject) => {
|
||||
secondResultResolve = resolve;
|
||||
});
|
||||
|
||||
async function* createPrompt(): AsyncIterable<SDKUserMessage> {
|
||||
const sessionId = crypto.randomUUID();
|
||||
|
||||
yield {
|
||||
type: 'user',
|
||||
session_id: sessionId,
|
||||
message: {
|
||||
role: 'user',
|
||||
content: 'Say "OK".',
|
||||
},
|
||||
parent_tool_use_id: null,
|
||||
};
|
||||
|
||||
await firstResultPromise;
|
||||
|
||||
yield {
|
||||
type: 'user',
|
||||
session_id: sessionId,
|
||||
message: {
|
||||
role: 'user',
|
||||
content: 'Write "updated" to test.txt.',
|
||||
},
|
||||
parent_tool_use_id: null,
|
||||
};
|
||||
await inputStreamDonePromise;
|
||||
}
|
||||
|
||||
const q = query({
|
||||
prompt: createPrompt(),
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
cwd: testDir,
|
||||
permissionMode: 'default',
|
||||
coreTools: ['read_file', 'write_file'],
|
||||
canUseTool: async (toolName, input) => {
|
||||
inputStreamDoneResolve();
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
canUseToolCalledResolve();
|
||||
|
||||
return {
|
||||
behavior: 'allow',
|
||||
updatedInput: input,
|
||||
};
|
||||
},
|
||||
debug: false,
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const loop = async () => {
|
||||
let resultCount = 0;
|
||||
for await (const _message of q) {
|
||||
console.log(JSON.stringify(_message, null, 2));
|
||||
// Consume messages until completion.
|
||||
if (isSDKResultMessage(_message)) {
|
||||
resultCount += 1;
|
||||
if (resultCount === 1) {
|
||||
firstResultResolve();
|
||||
}
|
||||
if (resultCount === 2) {
|
||||
secondResultResolve();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
loop();
|
||||
|
||||
await firstResultPromise;
|
||||
await canUseToolCalledPromise;
|
||||
await secondResultPromise;
|
||||
|
||||
const content = await helper.readFile('test.txt');
|
||||
expect(content).toBe('original content');
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error Handling and Recovery', () => {
|
||||
it('should handle invalid executable path', async () => {
|
||||
try {
|
||||
|
||||
@@ -12,7 +12,12 @@
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { query, isSDKAssistantMessage, type SDKMessage } from '@qwen-code/sdk';
|
||||
import {
|
||||
query,
|
||||
isSDKAssistantMessage,
|
||||
type SDKMessage,
|
||||
type SDKUserMessage,
|
||||
} from '@qwen-code/sdk';
|
||||
import {
|
||||
SDKTestHelper,
|
||||
extractText,
|
||||
@@ -739,4 +744,229 @@ describe('Tool Control Parameters (E2E)', () => {
|
||||
TEST_TIMEOUT,
|
||||
);
|
||||
});
|
||||
|
||||
describe('canUseTool with asyncGenerator prompt', () => {
|
||||
it(
|
||||
'should invoke canUseTool callback when using asyncGenerator as prompt',
|
||||
async () => {
|
||||
await helper.createFile('test.txt', 'original content');
|
||||
|
||||
const canUseToolCalls: Array<{
|
||||
toolName: string;
|
||||
input: Record<string, unknown>;
|
||||
}> = [];
|
||||
|
||||
// Create an async generator that yields a single message
|
||||
async function* createPrompt(): AsyncIterable<SDKUserMessage> {
|
||||
yield {
|
||||
type: 'user',
|
||||
session_id: crypto.randomUUID(),
|
||||
message: {
|
||||
role: 'user',
|
||||
content: 'Read test.txt and then write "updated" to it.',
|
||||
},
|
||||
parent_tool_use_id: null,
|
||||
};
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 3000));
|
||||
}
|
||||
|
||||
const q = query({
|
||||
prompt: createPrompt(),
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
cwd: testDir,
|
||||
permissionMode: 'default',
|
||||
coreTools: ['read_file', 'write_file'],
|
||||
allowedTools: [],
|
||||
canUseTool: async (toolName, input) => {
|
||||
canUseToolCalls.push({ toolName, input });
|
||||
return {
|
||||
behavior: 'allow',
|
||||
updatedInput: input,
|
||||
};
|
||||
},
|
||||
debug: false,
|
||||
},
|
||||
});
|
||||
|
||||
const messages: SDKMessage[] = [];
|
||||
|
||||
try {
|
||||
for await (const message of q) {
|
||||
messages.push(message);
|
||||
}
|
||||
|
||||
const toolCalls = findToolCalls(messages);
|
||||
const toolNames = toolCalls.map((tc) => tc.toolUse.name);
|
||||
|
||||
// Both tools should have been executed
|
||||
expect(toolNames).toContain('read_file');
|
||||
expect(toolNames).toContain('write_file');
|
||||
|
||||
const toolsCalledInCallback = canUseToolCalls.map(
|
||||
(call) => call.toolName,
|
||||
);
|
||||
expect(toolsCalledInCallback).toContain('write_file');
|
||||
|
||||
const writeFileResults = findToolResults(messages, 'write_file');
|
||||
expect(writeFileResults.length).toBeGreaterThan(0);
|
||||
|
||||
// Verify file was modified
|
||||
const content = await helper.readFile('test.txt');
|
||||
expect(content).toBe('updated');
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
},
|
||||
TEST_TIMEOUT,
|
||||
);
|
||||
|
||||
it(
|
||||
'should deny tool when canUseTool returns deny with asyncGenerator prompt',
|
||||
async () => {
|
||||
await helper.createFile('test.txt', 'original content');
|
||||
|
||||
// Create an async generator that yields a single message
|
||||
async function* createPrompt(): AsyncIterable<SDKUserMessage> {
|
||||
yield {
|
||||
type: 'user',
|
||||
session_id: crypto.randomUUID(),
|
||||
message: {
|
||||
role: 'user',
|
||||
content: 'Write "modified" to test.txt.',
|
||||
},
|
||||
parent_tool_use_id: null,
|
||||
};
|
||||
await new Promise((resolve) => setTimeout(resolve, 3000));
|
||||
}
|
||||
|
||||
const q = query({
|
||||
prompt: createPrompt(),
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
cwd: testDir,
|
||||
permissionMode: 'default',
|
||||
coreTools: ['read_file', 'write_file'],
|
||||
canUseTool: async (toolName) => {
|
||||
if (toolName === 'write_file') {
|
||||
return {
|
||||
behavior: 'deny',
|
||||
message: 'Write operations are not allowed',
|
||||
};
|
||||
}
|
||||
return { behavior: 'allow', updatedInput: {} };
|
||||
},
|
||||
debug: false,
|
||||
},
|
||||
});
|
||||
|
||||
const messages: SDKMessage[] = [];
|
||||
|
||||
try {
|
||||
for await (const message of q) {
|
||||
messages.push(message);
|
||||
}
|
||||
|
||||
// write_file should have been attempted but stream was closed
|
||||
const writeFileResults = findToolResults(messages, 'write_file');
|
||||
expect(writeFileResults.length).toBeGreaterThan(0);
|
||||
for (const result of writeFileResults) {
|
||||
expect(result.content).toContain(
|
||||
'[Operation Cancelled] Reason: Write operations are not allowed',
|
||||
);
|
||||
}
|
||||
|
||||
// File content should remain unchanged (because write was denied)
|
||||
const content = await helper.readFile('test.txt');
|
||||
expect(content).toBe('original content');
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
},
|
||||
TEST_TIMEOUT,
|
||||
);
|
||||
|
||||
it(
|
||||
'should support multi-turn conversation with canUseTool using asyncGenerator',
|
||||
async () => {
|
||||
await helper.createFile('data.txt', 'initial data');
|
||||
|
||||
const canUseToolCalls: string[] = [];
|
||||
|
||||
// Create an async generator that yields multiple messages
|
||||
async function* createMultiTurnPrompt(): AsyncIterable<SDKUserMessage> {
|
||||
const sessionId = crypto.randomUUID();
|
||||
|
||||
yield {
|
||||
type: 'user',
|
||||
session_id: sessionId,
|
||||
message: {
|
||||
role: 'user',
|
||||
content: 'Read data.txt and tell me what it contains.',
|
||||
},
|
||||
parent_tool_use_id: null,
|
||||
};
|
||||
|
||||
// Small delay to simulate multi-turn conversation
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
yield {
|
||||
type: 'user',
|
||||
session_id: sessionId,
|
||||
message: {
|
||||
role: 'user',
|
||||
content: 'Now append " - updated" to the file content.',
|
||||
},
|
||||
parent_tool_use_id: null,
|
||||
};
|
||||
}
|
||||
|
||||
const q = query({
|
||||
prompt: createMultiTurnPrompt(),
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
cwd: testDir,
|
||||
permissionMode: 'default',
|
||||
coreTools: ['read_file', 'write_file'],
|
||||
canUseTool: async (toolName) => {
|
||||
canUseToolCalls.push(toolName);
|
||||
return { behavior: 'allow', updatedInput: {} };
|
||||
},
|
||||
debug: false,
|
||||
},
|
||||
});
|
||||
|
||||
const messages: SDKMessage[] = [];
|
||||
|
||||
try {
|
||||
for await (const message of q) {
|
||||
messages.push(message);
|
||||
}
|
||||
|
||||
const toolCalls = findToolCalls(messages);
|
||||
const toolNames = toolCalls.map((tc) => tc.toolUse.name);
|
||||
|
||||
// Should have read_file and write_file calls
|
||||
expect(toolNames).toContain('read_file');
|
||||
expect(toolNames).toContain('write_file');
|
||||
|
||||
// canUseTool should not be called once stream is closed
|
||||
expect(canUseToolCalls).toHaveLength(0);
|
||||
|
||||
const writeFileResults = findToolResults(messages, 'write_file');
|
||||
expect(writeFileResults.length).toBeGreaterThan(0);
|
||||
for (const result of writeFileResults) {
|
||||
expect(result.content).toContain('Error: Input closed');
|
||||
}
|
||||
|
||||
const content = await helper.readFile('data.txt');
|
||||
expect(content).toBe('initial data');
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
},
|
||||
TEST_TIMEOUT,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -35,6 +35,7 @@ export interface IControlContext {
|
||||
permissionMode: PermissionMode;
|
||||
sdkMcpServers: Set<string>;
|
||||
mcpClients: Map<string, { client: Client; config: MCPServerConfig }>;
|
||||
inputClosed: boolean;
|
||||
|
||||
onInterrupt?: () => void;
|
||||
}
|
||||
@@ -52,6 +53,7 @@ export class ControlContext implements IControlContext {
|
||||
permissionMode: PermissionMode;
|
||||
sdkMcpServers: Set<string>;
|
||||
mcpClients: Map<string, { client: Client; config: MCPServerConfig }>;
|
||||
inputClosed: boolean;
|
||||
|
||||
onInterrupt?: () => void;
|
||||
|
||||
@@ -71,6 +73,7 @@ export class ControlContext implements IControlContext {
|
||||
this.permissionMode = options.permissionMode || 'default';
|
||||
this.sdkMcpServers = new Set();
|
||||
this.mcpClients = new Map();
|
||||
this.inputClosed = false;
|
||||
this.onInterrupt = options.onInterrupt;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,6 +42,7 @@ function createMockContext(debugMode: boolean = false): IControlContext {
|
||||
permissionMode: 'default',
|
||||
sdkMcpServers: new Set<string>(),
|
||||
mcpClients: new Map(),
|
||||
inputClosed: false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -637,6 +638,130 @@ describe('ControlDispatcher', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('markInputClosed', () => {
|
||||
it('should reject all pending outgoing requests when input closes', () => {
|
||||
const requestId1 = 'reject-req-1';
|
||||
const requestId2 = 'reject-req-2';
|
||||
const resolve1 = vi.fn();
|
||||
const resolve2 = vi.fn();
|
||||
const reject1 = vi.fn();
|
||||
const reject2 = vi.fn();
|
||||
const timeoutId1 = setTimeout(() => {}, 1000);
|
||||
const timeoutId2 = setTimeout(() => {}, 1000);
|
||||
const clearTimeoutSpy = vi.spyOn(global, 'clearTimeout');
|
||||
|
||||
const register = (
|
||||
dispatcher as unknown as {
|
||||
registerOutgoingRequest: (
|
||||
id: string,
|
||||
controller: string,
|
||||
resolve: (response: ControlResponse) => void,
|
||||
reject: (error: Error) => void,
|
||||
timeoutId: NodeJS.Timeout,
|
||||
) => void;
|
||||
}
|
||||
).registerOutgoingRequest.bind(dispatcher);
|
||||
|
||||
register(requestId1, 'SystemController', resolve1, reject1, timeoutId1);
|
||||
register(requestId2, 'SystemController', resolve2, reject2, timeoutId2);
|
||||
|
||||
dispatcher.markInputClosed();
|
||||
|
||||
expect(reject1).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ message: 'Input closed' }),
|
||||
);
|
||||
expect(reject2).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ message: 'Input closed' }),
|
||||
);
|
||||
expect(clearTimeoutSpy).toHaveBeenCalledWith(timeoutId1);
|
||||
expect(clearTimeoutSpy).toHaveBeenCalledWith(timeoutId2);
|
||||
});
|
||||
|
||||
it('should mark input as closed on context', () => {
|
||||
dispatcher.markInputClosed();
|
||||
expect(mockContext.inputClosed).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle empty pending requests gracefully', () => {
|
||||
expect(() => dispatcher.markInputClosed()).not.toThrow();
|
||||
});
|
||||
|
||||
it('should be idempotent when called multiple times', () => {
|
||||
const requestId = 'idempotent-req';
|
||||
const resolve = vi.fn();
|
||||
const reject = vi.fn();
|
||||
const timeoutId = setTimeout(() => {}, 1000);
|
||||
|
||||
(
|
||||
dispatcher as unknown as {
|
||||
registerOutgoingRequest: (
|
||||
id: string,
|
||||
controller: string,
|
||||
resolve: (response: ControlResponse) => void,
|
||||
reject: (error: Error) => void,
|
||||
timeoutId: NodeJS.Timeout,
|
||||
) => void;
|
||||
}
|
||||
).registerOutgoingRequest(
|
||||
requestId,
|
||||
'SystemController',
|
||||
resolve,
|
||||
reject,
|
||||
timeoutId,
|
||||
);
|
||||
|
||||
dispatcher.markInputClosed();
|
||||
const firstRejectCount = vi.mocked(reject).mock.calls.length;
|
||||
|
||||
// Call again - should not reject again
|
||||
dispatcher.markInputClosed();
|
||||
const secondRejectCount = vi.mocked(reject).mock.calls.length;
|
||||
|
||||
expect(secondRejectCount).toBe(firstRejectCount);
|
||||
});
|
||||
|
||||
it('should log input closure in debug mode', () => {
|
||||
const context = createMockContext(true);
|
||||
const consoleSpy = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => {});
|
||||
|
||||
const dispatcherWithDebug = new ControlDispatcher(context);
|
||||
const requestId = 'reject-req-debug';
|
||||
const resolve = vi.fn();
|
||||
const reject = vi.fn();
|
||||
const timeoutId = setTimeout(() => {}, 1000);
|
||||
|
||||
(
|
||||
dispatcherWithDebug as unknown as {
|
||||
registerOutgoingRequest: (
|
||||
id: string,
|
||||
controller: string,
|
||||
resolve: (response: ControlResponse) => void,
|
||||
reject: (error: Error) => void,
|
||||
timeoutId: NodeJS.Timeout,
|
||||
) => void;
|
||||
}
|
||||
).registerOutgoingRequest(
|
||||
requestId,
|
||||
'SystemController',
|
||||
resolve,
|
||||
reject,
|
||||
timeoutId,
|
||||
);
|
||||
|
||||
dispatcherWithDebug.markInputClosed();
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
'[ControlDispatcher] Input closed, rejecting 1 pending outgoing requests',
|
||||
),
|
||||
);
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('shutdown', () => {
|
||||
it('should cancel all pending incoming requests', () => {
|
||||
const requestId1 = 'shutdown-req-1';
|
||||
|
||||
@@ -207,6 +207,36 @@ export class ControlDispatcher implements IPendingRequestRegistry {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks stdin as closed and rejects all pending outgoing requests.
|
||||
* After this is called, new outgoing requests will be rejected immediately.
|
||||
* This should be called when stdin closes to avoid waiting for responses.
|
||||
*/
|
||||
markInputClosed(): void {
|
||||
if (this.context.inputClosed) {
|
||||
return; // Already marked as closed
|
||||
}
|
||||
|
||||
this.context.inputClosed = true;
|
||||
|
||||
const requestIds = Array.from(this.pendingOutgoingRequests.keys());
|
||||
|
||||
if (this.context.debugMode) {
|
||||
console.error(
|
||||
`[ControlDispatcher] Input closed, rejecting ${requestIds.length} pending outgoing requests`,
|
||||
);
|
||||
}
|
||||
|
||||
// Reject all currently pending outgoing requests
|
||||
for (const id of requestIds) {
|
||||
const pending = this.pendingOutgoingRequests.get(id);
|
||||
if (pending) {
|
||||
this.deregisterOutgoingRequest(id);
|
||||
pending.reject(new Error('Input closed'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops all pending requests and cleans up all controllers
|
||||
*/
|
||||
@@ -243,7 +273,7 @@ export class ControlDispatcher implements IPendingRequestRegistry {
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers an incoming request in the pending registry
|
||||
* Registers an incoming request in the pending registry.
|
||||
*/
|
||||
registerIncomingRequest(
|
||||
requestId: string,
|
||||
|
||||
@@ -124,6 +124,11 @@ export abstract class BaseController {
|
||||
timeoutMs: number = DEFAULT_REQUEST_TIMEOUT_MS,
|
||||
signal?: AbortSignal,
|
||||
): Promise<ControlResponse> {
|
||||
// Check if stream is closed
|
||||
if (this.context.inputClosed) {
|
||||
throw new Error('Input closed');
|
||||
}
|
||||
|
||||
// Check if already aborted
|
||||
if (signal?.aborted) {
|
||||
throw new Error('Request aborted');
|
||||
|
||||
@@ -469,21 +469,27 @@ export class PermissionController extends BaseController {
|
||||
error,
|
||||
);
|
||||
}
|
||||
// On error, use default cancel message
|
||||
|
||||
// Extract error message
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
|
||||
// On error, pass error message as cancel message
|
||||
// Only pass payload for exec and mcp types that support it
|
||||
const confirmationType = toolCall.confirmationDetails.type;
|
||||
if (['edit', 'exec', 'mcp'].includes(confirmationType)) {
|
||||
const execOrMcpDetails = toolCall.confirmationDetails as
|
||||
| ToolExecuteConfirmationDetails
|
||||
| ToolMcpConfirmationDetails;
|
||||
await execOrMcpDetails.onConfirm(
|
||||
ToolConfirmationOutcome.Cancel,
|
||||
undefined,
|
||||
);
|
||||
await execOrMcpDetails.onConfirm(ToolConfirmationOutcome.Cancel, {
|
||||
cancelMessage: `Error: ${errorMessage}`,
|
||||
});
|
||||
} else {
|
||||
// For other types, don't pass payload (backward compatible)
|
||||
await toolCall.confirmationDetails.onConfirm(
|
||||
ToolConfirmationOutcome.Cancel,
|
||||
{
|
||||
cancelMessage: `Error: ${errorMessage}`,
|
||||
},
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
|
||||
@@ -153,6 +153,7 @@ describe('runNonInteractiveStreamJson', () => {
|
||||
handleControlResponse: ReturnType<typeof vi.fn>;
|
||||
handleCancel: ReturnType<typeof vi.fn>;
|
||||
shutdown: ReturnType<typeof vi.fn>;
|
||||
markInputClosed: ReturnType<typeof vi.fn>;
|
||||
getPendingIncomingRequestCount: ReturnType<typeof vi.fn>;
|
||||
waitForPendingIncomingRequests: ReturnType<typeof vi.fn>;
|
||||
sdkMcpController: {
|
||||
@@ -192,6 +193,7 @@ describe('runNonInteractiveStreamJson', () => {
|
||||
handleControlResponse: vi.fn(),
|
||||
handleCancel: vi.fn(),
|
||||
shutdown: vi.fn(),
|
||||
markInputClosed: vi.fn(),
|
||||
getPendingIncomingRequestCount: vi.fn().mockReturnValue(0),
|
||||
waitForPendingIncomingRequests: vi.fn().mockResolvedValue(undefined),
|
||||
sdkMcpController: {
|
||||
|
||||
@@ -596,7 +596,14 @@ class Session {
|
||||
throw streamError;
|
||||
}
|
||||
|
||||
// Stream ended - wait for all pending work before shutdown
|
||||
// Stdin closed - mark input as closed in dispatcher
|
||||
// This will reject all current pending outgoing requests AND any future requests
|
||||
// that might be registered by async message handlers still running
|
||||
if (this.dispatcher) {
|
||||
this.dispatcher.markInputClosed();
|
||||
}
|
||||
|
||||
// Wait for all pending work before shutdown
|
||||
await this.waitForAllPendingWork();
|
||||
await this.shutdown();
|
||||
} catch (error) {
|
||||
|
||||
@@ -376,7 +376,7 @@ describe('InputPrompt', () => {
|
||||
it('should handle Ctrl+V when clipboard has an image', async () => {
|
||||
vi.mocked(clipboardUtils.clipboardHasImage).mockResolvedValue(true);
|
||||
vi.mocked(clipboardUtils.saveClipboardImage).mockResolvedValue(
|
||||
'/test/.qwen-code-clipboard/clipboard-123.png',
|
||||
'/test/.gemini-clipboard/clipboard-123.png',
|
||||
);
|
||||
|
||||
const { stdin, unmount } = renderWithProviders(
|
||||
@@ -436,7 +436,7 @@ describe('InputPrompt', () => {
|
||||
it('should insert image path at cursor position with proper spacing', async () => {
|
||||
const imagePath = path.join(
|
||||
'test',
|
||||
'.qwen-code-clipboard',
|
||||
'.gemini-clipboard',
|
||||
'clipboard-456.png',
|
||||
);
|
||||
vi.mocked(clipboardUtils.clipboardHasImage).mockResolvedValue(true);
|
||||
|
||||
@@ -1,17 +1,12 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import * as fs from 'node:fs/promises';
|
||||
import * as path from 'node:path';
|
||||
import {
|
||||
execCommand,
|
||||
ensureClipboardImageDir,
|
||||
generateClipboardImageFilename,
|
||||
cleanupOldClipboardImages as cleanupOldImages,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import { execCommand } from '@qwen-code/qwen-code-core';
|
||||
|
||||
/**
|
||||
* Checks if the system clipboard contains an image (macOS only for now)
|
||||
@@ -35,7 +30,6 @@ export async function clipboardHasImage(): Promise<boolean> {
|
||||
|
||||
/**
|
||||
* Saves the image from clipboard to a temporary file (macOS only for now)
|
||||
* Uses osascript to read from system clipboard and save to file.
|
||||
* @param targetDir The target directory to create temp files within
|
||||
* @returns The path to the saved image file, or null if no image or error
|
||||
*/
|
||||
@@ -50,7 +44,11 @@ export async function saveClipboardImage(
|
||||
// Create a temporary directory for clipboard images within the target directory
|
||||
// This avoids security restrictions on paths outside the target directory
|
||||
const baseDir = targetDir || process.cwd();
|
||||
const tempDir = await ensureClipboardImageDir(baseDir);
|
||||
const tempDir = path.join(baseDir, '.gemini-clipboard');
|
||||
await fs.mkdir(tempDir, { recursive: true });
|
||||
|
||||
// Generate a unique filename with timestamp
|
||||
const timestamp = new Date().getTime();
|
||||
|
||||
// Try different image formats in order of preference
|
||||
const formats = [
|
||||
@@ -63,7 +61,7 @@ export async function saveClipboardImage(
|
||||
for (const format of formats) {
|
||||
const tempFilePath = path.join(
|
||||
tempDir,
|
||||
generateClipboardImageFilename(format.extension),
|
||||
`clipboard-${timestamp}.${format.extension}`,
|
||||
);
|
||||
|
||||
// Try to save clipboard as this format
|
||||
@@ -120,6 +118,28 @@ export async function saveClipboardImage(
|
||||
export async function cleanupOldClipboardImages(
|
||||
targetDir?: string,
|
||||
): Promise<void> {
|
||||
const baseDir = targetDir || process.cwd();
|
||||
await cleanupOldImages(baseDir);
|
||||
try {
|
||||
const baseDir = targetDir || process.cwd();
|
||||
const tempDir = path.join(baseDir, '.gemini-clipboard');
|
||||
const files = await fs.readdir(tempDir);
|
||||
const oneHourAgo = Date.now() - 60 * 60 * 1000;
|
||||
|
||||
for (const file of files) {
|
||||
if (
|
||||
file.startsWith('clipboard-') &&
|
||||
(file.endsWith('.png') ||
|
||||
file.endsWith('.jpg') ||
|
||||
file.endsWith('.tiff') ||
|
||||
file.endsWith('.gif'))
|
||||
) {
|
||||
const filePath = path.join(tempDir, file);
|
||||
const stats = await fs.stat(filePath);
|
||||
if (stats.mtimeMs < oneHourAgo) {
|
||||
await fs.unlink(filePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors in cleanup
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,7 +76,6 @@ export * from './utils/subagentGenerator.js';
|
||||
export * from './utils/projectSummary.js';
|
||||
export * from './utils/promptIdContext.js';
|
||||
export * from './utils/thoughtUtils.js';
|
||||
export * from './utils/clipboardImageStorage.js';
|
||||
|
||||
// Config resolution utilities
|
||||
export * from './utils/configResolver.js';
|
||||
|
||||
@@ -1,246 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import * as fs from 'node:fs/promises';
|
||||
import * as fsSync from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import * as os from 'node:os';
|
||||
import {
|
||||
CLIPBOARD_IMAGE_DIR,
|
||||
CLEANUP_THRESHOLD_MS,
|
||||
SUPPORTED_CLIPBOARD_IMAGE_EXTENSIONS,
|
||||
getClipboardImageDir,
|
||||
ensureClipboardImageDir,
|
||||
ensureClipboardImageDirSync,
|
||||
generateClipboardImageFilename,
|
||||
saveBase64Image,
|
||||
saveBase64ImageSync,
|
||||
cleanupOldClipboardImages,
|
||||
isSupportedClipboardImageExtension,
|
||||
} from './clipboardImageStorage.js';
|
||||
|
||||
describe('clipboardImageStorage', () => {
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
// Create a temporary directory for tests
|
||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'clipboard-test-'));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
// Clean up temporary directory
|
||||
try {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
});
|
||||
|
||||
describe('constants', () => {
|
||||
it('should have correct clipboard image directory name', () => {
|
||||
expect(CLIPBOARD_IMAGE_DIR).toBe('.qwen-code-clipboard');
|
||||
});
|
||||
|
||||
it('should have correct cleanup threshold (1 hour)', () => {
|
||||
expect(CLEANUP_THRESHOLD_MS).toBe(60 * 60 * 1000);
|
||||
});
|
||||
|
||||
it('should support common image extensions', () => {
|
||||
expect(SUPPORTED_CLIPBOARD_IMAGE_EXTENSIONS).toContain('.png');
|
||||
expect(SUPPORTED_CLIPBOARD_IMAGE_EXTENSIONS).toContain('.jpg');
|
||||
expect(SUPPORTED_CLIPBOARD_IMAGE_EXTENSIONS).toContain('.jpeg');
|
||||
expect(SUPPORTED_CLIPBOARD_IMAGE_EXTENSIONS).toContain('.gif');
|
||||
expect(SUPPORTED_CLIPBOARD_IMAGE_EXTENSIONS).toContain('.webp');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getClipboardImageDir', () => {
|
||||
it('should return correct path', () => {
|
||||
const result = getClipboardImageDir('/workspace');
|
||||
expect(result).toBe(path.join('/workspace', '.qwen-code-clipboard'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('ensureClipboardImageDir', () => {
|
||||
it('should create directory if not exists', async () => {
|
||||
const dir = await ensureClipboardImageDir(tempDir);
|
||||
expect(dir).toBe(path.join(tempDir, CLIPBOARD_IMAGE_DIR));
|
||||
|
||||
const stats = await fs.stat(dir);
|
||||
expect(stats.isDirectory()).toBe(true);
|
||||
});
|
||||
|
||||
it('should not fail if directory already exists', async () => {
|
||||
await ensureClipboardImageDir(tempDir);
|
||||
const dir = await ensureClipboardImageDir(tempDir);
|
||||
expect(dir).toBe(path.join(tempDir, CLIPBOARD_IMAGE_DIR));
|
||||
});
|
||||
});
|
||||
|
||||
describe('ensureClipboardImageDirSync', () => {
|
||||
it('should create directory if not exists', () => {
|
||||
const dir = ensureClipboardImageDirSync(tempDir);
|
||||
expect(dir).toBe(path.join(tempDir, CLIPBOARD_IMAGE_DIR));
|
||||
expect(fsSync.existsSync(dir)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateClipboardImageFilename', () => {
|
||||
it('should generate filename with timestamp and extension', () => {
|
||||
const filename = generateClipboardImageFilename('.png');
|
||||
expect(filename).toMatch(/^clipboard-\d+\.png$/);
|
||||
});
|
||||
|
||||
it('should handle extension without dot', () => {
|
||||
const filename = generateClipboardImageFilename('jpg');
|
||||
expect(filename).toMatch(/^clipboard-\d+\.jpg$/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('saveBase64Image', () => {
|
||||
it('should save base64 image to file', async () => {
|
||||
// Simple 1x1 red PNG in base64
|
||||
const base64Data =
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==';
|
||||
|
||||
const relativePath = await saveBase64Image(
|
||||
base64Data,
|
||||
'test.png',
|
||||
tempDir,
|
||||
);
|
||||
|
||||
expect(relativePath).not.toBeNull();
|
||||
expect(relativePath).toMatch(
|
||||
/^\.qwen-code-clipboard\/clipboard-\d+\.png$/,
|
||||
);
|
||||
|
||||
const fullPath = path.join(tempDir, relativePath!);
|
||||
const stats = await fs.stat(fullPath);
|
||||
expect(stats.size).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should handle data URL format', async () => {
|
||||
const base64Data =
|
||||
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==';
|
||||
|
||||
const relativePath = await saveBase64Image(
|
||||
base64Data,
|
||||
'test.png',
|
||||
tempDir,
|
||||
);
|
||||
|
||||
expect(relativePath).not.toBeNull();
|
||||
|
||||
const fullPath = path.join(tempDir, relativePath!);
|
||||
const stats = await fs.stat(fullPath);
|
||||
expect(stats.size).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should use default extension if not provided', async () => {
|
||||
const base64Data =
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==';
|
||||
|
||||
const relativePath = await saveBase64Image(base64Data, 'noext', tempDir);
|
||||
|
||||
expect(relativePath).toMatch(/\.png$/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('saveBase64ImageSync', () => {
|
||||
it('should save base64 image to file synchronously', () => {
|
||||
const base64Data =
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==';
|
||||
|
||||
const relativePath = saveBase64ImageSync(base64Data, 'test.png', tempDir);
|
||||
|
||||
expect(relativePath).not.toBeNull();
|
||||
expect(relativePath).toMatch(
|
||||
/^\.qwen-code-clipboard\/clipboard-\d+\.png$/,
|
||||
);
|
||||
|
||||
const fullPath = path.join(tempDir, relativePath!);
|
||||
expect(fsSync.existsSync(fullPath)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cleanupOldClipboardImages', () => {
|
||||
it('should remove files older than threshold', async () => {
|
||||
// Create clipboard directory
|
||||
const clipboardDir = await ensureClipboardImageDir(tempDir);
|
||||
|
||||
// Create an old file
|
||||
const oldFilePath = path.join(clipboardDir, 'clipboard-1234567890.png');
|
||||
await fs.writeFile(oldFilePath, 'test');
|
||||
|
||||
// Set mtime to 2 hours ago
|
||||
const twoHoursAgo = new Date(Date.now() - 2 * 60 * 60 * 1000);
|
||||
await fs.utimes(oldFilePath, twoHoursAgo, twoHoursAgo);
|
||||
|
||||
// Create a new file
|
||||
const newFilePath = path.join(clipboardDir, 'clipboard-9999999999.png');
|
||||
await fs.writeFile(newFilePath, 'test');
|
||||
|
||||
// Run cleanup
|
||||
await cleanupOldClipboardImages(tempDir);
|
||||
|
||||
// Old file should be deleted
|
||||
await expect(fs.access(oldFilePath)).rejects.toThrow();
|
||||
|
||||
// New file should still exist
|
||||
await expect(fs.access(newFilePath)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('should not fail if directory does not exist', async () => {
|
||||
// Should not throw
|
||||
await expect(cleanupOldClipboardImages(tempDir)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('should only clean clipboard-* files', async () => {
|
||||
const clipboardDir = await ensureClipboardImageDir(tempDir);
|
||||
|
||||
// Create a non-clipboard file
|
||||
const otherFilePath = path.join(clipboardDir, 'other-file.png');
|
||||
await fs.writeFile(otherFilePath, 'test');
|
||||
|
||||
// Set mtime to 2 hours ago
|
||||
const twoHoursAgo = new Date(Date.now() - 2 * 60 * 60 * 1000);
|
||||
await fs.utimes(otherFilePath, twoHoursAgo, twoHoursAgo);
|
||||
|
||||
// Run cleanup
|
||||
await cleanupOldClipboardImages(tempDir);
|
||||
|
||||
// Other file should still exist
|
||||
await expect(fs.access(otherFilePath)).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('isSupportedClipboardImageExtension', () => {
|
||||
it('should return true for supported extensions', () => {
|
||||
expect(isSupportedClipboardImageExtension('.png')).toBe(true);
|
||||
expect(isSupportedClipboardImageExtension('.jpg')).toBe(true);
|
||||
expect(isSupportedClipboardImageExtension('.jpeg')).toBe(true);
|
||||
expect(isSupportedClipboardImageExtension('.gif')).toBe(true);
|
||||
expect(isSupportedClipboardImageExtension('.webp')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for extensions without dot', () => {
|
||||
expect(isSupportedClipboardImageExtension('png')).toBe(true);
|
||||
expect(isSupportedClipboardImageExtension('jpg')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for unsupported extensions', () => {
|
||||
expect(isSupportedClipboardImageExtension('.txt')).toBe(false);
|
||||
expect(isSupportedClipboardImageExtension('.pdf')).toBe(false);
|
||||
expect(isSupportedClipboardImageExtension('.doc')).toBe(false);
|
||||
});
|
||||
|
||||
it('should be case insensitive', () => {
|
||||
expect(isSupportedClipboardImageExtension('.PNG')).toBe(true);
|
||||
expect(isSupportedClipboardImageExtension('.JPG')).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,215 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import * as fs from 'node:fs/promises';
|
||||
import * as fsSync from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
|
||||
/**
|
||||
* Directory name for storing clipboard images
|
||||
* This directory is NOT in .gitignore so the AI can access pasted images
|
||||
*/
|
||||
export const CLIPBOARD_IMAGE_DIR = '.qwen-code-clipboard';
|
||||
|
||||
/**
|
||||
* Default cleanup threshold: 1 hour
|
||||
*/
|
||||
export const CLEANUP_THRESHOLD_MS = 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Supported image extensions for clipboard images
|
||||
*/
|
||||
export const SUPPORTED_CLIPBOARD_IMAGE_EXTENSIONS = [
|
||||
'.png',
|
||||
'.jpg',
|
||||
'.jpeg',
|
||||
'.gif',
|
||||
'.tiff',
|
||||
'.webp',
|
||||
'.bmp',
|
||||
];
|
||||
|
||||
/**
|
||||
* Get the full path to the clipboard image directory
|
||||
* @param baseDir The base directory (usually workspace root)
|
||||
* @returns Full path to the clipboard image directory
|
||||
*/
|
||||
export function getClipboardImageDir(baseDir: string): string {
|
||||
return path.join(baseDir, CLIPBOARD_IMAGE_DIR);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the clipboard image directory exists
|
||||
* @param baseDir The base directory (usually workspace root)
|
||||
* @returns Full path to the clipboard image directory
|
||||
*/
|
||||
export async function ensureClipboardImageDir(
|
||||
baseDir: string,
|
||||
): Promise<string> {
|
||||
const dir = getClipboardImageDir(baseDir);
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
return dir;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the clipboard image directory exists (sync version)
|
||||
* @param baseDir The base directory (usually workspace root)
|
||||
* @returns Full path to the clipboard image directory
|
||||
*/
|
||||
export function ensureClipboardImageDirSync(baseDir: string): string {
|
||||
const dir = getClipboardImageDir(baseDir);
|
||||
if (!fsSync.existsSync(dir)) {
|
||||
fsSync.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
return dir;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a unique filename for a clipboard image
|
||||
* @param extension File extension (with or without dot)
|
||||
* @returns Generated filename like "clipboard-1234567890.png"
|
||||
*/
|
||||
export function generateClipboardImageFilename(extension: string): string {
|
||||
const timestamp = Date.now();
|
||||
const ext = extension.startsWith('.') ? extension : `.${extension}`;
|
||||
return `clipboard-${timestamp}${ext}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save a base64 encoded image to the clipboard image directory
|
||||
* @param base64Data Base64 encoded image data (with or without data URL prefix)
|
||||
* @param fileName Original filename or generated filename
|
||||
* @param baseDir The base directory (usually workspace root)
|
||||
* @returns Relative path from baseDir to the saved file, or null if failed
|
||||
*/
|
||||
export async function saveBase64Image(
|
||||
base64Data: string,
|
||||
fileName: string,
|
||||
baseDir: string,
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
const dir = await ensureClipboardImageDir(baseDir);
|
||||
|
||||
// Generate unique filename
|
||||
const ext = path.extname(fileName) || '.png';
|
||||
const tempFileName = generateClipboardImageFilename(ext);
|
||||
const tempFilePath = path.join(dir, tempFileName);
|
||||
|
||||
// Extract base64 data if it's a data URL
|
||||
let pureBase64 = base64Data;
|
||||
const dataUrlMatch = base64Data.match(/^data:[^;]+;base64,(.+)$/);
|
||||
if (dataUrlMatch) {
|
||||
pureBase64 = dataUrlMatch[1];
|
||||
}
|
||||
|
||||
// Write file
|
||||
const buffer = Buffer.from(pureBase64, 'base64');
|
||||
await fs.writeFile(tempFilePath, buffer);
|
||||
|
||||
// Return relative path from baseDir
|
||||
return path.relative(baseDir, tempFilePath);
|
||||
} catch (error) {
|
||||
console.error('[clipboardImageStorage] Failed to save image:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save a base64 encoded image to the clipboard image directory (sync version)
|
||||
* @param base64Data Base64 encoded image data (with or without data URL prefix)
|
||||
* @param fileName Original filename or generated filename
|
||||
* @param baseDir The base directory (usually workspace root)
|
||||
* @returns Relative path from baseDir to the saved file, or null if failed
|
||||
*/
|
||||
export function saveBase64ImageSync(
|
||||
base64Data: string,
|
||||
fileName: string,
|
||||
baseDir: string,
|
||||
): string | null {
|
||||
try {
|
||||
const dir = ensureClipboardImageDirSync(baseDir);
|
||||
|
||||
// Generate unique filename
|
||||
const ext = path.extname(fileName) || '.png';
|
||||
const tempFileName = generateClipboardImageFilename(ext);
|
||||
const tempFilePath = path.join(dir, tempFileName);
|
||||
|
||||
// Extract base64 data if it's a data URL
|
||||
let pureBase64 = base64Data;
|
||||
const dataUrlMatch = base64Data.match(/^data:[^;]+;base64,(.+)$/);
|
||||
if (dataUrlMatch) {
|
||||
pureBase64 = dataUrlMatch[1];
|
||||
}
|
||||
|
||||
// Write file
|
||||
const buffer = Buffer.from(pureBase64, 'base64');
|
||||
fsSync.writeFileSync(tempFilePath, buffer);
|
||||
|
||||
// Return relative path from baseDir
|
||||
return path.relative(baseDir, tempFilePath);
|
||||
} catch (error) {
|
||||
console.error('[clipboardImageStorage] Failed to save image:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up old clipboard image files
|
||||
* Removes files older than the specified threshold
|
||||
* @param baseDir The base directory (usually workspace root)
|
||||
* @param thresholdMs Age threshold in milliseconds (default: 1 hour)
|
||||
*/
|
||||
export async function cleanupOldClipboardImages(
|
||||
baseDir: string,
|
||||
thresholdMs: number = CLEANUP_THRESHOLD_MS,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const dir = getClipboardImageDir(baseDir);
|
||||
|
||||
// Check if directory exists
|
||||
try {
|
||||
await fs.access(dir);
|
||||
} catch {
|
||||
// Directory doesn't exist, nothing to clean
|
||||
return;
|
||||
}
|
||||
|
||||
const files = await fs.readdir(dir);
|
||||
const cutoffTime = Date.now() - thresholdMs;
|
||||
|
||||
for (const file of files) {
|
||||
// Only clean up clipboard-* files with supported extensions
|
||||
if (file.startsWith('clipboard-')) {
|
||||
const ext = path.extname(file).toLowerCase();
|
||||
if (SUPPORTED_CLIPBOARD_IMAGE_EXTENSIONS.includes(ext)) {
|
||||
const filePath = path.join(dir, file);
|
||||
try {
|
||||
const stats = await fs.stat(filePath);
|
||||
if (stats.mtimeMs < cutoffTime) {
|
||||
await fs.unlink(filePath);
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors for individual files
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors in cleanup
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a file extension is a supported clipboard image format
|
||||
* @param extension File extension (with or without dot)
|
||||
* @returns true if supported
|
||||
*/
|
||||
export function isSupportedClipboardImageExtension(extension: string): boolean {
|
||||
const ext = extension.startsWith('.')
|
||||
? extension.toLowerCase()
|
||||
: `.${extension.toLowerCase()}`;
|
||||
return SUPPORTED_CLIPBOARD_IMAGE_EXTENSIONS.includes(ext);
|
||||
}
|
||||
@@ -663,7 +663,21 @@ export class Query implements AsyncIterable<SDKMessage> {
|
||||
},
|
||||
);
|
||||
|
||||
this.transport.write(serializeJsonLine(request));
|
||||
try {
|
||||
this.transport.write(serializeJsonLine(request));
|
||||
} catch (error) {
|
||||
const pending = this.pendingControlRequests.get(requestId);
|
||||
if (pending) {
|
||||
clearTimeout(pending.timeout);
|
||||
this.pendingControlRequests.delete(requestId);
|
||||
}
|
||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||
logger.error(`Failed to send control request: ${errorMsg}`);
|
||||
return Promise.reject(
|
||||
new Error(`Failed to send control request: ${errorMsg}`),
|
||||
);
|
||||
}
|
||||
|
||||
return responsePromise;
|
||||
}
|
||||
|
||||
@@ -687,7 +701,15 @@ export class Query implements AsyncIterable<SDKMessage> {
|
||||
},
|
||||
};
|
||||
|
||||
this.transport.write(serializeJsonLine(response));
|
||||
try {
|
||||
this.transport.write(serializeJsonLine(response));
|
||||
} catch (error) {
|
||||
// Write failed - log and ignore since response cannot be delivered
|
||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||
logger.warn(
|
||||
`Failed to send control response for request ${requestId}: ${errorMsg}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
@@ -790,11 +812,7 @@ export class Query implements AsyncIterable<SDKMessage> {
|
||||
* The timeout ensures we don't hang indefinitely - either the turn proceeds
|
||||
* normally, or it fails with a timeout, but Promise.race will always resolve.
|
||||
*/
|
||||
if (
|
||||
!this.isSingleTurn &&
|
||||
this.sdkMcpTransports.size > 0 &&
|
||||
this.firstResultReceivedPromise
|
||||
) {
|
||||
if (this.firstResultReceivedPromise) {
|
||||
const streamCloseTimeout =
|
||||
this.options.timeout?.streamClose ?? DEFAULT_STREAM_CLOSE_TIMEOUT;
|
||||
let timeoutId: NodeJS.Timeout | undefined;
|
||||
|
||||
@@ -18,6 +18,7 @@ export class ProcessTransport implements Transport {
|
||||
private ready = false;
|
||||
private _exitError: Error | null = null;
|
||||
private closed = false;
|
||||
private inputClosed = false;
|
||||
private abortController: AbortController;
|
||||
private processExitHandler: (() => void) | null = null;
|
||||
private abortHandler: (() => void) | null = null;
|
||||
@@ -210,6 +211,7 @@ export class ProcessTransport implements Transport {
|
||||
|
||||
this.ready = false;
|
||||
this.closed = true;
|
||||
this.inputClosed = true;
|
||||
}
|
||||
|
||||
async waitForExit(): Promise<void> {
|
||||
@@ -273,8 +275,16 @@ export class ProcessTransport implements Transport {
|
||||
throw new Error('Cannot write to closed transport');
|
||||
}
|
||||
|
||||
if (this.childStdin.writableEnded) {
|
||||
throw new Error('Cannot write to ended stream');
|
||||
if (this.inputClosed) {
|
||||
throw new Error('Input stream closed');
|
||||
}
|
||||
|
||||
if (this.childStdin.writableEnded || this.childStdin.destroyed) {
|
||||
this.inputClosed = true;
|
||||
logger.warn(
|
||||
`Cannot write to ${this.childStdin.writableEnded ? 'ended' : 'destroyed'} stdin stream, ignoring write`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.childProcess?.killed || this.childProcess?.exitCode !== null) {
|
||||
@@ -301,10 +311,25 @@ export class ProcessTransport implements Transport {
|
||||
logger.debug(`Write successful (${message.length} bytes)`);
|
||||
}
|
||||
} catch (error) {
|
||||
// Check if this is a stream-closed error (EPIPE, ERR_STREAM_WRITE_AFTER_END, etc.)
|
||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||
const isStreamClosedError =
|
||||
errorMsg.includes('EPIPE') ||
|
||||
errorMsg.includes('ERR_STREAM_WRITE_AFTER_END') ||
|
||||
errorMsg.includes('write after end');
|
||||
|
||||
if (isStreamClosedError) {
|
||||
// Soft-fail: log and return without throwing or changing ready state
|
||||
this.inputClosed = true;
|
||||
logger.warn(`Stream closed, cannot write: ${errorMsg}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// For other errors, maintain original behavior
|
||||
this.ready = false;
|
||||
const errorMsg = `Failed to write to stdin: ${error instanceof Error ? error.message : String(error)}`;
|
||||
logger.error(errorMsg);
|
||||
throw new Error(errorMsg);
|
||||
const fullErrorMsg = `Failed to write to stdin: ${errorMsg}`;
|
||||
logger.error(fullErrorMsg);
|
||||
throw new Error(fullErrorMsg);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -344,6 +369,7 @@ export class ProcessTransport implements Transport {
|
||||
endInput(): void {
|
||||
if (this.childStdin) {
|
||||
this.childStdin.end();
|
||||
this.inputClosed = true;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -647,7 +647,7 @@ describe('ProcessTransport', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw if writing to ended stream', () => {
|
||||
it('should not throw when writing to ended stream (soft-fail)', () => {
|
||||
mockPrepareSpawnInfo.mockReturnValue({
|
||||
command: 'qwen',
|
||||
args: [],
|
||||
@@ -664,9 +664,8 @@ describe('ProcessTransport', () => {
|
||||
|
||||
mockStdin.end();
|
||||
|
||||
expect(() => transport.write('test')).toThrow(
|
||||
'Cannot write to ended stream',
|
||||
);
|
||||
// Should not throw - soft-fail behavior
|
||||
expect(() => transport.write('test')).not.toThrow();
|
||||
});
|
||||
|
||||
it('should throw if writing to terminated process', () => {
|
||||
|
||||
@@ -261,6 +261,20 @@ function createControlCancel(requestId: string): ControlCancelRequest {
|
||||
};
|
||||
}
|
||||
|
||||
async function respondToInitialize(
|
||||
transport: MockTransport,
|
||||
query: Query,
|
||||
): Promise<void> {
|
||||
await vi.waitFor(() => {
|
||||
expect(transport.writtenMessages.length).toBeGreaterThan(0);
|
||||
});
|
||||
const initRequest = transport.getLastWrittenMessage() as CLIControlRequest;
|
||||
transport.simulateMessage(
|
||||
createControlResponse(initRequest.request_id, true, {}),
|
||||
);
|
||||
await query.initialized;
|
||||
}
|
||||
|
||||
describe('Query', () => {
|
||||
let transport: MockTransport;
|
||||
|
||||
@@ -295,6 +309,7 @@ describe('Query', () => {
|
||||
expect(initRequest.type).toBe('control_request');
|
||||
expect(initRequest.request.subtype).toBe('initialize');
|
||||
|
||||
await respondToInitialize(transport, query);
|
||||
await query.close();
|
||||
});
|
||||
|
||||
@@ -307,6 +322,8 @@ describe('Query', () => {
|
||||
|
||||
expect(query1.getSessionId()).not.toBe(query2.getSessionId());
|
||||
|
||||
await respondToInitialize(transport, query1);
|
||||
await respondToInitialize(transport2, query2);
|
||||
await query1.close();
|
||||
await query2.close();
|
||||
await transport2.close();
|
||||
@@ -338,6 +355,8 @@ describe('Query', () => {
|
||||
it('should route user messages to output stream', async () => {
|
||||
const query = new Query(transport, { cwd: '/test' });
|
||||
|
||||
await respondToInitialize(transport, query);
|
||||
|
||||
const userMsg = createUserMessage('Hello');
|
||||
transport.simulateMessage(userMsg);
|
||||
|
||||
@@ -351,6 +370,8 @@ describe('Query', () => {
|
||||
it('should route assistant messages to output stream', async () => {
|
||||
const query = new Query(transport, { cwd: '/test' });
|
||||
|
||||
await respondToInitialize(transport, query);
|
||||
|
||||
const assistantMsg = createAssistantMessage('Response');
|
||||
transport.simulateMessage(assistantMsg);
|
||||
|
||||
@@ -364,6 +385,8 @@ describe('Query', () => {
|
||||
it('should route system messages to output stream', async () => {
|
||||
const query = new Query(transport, { cwd: '/test' });
|
||||
|
||||
await respondToInitialize(transport, query);
|
||||
|
||||
const systemMsg = createSystemMessage('session_start');
|
||||
transport.simulateMessage(systemMsg);
|
||||
|
||||
@@ -377,6 +400,8 @@ describe('Query', () => {
|
||||
it('should route result messages to output stream', async () => {
|
||||
const query = new Query(transport, { cwd: '/test' });
|
||||
|
||||
await respondToInitialize(transport, query);
|
||||
|
||||
const resultMsg = createResultMessage(true);
|
||||
transport.simulateMessage(resultMsg);
|
||||
|
||||
@@ -390,6 +415,8 @@ describe('Query', () => {
|
||||
it('should route partial assistant messages to output stream', async () => {
|
||||
const query = new Query(transport, { cwd: '/test' });
|
||||
|
||||
await respondToInitialize(transport, query);
|
||||
|
||||
const partialMsg = createPartialMessage();
|
||||
transport.simulateMessage(partialMsg);
|
||||
|
||||
@@ -403,6 +430,8 @@ describe('Query', () => {
|
||||
it('should handle unknown message types', async () => {
|
||||
const query = new Query(transport, { cwd: '/test' });
|
||||
|
||||
await respondToInitialize(transport, query);
|
||||
|
||||
const unknownMsg = { type: 'unknown', data: 'test' };
|
||||
transport.simulateMessage(unknownMsg);
|
||||
|
||||
@@ -416,6 +445,8 @@ describe('Query', () => {
|
||||
it('should yield messages in order', async () => {
|
||||
const query = new Query(transport, { cwd: '/test' });
|
||||
|
||||
await respondToInitialize(transport, query);
|
||||
|
||||
const msg1 = createUserMessage('First');
|
||||
const msg2 = createAssistantMessage('Second');
|
||||
const msg3 = createResultMessage(true);
|
||||
@@ -445,6 +476,8 @@ describe('Query', () => {
|
||||
canUseTool,
|
||||
});
|
||||
|
||||
await respondToInitialize(transport, query);
|
||||
|
||||
const controlReq = createControlRequest('can_use_tool');
|
||||
transport.simulateMessage(controlReq);
|
||||
|
||||
@@ -469,6 +502,8 @@ describe('Query', () => {
|
||||
canUseTool,
|
||||
});
|
||||
|
||||
await respondToInitialize(transport, query);
|
||||
|
||||
const controlReq = createControlRequest('can_use_tool', 'perm-req-1');
|
||||
transport.simulateMessage(controlReq);
|
||||
|
||||
@@ -495,6 +530,8 @@ describe('Query', () => {
|
||||
canUseTool,
|
||||
});
|
||||
|
||||
await respondToInitialize(transport, query);
|
||||
|
||||
const controlReq = createControlRequest('can_use_tool', 'perm-req-2');
|
||||
transport.simulateMessage(controlReq);
|
||||
|
||||
@@ -519,6 +556,8 @@ describe('Query', () => {
|
||||
cwd: '/test',
|
||||
});
|
||||
|
||||
await respondToInitialize(transport, query);
|
||||
|
||||
const controlReq = createControlRequest('can_use_tool', 'perm-req-3');
|
||||
transport.simulateMessage(controlReq);
|
||||
|
||||
@@ -554,6 +593,8 @@ describe('Query', () => {
|
||||
},
|
||||
});
|
||||
|
||||
await respondToInitialize(transport, query);
|
||||
|
||||
const controlReq = createControlRequest('can_use_tool', 'perm-req-4');
|
||||
transport.simulateMessage(controlReq);
|
||||
|
||||
@@ -583,6 +624,8 @@ describe('Query', () => {
|
||||
canUseTool,
|
||||
});
|
||||
|
||||
await respondToInitialize(transport, query);
|
||||
|
||||
const controlReq = createControlRequest('can_use_tool', 'perm-req-5');
|
||||
transport.simulateMessage(controlReq);
|
||||
|
||||
@@ -613,6 +656,8 @@ describe('Query', () => {
|
||||
canUseTool,
|
||||
});
|
||||
|
||||
await respondToInitialize(transport, query);
|
||||
|
||||
const controlReq = createControlRequest('can_use_tool', 'perm-req-6');
|
||||
transport.simulateMessage(controlReq);
|
||||
|
||||
@@ -644,6 +689,8 @@ describe('Query', () => {
|
||||
canUseTool,
|
||||
});
|
||||
|
||||
await respondToInitialize(transport, query);
|
||||
|
||||
const controlReq = createControlRequest('can_use_tool', 'perm-req-7');
|
||||
transport.simulateMessage(controlReq);
|
||||
|
||||
@@ -684,6 +731,8 @@ describe('Query', () => {
|
||||
canUseTool,
|
||||
});
|
||||
|
||||
await respondToInitialize(transport, query);
|
||||
|
||||
const controlReq = createControlRequest('can_use_tool', 'cancel-req-1');
|
||||
transport.simulateMessage(controlReq);
|
||||
|
||||
@@ -703,6 +752,8 @@ describe('Query', () => {
|
||||
cwd: '/test',
|
||||
});
|
||||
|
||||
await respondToInitialize(transport, query);
|
||||
|
||||
// Send cancel for non-existent request
|
||||
transport.simulateMessage(createControlCancel('unknown-req'));
|
||||
|
||||
@@ -717,24 +768,16 @@ describe('Query', () => {
|
||||
it('should support streamInput() for follow-up messages', async () => {
|
||||
const query = new Query(transport, { cwd: '/test' });
|
||||
|
||||
// Respond to initialize
|
||||
await vi.waitFor(() => {
|
||||
expect(transport.writtenMessages.length).toBeGreaterThan(0);
|
||||
});
|
||||
const initRequest =
|
||||
transport.getLastWrittenMessage() as CLIControlRequest;
|
||||
transport.simulateMessage(
|
||||
createControlResponse(initRequest.request_id, true, {}),
|
||||
);
|
||||
|
||||
await query.initialized;
|
||||
await respondToInitialize(transport, query);
|
||||
|
||||
async function* messageGenerator() {
|
||||
yield createUserMessage('Follow-up 1');
|
||||
yield createUserMessage('Follow-up 2');
|
||||
}
|
||||
|
||||
await query.streamInput(messageGenerator());
|
||||
const streamPromise = query.streamInput(messageGenerator());
|
||||
transport.simulateMessage(createResultMessage(true));
|
||||
await streamPromise;
|
||||
|
||||
const messages = transport.getAllWrittenMessages();
|
||||
const userMessages = messages.filter(
|
||||
@@ -753,24 +796,16 @@ describe('Query', () => {
|
||||
const query = new Query(transport, { cwd: '/test' });
|
||||
const sessionId = query.getSessionId();
|
||||
|
||||
// Respond to initialize
|
||||
await vi.waitFor(() => {
|
||||
expect(transport.writtenMessages.length).toBeGreaterThan(0);
|
||||
});
|
||||
const initRequest =
|
||||
transport.getLastWrittenMessage() as CLIControlRequest;
|
||||
transport.simulateMessage(
|
||||
createControlResponse(initRequest.request_id, true, {}),
|
||||
);
|
||||
|
||||
await query.initialized;
|
||||
await respondToInitialize(transport, query);
|
||||
|
||||
async function* messageGenerator() {
|
||||
yield createUserMessage('Turn 1', sessionId);
|
||||
yield createUserMessage('Turn 2', sessionId);
|
||||
}
|
||||
|
||||
await query.streamInput(messageGenerator());
|
||||
const streamPromise = query.streamInput(messageGenerator());
|
||||
transport.simulateMessage(createResultMessage(true));
|
||||
await streamPromise;
|
||||
|
||||
const messages = transport.getAllWrittenMessages();
|
||||
const userMessages = messages.filter(
|
||||
@@ -790,6 +825,7 @@ describe('Query', () => {
|
||||
|
||||
it('should throw if streamInput() called on closed query', async () => {
|
||||
const query = new Query(transport, { cwd: '/test' });
|
||||
await respondToInitialize(transport, query);
|
||||
await query.close();
|
||||
|
||||
async function* messageGenerator() {
|
||||
@@ -808,17 +844,7 @@ describe('Query', () => {
|
||||
abortController,
|
||||
});
|
||||
|
||||
// Respond to initialize
|
||||
await vi.waitFor(() => {
|
||||
expect(transport.writtenMessages.length).toBeGreaterThan(0);
|
||||
});
|
||||
const initRequest =
|
||||
transport.getLastWrittenMessage() as CLIControlRequest;
|
||||
transport.simulateMessage(
|
||||
createControlResponse(initRequest.request_id, true, {}),
|
||||
);
|
||||
|
||||
await query.initialized;
|
||||
await respondToInitialize(transport, query);
|
||||
|
||||
async function* messageGenerator() {
|
||||
yield createUserMessage('Message 1');
|
||||
@@ -826,7 +852,9 @@ describe('Query', () => {
|
||||
yield createUserMessage('Message 2'); // Should not be sent
|
||||
}
|
||||
|
||||
await query.streamInput(messageGenerator());
|
||||
const streamPromise = query.streamInput(messageGenerator());
|
||||
transport.simulateMessage(createResultMessage(true));
|
||||
await streamPromise;
|
||||
|
||||
await query.close();
|
||||
});
|
||||
@@ -835,6 +863,8 @@ describe('Query', () => {
|
||||
describe('Lifecycle Management', () => {
|
||||
it('should close transport on close()', async () => {
|
||||
const query = new Query(transport, { cwd: '/test' });
|
||||
|
||||
await respondToInitialize(transport, query);
|
||||
await query.close();
|
||||
|
||||
expect(transport.closed).toBe(true);
|
||||
@@ -842,6 +872,7 @@ describe('Query', () => {
|
||||
|
||||
it('should mark query as closed', async () => {
|
||||
const query = new Query(transport, { cwd: '/test' });
|
||||
await respondToInitialize(transport, query);
|
||||
expect(query.isClosed()).toBe(false);
|
||||
|
||||
await query.close();
|
||||
@@ -851,6 +882,8 @@ describe('Query', () => {
|
||||
it('should complete output stream on close()', async () => {
|
||||
const query = new Query(transport, { cwd: '/test' });
|
||||
|
||||
await respondToInitialize(transport, query);
|
||||
|
||||
const iterationPromise = (async () => {
|
||||
const messages: SDKMessage[] = [];
|
||||
for await (const msg of query) {
|
||||
@@ -869,6 +902,8 @@ describe('Query', () => {
|
||||
it('should be idempotent when closing multiple times', async () => {
|
||||
const query = new Query(transport, { cwd: '/test' });
|
||||
|
||||
await respondToInitialize(transport, query);
|
||||
|
||||
await query.close();
|
||||
await query.close();
|
||||
await query.close();
|
||||
@@ -883,6 +918,8 @@ describe('Query', () => {
|
||||
abortController,
|
||||
});
|
||||
|
||||
await respondToInitialize(transport, query);
|
||||
|
||||
abortController.abort();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
@@ -909,6 +946,8 @@ describe('Query', () => {
|
||||
it('should support for await loop', async () => {
|
||||
const query = new Query(transport, { cwd: '/test' });
|
||||
|
||||
await respondToInitialize(transport, query);
|
||||
|
||||
const messages: SDKMessage[] = [];
|
||||
const iterationPromise = (async () => {
|
||||
for await (const msg of query) {
|
||||
@@ -931,6 +970,8 @@ describe('Query', () => {
|
||||
it('should complete iteration when query closes', async () => {
|
||||
const query = new Query(transport, { cwd: '/test' });
|
||||
|
||||
await respondToInitialize(transport, query);
|
||||
|
||||
const messages: SDKMessage[] = [];
|
||||
const iterationPromise = (async () => {
|
||||
for await (const msg of query) {
|
||||
@@ -953,6 +994,8 @@ describe('Query', () => {
|
||||
it('should propagate transport errors', async () => {
|
||||
const query = new Query(transport, { cwd: '/test' });
|
||||
|
||||
await respondToInitialize(transport, query);
|
||||
|
||||
const iterationPromise = (async () => {
|
||||
for await (const msg of query) {
|
||||
void msg;
|
||||
@@ -971,17 +1014,7 @@ describe('Query', () => {
|
||||
it('should provide interrupt() method', async () => {
|
||||
const query = new Query(transport, { cwd: '/test' });
|
||||
|
||||
// Respond to initialize
|
||||
await vi.waitFor(() => {
|
||||
expect(transport.writtenMessages.length).toBeGreaterThan(0);
|
||||
});
|
||||
const initRequest =
|
||||
transport.getLastWrittenMessage() as CLIControlRequest;
|
||||
transport.simulateMessage(
|
||||
createControlResponse(initRequest.request_id, true, {}),
|
||||
);
|
||||
|
||||
await query.initialized;
|
||||
await respondToInitialize(transport, query);
|
||||
|
||||
const interruptPromise = query.interrupt();
|
||||
|
||||
@@ -1011,17 +1044,7 @@ describe('Query', () => {
|
||||
it('should provide setPermissionMode() method', async () => {
|
||||
const query = new Query(transport, { cwd: '/test' });
|
||||
|
||||
// Respond to initialize
|
||||
await vi.waitFor(() => {
|
||||
expect(transport.writtenMessages.length).toBeGreaterThan(0);
|
||||
});
|
||||
const initRequest =
|
||||
transport.getLastWrittenMessage() as CLIControlRequest;
|
||||
transport.simulateMessage(
|
||||
createControlResponse(initRequest.request_id, true, {}),
|
||||
);
|
||||
|
||||
await query.initialized;
|
||||
await respondToInitialize(transport, query);
|
||||
|
||||
const setModePromise = query.setPermissionMode('yolo');
|
||||
|
||||
@@ -1051,17 +1074,7 @@ describe('Query', () => {
|
||||
it('should provide setModel() method', async () => {
|
||||
const query = new Query(transport, { cwd: '/test' });
|
||||
|
||||
// Respond to initialize
|
||||
await vi.waitFor(() => {
|
||||
expect(transport.writtenMessages.length).toBeGreaterThan(0);
|
||||
});
|
||||
const initRequest =
|
||||
transport.getLastWrittenMessage() as CLIControlRequest;
|
||||
transport.simulateMessage(
|
||||
createControlResponse(initRequest.request_id, true, {}),
|
||||
);
|
||||
|
||||
await query.initialized;
|
||||
await respondToInitialize(transport, query);
|
||||
|
||||
const setModelPromise = query.setModel('new-model');
|
||||
|
||||
@@ -1091,17 +1104,7 @@ describe('Query', () => {
|
||||
it('should provide supportedCommands() method', async () => {
|
||||
const query = new Query(transport, { cwd: '/test' });
|
||||
|
||||
// Respond to initialize
|
||||
await vi.waitFor(() => {
|
||||
expect(transport.writtenMessages.length).toBeGreaterThan(0);
|
||||
});
|
||||
const initRequest =
|
||||
transport.getLastWrittenMessage() as CLIControlRequest;
|
||||
transport.simulateMessage(
|
||||
createControlResponse(initRequest.request_id, true, {}),
|
||||
);
|
||||
|
||||
await query.initialized;
|
||||
await respondToInitialize(transport, query);
|
||||
|
||||
const commandsPromise = query.supportedCommands();
|
||||
|
||||
@@ -1135,17 +1138,7 @@ describe('Query', () => {
|
||||
it('should provide mcpServerStatus() method', async () => {
|
||||
const query = new Query(transport, { cwd: '/test' });
|
||||
|
||||
// Respond to initialize
|
||||
await vi.waitFor(() => {
|
||||
expect(transport.writtenMessages.length).toBeGreaterThan(0);
|
||||
});
|
||||
const initRequest =
|
||||
transport.getLastWrittenMessage() as CLIControlRequest;
|
||||
transport.simulateMessage(
|
||||
createControlResponse(initRequest.request_id, true, {}),
|
||||
);
|
||||
|
||||
await query.initialized;
|
||||
await respondToInitialize(transport, query);
|
||||
|
||||
const statusPromise = query.mcpServerStatus();
|
||||
|
||||
@@ -1180,6 +1173,7 @@ describe('Query', () => {
|
||||
|
||||
it('should throw if methods called on closed query', async () => {
|
||||
const query = new Query(transport, { cwd: '/test' });
|
||||
await respondToInitialize(transport, query);
|
||||
await query.close();
|
||||
|
||||
await expect(query.interrupt()).rejects.toThrow('Query is closed');
|
||||
@@ -1198,6 +1192,8 @@ describe('Query', () => {
|
||||
it('should propagate transport errors to stream', async () => {
|
||||
const query = new Query(transport, { cwd: '/test' });
|
||||
|
||||
await respondToInitialize(transport, query);
|
||||
|
||||
const error = new Error('Transport failure');
|
||||
transport.simulateError(error);
|
||||
|
||||
@@ -1214,17 +1210,7 @@ describe('Query', () => {
|
||||
},
|
||||
});
|
||||
|
||||
// Respond to initialize
|
||||
await vi.waitFor(() => {
|
||||
expect(transport.writtenMessages.length).toBeGreaterThan(0);
|
||||
});
|
||||
const initRequest =
|
||||
transport.getLastWrittenMessage() as CLIControlRequest;
|
||||
transport.simulateMessage(
|
||||
createControlResponse(initRequest.request_id, true, {}),
|
||||
);
|
||||
|
||||
await query.initialized;
|
||||
await respondToInitialize(transport, query);
|
||||
|
||||
// Call interrupt but don't respond - should timeout
|
||||
const interruptPromise = query.interrupt();
|
||||
@@ -1237,17 +1223,7 @@ describe('Query', () => {
|
||||
it('should handle malformed control responses', async () => {
|
||||
const query = new Query(transport, { cwd: '/test' });
|
||||
|
||||
// Respond to initialize
|
||||
await vi.waitFor(() => {
|
||||
expect(transport.writtenMessages.length).toBeGreaterThan(0);
|
||||
});
|
||||
const initRequest =
|
||||
transport.getLastWrittenMessage() as CLIControlRequest;
|
||||
transport.simulateMessage(
|
||||
createControlResponse(initRequest.request_id, true, {}),
|
||||
);
|
||||
|
||||
await query.initialized;
|
||||
await respondToInitialize(transport, query);
|
||||
|
||||
const interruptPromise = query.interrupt();
|
||||
|
||||
@@ -1284,6 +1260,8 @@ describe('Query', () => {
|
||||
it('should handle CLI sending error result message', async () => {
|
||||
const query = new Query(transport, { cwd: '/test' });
|
||||
|
||||
await respondToInitialize(transport, query);
|
||||
|
||||
const errorResult = createResultMessage(false);
|
||||
transport.simulateMessage(errorResult);
|
||||
|
||||
@@ -1303,6 +1281,8 @@ describe('Query', () => {
|
||||
true, // singleTurn = true
|
||||
);
|
||||
|
||||
await respondToInitialize(transport, query);
|
||||
|
||||
const resultMsg = createResultMessage(true);
|
||||
transport.simulateMessage(resultMsg);
|
||||
|
||||
@@ -1320,6 +1300,8 @@ describe('Query', () => {
|
||||
false, // singleTurn = false
|
||||
);
|
||||
|
||||
await respondToInitialize(transport, query);
|
||||
|
||||
const resultMsg = createResultMessage(true);
|
||||
transport.simulateMessage(resultMsg);
|
||||
|
||||
@@ -1332,19 +1314,23 @@ describe('Query', () => {
|
||||
});
|
||||
|
||||
describe('State Management', () => {
|
||||
it('should track session ID', () => {
|
||||
it('should track session ID', async () => {
|
||||
const query = new Query(transport, { cwd: '/test' });
|
||||
const sessionId = query.getSessionId();
|
||||
|
||||
expect(sessionId).toBeTruthy();
|
||||
expect(typeof sessionId).toBe('string');
|
||||
expect(sessionId.length).toBeGreaterThan(0);
|
||||
|
||||
await respondToInitialize(transport, query);
|
||||
await query.close();
|
||||
});
|
||||
|
||||
it('should track closed state', async () => {
|
||||
const query = new Query(transport, { cwd: '/test' });
|
||||
|
||||
expect(query.isClosed()).toBe(false);
|
||||
await respondToInitialize(transport, query);
|
||||
await query.close();
|
||||
expect(query.isClosed()).toBe(true);
|
||||
});
|
||||
@@ -1352,17 +1338,7 @@ describe('Query', () => {
|
||||
it('should provide endInput() method', async () => {
|
||||
const query = new Query(transport, { cwd: '/test' });
|
||||
|
||||
// Respond to initialize
|
||||
await vi.waitFor(() => {
|
||||
expect(transport.writtenMessages.length).toBeGreaterThan(0);
|
||||
});
|
||||
const initRequest =
|
||||
transport.getLastWrittenMessage() as CLIControlRequest;
|
||||
transport.simulateMessage(
|
||||
createControlResponse(initRequest.request_id, true, {}),
|
||||
);
|
||||
|
||||
await query.initialized;
|
||||
await respondToInitialize(transport, query);
|
||||
|
||||
query.endInput();
|
||||
expect(transport.endInputCalled).toBe(true);
|
||||
@@ -1372,6 +1348,7 @@ describe('Query', () => {
|
||||
|
||||
it('should throw if endInput() called on closed query', async () => {
|
||||
const query = new Query(transport, { cwd: '/test' });
|
||||
await respondToInitialize(transport, query);
|
||||
await query.close();
|
||||
|
||||
expect(() => query.endInput()).toThrow('Query is closed');
|
||||
@@ -1382,6 +1359,8 @@ describe('Query', () => {
|
||||
it('should handle empty message stream', async () => {
|
||||
const query = new Query(transport, { cwd: '/test' });
|
||||
|
||||
await respondToInitialize(transport, query);
|
||||
|
||||
transport.simulateClose();
|
||||
|
||||
const result = await query.next();
|
||||
@@ -1393,6 +1372,8 @@ describe('Query', () => {
|
||||
it('should handle rapid message flow', async () => {
|
||||
const query = new Query(transport, { cwd: '/test' });
|
||||
|
||||
await respondToInitialize(transport, query);
|
||||
|
||||
// Simulate rapid messages
|
||||
for (let i = 0; i < 100; i++) {
|
||||
transport.simulateMessage(createUserMessage(`Message ${i}`));
|
||||
@@ -1414,6 +1395,8 @@ describe('Query', () => {
|
||||
it('should handle close during message iteration', async () => {
|
||||
const query = new Query(transport, { cwd: '/test' });
|
||||
|
||||
await respondToInitialize(transport, query);
|
||||
|
||||
const iterationPromise = (async () => {
|
||||
const messages: SDKMessage[] = [];
|
||||
for await (const msg of query) {
|
||||
|
||||
@@ -21,7 +21,7 @@ import type {
|
||||
AcpConnectionCallbacks,
|
||||
} from '../types/connectionTypes.js';
|
||||
import { AcpMessageHandler } from './acpMessageHandler.js';
|
||||
import { AcpSessionManager, type PromptContent } from './acpSessionManager.js';
|
||||
import { AcpSessionManager } from './acpSessionManager.js';
|
||||
import * as fs from 'node:fs';
|
||||
|
||||
/**
|
||||
@@ -306,12 +306,12 @@ export class AcpConnection {
|
||||
}
|
||||
|
||||
/**
|
||||
* Send prompt message with support for multimodal content
|
||||
* Send prompt message
|
||||
*
|
||||
* @param prompt - Either a plain text string or array of content items
|
||||
* @param prompt - Prompt content
|
||||
* @returns Response
|
||||
*/
|
||||
async sendPrompt(prompt: string | PromptContent[]): Promise<AcpResponse> {
|
||||
async sendPrompt(prompt: string): Promise<AcpResponse> {
|
||||
return this.sessionManager.sendPrompt(
|
||||
prompt,
|
||||
this.child,
|
||||
|
||||
@@ -21,13 +21,6 @@ import type { PendingRequest } from '../types/connectionTypes.js';
|
||||
import type { ChildProcess } from 'child_process';
|
||||
import { isWindows } from '../utils/platform.js';
|
||||
|
||||
/**
|
||||
* Prompt content types for multimodal messages
|
||||
*/
|
||||
export type PromptContent =
|
||||
| { type: 'text'; text: string }
|
||||
| { type: 'image'; data: string; mimeType: string };
|
||||
|
||||
/**
|
||||
* ACP Session Manager Class
|
||||
* Provides session initialization, authentication, creation, loading, and switching functionality
|
||||
@@ -220,9 +213,9 @@ export class AcpSessionManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* Send prompt message with support for multimodal content (text and images)
|
||||
* Send prompt message
|
||||
*
|
||||
* @param prompt - Either a plain text string or array of content items
|
||||
* @param prompt - Prompt content
|
||||
* @param child - Child process instance
|
||||
* @param pendingRequests - Pending requests map
|
||||
* @param nextRequestId - Request ID counter
|
||||
@@ -230,7 +223,7 @@ export class AcpSessionManager {
|
||||
* @throws Error when there is no active session
|
||||
*/
|
||||
async sendPrompt(
|
||||
prompt: string | PromptContent[],
|
||||
prompt: string,
|
||||
child: ChildProcess | null,
|
||||
pendingRequests: Map<number, PendingRequest<unknown>>,
|
||||
nextRequestId: { value: number },
|
||||
@@ -239,35 +232,11 @@ export class AcpSessionManager {
|
||||
throw new Error('No active ACP session');
|
||||
}
|
||||
|
||||
// Convert string to array format for backward compatibility
|
||||
const promptContent: PromptContent[] =
|
||||
typeof prompt === 'string' ? [{ type: 'text', text: prompt }] : prompt;
|
||||
|
||||
// Debug log to see what we're sending
|
||||
console.log(
|
||||
'[ACP] Sending prompt with content:',
|
||||
JSON.stringify(promptContent, null, 2),
|
||||
);
|
||||
console.log(
|
||||
'[ACP] Content types:',
|
||||
promptContent.map((c) => c.type),
|
||||
);
|
||||
if (promptContent.some((c) => c.type === 'image')) {
|
||||
console.log('[ACP] Message includes images');
|
||||
promptContent.forEach((content, index) => {
|
||||
if (content.type === 'image') {
|
||||
console.log(
|
||||
`[ACP] Image ${index}: mimeType=${content.mimeType}, data length=${content.data.length}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return await this.sendRequest(
|
||||
AGENT_METHODS.session_prompt,
|
||||
{
|
||||
sessionId: this.sessionId,
|
||||
prompt: promptContent,
|
||||
prompt: [{ type: 'text', text: prompt }],
|
||||
},
|
||||
child,
|
||||
pendingRequests,
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
import { AcpConnection } from './acpConnection.js';
|
||||
import type { PromptContent } from './acpSessionManager.js';
|
||||
import type {
|
||||
AcpSessionUpdate,
|
||||
AcpPermissionRequest,
|
||||
@@ -216,7 +215,7 @@ export class QwenAgentManager {
|
||||
*
|
||||
* @param message - Message content
|
||||
*/
|
||||
async sendMessage(message: string | PromptContent[]): Promise<void> {
|
||||
async sendMessage(message: string): Promise<void> {
|
||||
await this.connection.sendPrompt(message);
|
||||
}
|
||||
|
||||
|
||||
@@ -10,14 +10,6 @@ export interface ChatMessage {
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
timestamp: number;
|
||||
attachments?: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
size: number;
|
||||
data: string;
|
||||
timestamp: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface PlanEntry {
|
||||
|
||||
@@ -51,9 +51,6 @@ import {
|
||||
DEFAULT_TOKEN_LIMIT,
|
||||
tokenLimit,
|
||||
} from '@qwen-code/qwen-code-core/src/core/tokenLimits.js';
|
||||
import type { ImageAttachment } from './utils/imageUtils.js';
|
||||
import { formatFileSize, MAX_TOTAL_IMAGE_SIZE } from './utils/imageUtils.js';
|
||||
import { usePasteHandler } from './hooks/usePasteHandler.js';
|
||||
|
||||
export const App: React.FC = () => {
|
||||
const vscode = useVSCode();
|
||||
@@ -71,7 +68,6 @@ export const App: React.FC = () => {
|
||||
|
||||
// UI state
|
||||
const [inputText, setInputText] = useState('');
|
||||
const [attachedImages, setAttachedImages] = useState<ImageAttachment[]>([]);
|
||||
const [permissionRequest, setPermissionRequest] = useState<{
|
||||
options: PermissionOption[];
|
||||
toolCall: PermissionToolCall;
|
||||
@@ -247,54 +243,10 @@ export const App: React.FC = () => {
|
||||
completion.query,
|
||||
]);
|
||||
|
||||
// Image handling
|
||||
const handleAddImages = useCallback((newImages: ImageAttachment[]) => {
|
||||
setAttachedImages((prev) => {
|
||||
const currentTotal = prev.reduce((sum, img) => sum + img.size, 0);
|
||||
let runningTotal = currentTotal;
|
||||
const accepted: ImageAttachment[] = [];
|
||||
|
||||
for (const img of newImages) {
|
||||
if (runningTotal + img.size > MAX_TOTAL_IMAGE_SIZE) {
|
||||
console.warn(
|
||||
`Skipping image "${img.name}" – total attachment size would exceed ${formatFileSize(MAX_TOTAL_IMAGE_SIZE)}.`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
accepted.push(img);
|
||||
runningTotal += img.size;
|
||||
}
|
||||
|
||||
if (accepted.length === 0) {
|
||||
return prev;
|
||||
}
|
||||
return [...prev, ...accepted];
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleRemoveImage = useCallback((imageId: string) => {
|
||||
setAttachedImages((prev) => prev.filter((img) => img.id !== imageId));
|
||||
}, []);
|
||||
|
||||
const clearImages = useCallback(() => {
|
||||
setAttachedImages([]);
|
||||
}, []);
|
||||
|
||||
// Initialize paste handler
|
||||
const { handlePaste } = usePasteHandler({
|
||||
onImagesAdded: handleAddImages,
|
||||
onError: (error) => {
|
||||
console.error('Paste error:', error);
|
||||
// You can show a toast/notification here if needed
|
||||
},
|
||||
});
|
||||
|
||||
// Message submission
|
||||
const { handleSubmit: submitMessage } = useMessageSubmit({
|
||||
inputText,
|
||||
setInputText,
|
||||
attachedImages,
|
||||
clearImages,
|
||||
messageHandling,
|
||||
fileContext,
|
||||
skipAutoActiveContext,
|
||||
@@ -714,7 +666,6 @@ export const App: React.FC = () => {
|
||||
timestamp={msg.timestamp || 0}
|
||||
onFileClick={handleFileClick}
|
||||
fileContext={msg.fileContext}
|
||||
attachments={msg.attachments}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -863,7 +814,6 @@ export const App: React.FC = () => {
|
||||
activeSelection={fileContext.activeSelection}
|
||||
skipAutoActiveContext={skipAutoActiveContext}
|
||||
contextUsage={contextUsage}
|
||||
attachedImages={attachedImages}
|
||||
onInputChange={setInputText}
|
||||
onCompositionStart={() => setIsComposing(true)}
|
||||
onCompositionEnd={() => setIsComposing(false)}
|
||||
@@ -876,8 +826,6 @@ export const App: React.FC = () => {
|
||||
onToggleSkipAutoActiveContext={() =>
|
||||
setSkipAutoActiveContext((v) => !v)
|
||||
}
|
||||
onPaste={handlePaste}
|
||||
onRemoveImage={handleRemoveImage}
|
||||
onShowCommandMenu={async () => {
|
||||
if (inputFieldRef.current) {
|
||||
inputFieldRef.current.focus();
|
||||
|
||||
@@ -38,7 +38,7 @@ export class WebViewContent {
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src ${panel.webview.cspSource} data:; script-src ${panel.webview.cspSource}; style-src ${panel.webview.cspSource} 'unsafe-inline';">
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src ${panel.webview.cspSource}; script-src ${panel.webview.cspSource}; style-src ${panel.webview.cspSource} 'unsafe-inline';">
|
||||
<title>Qwen Code</title>
|
||||
</head>
|
||||
<body data-extension-uri="${safeExtensionUri}">
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import type { ImageAttachment } from '../utils/imageUtils.js';
|
||||
|
||||
interface ImagePreviewProps {
|
||||
images: ImageAttachment[];
|
||||
onRemove: (id: string) => void;
|
||||
}
|
||||
|
||||
export const ImagePreview: React.FC<ImagePreviewProps> = ({
|
||||
images,
|
||||
onRemove,
|
||||
}) => {
|
||||
if (!images || images.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="image-preview-container flex gap-2 px-2 pb-2">
|
||||
{images.map((image) => (
|
||||
<div key={image.id} className="image-preview-item relative group">
|
||||
<div className="relative">
|
||||
<img
|
||||
src={image.data}
|
||||
alt={image.name}
|
||||
className="w-14 h-14 object-cover rounded-md border border-gray-500 dark:border-gray-600"
|
||||
title={image.name}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRemove(image.id)}
|
||||
className="absolute -top-2 -right-2 w-5 h-5 bg-gray-700 dark:bg-gray-600 text-white rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity hover:bg-gray-800 dark:hover:bg-gray-500"
|
||||
aria-label={`Remove ${image.name}`}
|
||||
>
|
||||
<svg
|
||||
className="w-3 h-3"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -22,8 +22,6 @@ import type { CompletionItem } from '../../../types/completionItemTypes.js';
|
||||
import { getApprovalModeInfoFromString } from '../../../types/acpTypes.js';
|
||||
import type { ApprovalModeValue } from '../../../types/approvalModeValueTypes.js';
|
||||
import { ContextIndicator } from './ContextIndicator.js';
|
||||
import { ImagePreview } from '../ImagePreview.js';
|
||||
import type { ImageAttachment } from '../../utils/imageUtils.js';
|
||||
|
||||
interface InputFormProps {
|
||||
inputText: string;
|
||||
@@ -44,7 +42,6 @@ interface InputFormProps {
|
||||
usedTokens: number;
|
||||
tokenLimit: number;
|
||||
} | null;
|
||||
attachedImages?: ImageAttachment[];
|
||||
onInputChange: (text: string) => void;
|
||||
onCompositionStart: () => void;
|
||||
onCompositionEnd: () => void;
|
||||
@@ -57,8 +54,6 @@ interface InputFormProps {
|
||||
onToggleSkipAutoActiveContext: () => void;
|
||||
onShowCommandMenu: () => void;
|
||||
onAttachContext: () => void;
|
||||
onPaste?: (e: React.ClipboardEvent) => void;
|
||||
onRemoveImage?: (id: string) => void;
|
||||
completionIsOpen: boolean;
|
||||
completionItems?: CompletionItem[];
|
||||
onCompletionSelect?: (item: CompletionItem) => void;
|
||||
@@ -108,7 +103,6 @@ export const InputForm: React.FC<InputFormProps> = ({
|
||||
activeSelection,
|
||||
skipAutoActiveContext,
|
||||
contextUsage,
|
||||
attachedImages = [],
|
||||
onInputChange,
|
||||
onCompositionStart,
|
||||
onCompositionEnd,
|
||||
@@ -120,8 +114,6 @@ export const InputForm: React.FC<InputFormProps> = ({
|
||||
onToggleSkipAutoActiveContext,
|
||||
onShowCommandMenu,
|
||||
onAttachContext,
|
||||
onPaste,
|
||||
onRemoveImage,
|
||||
completionIsOpen,
|
||||
completionItems,
|
||||
onCompletionSelect,
|
||||
@@ -168,7 +160,7 @@ export const InputForm: React.FC<InputFormProps> = ({
|
||||
{/* Banner area */}
|
||||
<div className="input-banner" />
|
||||
|
||||
<div className="relative flex flex-col z-[1]">
|
||||
<div className="relative flex z-[1]">
|
||||
{completionIsOpen &&
|
||||
completionItems &&
|
||||
completionItems.length > 0 &&
|
||||
@@ -206,14 +198,8 @@ export const InputForm: React.FC<InputFormProps> = ({
|
||||
onCompositionStart={onCompositionStart}
|
||||
onCompositionEnd={onCompositionEnd}
|
||||
onKeyDown={handleKeyDown}
|
||||
onPaste={onPaste}
|
||||
suppressContentEditableWarning
|
||||
/>
|
||||
|
||||
{/* Image Preview area - shown at the bottom inside the input box */}
|
||||
{attachedImages.length > 0 && onRemoveImage && (
|
||||
<ImagePreview images={attachedImages} onRemove={onRemoveImage} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="composer-actions">
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
|
||||
import type React from 'react';
|
||||
import { MessageContent } from './MessageContent.js';
|
||||
import type { ImageAttachment } from '../../utils/imageUtils.js';
|
||||
|
||||
interface FileContext {
|
||||
fileName: string;
|
||||
@@ -20,7 +19,6 @@ interface UserMessageProps {
|
||||
timestamp: number;
|
||||
onFileClick?: (path: string) => void;
|
||||
fileContext?: FileContext;
|
||||
attachments?: ImageAttachment[];
|
||||
}
|
||||
|
||||
export const UserMessage: React.FC<UserMessageProps> = ({
|
||||
@@ -28,7 +26,6 @@ export const UserMessage: React.FC<UserMessageProps> = ({
|
||||
timestamp: _timestamp,
|
||||
onFileClick,
|
||||
fileContext,
|
||||
attachments,
|
||||
}) => {
|
||||
// Generate display text for file context
|
||||
const getFileContextDisplay = () => {
|
||||
@@ -69,24 +66,6 @@ export const UserMessage: React.FC<UserMessageProps> = ({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Display attached images */}
|
||||
{attachments && attachments.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{attachments.map((attachment) => (
|
||||
<div key={attachment.id} className="relative">
|
||||
<img
|
||||
src={attachment.data}
|
||||
alt={attachment.name}
|
||||
className="max-w-[200px] max-h-[200px] rounded-md border border-gray-300 dark:border-gray-600"
|
||||
style={{
|
||||
objectFit: 'contain',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* File context indicator */}
|
||||
{fileContextDisplay && (
|
||||
<div className="mt-1">
|
||||
|
||||
@@ -9,11 +9,6 @@ import { BaseMessageHandler } from './BaseMessageHandler.js';
|
||||
import type { ChatMessage } from '../../services/qwenAgentManager.js';
|
||||
import type { ApprovalModeValue } from '../../types/approvalModeValueTypes.js';
|
||||
import { ACP_ERROR_CODES } from '../../constants/acpSchema.js';
|
||||
import type { PromptContent } from '../../services/acpSessionManager.js';
|
||||
import {
|
||||
cleanupOldClipboardImages,
|
||||
saveBase64ImageSync,
|
||||
} from '@qwen-code/qwen-code-core/src/utils/clipboardImageStorage.js';
|
||||
|
||||
const AUTH_REQUIRED_CODE_PATTERN = `(code: ${ACP_ERROR_CODES.AUTH_REQUIRED})`;
|
||||
|
||||
@@ -50,51 +45,29 @@ export class SessionMessageHandler extends BaseMessageHandler {
|
||||
}
|
||||
|
||||
async handle(message: { type: string; data?: unknown }): Promise<void> {
|
||||
type SendMessagePayload = {
|
||||
text?: string;
|
||||
context?: Array<{
|
||||
type: string;
|
||||
name: string;
|
||||
value: string;
|
||||
startLine?: number;
|
||||
endLine?: number;
|
||||
}>;
|
||||
fileContext?: {
|
||||
fileName: string;
|
||||
filePath: string;
|
||||
startLine?: number;
|
||||
endLine?: number;
|
||||
};
|
||||
attachments?: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
size: number;
|
||||
data: string;
|
||||
timestamp: number;
|
||||
}>;
|
||||
};
|
||||
|
||||
type MessageData = {
|
||||
text?: string;
|
||||
context?: SendMessagePayload['context'];
|
||||
fileContext?: SendMessagePayload['fileContext'];
|
||||
attachments?: SendMessagePayload['attachments'];
|
||||
sessionId?: string;
|
||||
cursor?: number;
|
||||
size?: number;
|
||||
tag?: string;
|
||||
};
|
||||
|
||||
const data = message.data as MessageData | undefined;
|
||||
const data = message.data as Record<string, unknown> | undefined;
|
||||
|
||||
switch (message.type) {
|
||||
case 'sendMessage':
|
||||
await this.handleSendMessage(
|
||||
data?.text || '',
|
||||
data?.context,
|
||||
data?.fileContext,
|
||||
data?.attachments,
|
||||
(data?.text as string) || '',
|
||||
data?.context as
|
||||
| Array<{
|
||||
type: string;
|
||||
name: string;
|
||||
value: string;
|
||||
startLine?: number;
|
||||
endLine?: number;
|
||||
}>
|
||||
| undefined,
|
||||
data?.fileContext as
|
||||
| {
|
||||
fileName: string;
|
||||
filePath: string;
|
||||
startLine?: number;
|
||||
endLine?: number;
|
||||
}
|
||||
| undefined,
|
||||
);
|
||||
break;
|
||||
|
||||
@@ -103,19 +76,22 @@ export class SessionMessageHandler extends BaseMessageHandler {
|
||||
break;
|
||||
|
||||
case 'switchQwenSession':
|
||||
await this.handleSwitchQwenSession(data?.sessionId || '');
|
||||
await this.handleSwitchQwenSession((data?.sessionId as string) || '');
|
||||
break;
|
||||
|
||||
case 'getQwenSessions':
|
||||
await this.handleGetQwenSessions(data?.cursor, data?.size);
|
||||
await this.handleGetQwenSessions(
|
||||
(data?.cursor as number | undefined) ?? undefined,
|
||||
(data?.size as number | undefined) ?? undefined,
|
||||
);
|
||||
break;
|
||||
|
||||
case 'saveSession':
|
||||
await this.handleSaveSession(data?.tag || '');
|
||||
await this.handleSaveSession((data?.tag as string) || '');
|
||||
break;
|
||||
|
||||
case 'resumeSession':
|
||||
await this.handleResumeSession(data?.sessionId || '');
|
||||
await this.handleResumeSession((data?.sessionId as string) || '');
|
||||
break;
|
||||
|
||||
case 'openNewChatTab':
|
||||
@@ -158,34 +134,6 @@ export class SessionMessageHandler extends BaseMessageHandler {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save base64 image to a temporary file
|
||||
* Uses the shared clipboard image storage utility from core package.
|
||||
* @param base64Data The base64 encoded image data (with or without data URL prefix)
|
||||
* @param fileName Original filename
|
||||
* @returns The relative path to the saved file or null if failed
|
||||
*/
|
||||
private saveImageToFile(base64Data: string, fileName: string): string | null {
|
||||
// Get workspace folder
|
||||
const workspaceFolder = vscode.workspace.workspaceFolders?.[0];
|
||||
if (!workspaceFolder) {
|
||||
console.error('[SessionMessageHandler] No workspace folder found');
|
||||
return null;
|
||||
}
|
||||
|
||||
const relativePath = saveBase64ImageSync(
|
||||
base64Data,
|
||||
fileName,
|
||||
workspaceFolder.uri.fsPath,
|
||||
);
|
||||
|
||||
if (relativePath) {
|
||||
console.log('[SessionMessageHandler] Saved image to:', relativePath);
|
||||
}
|
||||
|
||||
return relativePath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current stream content
|
||||
*/
|
||||
@@ -287,23 +235,8 @@ export class SessionMessageHandler extends BaseMessageHandler {
|
||||
startLine?: number;
|
||||
endLine?: number;
|
||||
},
|
||||
attachments?: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
size: number;
|
||||
data: string;
|
||||
timestamp: number;
|
||||
}>,
|
||||
): Promise<void> {
|
||||
console.log('[SessionMessageHandler] handleSendMessage called with:', text);
|
||||
if (attachments && attachments.length > 0) {
|
||||
console.log(
|
||||
'[SessionMessageHandler] Message includes',
|
||||
attachments.length,
|
||||
'image attachments',
|
||||
);
|
||||
}
|
||||
|
||||
// Format message with file context if present
|
||||
let formattedText = text;
|
||||
@@ -320,100 +253,6 @@ export class SessionMessageHandler extends BaseMessageHandler {
|
||||
formattedText = `${contextParts}\n\n${text}`;
|
||||
}
|
||||
|
||||
if (!formattedText && (!attachments || attachments.length === 0)) {
|
||||
this.sendToWebView({
|
||||
type: 'error',
|
||||
data: { message: 'Message is empty.' },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Build prompt content
|
||||
let promptContent: PromptContent[] = [];
|
||||
|
||||
// Add text content (with context if present)
|
||||
if (formattedText) {
|
||||
promptContent.push({
|
||||
type: 'text',
|
||||
text: formattedText,
|
||||
});
|
||||
}
|
||||
|
||||
// Add image attachments - save to files and reference them
|
||||
if (attachments && attachments.length > 0) {
|
||||
console.log(
|
||||
'[SessionMessageHandler] Processing attachments - saving to files',
|
||||
);
|
||||
|
||||
// Save images as files and add references to the text
|
||||
const imageReferences: string[] = [];
|
||||
|
||||
for (const attachment of attachments) {
|
||||
console.log('[SessionMessageHandler] Processing attachment:', {
|
||||
id: attachment.id,
|
||||
name: attachment.name,
|
||||
type: attachment.type,
|
||||
dataLength: attachment.data.length,
|
||||
});
|
||||
|
||||
// Save image to file (sync operation using shared utility)
|
||||
const imagePath = this.saveImageToFile(
|
||||
attachment.data,
|
||||
attachment.name,
|
||||
);
|
||||
if (imagePath) {
|
||||
// Add file reference to the message (like CLI does with @path)
|
||||
imageReferences.push(`@${imagePath}`);
|
||||
console.log(
|
||||
'[SessionMessageHandler] Added image reference:',
|
||||
`@${imagePath}`,
|
||||
);
|
||||
} else {
|
||||
console.warn(
|
||||
'[SessionMessageHandler] Failed to save image:',
|
||||
attachment.name,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const workspaceFolder = vscode.workspace.workspaceFolders?.[0];
|
||||
if (workspaceFolder) {
|
||||
cleanupOldClipboardImages(workspaceFolder.uri.fsPath).catch((error) => {
|
||||
console.warn(
|
||||
'[SessionMessageHandler] Failed to cleanup clipboard images:',
|
||||
error,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// Add image references to the text
|
||||
if (imageReferences.length > 0) {
|
||||
const imageText = imageReferences.join(' ');
|
||||
// Update the formatted text with image references
|
||||
const updatedText = formattedText
|
||||
? `${formattedText}\n\n${imageText}`
|
||||
: imageText;
|
||||
|
||||
// Replace the prompt content with updated text
|
||||
promptContent = [
|
||||
{
|
||||
type: 'text',
|
||||
text: updatedText,
|
||||
},
|
||||
];
|
||||
|
||||
console.log(
|
||||
'[SessionMessageHandler] Updated text with image references:',
|
||||
updatedText,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('[SessionMessageHandler] Final promptContent:', {
|
||||
count: promptContent.length,
|
||||
types: promptContent.map((c) => c.type),
|
||||
});
|
||||
|
||||
// Ensure we have an active conversation
|
||||
if (!this.currentConversationId) {
|
||||
console.log(
|
||||
@@ -482,19 +321,17 @@ export class SessionMessageHandler extends BaseMessageHandler {
|
||||
role: 'user',
|
||||
content: text,
|
||||
timestamp: Date.now(),
|
||||
...(attachments && attachments.length > 0 ? { attachments } : {}),
|
||||
};
|
||||
|
||||
// Store the original message with just text
|
||||
await this.conversationStore.addMessage(
|
||||
this.currentConversationId,
|
||||
userMessage,
|
||||
);
|
||||
|
||||
// Send to WebView with file context and attachments
|
||||
// Send to WebView
|
||||
this.sendToWebView({
|
||||
type: 'message',
|
||||
data: { ...userMessage, fileContext, attachments },
|
||||
data: { ...userMessage, fileContext },
|
||||
});
|
||||
|
||||
// Check if agent is connected
|
||||
@@ -542,8 +379,7 @@ export class SessionMessageHandler extends BaseMessageHandler {
|
||||
data: { timestamp: Date.now() },
|
||||
});
|
||||
|
||||
// Send multimodal content instead of plain text
|
||||
await this.agentManager.sendMessage(promptContent);
|
||||
await this.agentManager.sendMessage(formattedText);
|
||||
|
||||
// Save assistant message
|
||||
if (this.currentStreamContent && this.currentConversationId) {
|
||||
@@ -736,13 +572,7 @@ export class SessionMessageHandler extends BaseMessageHandler {
|
||||
}
|
||||
|
||||
// Get session details (includes cwd and filePath when using ACP)
|
||||
type SessionDetails = {
|
||||
id?: string;
|
||||
sessionId?: string;
|
||||
cwd?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
let sessionDetails: SessionDetails | null = null;
|
||||
let sessionDetails: Record<string, unknown> | null = null;
|
||||
try {
|
||||
const allSessions = await this.agentManager.getSessionList();
|
||||
sessionDetails =
|
||||
@@ -771,7 +601,7 @@ export class SessionMessageHandler extends BaseMessageHandler {
|
||||
|
||||
const loadResponse = await this.agentManager.loadSessionViaAcp(
|
||||
sessionId,
|
||||
sessionDetails?.cwd,
|
||||
(sessionDetails?.cwd as string | undefined) || undefined,
|
||||
);
|
||||
console.log(
|
||||
'[SessionMessageHandler] session/load succeeded (per ACP spec result is null; actual history comes via session/update):',
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
*/
|
||||
|
||||
import { useState, useRef, useCallback } from 'react';
|
||||
import type { ImageAttachment } from '../../utils/imageUtils.js';
|
||||
|
||||
export interface TextMessage {
|
||||
role: 'user' | 'assistant' | 'thinking';
|
||||
@@ -17,7 +16,6 @@ export interface TextMessage {
|
||||
startLine?: number;
|
||||
endLine?: number;
|
||||
};
|
||||
attachments?: ImageAttachment[];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -7,14 +7,11 @@
|
||||
import { useCallback } from 'react';
|
||||
import type { VSCodeAPI } from './useVSCode.js';
|
||||
import { getRandomLoadingMessage } from '../../constants/loadingMessages.js';
|
||||
import type { ImageAttachment } from '../utils/imageUtils.js';
|
||||
|
||||
interface UseMessageSubmitProps {
|
||||
vscode: VSCodeAPI;
|
||||
inputText: string;
|
||||
setInputText: (text: string) => void;
|
||||
attachedImages?: ImageAttachment[];
|
||||
clearImages?: () => void;
|
||||
inputFieldRef: React.RefObject<HTMLDivElement>;
|
||||
isStreaming: boolean;
|
||||
isWaitingForResponse: boolean;
|
||||
@@ -42,8 +39,6 @@ export const useMessageSubmit = ({
|
||||
vscode,
|
||||
inputText,
|
||||
setInputText,
|
||||
attachedImages = [],
|
||||
clearImages,
|
||||
inputFieldRef,
|
||||
isStreaming,
|
||||
isWaitingForResponse,
|
||||
@@ -147,7 +142,6 @@ export const useMessageSubmit = ({
|
||||
text: inputText,
|
||||
context: context.length > 0 ? context : undefined,
|
||||
fileContext: fileContextForMessage,
|
||||
attachments: attachedImages.length > 0 ? attachedImages : undefined,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -159,15 +153,9 @@ export const useMessageSubmit = ({
|
||||
inputFieldRef.current.setAttribute('data-empty', 'true');
|
||||
}
|
||||
fileContext.clearFileReferences();
|
||||
// Clear attached images after sending
|
||||
if (clearImages) {
|
||||
clearImages();
|
||||
}
|
||||
},
|
||||
[
|
||||
inputText,
|
||||
attachedImages,
|
||||
clearImages,
|
||||
isStreaming,
|
||||
setInputText,
|
||||
inputFieldRef,
|
||||
|
||||
@@ -1,124 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { useCallback, useRef } from 'react';
|
||||
import {
|
||||
createImageAttachment,
|
||||
generatePastedImageName,
|
||||
isSupportedImage,
|
||||
isWithinSizeLimit,
|
||||
formatFileSize,
|
||||
type ImageAttachment,
|
||||
} from '../utils/imageUtils.js';
|
||||
|
||||
interface UsePasteHandlerOptions {
|
||||
onImagesAdded?: (images: ImageAttachment[]) => void;
|
||||
onTextPaste?: (text: string) => void;
|
||||
onError?: (error: string) => void;
|
||||
}
|
||||
|
||||
export function usePasteHandler({
|
||||
onImagesAdded,
|
||||
onTextPaste,
|
||||
onError,
|
||||
}: UsePasteHandlerOptions) {
|
||||
const processingRef = useRef(false);
|
||||
|
||||
const handlePaste = useCallback(
|
||||
async (event: React.ClipboardEvent | ClipboardEvent) => {
|
||||
// Prevent duplicate processing
|
||||
if (processingRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const clipboardData = event.clipboardData;
|
||||
if (!clipboardData) {
|
||||
return;
|
||||
}
|
||||
|
||||
const files = clipboardData.files;
|
||||
const hasFiles = files && files.length > 0;
|
||||
const imageFiles = hasFiles
|
||||
? Array.from(files).filter((file) => file.type.startsWith('image/'))
|
||||
: [];
|
||||
|
||||
// Check if there are image files in the clipboard
|
||||
if (imageFiles.length > 0) {
|
||||
processingRef.current = true;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
const imageAttachments: ImageAttachment[] = [];
|
||||
const errors: string[] = [];
|
||||
|
||||
try {
|
||||
for (const file of imageFiles) {
|
||||
// Check if it's a supported image type
|
||||
if (!isSupportedImage(file)) {
|
||||
errors.push(`Unsupported image type: ${file.type}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check file size
|
||||
if (!isWithinSizeLimit(file)) {
|
||||
errors.push(
|
||||
`Image "${file.name || 'pasted image'}" is too large (${formatFileSize(
|
||||
file.size,
|
||||
)}). Maximum size is 10MB.`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
// If the file doesn't have a name (clipboard paste), generate one
|
||||
const imageFile =
|
||||
file.name && file.name !== 'image.png'
|
||||
? file
|
||||
: new File([file], generatePastedImageName(file.type), {
|
||||
type: file.type,
|
||||
});
|
||||
|
||||
const attachment = await createImageAttachment(imageFile);
|
||||
if (attachment) {
|
||||
imageAttachments.push(attachment);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to process pasted image:', error);
|
||||
errors.push(
|
||||
`Failed to process image "${file.name || 'pasted image'}"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Report errors if any
|
||||
if (errors.length > 0 && onError) {
|
||||
onError(errors.join('\n'));
|
||||
}
|
||||
|
||||
// Add successfully processed images
|
||||
if (imageAttachments.length > 0 && onImagesAdded) {
|
||||
onImagesAdded(imageAttachments);
|
||||
}
|
||||
} finally {
|
||||
processingRef.current = false;
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle text paste
|
||||
const text = clipboardData.getData('text/plain');
|
||||
if (text && onTextPaste) {
|
||||
// Let the default paste behavior handle text
|
||||
// unless we want to process it specially
|
||||
onTextPaste(text);
|
||||
}
|
||||
},
|
||||
[onImagesAdded, onTextPaste, onError],
|
||||
);
|
||||
|
||||
return { handlePaste };
|
||||
}
|
||||
@@ -358,20 +358,6 @@ export const useWebViewMessages = ({
|
||||
role?: 'user' | 'assistant' | 'thinking';
|
||||
content?: string;
|
||||
timestamp?: number;
|
||||
fileContext?: {
|
||||
fileName: string;
|
||||
filePath: string;
|
||||
startLine?: number;
|
||||
endLine?: number;
|
||||
};
|
||||
attachments?: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
size: number;
|
||||
data: string;
|
||||
timestamp: number;
|
||||
}>;
|
||||
};
|
||||
handlers.messageHandling.addMessage(
|
||||
msg as unknown as Parameters<
|
||||
|
||||
@@ -1,163 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { beforeAll, describe, it, expect } from 'vitest';
|
||||
import { Buffer } from 'node:buffer';
|
||||
|
||||
// Polyfill browser APIs for Node test environment
|
||||
const g = globalThis as typeof globalThis & {
|
||||
FileReader?: typeof FileReader;
|
||||
atob?: typeof atob;
|
||||
File?: typeof File;
|
||||
};
|
||||
|
||||
if (!g.atob) {
|
||||
g.atob = (b64: string) => Buffer.from(b64, 'base64').toString('binary');
|
||||
}
|
||||
|
||||
if (!g.FileReader) {
|
||||
class MockFileReader {
|
||||
result: string | ArrayBuffer | null = null;
|
||||
onload: ((ev: ProgressEvent<FileReader>) => void) | null = null;
|
||||
onerror: ((ev: unknown) => void) | null = null;
|
||||
|
||||
readAsDataURL(blob: Blob) {
|
||||
blob
|
||||
.arrayBuffer()
|
||||
.then((buf) => {
|
||||
const base64 = Buffer.from(buf).toString('base64');
|
||||
const mime =
|
||||
(blob as { type?: string }).type || 'application/octet-stream';
|
||||
this.result = `data:${mime};base64,${base64}`;
|
||||
this.onload?.({} as ProgressEvent<FileReader>);
|
||||
})
|
||||
.catch((err) => {
|
||||
this.onerror?.(err);
|
||||
});
|
||||
}
|
||||
}
|
||||
g.FileReader = MockFileReader as unknown as typeof FileReader;
|
||||
}
|
||||
|
||||
if (!g.File) {
|
||||
class MockFile extends Blob {
|
||||
name: string;
|
||||
lastModified: number;
|
||||
constructor(
|
||||
bits: BlobPart[],
|
||||
name: string,
|
||||
options?: BlobPropertyBag & { lastModified?: number },
|
||||
) {
|
||||
super(bits, options);
|
||||
this.name = name;
|
||||
this.lastModified = options?.lastModified ?? Date.now();
|
||||
}
|
||||
}
|
||||
g.File = MockFile as unknown as typeof File;
|
||||
}
|
||||
|
||||
let fileToBase64: typeof import('./imageUtils.js').fileToBase64;
|
||||
let isSupportedImage: typeof import('./imageUtils.js').isSupportedImage;
|
||||
let isWithinSizeLimit: typeof import('./imageUtils.js').isWithinSizeLimit;
|
||||
let formatFileSize: typeof import('./imageUtils.js').formatFileSize;
|
||||
let generateImageId: typeof import('./imageUtils.js').generateImageId;
|
||||
let getExtensionFromMimeType: typeof import('./imageUtils.js').getExtensionFromMimeType;
|
||||
|
||||
beforeAll(async () => {
|
||||
const mod = await import('./imageUtils.js');
|
||||
fileToBase64 = mod.fileToBase64;
|
||||
isSupportedImage = mod.isSupportedImage;
|
||||
isWithinSizeLimit = mod.isWithinSizeLimit;
|
||||
formatFileSize = mod.formatFileSize;
|
||||
generateImageId = mod.generateImageId;
|
||||
getExtensionFromMimeType = mod.getExtensionFromMimeType;
|
||||
});
|
||||
|
||||
describe('Image Utils', () => {
|
||||
describe('isSupportedImage', () => {
|
||||
it('should accept supported image types', () => {
|
||||
const pngFile = new File([''], 'test.png', { type: 'image/png' });
|
||||
const jpegFile = new File([''], 'test.jpg', { type: 'image/jpeg' });
|
||||
const gifFile = new File([''], 'test.gif', { type: 'image/gif' });
|
||||
|
||||
expect(isSupportedImage(pngFile)).toBe(true);
|
||||
expect(isSupportedImage(jpegFile)).toBe(true);
|
||||
expect(isSupportedImage(gifFile)).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject unsupported file types', () => {
|
||||
const textFile = new File([''], 'test.txt', { type: 'text/plain' });
|
||||
const pdfFile = new File([''], 'test.pdf', { type: 'application/pdf' });
|
||||
|
||||
expect(isSupportedImage(textFile)).toBe(false);
|
||||
expect(isSupportedImage(pdfFile)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isWithinSizeLimit', () => {
|
||||
it('should accept files under 10MB', () => {
|
||||
const smallFile = new File(['a'.repeat(1024 * 1024)], 'small.png', {
|
||||
type: 'image/png',
|
||||
});
|
||||
expect(isWithinSizeLimit(smallFile)).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject files over 10MB', () => {
|
||||
// Create a mock file with size property
|
||||
const largeFile = {
|
||||
size: 11 * 1024 * 1024, // 11MB
|
||||
} as File;
|
||||
expect(isWithinSizeLimit(largeFile)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatFileSize', () => {
|
||||
it('should format bytes correctly', () => {
|
||||
expect(formatFileSize(0)).toBe('0 B');
|
||||
expect(formatFileSize(512)).toBe('512 B');
|
||||
expect(formatFileSize(1024)).toBe('1 KB');
|
||||
expect(formatFileSize(1536)).toBe('1.5 KB');
|
||||
expect(formatFileSize(1024 * 1024)).toBe('1 MB');
|
||||
expect(formatFileSize(1.5 * 1024 * 1024)).toBe('1.5 MB');
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateImageId', () => {
|
||||
it('should generate unique IDs', () => {
|
||||
const id1 = generateImageId();
|
||||
const id2 = generateImageId();
|
||||
|
||||
expect(id1).toMatch(/^img_\d+_[a-z0-9]+$/);
|
||||
expect(id2).toMatch(/^img_\d+_[a-z0-9]+$/);
|
||||
expect(id1).not.toBe(id2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getExtensionFromMimeType', () => {
|
||||
it('should return correct extensions', () => {
|
||||
expect(getExtensionFromMimeType('image/png')).toBe('.png');
|
||||
expect(getExtensionFromMimeType('image/jpeg')).toBe('.jpg');
|
||||
expect(getExtensionFromMimeType('image/gif')).toBe('.gif');
|
||||
expect(getExtensionFromMimeType('image/webp')).toBe('.webp');
|
||||
expect(getExtensionFromMimeType('unknown/type')).toBe('.png'); // default
|
||||
});
|
||||
});
|
||||
|
||||
describe('fileToBase64', () => {
|
||||
it('should convert file to base64', async () => {
|
||||
const content = 'test content';
|
||||
const file = new File([content], 'test.txt', { type: 'text/plain' });
|
||||
|
||||
const base64 = await fileToBase64(file);
|
||||
expect(base64).toMatch(/^data:text\/plain;base64,/);
|
||||
|
||||
// Decode and verify content
|
||||
const base64Content = base64.split(',')[1];
|
||||
const decoded = atob(base64Content);
|
||||
expect(decoded).toBe(content);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,155 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
// Supported image MIME types
|
||||
export const SUPPORTED_IMAGE_TYPES = [
|
||||
'image/png',
|
||||
'image/jpeg',
|
||||
'image/jpg',
|
||||
'image/gif',
|
||||
'image/webp',
|
||||
'image/bmp',
|
||||
];
|
||||
|
||||
// Maximum file size in bytes (10MB)
|
||||
export const MAX_IMAGE_SIZE = 10 * 1024 * 1024;
|
||||
// Maximum total size for all images in a single message (20MB)
|
||||
export const MAX_TOTAL_IMAGE_SIZE = 20 * 1024 * 1024;
|
||||
|
||||
export interface ImageAttachment {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
size: number;
|
||||
data: string; // base64 encoded
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a File or Blob to base64 string
|
||||
*/
|
||||
export async function fileToBase64(file: File | Blob): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
const result = reader.result as string;
|
||||
resolve(result);
|
||||
};
|
||||
reader.onerror = reject;
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a file is a supported image type
|
||||
*/
|
||||
export function isSupportedImage(file: File): boolean {
|
||||
return SUPPORTED_IMAGE_TYPES.includes(file.type);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a file size is within limits
|
||||
*/
|
||||
export function isWithinSizeLimit(file: File): boolean {
|
||||
return file.size <= MAX_IMAGE_SIZE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a unique ID for an image attachment
|
||||
*/
|
||||
export function generateImageId(): string {
|
||||
return `img_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a human-readable file size
|
||||
*/
|
||||
export function formatFileSize(bytes: number): string {
|
||||
if (bytes === 0) {
|
||||
return '0 B';
|
||||
}
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract image dimensions from base64 string
|
||||
*/
|
||||
export async function getImageDimensions(
|
||||
base64: string,
|
||||
): Promise<{ width: number; height: number }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
resolve({ width: img.width, height: img.height });
|
||||
};
|
||||
img.onerror = reject;
|
||||
img.src = base64;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an ImageAttachment from a File
|
||||
*/
|
||||
export async function createImageAttachment(
|
||||
file: File,
|
||||
): Promise<ImageAttachment | null> {
|
||||
if (!isSupportedImage(file)) {
|
||||
console.warn('Unsupported image type:', file.type);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!isWithinSizeLimit(file)) {
|
||||
console.warn('Image file too large:', formatFileSize(file.size));
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const base64Data = await fileToBase64(file);
|
||||
return {
|
||||
id: generateImageId(),
|
||||
name: file.name || `image_${Date.now()}`,
|
||||
type: file.type,
|
||||
size: file.size,
|
||||
data: base64Data,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Failed to create image attachment:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get extension from MIME type
|
||||
*/
|
||||
export function getExtensionFromMimeType(mimeType: string): string {
|
||||
const mimeMap: Record<string, string> = {
|
||||
'image/png': '.png',
|
||||
'image/jpeg': '.jpg',
|
||||
'image/jpg': '.jpg',
|
||||
'image/gif': '.gif',
|
||||
'image/webp': '.webp',
|
||||
'image/bmp': '.bmp',
|
||||
'image/svg+xml': '.svg',
|
||||
};
|
||||
return mimeMap[mimeType] || '.png';
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a clean filename for pasted images
|
||||
*/
|
||||
export function generatePastedImageName(mimeType: string): string {
|
||||
const now = new Date();
|
||||
const timeStr = `${now.getHours().toString().padStart(2, '0')}${now
|
||||
.getMinutes()
|
||||
.toString()
|
||||
.padStart(2, '0')}${now.getSeconds().toString().padStart(2, '0')}`;
|
||||
const ext = getExtensionFromMimeType(mimeType);
|
||||
return `pasted_image_${timeStr}${ext}`;
|
||||
}
|
||||
Reference in New Issue
Block a user