diff --git a/modules/openai/chat-page.php b/modules/openai/chat-page.php index 50a94e1..f7f5b28 100644 --- a/modules/openai/chat-page.php +++ b/modules/openai/chat-page.php @@ -22,6 +22,12 @@ function personalos_map_notebook_to_para_item( $notebook ) { ); } +/** + * Get messages from a post and parse them into UIMessage format + * + * @param int $post_id The post ID to retrieve messages from. + * @return array Parsed messages. + */ /** * Get messages from a post and parse them into UIMessage format * @@ -38,18 +44,18 @@ function personalos_get_messages_from_post( $post_id ) { $messages = array(); foreach ( $blocks as $block ) { - if ( $block['blockName'] === 'pos/ai-message' ) { + if ( 'pos/ai-message' === $block['blockName'] ) { $role = $block['attrs']['role'] ?? 'user'; - // Handle different content structures if necessary, for now assume string content in attrs or innerContent - // Gutenberg blocks usually store content in innerContent for HTML, but pos/ai-message might be different. - // Looking at save_backscroll implementation: - // $content_blocks[] = get_comment_delimited_block_content( ... 'content' => $content ... ) - // This usually implies attribute storage or innerHTML if it's a dynamic block saving. - // However, save_backscroll uses get_comment_delimited_block_content which suggests attributes serialization. - - $content = $block['attrs']['content'] ?? ''; $id = $block['attrs']['id'] ?? 'generated_' . uniqid(); + // Content is stored in innerHTML as ... + $content = ''; + if ( ! empty( $block['innerHTML'] ) ) { + if ( preg_match( '/(.*?)<\/span>/s', $block['innerHTML'], $matches ) ) { + $content = html_entity_decode( $matches[1], ENT_QUOTES, 'UTF-8' ); + } + } + $messages[] = array( 'id' => $id, 'role' => $role, diff --git a/modules/openai/class-openai-module.php b/modules/openai/class-openai-module.php index 785b08c..032a010 100644 --- a/modules/openai/class-openai-module.php +++ b/modules/openai/class-openai-module.php @@ -1944,15 +1944,16 @@ public function save_backscroll( array $backscroll, array $search_args, bool $ap } } - // Create message block + // Create message block with content in innerHTML (not attributes) + // This preserves newlines naturally without escaping hacks + $inner_html = '' . esc_html( $content ) . ''; $content_blocks[] = get_comment_delimited_block_content( 'pos/ai-message', array( - 'role' => $role, - 'content' => $content, - 'id' => $message_id, + 'role' => $role, + 'id' => $message_id, ), - '' + $inner_html ); } } @@ -1992,6 +1993,9 @@ public function save_backscroll( array $backscroll, array $search_args, bool $ap $post_data['post_content'] = implode( "\n\n", $content_blocks ); } + // Note: wp_slash is handled by preserve_ai_message_newlines filter + // to protect escaped newlines from wp_unslash + // Only update if there is content to update or we are not just appending empty content // But here we might want to update just to ensure touch? if ( isset( $post_data['post_content'] ) || isset( $post_data['post_title'] ) || ! $append ) { @@ -2001,6 +2005,7 @@ public function save_backscroll( array $backscroll, array $search_args, bool $ap } } else { // Create new + // Note: wp_slash is handled by preserve_ai_message_newlines filter $post_data['post_content'] = implode( "\n\n", $content_blocks ); // Ensure defaults for new post if ( empty( $post_data['post_title'] ) ) { diff --git a/src-chatbot/app/globals.css b/src-chatbot/app/globals.css index 3409b98..7487002 100644 --- a/src-chatbot/app/globals.css +++ b/src-chatbot/app/globals.css @@ -99,8 +99,32 @@ @apply border-border; } + html, body { + overflow-x: hidden; + width: 100%; + max-width: 100vw; + } + body { @apply bg-background text-foreground; + /* PWA safe area handling for iOS notch and home indicator */ + padding-top: env(safe-area-inset-top); + padding-bottom: env(safe-area-inset-bottom); + padding-left: env(safe-area-inset-left); + padding-right: env(safe-area-inset-right); + min-height: 100vh; + min-height: 100dvh; + } + + /* Ensure proper text wrapping everywhere */ + p, li, a, span, div { + overflow-wrap: break-word; + word-wrap: break-word; + word-break: break-word; + } + + a { + word-break: break-all; } } diff --git a/src-chatbot/app/layout.tsx b/src-chatbot/app/layout.tsx index 1ea4bcb..0b9cd27 100644 --- a/src-chatbot/app/layout.tsx +++ b/src-chatbot/app/layout.tsx @@ -1,6 +1,7 @@ import type { Metadata } from 'next'; import { Geist, Geist_Mono } from 'next/font/google'; import ClientOnly from '@/components/client-only'; +import { PWARegister } from '@/components/pwa-register'; // import { GeistSans } from 'geist/font/sans'; // Removed due to resolution issues import './globals.css'; @@ -13,7 +14,7 @@ export const metadata: Metadata = { applicationName: 'PersonalOS', appleWebApp: { capable: true, - statusBarStyle: 'default', + statusBarStyle: 'black-translucent', title: 'Personal Chat', // startupImage: [ // '/images/apple-touch-startup-image-768x1004.png', @@ -32,7 +33,7 @@ export const metadata: Metadata = { { url: '/wp-content/plugins/personalos/build/chatbot/images/apple-icon.png', sizes: '180x180', type: 'image/png' }, ], }, -// manifest: '/wp-content/plugins/personalos/build/chatbot/manifest.json', + manifest: '/wp-content/plugins/personalos/build/chatbot/manifest.json', }; export const viewport = { @@ -90,7 +91,7 @@ export default async function RootLayout({ > - + @@ -102,6 +103,7 @@ export default async function RootLayout({ /> + {children} diff --git a/src-chatbot/components/markdown.tsx b/src-chatbot/components/markdown.tsx index 6e42887..d4bb01d 100644 --- a/src-chatbot/components/markdown.tsx +++ b/src-chatbot/components/markdown.tsx @@ -5,25 +5,11 @@ import remarkGfm from 'remark-gfm'; import { CodeBlock } from './code-block'; /** - * Decode HTML entities and normalize text for markdown rendering - * Handles cases where newlines are encoded as "n" or "nn" characters + * Decode HTML entities for markdown rendering. */ -function decodeAndNormalizeText(text: string): string { +function decodeHtmlEntities(text: string): string { let decoded = text; - // Remove HTML tags but preserve their content - decoded = decoded.replace(/<[^>]*>/g, ''); - - // Replace encoded newlines: "nn" = double newline, "n" = single newline - // Pattern: "nn" followed by "-" or space, or "n" followed by "-" or space - // Replace "nn" first (double newline) before single "n" to avoid double replacement - decoded = decoded.replace(/nn(?=\s*-)/g, '\n\n'); - decoded = decoded.replace(/n(?=\s*-)/g, '\n'); - - // Also handle cases where "nn" or "n" appears at end of line or before other whitespace - decoded = decoded.replace(/nn(?=\s)/g, '\n\n'); - decoded = decoded.replace(/(? = { @@ -136,12 +122,12 @@ const components: Partial = { const remarkPlugins = [remarkGfm]; const NonMemoizedMarkdown = ({ children }: { children: string }) => { - // Decode HTML entities and normalize the text before rendering - const normalizedText = decodeAndNormalizeText(children); + // Decode HTML entities before rendering + const decodedText = decodeHtmlEntities(children); return ( - {normalizedText} + {decodedText} ); }; diff --git a/src-chatbot/components/pwa-register.tsx b/src-chatbot/components/pwa-register.tsx new file mode 100644 index 0000000..16aaa9d --- /dev/null +++ b/src-chatbot/components/pwa-register.tsx @@ -0,0 +1,19 @@ +'use client'; + +import { useEffect } from 'react'; + +export function PWARegister() { + useEffect(() => { + if ('serviceWorker' in navigator) { + navigator.serviceWorker + .register('/wp-content/plugins/personalos/build/chatbot/sw.js') + .catch((error) => { + console.log('Service worker registration failed:', error); + }); + } + }, []); + + return null; +} + + diff --git a/src-chatbot/public/manifest.json b/src-chatbot/public/manifest.json index 1fa8fad..9b2312f 100644 --- a/src-chatbot/public/manifest.json +++ b/src-chatbot/public/manifest.json @@ -2,16 +2,19 @@ "name": "Personal Chat", "short_name": "PersonalOS", "description": "Personal Chat Assistant", - "start_url": "/", + "start_url": "/wp-admin/admin.php?page=personalos-chatbot", + "id": "/wp-admin/admin.php?page=personalos-chatbot", "display": "standalone", + "display_override": ["standalone", "minimal-ui"], + "orientation": "portrait", "background_color": "#ffffff", "theme_color": "#000000", "icons": [ { - "src": "/images/apple-icon.png", + "src": "/wp-content/plugins/personalos/build/chatbot/images/apple-icon.png", "sizes": "180x180", "type": "image/png", "purpose": "any maskable" } ] -} \ No newline at end of file +} diff --git a/src-chatbot/public/sw.js b/src-chatbot/public/sw.js new file mode 100644 index 0000000..e923542 --- /dev/null +++ b/src-chatbot/public/sw.js @@ -0,0 +1,11 @@ +// Service Worker for PersonalOS PWA +// Minimal service worker - just enough to enable PWA installation + +self.addEventListener('install', () => { + self.skipWaiting(); +}); + +self.addEventListener('activate', (event) => { + event.waitUntil(clients.claim()); +}); + diff --git a/src/openai/blocks/message/block.json b/src/openai/blocks/message/block.json index 272a117..c180d5d 100644 --- a/src/openai/blocks/message/block.json +++ b/src/openai/blocks/message/block.json @@ -11,6 +11,8 @@ "attributes": { "content": { "type": "string", + "source": "text", + "selector": ".ai-message-text", "default": "" }, "role": { diff --git a/src/openai/blocks/message/index.css b/src/openai/blocks/message/index.css index a2792bc..6b5c2d5 100644 --- a/src/openai/blocks/message/index.css +++ b/src/openai/blocks/message/index.css @@ -477,4 +477,40 @@ .wp-block-pos-ai-message .markdown-content td { padding: 6px 8px; } -} \ No newline at end of file +} + +/* Editor textarea styling */ +.wp-block-pos-ai-message .message-editor { + width: 100%; + min-height: 100px; + padding: 0; + border: none; + background: transparent; + font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', 'Consolas', 'source-code-pro', monospace; + font-size: 13px; + line-height: 1.6; + color: inherit; + resize: vertical; + outline: none; +} + +.wp-block-pos-ai-message .message-editor::placeholder { + color: inherit; + opacity: 0.5; +} + +/* Click-to-edit hint */ +.wp-block-pos-ai-message .markdown-content { + cursor: text; +} + +.wp-block-pos-ai-message.is-selected .markdown-content:not(:has(textarea))::after { + content: 'Click to edit'; + position: absolute; + bottom: 8px; + right: 12px; + font-size: 10px; + opacity: 0.4; + font-style: italic; + pointer-events: none; +} \ No newline at end of file diff --git a/src/openai/blocks/message/index.js b/src/openai/blocks/message/index.js index 362053f..1fdcc3d 100644 --- a/src/openai/blocks/message/index.js +++ b/src/openai/blocks/message/index.js @@ -1,16 +1,8 @@ import './index.css'; import { registerBlockType } from '@wordpress/blocks'; -import { - InspectorControls, - useBlockProps -} from '@wordpress/block-editor'; -import { - PanelBody, - SelectControl, - TextControl -} from '@wordpress/components'; +import { useBlockProps } from '@wordpress/block-editor'; import { __ } from '@wordpress/i18n'; -import { useMemo } from '@wordpress/element'; +import { useState, useMemo } from '@wordpress/element'; import Showdown from 'showdown'; import metadata from './block.json'; @@ -21,14 +13,14 @@ const converter = new Showdown.Converter({ tasklists: true, ghCodeBlocks: true, ghMentions: false, - simpleLineBreaks: false, // Use false for better paragraph handling + simpleLineBreaks: false, requireSpaceBeforeHeadingText: true, openLinksInNewWindow: true, backslashEscapesHTMLTags: true, underline: true, emoji: true, splitAdjacentBlockquotes: true, - noHeaderId: true, // Disable header IDs for cleaner HTML + noHeaderId: true, parseImgDimensions: true, headerLevelStart: 1, smoothLivePreview: true @@ -38,13 +30,10 @@ const converter = new Showdown.Converter({ converter.addExtension({ type: 'output', filter: function (text) { - // Convert double line breaks to paragraphs properly text = text.replace(/\n\n/g, '

'); - // Wrap content in paragraph tags if not already wrapped if (!text.startsWith('

') && !text.startsWith('') && !text.startsWith('

    ') && !text.startsWith('
    ') && !text.startsWith('
    ')) {
     			text = '

    ' + text + '

    '; } - // Clean up empty paragraphs text = text.replace(/

    <\/p>/g, ''); return text; } @@ -52,18 +41,13 @@ converter.addExtension({ // Register the message block registerBlockType( metadata, { - edit: ( { attributes, setAttributes } ) => { - const { content, role, id } = attributes; + edit: ( { attributes, setAttributes, isSelected } ) => { + const { content, role } = attributes; + const [ isEditing, setIsEditing ] = useState( false ); const blockProps = useBlockProps( { className: `message-role-${ role }` } ); - const roleOptions = [ - { label: __( 'User', 'personalos' ), value: 'user' }, - { label: __( 'Assistant', 'personalos' ), value: 'assistant' }, - { label: __( 'System', 'personalos' ), value: 'system' } - ]; - const roleIcons = { user: '👤', assistant: '🤖', @@ -79,46 +63,50 @@ registerBlockType( metadata, { return converter.makeHtml( content ); } catch ( error ) { console.warn( 'Markdown parsing error:', error ); - // Fallback to plain text with basic formatting return content.replace( /\n/g, '
    ' ); } }, [ content ] ); - console.log( 'HTML', htmlContent ); + // Switch to view mode when block is deselected + if ( ! isSelected && isEditing ) { + setIsEditing( false ); + } + return (

    - - - setAttributes( { role: newRole } ) } - /> - setAttributes( { id: newId } ) } - help={ __( 'Optional unique identifier for this message', 'personalos' ) } - /> - - -
    { roleIcons[ role ] } { role } - { id && ID: { id } }
    - { content ? ( -
    setAttributes( { content: e.target.value } ) } + placeholder={ __( 'Enter message content (markdown supported)...', 'personalos' ) } + rows={ 10 } /> ) : ( -
    - { __( 'No content (populated via API)', 'personalos' ) } +
    setIsEditing( true ) } + role="button" + tabIndex={ 0 } + onKeyDown={ ( e ) => { + if ( e.key === 'Enter' || e.key === ' ' ) { + setIsEditing( true ); + } + } } + > + { content ? ( +
    + ) : ( +
    + { __( 'Click to edit message...', 'personalos' ) } +
    + ) }
    ) }
    @@ -126,8 +114,8 @@ registerBlockType( metadata, {
    ); }, - save: () => { - // No frontend rendering - editor only - return null; + save: ( { attributes } ) => { + const { content } = attributes; + return { content }; } -} ); \ No newline at end of file +} ); diff --git a/tests/unit/OpenAIModuleTest.php b/tests/unit/OpenAIModuleTest.php index c9fd2d2..d601be6 100644 --- a/tests/unit/OpenAIModuleTest.php +++ b/tests/unit/OpenAIModuleTest.php @@ -205,6 +205,264 @@ public function test_save_backscroll_missing_post_name() { $this->assertStringContainsString( 'chat-', $post->post_name ); // Default name format } + /** + * Test that newlines in content are preserved after initial save + */ + public function test_save_backscroll_preserves_newlines_on_initial_save() { + $content_with_newlines = "Hello!\n\nThis is a test.\n\n### Header\n- Item 1\n- Item 2"; + $backscroll = array( + array( + 'role' => 'user', + 'content' => 'Test question', + 'id' => 'user-1', + ), + array( + 'role' => 'assistant', + 'content' => $content_with_newlines, + 'id' => 'assistant-1', + ), + ); + $config = array( 'name' => 'test-newlines-initial' ); + + $reflection = new ReflectionClass( $this->module ); + $method = $reflection->getMethod( 'save_backscroll' ); + $method->setAccessible( true ); + + $post_id = $method->invokeArgs( $this->module, array( $backscroll, $config ) ); + $post = get_post( $post_id ); + + // Parse blocks to get the content back + $blocks = parse_blocks( $post->post_content ); + $assistant_block = null; + foreach ( $blocks as $block ) { + if ( 'pos/ai-message' === $block['blockName'] && 'assistant' === ( $block['attrs']['role'] ?? '' ) ) { + $assistant_block = $block; + break; + } + } + + $this->assertNotNull( $assistant_block, 'Assistant block should exist' ); + + // Content is now stored in innerHTML, extract it + $inner_html = $assistant_block['innerHTML'] ?? ''; + $this->assertStringContainsString( 'ai-message-text', $inner_html, 'Should have ai-message-text span' ); + + // Extract content and verify newlines are preserved + preg_match( '/(.*?)<\/span>/s', $inner_html, $matches ); + $saved_content = html_entity_decode( $matches[1] ?? '', ENT_QUOTES, 'UTF-8' ); + + $this->assertStringContainsString( "\n", $saved_content, 'Newlines should be preserved' ); + $this->assertStringNotContainsString( 'nn', $saved_content, 'Should not have corrupted nn' ); + } + + /** + * Test that newlines are preserved when appending messages to existing conversation + */ + public function test_save_backscroll_preserves_newlines_on_append() { + $first_content = "First response.\n\nWith newlines."; + $second_content = "Second response.\n\n### More content\n- List item"; + + $reflection = new ReflectionClass( $this->module ); + $method = $reflection->getMethod( 'save_backscroll' ); + $method->setAccessible( true ); + + // First save + $backscroll1 = array( + array( + 'role' => 'user', + 'content' => 'First question', + 'id' => 'user-1', + ), + array( + 'role' => 'assistant', + 'content' => $first_content, + 'id' => 'assistant-1', + ), + ); + $config = array( 'name' => 'test-newlines-append' ); + $post_id = $method->invokeArgs( $this->module, array( $backscroll1, $config ) ); + + // Append second turn + $backscroll2 = array( + array( + 'role' => 'user', + 'content' => 'Second question', + 'id' => 'user-2', + ), + array( + 'role' => 'assistant', + 'content' => $second_content, + 'id' => 'assistant-2', + ), + ); + $post_id2 = $method->invokeArgs( $this->module, array( $backscroll2, $config, true ) ); // append=true + + $this->assertEquals( $post_id, $post_id2, 'Should update same post' ); + + $post = get_post( $post_id ); + $blocks = parse_blocks( $post->post_content ); + + $assistant_blocks = array(); + foreach ( $blocks as $block ) { + if ( 'pos/ai-message' === $block['blockName'] && 'assistant' === ( $block['attrs']['role'] ?? '' ) ) { + $assistant_blocks[] = $block; + } + } + + $this->assertCount( 2, $assistant_blocks, 'Should have 2 assistant messages' ); + + // Helper to extract content from innerHTML + $extract_content = function( $block ) { + $inner_html = $block['innerHTML'] ?? ''; + preg_match( '/(.*?)<\/span>/s', $inner_html, $matches ); + return html_entity_decode( $matches[1] ?? '', ENT_QUOTES, 'UTF-8' ); + }; + + // Check first message still has proper newlines + $first_saved = $extract_content( $assistant_blocks[0] ); + $this->assertStringContainsString( "\n", $first_saved, 'First message should have newlines' ); + $this->assertStringNotContainsString( 'nn', $first_saved, 'First message should not be corrupted' ); + + // Check second message has proper newlines + $second_saved = $extract_content( $assistant_blocks[1] ); + $this->assertStringContainsString( "\n", $second_saved, 'Second message should have newlines' ); + $this->assertStringNotContainsString( 'nn', $second_saved, 'Second message should not be corrupted' ); + } + + /** + * Test that multiple rounds of appending don't corrupt newlines + */ + public function test_save_backscroll_preserves_newlines_multiple_appends() { + $reflection = new ReflectionClass( $this->module ); + $method = $reflection->getMethod( 'save_backscroll' ); + $method->setAccessible( true ); + + $config = array( 'name' => 'test-newlines-multi-append' ); + + // Initial save + $backscroll = array( + array( + 'role' => 'user', + 'content' => 'Question 1', + 'id' => 'user-1', + ), + array( + 'role' => 'assistant', + 'content' => "Answer 1.\n\nWith newlines.", + 'id' => 'assistant-1', + ), + ); + $post_id = $method->invokeArgs( $this->module, array( $backscroll, $config ) ); + + // Append 3 more times + for ( $i = 2; $i <= 4; $i++ ) { + $append_backscroll = array( + array( + 'role' => 'user', + 'content' => "Question $i", + 'id' => "user-$i", + ), + array( + 'role' => 'assistant', + 'content' => "Answer $i.\n\n### Section\n- Item", + 'id' => "assistant-$i", + ), + ); + $method->invokeArgs( $this->module, array( $append_backscroll, $config, true ) ); + } + + $post = get_post( $post_id ); + $blocks = parse_blocks( $post->post_content ); + + $assistant_blocks = array(); + foreach ( $blocks as $block ) { + if ( 'pos/ai-message' === $block['blockName'] && 'assistant' === ( $block['attrs']['role'] ?? '' ) ) { + $assistant_blocks[] = $block; + } + } + + $this->assertCount( 4, $assistant_blocks, 'Should have 4 assistant messages after multiple appends' ); + + // Helper to extract content from innerHTML + $extract_content = function( $block ) { + $inner_html = $block['innerHTML'] ?? ''; + preg_match( '/(.*?)<\/span>/s', $inner_html, $matches ); + return html_entity_decode( $matches[1] ?? '', ENT_QUOTES, 'UTF-8' ); + }; + + // Check ALL messages still have proper newlines (not corrupted) + foreach ( $assistant_blocks as $index => $block ) { + $content = $extract_content( $block ); + $this->assertStringContainsString( "\n", $content, "Message $index should have newlines" ); + $this->assertStringNotContainsString( 'nn', $content, "Message $index should not have corrupted 'nn'" ); + } + } + + /** + * Test that newlines survive a simulated Gutenberg editor save (wp_update_post directly) + * This simulates what happens when a user edits a chat in the block editor and saves. + */ + public function test_newlines_survive_gutenberg_editor_save() { + $content_with_newlines = "Response with newlines.\n\n### Header\n- Item"; + + $reflection = new ReflectionClass( $this->module ); + $method = $reflection->getMethod( 'save_backscroll' ); + $method->setAccessible( true ); + + // Initial save via save_backscroll + $backscroll = array( + array( + 'role' => 'user', + 'content' => 'Question', + 'id' => 'user-1', + ), + array( + 'role' => 'assistant', + 'content' => $content_with_newlines, + 'id' => 'assistant-1', + ), + ); + $config = array( 'name' => 'test-gutenberg-save' ); + $post_id = $method->invokeArgs( $this->module, array( $backscroll, $config ) ); + + // Get the current post content + $post = get_post( $post_id ); + $original_content = $post->post_content; + + // Verify it has the innerHTML structure + $this->assertStringContainsString( 'ai-message-text', $original_content, 'Should have ai-message-text span' ); + + // Simulate Gutenberg editor save - just re-save the same content via wp_update_post + // This is what happens when user clicks "Update" in the editor + wp_update_post( array( + 'ID' => $post_id, + 'post_content' => $original_content, + ) ); + + // Check if newlines survived + $post_after_edit = get_post( $post_id ); + $blocks = parse_blocks( $post_after_edit->post_content ); + + $assistant_block = null; + foreach ( $blocks as $block ) { + if ( 'pos/ai-message' === $block['blockName'] && 'assistant' === ( $block['attrs']['role'] ?? '' ) ) { + $assistant_block = $block; + break; + } + } + + $this->assertNotNull( $assistant_block, 'Assistant block should exist after editor save' ); + + // Extract content from innerHTML + $inner_html = $assistant_block['innerHTML'] ?? ''; + preg_match( '/(.*?)<\/span>/s', $inner_html, $matches ); + $saved_content = html_entity_decode( $matches[1] ?? '', ENT_QUOTES, 'UTF-8' ); + + // With innerHTML storage, newlines are preserved naturally - no escaping needed + $this->assertStringContainsString( "\n", $saved_content, 'Newlines should survive Gutenberg editor save' ); + $this->assertStringNotContainsString( 'nn', $saved_content, 'Should not have corrupted nn after editor save' ); + } + /** * Test save_backscroll error handling when notes module is not available */ diff --git a/tests/unit/OpenAIModuleVercelChatTest.php b/tests/unit/OpenAIModuleVercelChatTest.php index 64f2a0a..b8654c8 100644 --- a/tests/unit/OpenAIModuleVercelChatTest.php +++ b/tests/unit/OpenAIModuleVercelChatTest.php @@ -82,8 +82,8 @@ public function test_save_backscroll_append() { $this->assertStringContainsString( 'Hello', $post_updated->post_content ); $this->assertStringContainsString( 'Hi there', $post_updated->post_content ); - // Check structure: should have 2 block delimiters - $this->assertEquals( 2, substr_count( $post_updated->post_content, 'wp:pos/ai-message' ) ); + // Check structure: should have 2 blocks (count opening tags only, not closing) + $this->assertEquals( 2, substr_count( $post_updated->post_content, '