Merge pull request #209 from devchat-ai/52-i-hope-that-chat-messages-in-devchat-can-be-selectively-deleted

52 i hope that chat messages in devchat can be selectively deleted
This commit is contained in:
Rankin Zheng 2023-07-19 16:17:35 +08:00 committed by GitHub
commit 03a698e38a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 359 additions and 258 deletions

View File

@ -93,14 +93,12 @@ export function registerStatusBarItemClickCommand(context: vscode.ExtensionConte
const topicDeleteCallback = async (item: TopicTreeItem) => {
const confirm = 'Delete';
const cancel = 'Cancel';
const label = typeof item.label === 'string' ? item.label : item.label!.label;
const truncatedLabel = label.substring(0, 20) + (label.length > 20 ? '...' : '');
const result = await vscode.window.showWarningMessage(
`Are you sure you want to delete the topic "${truncatedLabel}"?`,
{ modal: true },
confirm,
cancel
confirm
);
if (result === confirm) {

View File

@ -6,7 +6,7 @@ import { doCommit } from './doCommit';
import { historyMessages } from './historyMessages';
import { regCommandList } from './regCommandList';
import { regContextList } from './regContextList';
import { sendMessage, stopDevChat, regeneration } from './sendMessage';
import { sendMessage, stopDevChat, regeneration, deleteChatMessage } from './sendMessage';
import { blockApply } from './showDiff';
import { showDiff } from './showDiff';
import { addConext } from './addContext';
@ -73,3 +73,6 @@ messageHandler.registerHandler('regActionList', regActionList);
// Apply action for code block
// Response: none
messageHandler.registerHandler('applyAction', applyAction);
// Delete chat message
// Response: { command: 'deletedChatMessage', result: <message id> }
messageHandler.registerHandler('deleteChatMessage', deleteChatMessage);

View File

@ -2,12 +2,13 @@
import * as vscode from 'vscode';
import { MessageHandler } from './messageHandler';
import { regInMessage, regOutMessage } from '../util/reg_messages';
import { stopDevChatBase, sendMessageBase } from './sendMessageBase';
import { stopDevChatBase, sendMessageBase, deleteChatMessageBase } from './sendMessageBase';
import { UiUtilWrapper } from '../util/uiUtil';
let _lastMessage: any = undefined;
regInMessage({command: 'sendMessage', text: '', hash: undefined});
regInMessage({command: 'sendMessage', text: '', parent_hash: undefined});
regOutMessage({ command: 'receiveMessage', text: 'xxxx', hash: 'xxx', user: 'xxx', date: 'xxx'});
regOutMessage({ command: 'receiveMessagePartial', text: 'xxxx', user: 'xxx', date: 'xxx'});
// message: { command: 'sendMessage', text: 'xxx', hash: 'xxx'}
@ -39,5 +40,26 @@ export async function stopDevChat(message: any, panel: vscode.WebviewPanel|vscod
stopDevChatBase(message);
}
regInMessage({command: 'deleteChatMessage', hash: 'xxx'});
regOutMessage({ command: 'deletedChatMessage', hash: 'xxxx'});
export async function deleteChatMessage(message: any, panel: vscode.WebviewPanel|vscode.WebviewView): Promise<void> {
// prompt user to confirm
const confirm = await vscode.window.showWarningMessage(
`Are you sure to delete this message?`,
{ modal: true },
'Delete'
);
if (confirm !== 'Delete') {
return;
}
const deleted = await deleteChatMessageBase(message);
if (deleted) {
MessageHandler.sendMessage(panel, { command: 'deletedChatMessage', hash: message.hash });
} else {
UiUtilWrapper.showErrorMessage('Delete message failed!');
}
}

View File

@ -104,22 +104,8 @@ export async function parseMessageAndSetOptions(message: any, chatOptions: any):
return parsedMessage;
}
// 将处理父哈希的部分提取到一个单独的函数中
export function getParentHash(message: any): string|undefined {
let parentHash = undefined;
logger.channel()?.info(`request message hash: ${message.hash}`);
if (message.hash) {
const hmessage = messageHistory.find(message.hash);
parentHash = hmessage ? hmessage.parentHash : undefined;
} else {
const hmessage = messageHistory.findLast();
parentHash = hmessage ? hmessage.hash : undefined;
}
logger.channel()?.info(`parent hash: ${parentHash}`);
return parentHash;
}
export async function handleTopic(parentHash:string, message: any, chatResponse: ChatResponse) {
export async function handleTopic(parentHash:string | undefined, message: any, chatResponse: ChatResponse) {
WaitCreateTopic = true;
try {
if (!chatResponse.isError) {
@ -158,10 +144,10 @@ export async function sendMessageBase(message: any, handlePartialData: (data: {
const chatOptions: any = {};
const parsedMessage = await parseMessageAndSetOptions(message, chatOptions);
const parentHash = getParentHash(message);
if (parentHash) {
chatOptions.parent = parentHash;
if (message.parent_hash) {
chatOptions.parent = message.parent_hash;
}
logger.channel()?.info(`parent hash: ${chatOptions.parent}`);
let partialDataText = '';
const onData = (partialResponse: ChatResponse) => {
@ -170,7 +156,7 @@ export async function sendMessageBase(message: any, handlePartialData: (data: {
};
const chatResponse = await devChat.chat(parsedMessage.text, chatOptions, onData);
await handleTopic(parentHash!, message, chatResponse);
await handleTopic(message.parent_hash, message, chatResponse);
const responseText = await handlerResponseText(partialDataText, chatResponse);
if (responseText === undefined) {
return;
@ -184,3 +170,19 @@ export async function stopDevChatBase(message: any): Promise<void> {
userStop = true;
devChat.stop();
}
// delete a chat message
// each message is identified by hash
export async function deleteChatMessageBase(message:{'hash': string}): Promise<boolean> {
// if hash is undefined, return
if (!message.hash) {
return true;
}
// delete the message from messageHistory
messageHistory.delete(message.hash);
// delete the message by devchat
const bSuccess = await devChat.delete(message.hash);
return bSuccess;
}

View File

@ -8,6 +8,7 @@ import { CommandRun } from "../util/commonUtil";
import ExtensionContextHolder from '../util/extensionContext';
import { UiUtilWrapper } from '../util/uiUtil';
import { ApiKeyManager } from '../util/apiKey';
import { exitCode } from 'process';
@ -234,6 +235,43 @@ class DevChat {
}
}
async delete(hash: string): Promise<boolean> {
const args = ["log", "--delete", hash];
const devChat = this.getDevChatPath();
const workspaceDir = UiUtilWrapper.workspaceFoldersFirstPath();
const openaiApiKey = process.env.OPENAI_API_KEY;
logger.channel()?.info(`Running devchat with arguments: ${args.join(" ")}`);
const spawnOptions = {
maxBuffer: 10 * 1024 * 1024, // Set maxBuffer to 10 MB
cwd: workspaceDir,
env: {
...process.env,
OPENAI_API_KEY: openaiApiKey,
},
};
const { exitCode: code, stdout, stderr } = await this.commandRun.spawnAsync(devChat, args, spawnOptions, undefined, undefined, undefined, undefined);
logger.channel()?.info(`Finish devchat with arguments: ${args.join(" ")}`);
if (stderr) {
logger.channel()?.error(`Error: ${stderr}`);
logger.channel()?.show();
return false;
}
if (stdout.indexOf('Failed to delete prompt') >= 0) {
logger.channel()?.error(`Failed to delete prompt: ${hash}`);
logger.channel()?.show();
return false;
}
if (code !== 0) {
logger.channel()?.error(`Exit code: ${code}`);
logger.channel()?.show();
return false;
}
return true;
}
async log(options: LogOptions = {}): Promise<LogEntry[]> {
const args = this.buildLogArgs(options);
const devChat = this.getDevChatPath();

View File

@ -30,6 +30,18 @@ export class MessageHistory {
find(hash: string) {
return this.history.find(message => message.hash === hash);
}
delete(hash: string) {
const index = this.history.findIndex(message => message.hash === hash);
if (index >= 0) {
this.history.splice(index, 1);
}
if (this.lastmessage?.hash === hash) {
this.lastmessage = null;
}
}
findLast() {
return this.lastmessage;
}

View File

@ -69,7 +69,7 @@ const StopButton = () => {
}
}}
onClick={() => {
dispatch(stopGenerating({ hasDone: false }));
dispatch(stopGenerating({ hasDone: false, message: null }));
messageUtil.sendMessage({
command: 'stopDevChat'
});
@ -124,8 +124,8 @@ const chatPanel = () => {
dispatch(startResponsing(message.text));
timer.start();
});
messageUtil.registerHandler('receiveMessage', (message: { text: string; isError: boolean }) => {
dispatch(stopGenerating({ hasDone: true }));
messageUtil.registerHandler('receiveMessage', (message: { text: string; isError: boolean, hash }) => {
dispatch(stopGenerating({ hasDone: true, message: message }));
if (message.isError) {
dispatch(happendError(message.text));
}

102
src/views/InputContexts.tsx Normal file
View File

@ -0,0 +1,102 @@
import { Accordion, Box, ActionIcon, ScrollArea, Center, Text } from "@mantine/core";
import { IconX } from "@tabler/icons-react";
import React from "react";
import { useAppDispatch, useAppSelector } from '@/views/hooks';
import {
selectContexts,
removeContext,
} from './inputSlice';
const InputContexts = () => {
const dispatch = useAppDispatch();
const contexts = useAppSelector(selectContexts);
return (<Accordion variant="contained" chevronPosition="left"
sx={{
backgroundColor: 'var(--vscode-menu-background)',
}}
styles={{
item: {
borderColor: 'var(--vscode-menu-border)',
backgroundColor: 'var(--vscode-menu-background)',
'&[data-active]': {
backgroundColor: 'var(--vscode-menu-background)',
}
},
control: {
height: 30,
borderRadius: 3,
backgroundColor: 'var(--vscode-menu-background)',
'&[aria-expanded="true"]': {
borderBottomLeftRadius: 0,
borderBottomRightRadius: 0,
},
'&:hover': {
backgroundColor: 'var(--vscode-menu-background)',
}
},
chevron: {
color: 'var(--vscode-menu-foreground)',
},
icon: {
color: 'var(--vscode-menu-foreground)',
},
label: {
color: 'var(--vscode-menu-foreground)',
},
panel: {
color: 'var(--vscode-menu-foreground)',
backgroundColor: 'var(--vscode-menu-background)',
overflow: 'hidden',
},
content: {
borderRadius: 3,
backgroundColor: 'var(--vscode-menu-background)',
}
}}>
{
contexts.map((item: any, index: number) => {
const { context } = item;
return (
<Accordion.Item key={`item-${index}`} value={`item-value-${index}`} >
<Box sx={{
display: 'flex', alignItems: 'center',
backgroundColor: 'var(--vscode-menu-background)',
}}>
<Accordion.Control w={'calc(100% - 40px)'}>
<Text truncate='end'>{'command' in context ? context.command : context.path}</Text>
</Accordion.Control>
<ActionIcon
mr={8}
size="sm"
sx={{
color: 'var(--vscode-menu-foreground)',
'&:hover': {
backgroundColor: 'var(--vscode-toolbar-activeBackground)'
}
}}
onClick={() => {
dispatch(removeContext(index));
}}>
<IconX size="1rem" />
</ActionIcon>
</Box>
<Accordion.Panel mah={300}>
<ScrollArea h={300} type="never">
{
context.content
? <pre style={{ overflowWrap: 'normal' }}>{context.content}</pre>
: <Center>
<Text c='gray.3'>No content</Text>
</Center>
}
</ScrollArea>
</Accordion.Panel>
</Accordion.Item>
);
})
}
</Accordion>);
};
export default InputContexts;

View File

@ -5,6 +5,7 @@ import React, { useState, useEffect } from "react";
import { IconGitBranchChecked, IconShellCommand, IconMouseRightClick } from "./Icons";
import messageUtil from '@/util/MessageUtil';
import { useAppDispatch, useAppSelector } from '@/views/hooks';
import InputContexts from '@/views/InputContexts';
import {
setValue,
@ -16,7 +17,6 @@ import {
selectContextMenus,
selectCommandMenus,
setCurrentMenuIndex,
removeContext,
clearContexts,
newContext,
openMenu,
@ -30,97 +30,6 @@ import {
startGenerating,
} from './chatSlice';
const InputContexts = () => {
const dispatch = useAppDispatch();
const contexts = useAppSelector(selectContexts);
return (<Accordion variant="contained" chevronPosition="left"
sx={{
backgroundColor: 'var(--vscode-menu-background)',
}}
styles={{
item: {
borderColor: 'var(--vscode-menu-border)',
backgroundColor: 'var(--vscode-menu-background)',
'&[data-active]': {
backgroundColor: 'var(--vscode-menu-background)',
}
},
control: {
height: 30,
borderRadius: 3,
backgroundColor: 'var(--vscode-menu-background)',
'&[aria-expanded="true"]': {
borderBottomLeftRadius: 0,
borderBottomRightRadius: 0,
},
'&:hover': {
backgroundColor: 'var(--vscode-menu-background)',
}
},
chevron: {
color: 'var(--vscode-menu-foreground)',
},
icon: {
color: 'var(--vscode-menu-foreground)',
},
label: {
color: 'var(--vscode-menu-foreground)',
},
panel: {
color: 'var(--vscode-menu-foreground)',
backgroundColor: 'var(--vscode-menu-background)',
overflow: 'hidden',
},
content: {
borderRadius: 3,
backgroundColor: 'var(--vscode-menu-background)',
}
}}>
{
contexts.map((item: any, index: number) => {
const { context } = item;
return (
<Accordion.Item key={`item-${index}`} value={`item-value-${index}`} >
<Box sx={{
display: 'flex', alignItems: 'center',
backgroundColor: 'var(--vscode-menu-background)',
}}>
<Accordion.Control w={'calc(100% - 40px)'}>
<Text truncate='end'>{'command' in context ? context.command : context.path}</Text>
</Accordion.Control>
<ActionIcon
mr={8}
size="sm"
sx={{
color: 'var(--vscode-menu-foreground)',
'&:hover': {
backgroundColor: 'var(--vscode-toolbar-activeBackground)'
}
}}
onClick={() => {
dispatch(removeContext(index));
}}>
<IconX size="1rem" />
</ActionIcon>
</Box>
<Accordion.Panel mah={300}>
<ScrollArea h={300} type="never">
{
context.content
? <pre style={{ overflowWrap: 'normal' }}>{context.content}</pre>
: <Center>
<Text c='gray.3'>No content</Text>
</Center>
}
</ScrollArea>
</Accordion.Panel>
</Accordion.Item>
);
})
}
</Accordion>);
};
const InputMessage = (props: any) => {
const { width } = props;

View File

@ -1,22 +1,13 @@
import { Center, Text, Flex, Avatar, Accordion, Box, Stack, Container, Divider, ActionIcon, Tooltip, CopyButton } from "@mantine/core";
import { Center, Text, Accordion, Box, Stack, Container, Divider } from "@mantine/core";
import React from "react";
import CodeBlock from "@/views/CodeBlock";
import MessageHeader from "@/views/MessageHeader";
// @ts-ignore
import SvgAvatarDevChat from '@/views/avatar_devchat.svg';
// @ts-ignore
import SvgAvatarUser from '@/views/avatar_spaceman.png';
import { IconCheck, IconCopy, Icon360 } from "@tabler/icons-react";
import { useAppDispatch, useAppSelector } from '@/views/hooks';
import { useAppSelector } from '@/views/hooks';
import {
selectMessages,
} from './chatSlice';
import {
setContexts,
setValue,
} from './inputSlice';
const MessageContext = (props: any) => {
@ -95,55 +86,6 @@ const MessageContext = (props: any) => {
);
};
const MessageHeader = (props: any) => {
const { type, message, contexts } = props;
const dispatch = useAppDispatch();
const [done, setDone] = React.useState(false);
return (<Flex
m='10px 0 10px 0'
gap="sm"
justify="flex-start"
align="center"
direction="row"
wrap="wrap">
{
type === 'bot'
? <Avatar
color="indigo"
size={25}
radius="xl"
src={SvgAvatarDevChat} />
: <Avatar
color="cyan"
size={25}
radius="xl"
src={SvgAvatarUser} />
}
<Text weight='bold'>{type === 'bot' ? 'DevChat' : 'User'}</Text>
{type === 'user'
? <Tooltip sx={{ padding: '3px', fontSize: 'var(--vscode-editor-font-size)' }} label={done ? 'Refilled' : 'Refill prompt'} withArrow position="left" color="gray">
<ActionIcon size='sm' style={{ marginLeft: 'auto' }}
onClick={() => {
dispatch(setValue(message));
dispatch(setContexts(contexts));
setDone(true);
setTimeout(() => { setDone(false); }, 2000);
}}>
{done ? <IconCheck size="1rem" /> : <Icon360 size="1.125rem" />}
</ActionIcon>
</Tooltip>
: <CopyButton value={message} timeout={2000}>
{({ copied, copy }) => (
<Tooltip sx={{ padding: '3px', fontSize: 'var(--vscode-editor-font-size)' }} label={copied ? 'Copied' : 'Copy message'} withArrow position="left" color="gray">
<ActionIcon size='xs' color={copied ? 'teal' : 'gray'} onClick={copy} style={{ marginLeft: 'auto' }}>
{copied ? <IconCheck size="1rem" /> : <IconCopy size="1rem" />}
</ActionIcon>
</Tooltip>
)}
</CopyButton>
}
</Flex >);
};
const MessageContainer = (props: any) => {
const { width } = props;
@ -163,9 +105,8 @@ const MessageContainer = (props: any) => {
}}>
<MessageHeader
key={`message-header-${index}`}
type={messageType}
message={messageText}
contexts={contexts} />
showDelete={index === messages.length - 2}
item={item} />
<Container
key={`message-container-${index}`}
sx={{

102
src/views/MessageHeader.tsx Normal file
View File

@ -0,0 +1,102 @@
import React from "react";
import { Text, Flex, Avatar, ActionIcon, Tooltip, CopyButton, SimpleGrid } from "@mantine/core";
// @ts-ignore
import SvgAvatarDevChat from '@/views/avatar_devchat.svg';
// @ts-ignore
import SvgAvatarUser from '@/views/avatar_spaceman.png';
import { IconCheck, IconCopy, Icon360, IconEdit, IconTrash } from "@tabler/icons-react";
import { useAppDispatch } from '@/views/hooks';
import {
setContexts,
setValue,
} from './inputSlice';
import {
deleteMessage,
popMessage
} from './chatSlice';
const MessageHeader = (props: any) => {
const { item, showEdit = false, showDelete = true } = props;
const { contexts, message, type, hash } = item;
const dispatch = useAppDispatch();
const [done, setDone] = React.useState(false);
return (<Flex
m='10px 0 10px 0'
gap="sm"
justify="flex-start"
align="center"
direction="row"
wrap="wrap">
{
type === 'bot'
? <Avatar
color="indigo"
size={25}
radius="xl"
src={SvgAvatarDevChat} />
: <Avatar
color="cyan"
size={25}
radius="xl"
src={SvgAvatarUser} />
}
<Text weight='bold'>{type === 'bot' ? 'DevChat' : 'User'}</Text>
{type === 'user'
? <Flex
gap="xs"
justify="flex-end"
align="center"
direction="row"
wrap="wrap"
style={{ marginLeft: 'auto' }}>
<Tooltip sx={{ padding: '3px', fontSize: 'var(--vscode-editor-font-size)' }} label={done ? 'Refilled' : 'Refill prompt'} withArrow position="left" color="gray">
<ActionIcon size='sm'
onClick={() => {
dispatch(setValue(message));
dispatch(setContexts(contexts));
setDone(true);
setTimeout(() => { setDone(false); }, 2000);
}}>
{done ? <IconCheck size="1rem" /> : <Icon360 size="1.125rem" />}
</ActionIcon>
</Tooltip>
{showEdit && <Tooltip sx={{ padding: '3px', fontSize: 'var(--vscode-editor-font-size)' }} label="Edit message" withArrow position="left" color="gray">
<ActionIcon size='sm'
onClick={() => {
}}>
<IconEdit size="1.125rem" />
</ActionIcon>
</Tooltip >}
{showDelete && hash !== 'message' && <Tooltip sx={{ padding: '3px', fontSize: 'var(--vscode-editor-font-size)' }} label="Delete message" withArrow position="left" color="gray">
<ActionIcon size='sm'
onClick={() => {
if (item.hash) {
dispatch(deleteMessage(item));
} else {
dispatch(popMessage());
dispatch(popMessage());
}
}}>
<IconTrash size="1.125rem" />
</ActionIcon>
</Tooltip >}
</Flex >
: <CopyButton value={message} timeout={2000}>
{({ copied, copy }) => (
<Tooltip sx={{ padding: '3px', fontSize: 'var(--vscode-editor-font-size)' }} label={copied ? 'Copied' : 'Copy message'} withArrow position="left" color="gray">
<ActionIcon size='xs' color={copied ? 'teal' : 'gray'} onClick={copy} style={{ marginLeft: 'auto' }}>
{copied ? <IconCheck size="1rem" /> : <IconCopy size="1rem" />}
</ActionIcon>
</Tooltip>
)}
</CopyButton>
}
</Flex >);
};
export default MessageHeader;

View File

@ -19,6 +19,22 @@ export const fetchHistoryMessages = createAsyncThunk<{ pageIndex: number, entrie
});
});
export const deleteMessage = createAsyncThunk<{ hash }, { hash }>('chat/deleteMessage', async (params) => {
const { hash } = params;
return new Promise((resolve, reject) => {
try {
messageUtil.sendMessage({ command: 'deleteChatMessage', hash: hash });
messageUtil.registerHandler('deletedChatMessage', (message) => {
resolve({
hash: message.hash
});
});
} catch (e) {
reject(e);
}
});
});
export const chatSlice = createSlice({
name: 'chat',
initialState: {
@ -41,9 +57,17 @@ export const chatSlice = createSlice({
state.hasDone = false;
state.errorMessage = '';
state.currentMessage = '';
let lastNonEmptyHash;
for (let i = state.messages.length - 1; i >= 0; i--) {
if (state.messages[i].hash) {
lastNonEmptyHash = state.messages[i].hash;
break;
}
}
messageUtil.sendMessage({
command: 'sendMessage',
text: action.payload
text: action.payload,
parent_hash: lastNonEmptyHash
});
},
reGenerating: (state) => {
@ -61,6 +85,17 @@ export const chatSlice = createSlice({
state.generating = false;
state.responsed = false;
state.hasDone = action.payload.hasDone;
if (action.payload.hasDone) {
const { hash } = action.payload.message;
const messagesLength = state.messages.length;
if (messagesLength > 1) {
state.messages[messagesLength - 2].hash = hash;
state.messages[messagesLength - 1].hash = hash;
} else if (messagesLength > 0) {
state.messages[messagesLength - 1].hash = hash;
}
}
},
startResponsing: (state, action) => {
state.responsed = true;
@ -110,8 +145,8 @@ export const chatSlice = createSlice({
const { hash, user, date, request, response, context } = item;
const contexts = context?.map(({ content, role }) => ({ context: JSON.parse(content) }));
return [
{ type: 'user', message: request, contexts: contexts },
{ type: 'bot', message: response },
{ type: 'user', message: request, contexts: contexts, date: date, hash: hash },
{ type: 'bot', message: response, date: date, hash: hash },
];
})
.flat();
@ -123,6 +158,13 @@ export const chatSlice = createSlice({
} else {
state.isLastPage = true;
}
})
.addCase(deleteMessage.fulfilled, (state, action) => {
const { hash } = action.payload;
const index = state.messages.findIndex((item: any) => item.hash === hash);
if (index > -1) {
state.messages.splice(index);
}
});
}
});

View File

@ -3,7 +3,7 @@ import { describe, it } from 'mocha';
import { Context } from 'mocha';
import sinon from 'sinon';
import * as path from 'path';
import { parseMessage, getInstructionFiles, parseMessageAndSetOptions, getParentHash, handleTopic, handlerResponseText, sendMessageBase, stopDevChatBase } from '../../src/handler/sendMessageBase';
import { parseMessage, getInstructionFiles, parseMessageAndSetOptions, handleTopic, handlerResponseText, sendMessageBase, stopDevChatBase } from '../../src/handler/sendMessageBase';
import DevChat, { ChatResponse } from '../../src/toolwrapper/devchat';
import CommandManager from '../../src/command/commandManager';
import messageHistory from '../../src/util/messageHistory';
@ -68,76 +68,6 @@ describe('sendMessageBase', () => {
});
});
describe('getParentHash', () => {
beforeEach(() => {
messageHistory.clear();
});
it('should return parent hash when message hash is provided and found in history', () => {
const message1 = {
hash: 'somehash1',
parentHash: 'parentHash1'
};
const message2 = {
hash: 'somehash2',
parentHash: 'parentHash2'
};
messageHistory.add(message1);
messageHistory.add(message2);
const message = {
hash: 'somehash1'
};
const result = getParentHash(message);
expect(result).to.equal('parentHash1');
});
it('should return undefined when message hash is provided but not found in history', () => {
const message1 = {
hash: 'somehash1',
parentHash: 'parentHash1'
};
const message2 = {
hash: 'somehash2',
parentHash: 'parentHash2'
};
messageHistory.add(message1);
messageHistory.add(message2);
const message = {
hash: 'nonexistenthash'
};
const result = getParentHash(message);
expect(result).to.be.undefined;
});
it('should return last message hash when message hash is not provided', () => {
const message1 = {
hash: 'somehash1',
parentHash: 'parentHash1'
};
const message2 = {
hash: 'somehash2',
parentHash: 'parentHash2'
};
messageHistory.add(message1);
messageHistory.add(message2);
const message = {};
const result = getParentHash(message);
expect(result).to.equal('somehash2');
});
it('should return undefined when message hash is not provided and history is empty', () => {
const message = {};
const result = getParentHash(message);
expect(result).to.be.undefined;
});
});
describe('handleTopic', () => {
it('should handle topic correctly', async () => {