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 => 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 wrapper) expect(prompt).toContain('Fix the greeting'); expect(prompt).toContain(''); expect(prompt).toContain('Hello world'); expect(prompt).toContain(''); // Verify instruction comes before selection expect(prompt.indexOf('Fix the greeting')).toBeLessThan(prompt.indexOf(' { 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 wrapper) expect(prompt).toContain('Add content'); expect(prompt).toContain(''); // Verify instruction comes before selection expect(prompt.indexOf('Add content')).toBeLessThan(prompt.indexOf(' { 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 tag expect(prompt).toContain(''); expect(prompt).toContain('notes/helper.md'); expect(prompt).toContain('docs/api.md'); expect(prompt).toContain(''); // Original content should still be present expect(prompt).toContain(' { const request: InlineEditRequest = { mode: 'selection', selectedText: 'test', instruction: 'Fix this', notePath: 'test.md', contextFiles: [], }; const prompt = buildInlineEditPrompt(request); expect(prompt).not.toContain(''); expect(prompt).toContain(' { const request: InlineEditRequest = { mode: 'selection', selectedText: 'test', instruction: 'Fix this', notePath: 'test.md', }; const prompt = buildInlineEditPrompt(request); expect(prompt).not.toContain(''); expect(prompt).toContain(' { 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(''); expect(prompt).toContain('utils.ts'); expect(prompt).toContain(' { it('should extract text from replacement tags', () => { const response = 'Here is the edit:\nFixed text here'; const result = parseInlineEditResponse(response); expect(result.success).toBe(true); expect(result.editedText).toBe('Fixed text here'); }); it('should handle multiline replacement content', () => { const response = 'Line 1\nLine 2\nLine 3'; 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 = 'const x = a < b && c > d;'; 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 = 'first then second'; const result = parseInlineEditResponse(response); expect(result.success).toBe(true); expect(result.editedText).toBe('first'); }); it('should handle empty replacement tags', () => { const response = ''; 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: 'fixed' }] }, }, { 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: 'fixed' }] }, }, { 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: 'fixed' }] }, }, { 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: 'fixed' }] }, }, { 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: 'fixed' }] }, }, { 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: 'fixed' }] }, }, { 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: 'fixed' }] }, }, { 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: 'fixed' }] }, }, { 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: 'final result' }] }, }, { 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: 'final result' }] }, }, { 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: 'result' }] }, }, { 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: 'result' }] }, }, { 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: 'fixed' }] }, }, ]); 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 wrapper) expect(prompt).toContain('add missing word'); expect(prompt).toContain(''); expect(prompt).toContain('The quick brown | jumps over #inline'); expect(prompt).toContain(''); // Verify instruction comes before cursor context expect(prompt.indexOf('add missing word')).toBeLessThan(prompt.indexOf(' { 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 wrapper) expect(prompt).toContain('add a new section'); expect(prompt).toContain(''); expect(prompt).toContain('# Introduction'); expect(prompt).toContain('| #inbetween'); expect(prompt).toContain('## Features'); expect(prompt).toContain(''); // Verify instruction comes before cursor context expect(prompt.indexOf('add a new section')).toBeLessThan(prompt.indexOf(' { 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:\ninserted text here'; 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 = 'Line 1\nLine 2\nLine 3'; 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 = 'replacedinserted'; 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 = '\n## New Section\n\nContent here\n'; 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 = ''; const result = parseInlineEditResponse(response); expect(result.success).toBe(true); expect(result.insertedText).toBe(''); }); it('should handle insertion with special characters', () => { const response = 'const x = a < b && c > d;'; 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: 'fox' }] }, }, { 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: '## Description\n\nNew section content' }] }, }, { 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'); }); }); });