yishentu--claudian
1224 行
39 KiB
TypeScript
1224 行
39 KiB
TypeScript
import '@/providers';
|
|
|
|
// eslint-disable-next-line jest/no-mocks-import
|
|
import {
|
|
getLastOptions,
|
|
resetMockMessages,
|
|
setMockMessages,
|
|
} from '@test/__mocks__/claude-agent-sdk';
|
|
import * as fs from 'fs';
|
|
|
|
// Mock fs module
|
|
jest.mock('fs');
|
|
|
|
// Now import after all mocks are set up
|
|
import { buildInlineEditPrompt, parseInlineEditResponse } from '@/core/prompt/inlineEdit';
|
|
import { getPathFromToolInput } from '@/core/tools/toolInput';
|
|
import type { InlineEditRequest } from '@/providers/claude/auxiliary/ClaudeInlineEditService';
|
|
import {
|
|
createReadOnlyHook,
|
|
InlineEditService,
|
|
} from '@/providers/claude/auxiliary/ClaudeInlineEditService';
|
|
import { buildCursorContext } from '@/utils/editor';
|
|
|
|
// Create a mock plugin
|
|
function createMockPlugin(settings = {}) {
|
|
return {
|
|
settings: {
|
|
model: 'sonnet',
|
|
thinkingBudget: 'off',
|
|
...settings,
|
|
},
|
|
app: {
|
|
vault: {
|
|
adapter: {
|
|
basePath: '/test/vault/path',
|
|
},
|
|
},
|
|
},
|
|
getActiveEnvironmentVariables: jest.fn().mockReturnValue(''),
|
|
getResolvedProviderCliPath: jest.fn().mockReturnValue('/fake/claude'),
|
|
} as any;
|
|
}
|
|
|
|
// Hook functions accept typed HookInput / return typed HookJSONOutput, but the
|
|
// implementation only reads tool_name/tool_input. Cast I/O to any in tests.
|
|
const callHook = async (hook: any, input: any, ...rest: any[]): Promise<any> =>
|
|
hook(input, ...rest);
|
|
|
|
describe('InlineEditService', () => {
|
|
let service: InlineEditService;
|
|
let mockPlugin: any;
|
|
|
|
beforeEach(() => {
|
|
jest.clearAllMocks();
|
|
resetMockMessages();
|
|
mockPlugin = createMockPlugin();
|
|
service = new InlineEditService(mockPlugin);
|
|
});
|
|
|
|
|
|
describe('buildPrompt', () => {
|
|
it('should build prompt with correct format', () => {
|
|
const request: InlineEditRequest = {
|
|
mode: 'selection',
|
|
selectedText: 'Hello world',
|
|
instruction: 'Fix the greeting',
|
|
notePath: 'notes/test.md',
|
|
};
|
|
|
|
const prompt = buildInlineEditPrompt(request);
|
|
|
|
// New format: instruction first, then XML context (no <query> wrapper)
|
|
expect(prompt).toContain('Fix the greeting');
|
|
expect(prompt).toContain('<editor_selection path="notes/test.md">');
|
|
expect(prompt).toContain('Hello world');
|
|
expect(prompt).toContain('</editor_selection>');
|
|
// Verify instruction comes before selection
|
|
expect(prompt.indexOf('Fix the greeting')).toBeLessThan(prompt.indexOf('<editor_selection'));
|
|
});
|
|
|
|
it('should preserve selected text with newlines', () => {
|
|
const request: InlineEditRequest = {
|
|
mode: 'selection',
|
|
selectedText: 'Line 1\nLine 2\nLine 3',
|
|
instruction: 'Fix formatting',
|
|
notePath: 'doc.md',
|
|
};
|
|
|
|
const prompt = buildInlineEditPrompt(request);
|
|
|
|
expect(prompt).toContain('Line 1\nLine 2\nLine 3');
|
|
});
|
|
|
|
it('should handle empty selected text', () => {
|
|
const request: InlineEditRequest = {
|
|
mode: 'selection',
|
|
selectedText: '',
|
|
instruction: 'Add content',
|
|
notePath: 'empty.md',
|
|
};
|
|
|
|
const prompt = buildInlineEditPrompt(request);
|
|
|
|
// New format: instruction first, then XML context (no <query> wrapper)
|
|
expect(prompt).toContain('Add content');
|
|
expect(prompt).toContain('<editor_selection path="empty.md">');
|
|
// Verify instruction comes before selection
|
|
expect(prompt.indexOf('Add content')).toBeLessThan(prompt.indexOf('<editor_selection'));
|
|
});
|
|
|
|
it('should append context files when provided', () => {
|
|
const request: InlineEditRequest = {
|
|
mode: 'selection',
|
|
selectedText: 'test',
|
|
instruction: 'Fix this',
|
|
notePath: 'test.md',
|
|
contextFiles: ['notes/helper.md', 'docs/api.md'],
|
|
};
|
|
|
|
const prompt = buildInlineEditPrompt(request);
|
|
|
|
// Context files should be appended with <context_files> tag
|
|
expect(prompt).toContain('<context_files>');
|
|
expect(prompt).toContain('notes/helper.md');
|
|
expect(prompt).toContain('docs/api.md');
|
|
expect(prompt).toContain('</context_files>');
|
|
// Original content should still be present
|
|
expect(prompt).toContain('<editor_selection');
|
|
expect(prompt).toContain('Fix this');
|
|
// Verify order: instruction, selection, context_files
|
|
expect(prompt.indexOf('Fix this')).toBeLessThan(prompt.indexOf('<editor_selection'));
|
|
expect(prompt.indexOf('<editor_selection')).toBeLessThan(prompt.indexOf('<context_files'));
|
|
});
|
|
|
|
it('should not modify prompt when contextFiles is empty', () => {
|
|
const request: InlineEditRequest = {
|
|
mode: 'selection',
|
|
selectedText: 'test',
|
|
instruction: 'Fix this',
|
|
notePath: 'test.md',
|
|
contextFiles: [],
|
|
};
|
|
|
|
const prompt = buildInlineEditPrompt(request);
|
|
|
|
expect(prompt).not.toContain('<context_files>');
|
|
expect(prompt).toContain('<editor_selection');
|
|
});
|
|
|
|
it('should not modify prompt when contextFiles is undefined', () => {
|
|
const request: InlineEditRequest = {
|
|
mode: 'selection',
|
|
selectedText: 'test',
|
|
instruction: 'Fix this',
|
|
notePath: 'test.md',
|
|
};
|
|
|
|
const prompt = buildInlineEditPrompt(request);
|
|
|
|
expect(prompt).not.toContain('<context_files>');
|
|
expect(prompt).toContain('<editor_selection');
|
|
});
|
|
|
|
it('should prepend context files for cursor mode', () => {
|
|
const request: InlineEditRequest = {
|
|
mode: 'cursor',
|
|
instruction: 'insert here',
|
|
notePath: 'test.md',
|
|
cursorContext: {
|
|
beforeCursor: 'before',
|
|
afterCursor: 'after',
|
|
isInbetween: false,
|
|
line: 0,
|
|
column: 6,
|
|
},
|
|
contextFiles: ['utils.ts'],
|
|
};
|
|
|
|
const prompt = buildInlineEditPrompt(request);
|
|
|
|
expect(prompt).toContain('<context_files>');
|
|
expect(prompt).toContain('utils.ts');
|
|
expect(prompt).toContain('<editor_cursor');
|
|
});
|
|
});
|
|
|
|
describe('parseResponse', () => {
|
|
it('should extract text from replacement tags', () => {
|
|
const response = 'Here is the edit:\n<replacement>Fixed text here</replacement>';
|
|
|
|
const result = parseInlineEditResponse(response);
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.editedText).toBe('Fixed text here');
|
|
});
|
|
|
|
it('should handle multiline replacement content', () => {
|
|
const response = '<replacement>Line 1\nLine 2\nLine 3</replacement>';
|
|
|
|
const result = parseInlineEditResponse(response);
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.editedText).toBe('Line 1\nLine 2\nLine 3');
|
|
});
|
|
|
|
it('should return clarification when no replacement tags', () => {
|
|
const response = 'Could you please clarify what you mean by "fix"?';
|
|
|
|
const result = parseInlineEditResponse(response);
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.clarification).toBe('Could you please clarify what you mean by "fix"?');
|
|
expect(result.editedText).toBeUndefined();
|
|
});
|
|
|
|
it('should return error for empty response', () => {
|
|
const result = parseInlineEditResponse('');
|
|
|
|
expect(result.success).toBe(false);
|
|
expect(result.error).toBe('Empty response');
|
|
});
|
|
|
|
it('should return error for whitespace-only response', () => {
|
|
const result = parseInlineEditResponse(' \n\t ');
|
|
|
|
expect(result.success).toBe(false);
|
|
expect(result.error).toBe('Empty response');
|
|
});
|
|
|
|
it('should handle replacement tags with special characters', () => {
|
|
const response = '<replacement>const x = a < b && c > d;</replacement>';
|
|
|
|
const result = parseInlineEditResponse(response);
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.editedText).toBe('const x = a < b && c > d;');
|
|
});
|
|
|
|
it('should extract first replacement tag if multiple exist', () => {
|
|
const response = '<replacement>first</replacement> then <replacement>second</replacement>';
|
|
|
|
const result = parseInlineEditResponse(response);
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.editedText).toBe('first');
|
|
});
|
|
|
|
it('should handle empty replacement tags', () => {
|
|
const response = '<replacement></replacement>';
|
|
|
|
const result = parseInlineEditResponse(response);
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.editedText).toBe('');
|
|
});
|
|
});
|
|
|
|
describe('editText', () => {
|
|
beforeEach(() => {
|
|
(fs.existsSync as jest.Mock).mockReturnValue(true);
|
|
});
|
|
|
|
it('should return error when vault path cannot be determined', async () => {
|
|
mockPlugin.app.vault.adapter.basePath = undefined;
|
|
service = new InlineEditService(mockPlugin);
|
|
|
|
const result = await service.editText({
|
|
mode: 'selection',
|
|
selectedText: 'test',
|
|
instruction: 'fix',
|
|
notePath: 'test.md',
|
|
});
|
|
|
|
expect(result.success).toBe(false);
|
|
expect(result.error).toContain('vault path');
|
|
});
|
|
|
|
it('should return error when claude CLI not found', async () => {
|
|
mockPlugin.getResolvedProviderCliPath.mockReturnValue(null);
|
|
|
|
const result = await service.editText({
|
|
mode: 'selection',
|
|
selectedText: 'test',
|
|
instruction: 'fix',
|
|
notePath: 'test.md',
|
|
});
|
|
|
|
expect(result.success).toBe(false);
|
|
expect(result.error).toContain('Claude CLI not found');
|
|
});
|
|
|
|
it('should use restricted read-only tools', async () => {
|
|
setMockMessages([
|
|
{ type: 'system', subtype: 'init', session_id: 'test-session' },
|
|
{
|
|
type: 'assistant',
|
|
message: { content: [{ type: 'text', text: '<replacement>fixed</replacement>' }] },
|
|
},
|
|
{ type: 'result' },
|
|
]);
|
|
|
|
await service.editText({
|
|
mode: 'selection',
|
|
selectedText: 'test',
|
|
instruction: 'fix',
|
|
notePath: 'test.md',
|
|
});
|
|
|
|
const options = getLastOptions();
|
|
expect(options?.tools).toContain('Read');
|
|
expect(options?.tools).toContain('Grep');
|
|
expect(options?.tools).toContain('Glob');
|
|
expect(options?.tools).toContain('LS');
|
|
expect(options?.tools).toContain('WebSearch');
|
|
expect(options?.tools).toContain('WebFetch');
|
|
// Should NOT include write tools
|
|
expect(options?.tools).not.toContain('Write');
|
|
expect(options?.tools).not.toContain('Edit');
|
|
expect(options?.tools).not.toContain('Bash');
|
|
});
|
|
|
|
it('should bypass permissions for read-only tools', async () => {
|
|
setMockMessages([
|
|
{ type: 'system', subtype: 'init', session_id: 'test-session' },
|
|
{
|
|
type: 'assistant',
|
|
message: { content: [{ type: 'text', text: '<replacement>fixed</replacement>' }] },
|
|
},
|
|
{ type: 'result' },
|
|
]);
|
|
|
|
await service.editText({
|
|
mode: 'selection',
|
|
selectedText: 'test',
|
|
instruction: 'fix',
|
|
notePath: 'test.md',
|
|
});
|
|
|
|
const options = getLastOptions();
|
|
expect(options?.permissionMode).toBe('bypassPermissions');
|
|
});
|
|
|
|
it('should set settingSources to project only when loadUserClaudeSettings is false', async () => {
|
|
mockPlugin.settings.loadUserClaudeSettings = false;
|
|
service = new InlineEditService(mockPlugin);
|
|
|
|
setMockMessages([
|
|
{ type: 'system', subtype: 'init', session_id: 'test-session' },
|
|
{
|
|
type: 'assistant',
|
|
message: { content: [{ type: 'text', text: '<replacement>fixed</replacement>' }] },
|
|
},
|
|
{ type: 'result' },
|
|
]);
|
|
|
|
await service.editText({
|
|
mode: 'selection',
|
|
selectedText: 'test',
|
|
instruction: 'fix',
|
|
notePath: 'test.md',
|
|
});
|
|
|
|
const options = getLastOptions();
|
|
expect(options?.settingSources).toEqual(['project', 'local']);
|
|
});
|
|
|
|
it('should set settingSources to include user when loadUserClaudeSettings is true', async () => {
|
|
mockPlugin.settings.loadUserClaudeSettings = true;
|
|
service = new InlineEditService(mockPlugin);
|
|
|
|
setMockMessages([
|
|
{ type: 'system', subtype: 'init', session_id: 'test-session' },
|
|
{
|
|
type: 'assistant',
|
|
message: { content: [{ type: 'text', text: '<replacement>fixed</replacement>' }] },
|
|
},
|
|
{ type: 'result' },
|
|
]);
|
|
|
|
await service.editText({
|
|
mode: 'selection',
|
|
selectedText: 'test',
|
|
instruction: 'fix',
|
|
notePath: 'test.md',
|
|
});
|
|
|
|
const options = getLastOptions();
|
|
expect(options?.settingSources).toEqual(['user', 'project', 'local']);
|
|
});
|
|
|
|
it('should set adaptive thinking for Claude models', async () => {
|
|
mockPlugin.settings.model = 'sonnet';
|
|
service = new InlineEditService(mockPlugin);
|
|
|
|
setMockMessages([
|
|
{ type: 'system', subtype: 'init', session_id: 'test-session' },
|
|
{
|
|
type: 'assistant',
|
|
message: { content: [{ type: 'text', text: '<replacement>fixed</replacement>' }] },
|
|
},
|
|
{ type: 'result' },
|
|
]);
|
|
|
|
await service.editText({
|
|
mode: 'selection',
|
|
selectedText: 'test',
|
|
instruction: 'fix',
|
|
notePath: 'test.md',
|
|
});
|
|
|
|
const options = getLastOptions();
|
|
expect(options?.thinking).toEqual({ type: 'adaptive' });
|
|
expect(options?.maxThinkingTokens).toBeUndefined();
|
|
});
|
|
|
|
it('should use the model override when provided', async () => {
|
|
mockPlugin.settings.model = 'sonnet';
|
|
service = new InlineEditService(mockPlugin);
|
|
service.setModelOverride('opus');
|
|
|
|
setMockMessages([
|
|
{ type: 'system', subtype: 'init', session_id: 'test-session' },
|
|
{
|
|
type: 'assistant',
|
|
message: { content: [{ type: 'text', text: '<replacement>fixed</replacement>' }] },
|
|
},
|
|
{ type: 'result' },
|
|
]);
|
|
|
|
await service.editText({
|
|
mode: 'selection',
|
|
selectedText: 'test',
|
|
instruction: 'fix',
|
|
notePath: 'test.md',
|
|
});
|
|
|
|
expect(getLastOptions()?.model).toBe('opus');
|
|
});
|
|
|
|
it('should set adaptive thinking with effort for custom models', async () => {
|
|
mockPlugin.settings.model = 'custom-model';
|
|
mockPlugin.settings.thinkingBudget = 'medium';
|
|
mockPlugin.settings.effortLevel = 'medium';
|
|
service = new InlineEditService(mockPlugin);
|
|
|
|
setMockMessages([
|
|
{ type: 'system', subtype: 'init', session_id: 'test-session' },
|
|
{
|
|
type: 'assistant',
|
|
message: { content: [{ type: 'text', text: '<replacement>fixed</replacement>' }] },
|
|
},
|
|
{ type: 'result' },
|
|
]);
|
|
|
|
await service.editText({
|
|
mode: 'selection',
|
|
selectedText: 'test',
|
|
instruction: 'fix',
|
|
notePath: 'test.md',
|
|
});
|
|
|
|
const options = getLastOptions();
|
|
expect(options?.thinking).toEqual({ type: 'adaptive' });
|
|
expect(options?.effort).toBe('medium');
|
|
expect(options?.maxThinkingTokens).toBeUndefined();
|
|
});
|
|
|
|
it('should capture session ID for conversation continuity', async () => {
|
|
setMockMessages([
|
|
{ type: 'system', subtype: 'init', session_id: 'inline-session-123' },
|
|
{
|
|
type: 'assistant',
|
|
message: { content: [{ type: 'text', text: 'What do you want to change?' }] },
|
|
},
|
|
{ type: 'result' },
|
|
]);
|
|
|
|
await service.editText({
|
|
mode: 'selection',
|
|
selectedText: 'test',
|
|
instruction: 'fix',
|
|
notePath: 'test.md',
|
|
});
|
|
|
|
// Verify session was captured by checking continueConversation resumes it
|
|
setMockMessages([
|
|
{
|
|
type: 'assistant',
|
|
message: { content: [{ type: 'text', text: '<replacement>fixed</replacement>' }] },
|
|
},
|
|
{ type: 'result' },
|
|
]);
|
|
|
|
await service.continueConversation('make it better');
|
|
|
|
const options = getLastOptions();
|
|
expect(options?.resume).toBe('inline-session-123');
|
|
});
|
|
|
|
it('should return clarification response', async () => {
|
|
setMockMessages([
|
|
{ type: 'system', subtype: 'init', session_id: 'test-session' },
|
|
{
|
|
type: 'assistant',
|
|
message: { content: [{ type: 'text', text: 'Could you clarify what "fix" means?' }] },
|
|
},
|
|
{ type: 'result' },
|
|
]);
|
|
|
|
const result = await service.editText({
|
|
mode: 'selection',
|
|
selectedText: 'broken code',
|
|
instruction: 'fix',
|
|
notePath: 'test.md',
|
|
});
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.clarification).toBe('Could you clarify what "fix" means?');
|
|
});
|
|
});
|
|
|
|
describe('continueConversation', () => {
|
|
beforeEach(() => {
|
|
(fs.existsSync as jest.Mock).mockReturnValue(true);
|
|
});
|
|
|
|
it('should return error when no active conversation', async () => {
|
|
const result = await service.continueConversation('more details');
|
|
|
|
expect(result.success).toBe(false);
|
|
expect(result.error).toContain('No active conversation');
|
|
});
|
|
|
|
it('should resume session on follow-up', async () => {
|
|
// First message to establish session
|
|
setMockMessages([
|
|
{ type: 'system', subtype: 'init', session_id: 'continue-session' },
|
|
{
|
|
type: 'assistant',
|
|
message: { content: [{ type: 'text', text: 'What do you want?' }] },
|
|
},
|
|
{ type: 'result' },
|
|
]);
|
|
|
|
await service.editText({
|
|
mode: 'selection',
|
|
selectedText: 'test',
|
|
instruction: 'fix',
|
|
notePath: 'test.md',
|
|
});
|
|
|
|
// Follow-up message
|
|
setMockMessages([
|
|
{
|
|
type: 'assistant',
|
|
message: { content: [{ type: 'text', text: '<replacement>final result</replacement>' }] },
|
|
},
|
|
{ type: 'result' },
|
|
]);
|
|
|
|
await service.continueConversation('make it blue');
|
|
|
|
const options = getLastOptions();
|
|
expect(options?.resume).toBe('continue-session');
|
|
});
|
|
|
|
it('should prepend context files when provided', async () => {
|
|
// First message to establish session
|
|
setMockMessages([
|
|
{ type: 'system', subtype: 'init', session_id: 'context-session' },
|
|
{
|
|
type: 'assistant',
|
|
message: { content: [{ type: 'text', text: 'What do you want?' }] },
|
|
},
|
|
{ type: 'result' },
|
|
]);
|
|
|
|
await service.editText({
|
|
mode: 'selection',
|
|
selectedText: 'test',
|
|
instruction: 'fix',
|
|
notePath: 'test.md',
|
|
});
|
|
|
|
// Follow-up message with context files
|
|
setMockMessages([
|
|
{
|
|
type: 'assistant',
|
|
message: { content: [{ type: 'text', text: '<replacement>final result</replacement>' }] },
|
|
},
|
|
{ type: 'result' },
|
|
]);
|
|
|
|
await service.continueConversation('make it blue', ['notes/helper.md', 'docs/api.md']);
|
|
|
|
// The prompt should include the context files
|
|
// Since we can't directly access the prompt, we verify the session resumed
|
|
const options = getLastOptions();
|
|
expect(options?.resume).toBe('context-session');
|
|
});
|
|
|
|
it('should not modify prompt when no context files provided', async () => {
|
|
// First message to establish session
|
|
setMockMessages([
|
|
{ type: 'system', subtype: 'init', session_id: 'no-context-session' },
|
|
{
|
|
type: 'assistant',
|
|
message: { content: [{ type: 'text', text: 'What do you want?' }] },
|
|
},
|
|
{ type: 'result' },
|
|
]);
|
|
|
|
await service.editText({
|
|
mode: 'selection',
|
|
selectedText: 'test',
|
|
instruction: 'fix',
|
|
notePath: 'test.md',
|
|
});
|
|
|
|
// Follow-up without context files
|
|
setMockMessages([
|
|
{
|
|
type: 'assistant',
|
|
message: { content: [{ type: 'text', text: '<replacement>result</replacement>' }] },
|
|
},
|
|
{ type: 'result' },
|
|
]);
|
|
|
|
await service.continueConversation('make it blue');
|
|
|
|
const options = getLastOptions();
|
|
expect(options?.resume).toBe('no-context-session');
|
|
});
|
|
|
|
it('should handle empty context files array', async () => {
|
|
// First message to establish session
|
|
setMockMessages([
|
|
{ type: 'system', subtype: 'init', session_id: 'empty-context-session' },
|
|
{
|
|
type: 'assistant',
|
|
message: { content: [{ type: 'text', text: 'What do you want?' }] },
|
|
},
|
|
{ type: 'result' },
|
|
]);
|
|
|
|
await service.editText({
|
|
mode: 'selection',
|
|
selectedText: 'test',
|
|
instruction: 'fix',
|
|
notePath: 'test.md',
|
|
});
|
|
|
|
// Follow-up with empty context files array
|
|
setMockMessages([
|
|
{
|
|
type: 'assistant',
|
|
message: { content: [{ type: 'text', text: '<replacement>result</replacement>' }] },
|
|
},
|
|
{ type: 'result' },
|
|
]);
|
|
|
|
await service.continueConversation('make it blue', []);
|
|
|
|
const options = getLastOptions();
|
|
expect(options?.resume).toBe('empty-context-session');
|
|
});
|
|
});
|
|
|
|
describe('resetConversation', () => {
|
|
it('should clear session so continueConversation fails', async () => {
|
|
(fs.existsSync as jest.Mock).mockReturnValue(true);
|
|
|
|
// First establish a session
|
|
setMockMessages([
|
|
{ type: 'system', subtype: 'init', session_id: 'some-session' },
|
|
{
|
|
type: 'assistant',
|
|
message: { content: [{ type: 'text', text: 'What do you want?' }] },
|
|
},
|
|
{ type: 'result' },
|
|
]);
|
|
|
|
await service.editText({
|
|
mode: 'selection',
|
|
selectedText: 'test',
|
|
instruction: 'fix',
|
|
notePath: 'test.md',
|
|
});
|
|
|
|
// Reset should clear the session
|
|
service.resetConversation();
|
|
|
|
// continueConversation should fail since session is cleared
|
|
const result = await service.continueConversation('more details');
|
|
expect(result.success).toBe(false);
|
|
expect(result.error).toContain('No active conversation');
|
|
});
|
|
});
|
|
|
|
describe('cancel', () => {
|
|
it('should abort ongoing request', async () => {
|
|
(fs.existsSync as jest.Mock).mockReturnValue(true);
|
|
|
|
setMockMessages([
|
|
{ type: 'system', subtype: 'init', session_id: 'test-session' },
|
|
{
|
|
type: 'assistant',
|
|
message: { content: [{ type: 'text', text: '<replacement>fixed</replacement>' }] },
|
|
},
|
|
]);
|
|
|
|
const editPromise = service.editText({
|
|
mode: 'selection',
|
|
selectedText: 'test',
|
|
instruction: 'fix',
|
|
notePath: 'test.md',
|
|
});
|
|
|
|
// Cancel immediately
|
|
service.cancel();
|
|
|
|
const result = await editPromise;
|
|
expect(result.success).toBe(false);
|
|
expect(result.error).toBe('Cancelled');
|
|
});
|
|
|
|
it('should handle cancel when no request is running', () => {
|
|
expect(() => service.cancel()).not.toThrow();
|
|
});
|
|
});
|
|
|
|
describe('read-only hook enforcement', () => {
|
|
it('should create hook that allows read-only tools', () => {
|
|
const hook = createReadOnlyHook();
|
|
|
|
expect(hook.hooks).toHaveLength(1);
|
|
});
|
|
|
|
it('should allow Read tool through hook', async () => {
|
|
const hook = createReadOnlyHook();
|
|
const result = await callHook(hook.hooks[0], { tool_name: 'Read', tool_input: { file_path: 'test.md' } });
|
|
|
|
expect(result.continue).toBe(true);
|
|
});
|
|
|
|
it('should allow Grep tool through hook', async () => {
|
|
const hook = createReadOnlyHook();
|
|
const result = await callHook(hook.hooks[0], { tool_name: 'Grep', tool_input: { pattern: 'test' } });
|
|
|
|
expect(result.continue).toBe(true);
|
|
});
|
|
|
|
it('should allow WebSearch tool through hook', async () => {
|
|
const hook = createReadOnlyHook();
|
|
const result = await callHook(hook.hooks[0], { tool_name: 'WebSearch', tool_input: { query: 'test' } });
|
|
|
|
expect(result.continue).toBe(true);
|
|
});
|
|
|
|
it('should block Write tool through hook', async () => {
|
|
const hook = createReadOnlyHook();
|
|
const result = await callHook(hook.hooks[0], { tool_name: 'Write', tool_input: { file_path: 'test.md' } });
|
|
|
|
expect(result.continue).toBe(false);
|
|
expect(result.hookSpecificOutput.permissionDecision).toBe('deny');
|
|
expect(result.hookSpecificOutput.permissionDecisionReason).toContain('not allowed');
|
|
});
|
|
|
|
it('should block Bash tool through hook', async () => {
|
|
const hook = createReadOnlyHook();
|
|
const result = await callHook(hook.hooks[0], { tool_name: 'Bash', tool_input: { command: 'rm -rf /' } });
|
|
|
|
expect(result.continue).toBe(false);
|
|
expect(result.hookSpecificOutput.permissionDecision).toBe('deny');
|
|
});
|
|
|
|
it('should block Edit tool through hook', async () => {
|
|
const hook = createReadOnlyHook();
|
|
const result = await callHook(hook.hooks[0], { tool_name: 'Edit', tool_input: { file_path: 'test.md' } });
|
|
|
|
expect(result.continue).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('error handling', () => {
|
|
beforeEach(() => {
|
|
(fs.existsSync as jest.Mock).mockReturnValue(true);
|
|
});
|
|
|
|
it('should surface SDK query errors', async () => {
|
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
const sdk = require('@anthropic-ai/claude-agent-sdk');
|
|
const spy = jest.spyOn(sdk, 'query').mockImplementation(() => {
|
|
throw new Error('boom');
|
|
});
|
|
|
|
try {
|
|
const result = await service.editText({
|
|
mode: 'selection',
|
|
selectedText: 'text',
|
|
instruction: 'edit',
|
|
notePath: 'note.md',
|
|
});
|
|
|
|
expect(result.success).toBe(false);
|
|
expect(result.error).toBe('boom');
|
|
} finally {
|
|
spy.mockRestore();
|
|
}
|
|
});
|
|
|
|
it('returns null path for unknown tool input', () => {
|
|
expect(getPathFromToolInput('Unknown', {})).toBeNull();
|
|
});
|
|
|
|
it('extracts LS path from tool input', () => {
|
|
expect(getPathFromToolInput('LS', { path: 'notes' })).toBe('notes');
|
|
});
|
|
});
|
|
|
|
describe('buildCursorContext', () => {
|
|
// Helper to create mock getLine function from array of lines
|
|
const createGetLine = (lines: string[]) => (line: number) => lines[line] ?? '';
|
|
|
|
describe('inline mode detection', () => {
|
|
it('should detect inline when cursor is in middle of text', () => {
|
|
const lines = ['Hello world'];
|
|
const ctx = buildCursorContext(createGetLine(lines), 1, 0, 6);
|
|
|
|
expect(ctx.isInbetween).toBe(false);
|
|
expect(ctx.beforeCursor).toBe('Hello ');
|
|
expect(ctx.afterCursor).toBe('world');
|
|
expect(ctx.line).toBe(0);
|
|
expect(ctx.column).toBe(6);
|
|
});
|
|
|
|
it('should detect inline when cursor is at line start with text after', () => {
|
|
const lines = ['Hello world'];
|
|
const ctx = buildCursorContext(createGetLine(lines), 1, 0, 0);
|
|
|
|
expect(ctx.isInbetween).toBe(false);
|
|
expect(ctx.beforeCursor).toBe('');
|
|
expect(ctx.afterCursor).toBe('Hello world');
|
|
});
|
|
|
|
it('should detect inline when cursor is at line end with text before', () => {
|
|
const lines = ['Hello world'];
|
|
const ctx = buildCursorContext(createGetLine(lines), 1, 0, 11);
|
|
|
|
expect(ctx.isInbetween).toBe(false);
|
|
expect(ctx.beforeCursor).toBe('Hello world');
|
|
expect(ctx.afterCursor).toBe('');
|
|
});
|
|
|
|
it('should preserve whitespace around cursor', () => {
|
|
const lines = [' hello world '];
|
|
const ctx = buildCursorContext(createGetLine(lines), 1, 0, 9);
|
|
|
|
expect(ctx.isInbetween).toBe(false);
|
|
expect(ctx.beforeCursor).toBe(' hello ');
|
|
expect(ctx.afterCursor).toBe(' world ');
|
|
});
|
|
});
|
|
|
|
describe('inbetween mode detection', () => {
|
|
it('should detect inbetween on empty line', () => {
|
|
const lines = ['First paragraph', '', 'Second paragraph'];
|
|
const ctx = buildCursorContext(createGetLine(lines), 3, 1, 0);
|
|
|
|
expect(ctx.isInbetween).toBe(true);
|
|
expect(ctx.beforeCursor).toBe('First paragraph');
|
|
expect(ctx.afterCursor).toBe('Second paragraph');
|
|
});
|
|
|
|
it('should detect inbetween on whitespace-only line', () => {
|
|
const lines = ['First paragraph', ' ', 'Second paragraph'];
|
|
const ctx = buildCursorContext(createGetLine(lines), 3, 1, 2);
|
|
|
|
expect(ctx.isInbetween).toBe(true);
|
|
expect(ctx.beforeCursor).toBe('First paragraph');
|
|
expect(ctx.afterCursor).toBe('Second paragraph');
|
|
});
|
|
|
|
it('should detect inbetween when cursor on line with only whitespace before and after', () => {
|
|
const lines = ['Content', ' \t ', 'More content'];
|
|
const ctx = buildCursorContext(createGetLine(lines), 3, 1, 2);
|
|
|
|
expect(ctx.isInbetween).toBe(true);
|
|
});
|
|
|
|
it('should find nearest non-empty line before cursor', () => {
|
|
const lines = ['Content', '', '', '', 'More'];
|
|
const ctx = buildCursorContext(createGetLine(lines), 5, 2, 0);
|
|
|
|
expect(ctx.isInbetween).toBe(true);
|
|
expect(ctx.beforeCursor).toBe('Content');
|
|
});
|
|
|
|
it('should find nearest non-empty line after cursor', () => {
|
|
const lines = ['Content', '', '', '', 'More'];
|
|
const ctx = buildCursorContext(createGetLine(lines), 5, 2, 0);
|
|
|
|
expect(ctx.isInbetween).toBe(true);
|
|
expect(ctx.afterCursor).toBe('More');
|
|
});
|
|
|
|
it('should handle cursor at document start (empty first line)', () => {
|
|
const lines = ['', 'First content'];
|
|
const ctx = buildCursorContext(createGetLine(lines), 2, 0, 0);
|
|
|
|
expect(ctx.isInbetween).toBe(true);
|
|
expect(ctx.beforeCursor).toBe('');
|
|
expect(ctx.afterCursor).toBe('First content');
|
|
});
|
|
|
|
it('should handle cursor at document end (empty last line)', () => {
|
|
const lines = ['Last content', ''];
|
|
const ctx = buildCursorContext(createGetLine(lines), 2, 1, 0);
|
|
|
|
expect(ctx.isInbetween).toBe(true);
|
|
expect(ctx.beforeCursor).toBe('Last content');
|
|
expect(ctx.afterCursor).toBe('');
|
|
});
|
|
|
|
it('should handle multiple consecutive empty lines', () => {
|
|
const lines = ['Para A', '', '', '', 'Para B'];
|
|
const ctx = buildCursorContext(createGetLine(lines), 5, 2, 0);
|
|
|
|
expect(ctx.isInbetween).toBe(true);
|
|
expect(ctx.beforeCursor).toBe('Para A');
|
|
expect(ctx.afterCursor).toBe('Para B');
|
|
});
|
|
});
|
|
|
|
describe('edge cases', () => {
|
|
it('should handle single line document with cursor', () => {
|
|
const lines = ['Only line'];
|
|
const ctx = buildCursorContext(createGetLine(lines), 1, 0, 5);
|
|
|
|
expect(ctx.isInbetween).toBe(false);
|
|
expect(ctx.beforeCursor).toBe('Only ');
|
|
expect(ctx.afterCursor).toBe('line');
|
|
});
|
|
|
|
it('should handle empty document', () => {
|
|
const lines = [''];
|
|
const ctx = buildCursorContext(createGetLine(lines), 1, 0, 0);
|
|
|
|
expect(ctx.isInbetween).toBe(true);
|
|
expect(ctx.beforeCursor).toBe('');
|
|
expect(ctx.afterCursor).toBe('');
|
|
});
|
|
|
|
it('should preserve line and column in context', () => {
|
|
const lines = ['Line 0', 'Line 1', 'Line 2'];
|
|
const ctx = buildCursorContext(createGetLine(lines), 3, 1, 3);
|
|
|
|
expect(ctx.line).toBe(1);
|
|
expect(ctx.column).toBe(3);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('buildCursorPrompt', () => {
|
|
it('should build inline cursor prompt correctly', () => {
|
|
const request: InlineEditRequest = {
|
|
mode: 'cursor',
|
|
instruction: 'add missing word',
|
|
notePath: 'notes/test.md',
|
|
cursorContext: {
|
|
beforeCursor: 'The quick brown ',
|
|
afterCursor: ' jumps over',
|
|
isInbetween: false,
|
|
line: 5,
|
|
column: 16,
|
|
},
|
|
};
|
|
|
|
const prompt = buildInlineEditPrompt(request);
|
|
|
|
// New format: instruction first, then XML context (no <query> wrapper)
|
|
expect(prompt).toContain('add missing word');
|
|
expect(prompt).toContain('<editor_cursor path="notes/test.md" line="6">');
|
|
expect(prompt).toContain('The quick brown | jumps over #inline');
|
|
expect(prompt).toContain('</editor_cursor>');
|
|
// Verify instruction comes before cursor context
|
|
expect(prompt.indexOf('add missing word')).toBeLessThan(prompt.indexOf('<editor_cursor'));
|
|
});
|
|
|
|
it('should build inbetween cursor prompt with surrounding context', () => {
|
|
const request: InlineEditRequest = {
|
|
mode: 'cursor',
|
|
instruction: 'add a new section',
|
|
notePath: 'docs/readme.md',
|
|
cursorContext: {
|
|
beforeCursor: '# Introduction',
|
|
afterCursor: '## Features',
|
|
isInbetween: true,
|
|
line: 3,
|
|
column: 0,
|
|
},
|
|
};
|
|
|
|
const prompt = buildInlineEditPrompt(request);
|
|
|
|
// New format: instruction first, then XML context (no <query> wrapper)
|
|
expect(prompt).toContain('add a new section');
|
|
expect(prompt).toContain('<editor_cursor path="docs/readme.md" line="4">');
|
|
expect(prompt).toContain('# Introduction');
|
|
expect(prompt).toContain('| #inbetween');
|
|
expect(prompt).toContain('## Features');
|
|
expect(prompt).toContain('</editor_cursor>');
|
|
// Verify instruction comes before cursor context
|
|
expect(prompt.indexOf('add a new section')).toBeLessThan(prompt.indexOf('<editor_cursor'));
|
|
});
|
|
|
|
it('should handle inbetween with no content before cursor', () => {
|
|
const request: InlineEditRequest = {
|
|
mode: 'cursor',
|
|
instruction: 'add header',
|
|
notePath: 'empty.md',
|
|
cursorContext: {
|
|
beforeCursor: '',
|
|
afterCursor: 'First paragraph',
|
|
isInbetween: true,
|
|
line: 0,
|
|
column: 0,
|
|
},
|
|
};
|
|
|
|
const prompt = buildInlineEditPrompt(request);
|
|
|
|
expect(prompt).toContain('| #inbetween');
|
|
expect(prompt).toContain('First paragraph');
|
|
expect(prompt).not.toMatch(/\n\n\| #inbetween/); // No double newline before marker
|
|
});
|
|
|
|
it('should handle inbetween with no content after cursor', () => {
|
|
const request: InlineEditRequest = {
|
|
mode: 'cursor',
|
|
instruction: 'add footer',
|
|
notePath: 'doc.md',
|
|
cursorContext: {
|
|
beforeCursor: 'Last paragraph',
|
|
afterCursor: '',
|
|
isInbetween: true,
|
|
line: 10,
|
|
column: 0,
|
|
},
|
|
};
|
|
|
|
const prompt = buildInlineEditPrompt(request);
|
|
|
|
expect(prompt).toContain('Last paragraph');
|
|
expect(prompt).toContain('| #inbetween');
|
|
});
|
|
});
|
|
|
|
describe('buildPrompt mode dispatch', () => {
|
|
it('should dispatch to selection prompt for selection mode', () => {
|
|
const request: InlineEditRequest = {
|
|
mode: 'selection',
|
|
instruction: 'fix this',
|
|
notePath: 'test.md',
|
|
selectedText: 'selected text here',
|
|
};
|
|
|
|
const prompt = buildInlineEditPrompt(request);
|
|
|
|
expect(prompt).toContain('selected text here');
|
|
expect(prompt).not.toContain('#inline');
|
|
expect(prompt).not.toContain('#inbetween');
|
|
});
|
|
|
|
it('should dispatch to cursor prompt for cursor mode', () => {
|
|
const request: InlineEditRequest = {
|
|
mode: 'cursor',
|
|
instruction: 'insert here',
|
|
notePath: 'test.md',
|
|
cursorContext: {
|
|
beforeCursor: 'before',
|
|
afterCursor: 'after',
|
|
isInbetween: false,
|
|
line: 0,
|
|
column: 6,
|
|
},
|
|
};
|
|
|
|
const prompt = buildInlineEditPrompt(request);
|
|
|
|
expect(prompt).toContain('before|after #inline');
|
|
});
|
|
});
|
|
|
|
describe('parseResponse with insertion tags', () => {
|
|
it('should extract text from insertion tags', () => {
|
|
const response = 'Here is the content:\n<insertion>inserted text here</insertion>';
|
|
|
|
const result = parseInlineEditResponse(response);
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.insertedText).toBe('inserted text here');
|
|
expect(result.editedText).toBeUndefined();
|
|
});
|
|
|
|
it('should handle multiline insertion content', () => {
|
|
const response = '<insertion>Line 1\nLine 2\nLine 3</insertion>';
|
|
|
|
const result = parseInlineEditResponse(response);
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.insertedText).toBe('Line 1\nLine 2\nLine 3');
|
|
});
|
|
|
|
it('should prefer replacement tags over insertion tags', () => {
|
|
const response = '<replacement>replaced</replacement><insertion>inserted</insertion>';
|
|
|
|
const result = parseInlineEditResponse(response);
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.editedText).toBe('replaced');
|
|
expect(result.insertedText).toBeUndefined();
|
|
});
|
|
|
|
it('should handle insertion tags with leading/trailing newlines', () => {
|
|
const response = '<insertion>\n## New Section\n\nContent here\n</insertion>';
|
|
|
|
const result = parseInlineEditResponse(response);
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.insertedText).toBe('\n## New Section\n\nContent here\n');
|
|
});
|
|
|
|
it('should handle empty insertion tags', () => {
|
|
const response = '<insertion></insertion>';
|
|
|
|
const result = parseInlineEditResponse(response);
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.insertedText).toBe('');
|
|
});
|
|
|
|
it('should handle insertion with special characters', () => {
|
|
const response = '<insertion>const x = a < b && c > d;</insertion>';
|
|
|
|
const result = parseInlineEditResponse(response);
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.insertedText).toBe('const x = a < b && c > d;');
|
|
});
|
|
|
|
it('should return clarification when no tags present', () => {
|
|
const response = 'What would you like me to insert?';
|
|
|
|
const result = parseInlineEditResponse(response);
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.clarification).toBe('What would you like me to insert?');
|
|
expect(result.insertedText).toBeUndefined();
|
|
expect(result.editedText).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('editText with cursor mode', () => {
|
|
beforeEach(() => {
|
|
(fs.existsSync as jest.Mock).mockReturnValue(true);
|
|
});
|
|
|
|
it('should handle cursor mode request', async () => {
|
|
setMockMessages([
|
|
{ type: 'system', subtype: 'init', session_id: 'cursor-session' },
|
|
{
|
|
type: 'assistant',
|
|
message: { content: [{ type: 'text', text: '<insertion>fox</insertion>' }] },
|
|
},
|
|
{ type: 'result' },
|
|
]);
|
|
|
|
const result = await service.editText({
|
|
mode: 'cursor',
|
|
instruction: 'what animal?',
|
|
notePath: 'test.md',
|
|
cursorContext: {
|
|
beforeCursor: 'The quick brown ',
|
|
afterCursor: ' jumps over',
|
|
isInbetween: false,
|
|
line: 0,
|
|
column: 16,
|
|
},
|
|
});
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.insertedText).toBe('fox');
|
|
});
|
|
|
|
it('should handle inbetween mode request', async () => {
|
|
setMockMessages([
|
|
{ type: 'system', subtype: 'init', session_id: 'inbetween-session' },
|
|
{
|
|
type: 'assistant',
|
|
message: { content: [{ type: 'text', text: '<insertion>## Description\n\nNew section content</insertion>' }] },
|
|
},
|
|
{ type: 'result' },
|
|
]);
|
|
|
|
const result = await service.editText({
|
|
mode: 'cursor',
|
|
instruction: 'add description section',
|
|
notePath: 'readme.md',
|
|
cursorContext: {
|
|
beforeCursor: '# Title',
|
|
afterCursor: '## Features',
|
|
isInbetween: true,
|
|
line: 2,
|
|
column: 0,
|
|
},
|
|
});
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.insertedText).toContain('## Description');
|
|
});
|
|
});
|
|
});
|