qwen-code/packages/core/src/utils/pathReader.test.ts
Shaojin Wen 83b394e423
feat(core): implement fork subagent for context sharing (#2936)
* feat(core): implement fork subagent for context sharing

- Make subagent_type optional in AgentTool
- Add forkSubagent.ts to build identical tool result prefixes
- Run fork processes in the background to preserve UX

* fix(core): fix test failures related to root execution and optional subagent_type

- Skip pathReader and edit tool permission tests when running as root
- Fix agent.test.ts to correctly mock execute call with extraHistory
- Remove unused imports in forkSubagent.ts

* fix(core): fix fork subagent bugs and add CacheSafeParams integration

Bug fixes:
- Fix AgentParams.subagent_type type: string -> string? (match schema)
- Fix undefined agentType passed to hook system (fallback to subagentConfig.name)
- Fix hook continuation missing extraHistory parameter
- Fix functionResponse missing id field (match coreToolScheduler pattern)
- Fix consecutive user messages in Gemini API (ensure history ends with model)
- Fix duplicate task_prompt when directive already in extraHistory
- Fix FORK_AGENT.systemPrompt empty string causing createChat to throw
- Fix redundant dynamic import of forkSubagent.js (merge into single import)
- Fix non-fork agent returning empty string on execution failure
- Fix misleading fork child rule referencing non-existent system prompt config
- Fix functionResponse.response key from {result:} to {output:} for consistency

CacheSafeParams integration:
- Retrieve parent's generationConfig via getCacheSafeParams() for cache sharing
- Add generationConfigOverride to CreateChatOptions and AgentHeadless.execute()
- Add toolsOverride to AgentHeadless.execute() for parent tool declarations
- Fork API requests now share byte-identical prefix with parent (DashScope cache hits)
- Graceful degradation when CacheSafeParams unavailable (first turn)

Docs:
- Add Fork Subagent section to sub-agents.md user manual
- Add fork-subagent-design.md design document

* fix(core): apply subagent tool exclusion to forked agents

Fork children were inheriting parent's cached tool declarations directly,
bypassing prepareTools() filtering and gaining access to AgentTool and
cron tools. Extract EXCLUDED_TOOLS_FOR_SUBAGENTS as a shared constant
and apply it to forkToolsOverride.

* fix(core): skip env history whenever extraHistory is provided

Previously gated on generationConfigOverride, which meant the no-cache
fallback path (CacheSafeParams unavailable) still ran getInitialChatHistory
and duplicated env bootstrap messages already present in the parent's
history. Gate on extraHistory instead so both fork paths skip env init.

* fix(core): use explicit skipEnvHistory flag for fork env handling

The previous fix gated env-init skipping on the presence of extraHistory,
but agent-interactive (arena) also passes extraHistory — its chatHistory is
env-stripped by stripStartupContext() and DOES need fresh env init for the
child's working directory. Skipping env there broke the interactive path.

Replace the implicit gate with an explicit skipEnvHistory option that only
fork sets (when extraHistory is present, since fork's history comes from
getHistory(true) and already contains env).

* fix(core): defend skipEnvHistory gate against empty extraHistory

Edge case: when the parent's rawHistory ends with a user message and has
length 1, extraHistory becomes []. The previous gate (extraHistory !==
undefined) would set skipEnvHistory: true, leaving the fork with neither
env bootstrap nor parent history. Check length > 0 so empty arrays fall
through to the normal env-init path.

* fix(core): apply skipEnvHistory to stop-hook retry execute

The second subagent.execute() call in the SubagentStop retry loop was
missing skipEnvHistory, so on retry the fork's env context would be
duplicated — same bug as the initial tanzhenxin report, just on a less
common code path.
2026-04-14 14:27:38 +08:00

448 lines
15 KiB
TypeScript

/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, afterEach, vi } from 'vitest';
import mock from 'mock-fs';
import * as path from 'node:path';
import { WorkspaceContext } from './workspaceContext.js';
import { readPathFromWorkspace } from './pathReader.js';
import type { Config } from '../config/config.js';
import { StandardFileSystemService } from '../services/fileSystemService.js';
import type { FileDiscoveryService } from '../services/fileDiscoveryService.js';
// --- Helper for creating a mock Config object ---
// We use the actual implementations of WorkspaceContext and FileSystemService
// to test the integration against mock-fs.
const createMockConfig = (
cwd: string,
otherDirs: string[] = [],
mockFileService?: FileDiscoveryService,
fileFilteringOptions?: {
respectGitIgnore: boolean;
respectQwenIgnore: boolean;
},
): Config => {
const workspace = new WorkspaceContext(cwd, otherDirs);
const fileSystemService = new StandardFileSystemService();
return {
getWorkspaceContext: () => workspace,
// TargetDir is used by processSingleFileContent to generate relative paths in errors/output
getTargetDir: () => cwd,
getFileSystemService: () => fileSystemService,
getFileService: () => mockFileService,
getFileFilteringOptions: () =>
fileFilteringOptions ?? {
respectGitIgnore: true,
respectQwenIgnore: true,
},
getTruncateToolOutputThreshold: () => 2500,
getTruncateToolOutputLines: () => 500,
getContentGeneratorConfig: () => ({
modalities: { image: true, pdf: true, audio: true, video: true },
}),
} as unknown as Config;
};
describe('readPathFromWorkspace', () => {
const CWD = path.resolve('/test/cwd');
const OTHER_DIR = path.resolve('/test/other');
const OUTSIDE_DIR = path.resolve('/test/outside');
afterEach(() => {
mock.restore();
vi.resetAllMocks();
});
it('should read a text file from the CWD', async () => {
mock({
[CWD]: {
'file.txt': 'hello from cwd',
},
});
const mockFileService = {
filterFiles: vi.fn((files) => files),
} as unknown as FileDiscoveryService;
const config = createMockConfig(CWD, [], mockFileService);
const result = await readPathFromWorkspace('file.txt', config);
// Expect [string] for text content
expect(result).toEqual(['hello from cwd']);
expect(mockFileService.filterFiles).toHaveBeenCalled();
});
it('should read a file from a secondary workspace directory', async () => {
mock({
[CWD]: {},
[OTHER_DIR]: {
'file.txt': 'hello from other dir',
},
});
const mockFileService = {
filterFiles: vi.fn((files) => files),
} as unknown as FileDiscoveryService;
const config = createMockConfig(CWD, [OTHER_DIR], mockFileService);
const result = await readPathFromWorkspace('file.txt', config);
expect(result).toEqual(['hello from other dir']);
});
it('should prioritize CWD when file exists in both CWD and secondary dir', async () => {
mock({
[CWD]: {
'file.txt': 'hello from cwd',
},
[OTHER_DIR]: {
'file.txt': 'hello from other dir',
},
});
const mockFileService = {
filterFiles: vi.fn((files) => files),
} as unknown as FileDiscoveryService;
const config = createMockConfig(CWD, [OTHER_DIR], mockFileService);
const result = await readPathFromWorkspace('file.txt', config);
expect(result).toEqual(['hello from cwd']);
});
it('should read an image file and return it as inlineData (Part object)', async () => {
// Use a real PNG header for robustness
const imageData = Buffer.from([
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
]);
mock({
[CWD]: {
'image.png': imageData,
},
});
const mockFileService = {
filterFiles: vi.fn((files) => files),
} as unknown as FileDiscoveryService;
const config = createMockConfig(CWD, [], mockFileService);
const result = await readPathFromWorkspace('image.png', config);
// Expect [Part] for image content
expect(result).toEqual([
{
inlineData: {
mimeType: 'image/png',
data: imageData.toString('base64'),
displayName: 'image.png',
},
},
]);
});
it('should read a generic binary file and return an info string', async () => {
// Data that is clearly binary (null bytes)
const binaryData = Buffer.from([0x00, 0x01, 0x02, 0x03]);
mock({
[CWD]: {
'data.bin': binaryData,
},
});
const mockFileService = {
filterFiles: vi.fn((files) => files),
} as unknown as FileDiscoveryService;
const config = createMockConfig(CWD, [], mockFileService);
const result = await readPathFromWorkspace('data.bin', config);
// Expect [string] containing the skip message from fileUtils
expect(result).toEqual(['Cannot display content of binary file: data.bin']);
});
it('should read a file from an absolute path if within workspace', async () => {
const absPath = path.join(OTHER_DIR, 'abs.txt');
mock({
[CWD]: {},
[OTHER_DIR]: {
'abs.txt': 'absolute content',
},
});
const mockFileService = {
filterFiles: vi.fn((files) => files),
} as unknown as FileDiscoveryService;
const config = createMockConfig(CWD, [OTHER_DIR], mockFileService);
const result = await readPathFromWorkspace(absPath, config);
expect(result).toEqual(['absolute content']);
});
describe('Directory Expansion', () => {
it('should expand a directory and read the content of its files', async () => {
mock({
[CWD]: {
'my-dir': {
'file1.txt': 'content of file 1',
'file2.md': 'content of file 2',
},
},
});
const mockFileService = {
filterFiles: vi.fn((files) => files),
} as unknown as FileDiscoveryService;
const config = createMockConfig(CWD, [], mockFileService);
const result = await readPathFromWorkspace('my-dir', config);
// Convert to a single string for easier, order-independent checking
const resultText = result
.map((p) => {
if (typeof p === 'string') return p;
if (typeof p === 'object' && p && 'text' in p) return p.text;
// This part is important for handling binary/image data which isn't just text
if (typeof p === 'object' && p && 'inlineData' in p) return '';
return p;
})
.join('');
expect(resultText).toContain(
'--- Start of content for directory: my-dir ---',
);
expect(resultText).toContain('--- file1.txt ---');
expect(resultText).toContain('content of file 1');
expect(resultText).toContain('--- file2.md ---');
expect(resultText).toContain('content of file 2');
expect(resultText).toContain(
'--- End of content for directory: my-dir ---',
);
});
it('should recursively expand a directory and read all nested files', async () => {
mock({
[CWD]: {
'my-dir': {
'file1.txt': 'content of file 1',
'sub-dir': {
'nested.txt': 'nested content',
},
},
},
});
const mockFileService = {
filterFiles: vi.fn((files) => files),
} as unknown as FileDiscoveryService;
const config = createMockConfig(CWD, [], mockFileService);
const result = await readPathFromWorkspace('my-dir', config);
const resultText = result
.map((p) => {
if (typeof p === 'string') return p;
if (typeof p === 'object' && p && 'text' in p) return p.text;
return '';
})
.join('');
expect(resultText).toContain('content of file 1');
expect(resultText).toContain('nested content');
expect(resultText).toContain(
`--- ${path.join('sub-dir', 'nested.txt')} ---`,
);
});
it('should handle mixed content and include files from subdirectories', async () => {
const imageData = Buffer.from([
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
]);
mock({
[CWD]: {
'mixed-dir': {
'info.txt': 'some text',
'photo.png': imageData,
'sub-dir': {
'nested.txt': 'this should be included',
},
'empty-sub-dir': {},
},
},
});
const mockFileService = {
filterFiles: vi.fn((files) => files),
} as unknown as FileDiscoveryService;
const config = createMockConfig(CWD, [], mockFileService);
const result = await readPathFromWorkspace('mixed-dir', config);
// Check for the text part
const textContent = result
.map((p) => {
if (typeof p === 'string') return p;
if (typeof p === 'object' && p && 'text' in p) return p.text;
return ''; // Ignore non-text parts for this assertion
})
.join('');
expect(textContent).toContain('some text');
expect(textContent).toContain('this should be included');
// Check for the image part
const imagePart = result.find(
(p) => typeof p === 'object' && 'inlineData' in p,
);
expect(imagePart).toEqual({
inlineData: {
mimeType: 'image/png',
data: imageData.toString('base64'),
displayName: 'photo.png',
},
});
});
it('should handle an empty directory', async () => {
mock({
[CWD]: {
'empty-dir': {},
},
});
const mockFileService = {
filterFiles: vi.fn((files) => files),
} as unknown as FileDiscoveryService;
const config = createMockConfig(CWD, [], mockFileService);
const result = await readPathFromWorkspace('empty-dir', config);
expect(result).toEqual([
{ text: '--- Start of content for directory: empty-dir ---\n' },
{ text: '--- End of content for directory: empty-dir ---' },
]);
});
});
describe('File Ignoring', () => {
it('should return an empty array for an ignored file', async () => {
mock({
[CWD]: {
'ignored.txt': 'ignored content',
},
});
const mockFileService = {
filterFiles: vi.fn(() => []), // Simulate the file being filtered out
} as unknown as FileDiscoveryService;
const config = createMockConfig(CWD, [], mockFileService);
const result = await readPathFromWorkspace('ignored.txt', config);
expect(result).toEqual([]);
expect(mockFileService.filterFiles).toHaveBeenCalledWith(
['ignored.txt'],
{
respectGitIgnore: true,
respectQwenIgnore: true,
},
);
});
it('should not read ignored files when expanding a directory', async () => {
mock({
[CWD]: {
'my-dir': {
'not-ignored.txt': 'visible',
'ignored.log': 'invisible',
},
},
});
const mockFileService = {
filterFiles: vi.fn((files: string[]) =>
files.filter((f) => !f.endsWith('ignored.log')),
),
} as unknown as FileDiscoveryService;
const config = createMockConfig(CWD, [], mockFileService);
const result = await readPathFromWorkspace('my-dir', config);
const resultText = result
.map((p) => {
if (typeof p === 'string') return p;
if (typeof p === 'object' && p && 'text' in p) return p.text;
return '';
})
.join('');
expect(resultText).toContain('visible');
expect(resultText).not.toContain('invisible');
expect(mockFileService.filterFiles).toHaveBeenCalled();
});
it('should pass respectGitIgnore: false from config to filterFiles', async () => {
mock({
[CWD]: {
'ignored.txt': 'ignored content',
},
});
const mockFileService = {
filterFiles: vi.fn((files) => files),
} as unknown as FileDiscoveryService;
const config = createMockConfig(CWD, [], mockFileService, {
respectGitIgnore: false,
respectQwenIgnore: true,
});
await readPathFromWorkspace('ignored.txt', config);
expect(mockFileService.filterFiles).toHaveBeenCalledWith(
['ignored.txt'],
{
respectGitIgnore: false,
respectQwenIgnore: true,
},
);
});
});
it('should throw an error for an absolute path outside the workspace', async () => {
const absPath = path.join(OUTSIDE_DIR, 'secret.txt');
mock({
[CWD]: {},
[OUTSIDE_DIR]: {
'secret.txt': 'secrets',
},
});
// OUTSIDE_DIR is not added to the config's workspace
const config = createMockConfig(CWD);
await expect(readPathFromWorkspace(absPath, config)).rejects.toThrow(
`Absolute path is outside of the allowed workspace: ${absPath}`,
);
});
it('should throw an error if a relative path is not found anywhere', async () => {
mock({
[CWD]: {},
[OTHER_DIR]: {},
});
const config = createMockConfig(CWD, [OTHER_DIR]);
await expect(
readPathFromWorkspace('not-found.txt', config),
).rejects.toThrow('Path not found in workspace: not-found.txt');
});
// mock-fs permission simulation is unreliable on Windows and when running as root.
it.skipIf(
process.platform === 'win32' || (process.getuid && process.getuid() === 0),
)(
'should return an error string if reading a file with no permissions',
async () => {
mock({
[CWD]: {
'unreadable.txt': mock.file({
content: 'you cannot read me',
mode: 0o222, // Write-only
}),
},
});
const mockFileService = {
filterFiles: vi.fn((files) => files),
} as unknown as FileDiscoveryService;
const config = createMockConfig(CWD, [], mockFileService);
// processSingleFileContent catches the error and returns an error string.
const result = await readPathFromWorkspace('unreadable.txt', config);
const textResult = result[0] as string;
// processSingleFileContent formats errors using the relative path from the target dir (CWD).
expect(textResult).toContain('Error reading file unreadable.txt');
expect(textResult).toMatch(/(EACCES|permission denied)/i);
},
);
it('should return an error string for files exceeding the size limit', async () => {
// Mock a file slightly larger than the 10MB limit defined in fileUtils.ts
const largeContent = 'a'.repeat(11 * 1024 * 1024); // 11MB
mock({
[CWD]: {
'large.txt': largeContent,
},
});
const mockFileService = {
filterFiles: vi.fn((files) => files),
} as unknown as FileDiscoveryService;
const config = createMockConfig(CWD, [], mockFileService);
const result = await readPathFromWorkspace('large.txt', config);
const textResult = result[0] as string;
// The error message comes directly from processSingleFileContent
expect(textResult).toBe('File size exceeds the 10MB limit.');
});
});