项目文件夹

文件
wehub-resource-sync 2771cff92b
CI / lint (push) Failing after 1s
CI / typecheck (push) Failing after 2s
CI / test (push) Failing after 1s
CI / build (push) Has been skipped
chore: import upstream snapshot with attribution
2026-07-13 12:22:56 +08:00

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');
});
});
});