dsh-im-ops/test/history-command.test.mjs
xmanrui 8c6c31a1b6 feat: add bounded history previews across nine channels
Add shared /history parsing, read-only session access, message filtering, and bounded replies. Default to three messages, cap at five, and exclude saved history commands.

Include channel integration tests, translations, command help, and the Issue #62 implementation plan.
2026-08-28 03:01:53 +08:00

601 lines
24 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import assert from 'node:assert/strict';
import test from 'node:test';
import {
isHistoryCommand,
runHistoryCommand,
} from '../src/channels/shared/history-command.mjs';
import {
getImHostLanguage,
setImHostLanguage,
} from '../src/channels/shared/i18n.mjs';
const KEY = 'direct:history-test';
const PRIVATE_DETAIL = 'DO_NOT_DISPLAY_PRIVATE_HISTORY_DETAIL';
const SAFE_FAILURE = /失败|无法|暂不支持|重试|不存在|变化|取消|unavailable|retry|failed|not exist|changed|cancel/iu;
function entry(seq, type, data, extra = {}) {
return { event: { seq, type, data, ...extra } };
}
function user(seq, text, extra = {}) {
return entry(seq, 'user/message', {
role: 'user',
source: { kind: 'user' },
content: [{ type: 'text', text }],
...extra,
}, { surfaceOp: 'append' });
}
function assistant(seq, turn, text, extra = {}) {
return entry(seq, 'assistant/message', {
turn,
step: 1,
message: { role: 'assistant', content: [{ type: 'text', text }] },
...extra,
}, { surfaceOp: 'append' });
}
function end(seq, turn, reason = { kind: 'completed' }) {
return entry(seq, 'turn/end', { turn, reason });
}
function dialogue(turns = 4) {
return Array.from({ length: turns }, (_, index) => {
const turn = index + 1;
return [
user(turn * 10, `history_user_${turn}`),
assistant(turn * 10 + 1, turn, `history_assistant_${turn}`),
end(turn * 10 + 2, turn),
];
}).flat();
}
function fixture({ sessionId = 'hist42', events = dialogue(), read } = {}) {
let binding = sessionId;
const calls = [];
const state = { sessionFor: () => binding };
const forbidden = (method) => () => assert.fail(`/history called mutating method ${method}`);
const session = {
async readHistory(options) {
calls.push({ sessionId: binding, options });
return read ? read(options, calls.length) : { events, hasMore: false };
},
ask: forbidden('ask'),
stopActiveTurn: forbidden('stopActiveTurn'),
steerActiveTurn: forbidden('steerActiveTurn'),
};
const harness = {
workspaceSession(id) {
assert.equal(id, sessionId, 'only the originally bound Session may be read');
return session;
},
createSession: forbidden('createSession'),
ensureRunning: forbidden('ensureRunning'),
executeCommand: forbidden('executeCommand'),
rpc: forbidden('raw RPC outside the scoped history handle'),
};
return {
calls,
harness,
session,
state,
bind(nextSessionId) { binding = nextSessionId; },
run(text = '/history', options = {}) {
return runHistoryCommand(text, harness, state, KEY, { isDirect: true, ...options });
},
};
}
function reply(result) {
assert.equal(result?.handled, true);
assert.equal(typeof result.message, 'string');
assert.ok(result.message.length > 0);
assert.ok(Array.isArray(result.messages));
assert.ok(result.messages.length > 0 && result.messages.length <= 3);
assert.equal(result.messages.join(''), result.message);
assert.ok(result.messages.every((part) => typeof part === 'string' && part.length <= 1_800));
assert.ok(result.message.length <= 3_000);
return result.message;
}
function selectedMarkers(message) {
return message.match(/history_(?:user|assistant)_\d+/gu) ?? [];
}
test('history reserves its command token, including malformed arguments, but not other text', async () => {
for (const text of [
'/history', ' /HiStOrY ', '/history 3', '/history 0', '/history 1 2',
'/history -1', '/history\nnot-a-number',
]) {
assert.equal(isHistoryCommand(text), true, text);
}
for (const text of [null, undefined, 42, '', 'history', '/history2', '/histories', 'hi /history']) {
assert.equal(isHistoryCommand(text), false, String(text));
assert.equal(await runHistoryCommand(text, {}, {}, KEY), null);
}
});
test('/history defaults to three individual messages, newest selected then oldest displayed', async () => {
const current = fixture();
const text = reply(await current.run());
assert.deepEqual(selectedMarkers(text), [
'history_assistant_3', 'history_user_4', 'history_assistant_4',
]);
assert.match(text, /hist42/);
assert.equal(current.calls.length, 1);
assert.equal(current.calls[0].options.maxMessages, 50);
assert.equal(current.calls[0].options.beforeSeq, undefined);
});
test('/history accepts positive integer counts and clamps every count above five', async () => {
const all = Array.from({ length: 4 }, (_, index) => [
`history_user_${index + 1}`, `history_assistant_${index + 1}`,
]).flat();
for (const [argument, count] of [
['1', 1], ['2', 2], ['3', 3], ['4', 4], ['5', 5], ['6', 5], ['100', 5],
['9'.repeat(400), 5], ['0003', 3],
]) {
const current = fixture();
const text = reply(await current.run(`/history ${argument}`));
assert.deepEqual(selectedMarkers(text), all.slice(-count), argument);
assert.equal(current.calls.length, 1, argument);
}
});
test('history omits stored history commands before applying the default and maximum counts', async () => {
const oldCommand = user(1, '/history');
oldCommand.event.time = Date.UTC(2020, 0, 1);
const currentCommand = user(40, ' /HiStOrY 3 ');
currentCommand.event.time = Date.now();
const events = [
oldCommand,
...dialogue(3),
user(33, '/history'),
user(34, '/history 10'),
currentCommand,
];
const normalMessages = Array.from({ length: 3 }, (_, index) => [
`history_user_${index + 1}`, `history_assistant_${index + 1}`,
]).flat();
for (const [command, count] of [['/history', 3], ['/history 10', 5]]) {
const current = fixture({ events });
const text = reply(await current.run(command));
assert.deepEqual(selectedMarkers(text), normalMessages.slice(-count));
assert.doesNotMatch(text, /\/history(?:\s|$)/iu);
}
});
test('ordinary history text can mention /history or start with a different command token', async () => {
const bodies = ['/history2', '请解释 /history 这个命令', '文档里也有 /history 10 的示例'];
const current = fixture({ events: bodies.map((body, index) => user(index + 1, body)) });
const text = reply(await current.run());
for (const body of bodies) assert.ok(text.includes(body), body);
});
test('filtered history backfills older pages to reach three or five normal messages', async () => {
const pages = [
{ events: [user(60, '/history'), user(61, '/history 10'), user(62, '/history 3')], hasMore: true },
{ events: [
user(50, 'history_user_3'), user(51, 'history_user_4'),
user(52, 'history_user_5'), user(53, 'history_user_6'), user(54, '/history 2'),
], hasMore: true },
{ events: [
user(10, 'history_user_1'), user(11, 'history_user_2'), user(12, '/history 10'),
], hasMore: false },
];
for (const [command, count, reads] of [['/history', 3, 2], ['/history 10', 5, 3]]) {
const current = fixture({ read: (_options, call) => pages[call - 1] });
const text = reply(await current.run(command));
assert.deepEqual(selectedMarkers(text), Array.from({ length: count }, (_, index) => (
`history_user_${7 - count + index}`
)));
assert.equal(current.calls.length, reads);
assert.deepEqual(current.calls.map(({ options }) => options.beforeSeq), [undefined, 60, 50].slice(0, reads));
assert.doesNotMatch(text, /\/history(?:\s|$)/iu);
}
});
test('only stored history commands produce the empty-history response', async () => {
const current = fixture({ events: [
user(1, '/history'), user(2, '/history 10'), user(3, ' /HISTORY 5 '),
] });
const text = reply(await current.run());
assert.match(text, /暂无|没有|no .*history|no .*message/iu);
assert.doesNotMatch(text, /\/history(?:\s|$)/iu);
});
test('invalid history counts are consumed as usage errors without querying the Session', async () => {
for (const argument of ['0', '000', '-1', '+3', '1.5', '1e3', 'no', '1 2', '3\n4', '3']) {
const current = fixture();
const text = reply(await current.run(`/history ${argument}`));
assert.match(text, /用法|usage/iu, argument);
assert.match(text, /\/history/u, argument);
assert.equal(current.calls.length, 0, argument);
}
});
test('history is private and text-only, including when private status is omitted', async () => {
for (const options of [
{ isDirect: false }, { isDirect: true, hasImages: true }, { isDirect: true, hasFiles: true },
]) {
const current = fixture();
const text = reply(await current.run('/history', options));
assert.match(text, /私聊|文字|图片|文件|direct|text|image|file/iu);
assert.equal(current.calls.length, 0);
}
const current = fixture();
reply(await runHistoryCommand('/history', current.harness, current.state, KEY));
assert.equal(current.calls.length, 0);
});
test('history never creates a Session and returns only the available records', async () => {
const unbound = fixture({ sessionId: null });
assert.match(reply(await unbound.run()), /绑定|会话|bound|session/iu);
assert.equal(unbound.calls.length, 0);
const empty = fixture({ events: [] });
assert.match(reply(await empty.run()), /暂无|没有|no .*history|no .*message/iu);
const short = fixture({ events: [user(1, 'history_user_1')] });
assert.deepEqual(selectedMarkers(reply(await short.run('/history 5'))), ['history_user_1']);
});
test('history accepts completed reasons from both current and older Host protocols', async () => {
for (const reason of [{ kind: 'completed' }, 'completed']) {
const current = fixture({ events: [
user(1, 'history_user_1'),
assistant(2, 1, 'history_assistant_1'),
end(3, 1, reason),
] });
assert.deepEqual(selectedMarkers(reply(await current.run())), [
'history_user_1', 'history_assistant_1',
]);
}
});
test('history filters injections, replacements, tools, reasoning, and intermediate replies', async () => {
const replacedUser = user(8, PRIVATE_DETAIL);
replacedUser.event.surfaceOp = 'replace';
const replacedAssistant = assistant(9, 1, PRIVATE_DETAIL);
replacedAssistant.event.surfaceOp = 'replace';
const unknownSource = user(10, PRIVATE_DETAIL, { source: undefined });
const missingOperation = user(11, PRIVATE_DETAIL);
delete missingOperation.event.surfaceOp;
const current = fixture({ events: [
user(1, 'history_user_1'),
user(2, PRIVATE_DETAIL, { source: { kind: 'plugin', pluginId: 'time-context' } }),
user(3, PRIVATE_DETAIL, { source: { kind: 'goal' } }),
assistant(4, 1, PRIVATE_DETAIL),
entry(5, 'tool/call', { name: 'bash', arguments: PRIVATE_DETAIL, turn: 1 }),
entry(6, 'tool/result', { text: PRIVATE_DETAIL, turn: 1 }, { surfaceOp: 'append' }),
entry(7, 'assistant/chunk', { turn: 1, chunk: { type: 'text-delta', text: PRIVATE_DETAIL } }),
replacedUser,
replacedAssistant,
unknownSource,
missingOperation,
entry(12, 'approval/requested', { text: PRIVATE_DETAIL }),
assistant(13, 1, '', {
step: 2,
message: { role: 'assistant', content: [
{ type: 'reasoning', text: PRIVATE_DETAIL },
{ type: 'text', text: 'history_assistant_1' },
{ type: 'tool-call', name: PRIVATE_DETAIL, arguments: PRIVATE_DETAIL },
] },
}),
end(14, 1),
] });
const text = reply(await current.run('/history 5'));
assert.deepEqual(selectedMarkers(text), ['history_user_1', 'history_assistant_1']);
assert.doesNotMatch(text, new RegExp(PRIVATE_DETAIL, 'u'));
});
test('history keeps a running user input but never shows its unfinished assistant output', async () => {
const current = fixture({ events: [
...dialogue(1),
user(20, 'history_user_2'),
assistant(21, 2, PRIVATE_DETAIL),
entry(22, 'assistant/chunk', { turn: 2, chunk: { type: 'text-delta', text: PRIVATE_DETAIL } }),
] });
const text = reply(await current.run());
assert.deepEqual(selectedMarkers(text), [
'history_user_1', 'history_assistant_1', 'history_user_2',
]);
assert.doesNotMatch(text, new RegExp(PRIVATE_DETAIL, 'u'));
});
test('history excludes assistant replies from every unsuccessful terminal state', async () => {
for (const reason of ['error', 'aborted', 'blocked', 'max-tokens', 'interrupted']) {
for (const terminal of [reason, { kind: reason }]) {
const current = fixture({ events: [
user(1, 'history_user_1'),
assistant(2, 1, PRIVATE_DETAIL),
end(3, 1, terminal),
] });
const text = reply(await current.run());
assert.deepEqual(selectedMarkers(text), ['history_user_1'], reason);
assert.doesNotMatch(text, new RegExp(PRIVATE_DETAIL, 'u'), reason);
}
}
});
test('history does not substitute an intermediate answer for an interrupted final message', async () => {
const current = fixture({ events: [
user(1, 'history_user_1'),
assistant(2, 1, PRIVATE_DETAIL),
assistant(3, 1, PRIVATE_DETAIL, { step: 2, interrupted: true }),
end(4, 1),
] });
const text = reply(await current.run());
assert.deepEqual(selectedMarkers(text), ['history_user_1']);
assert.doesNotMatch(text, new RegExp(PRIVATE_DETAIL, 'u'));
});
test('a final reply with no text gets a placeholder, not an earlier step or hidden blocks', async () => {
const current = fixture({ events: [
user(1, 'history_user_1'),
assistant(2, 1, PRIVATE_DETAIL),
assistant(3, 1, '', {
step: 2,
message: { role: 'assistant', content: [{ type: 'reasoning', text: PRIVATE_DETAIL }] },
}),
end(4, 1),
] });
const text = reply(await current.run('/history 1'));
assert.deepEqual(selectedMarkers(text), []);
assert.match(text, /文字|text/iu);
assert.match(text, /助手|assistant/iu);
assert.doesNotMatch(text, new RegExp(PRIVATE_DETAIL, 'u'));
});
test('structured attachments are represented without exposing their payload or fetching files', async () => {
const current = fixture({ events: [user(1, '', {
content: [
{ type: 'text', text: 'history_user_1' },
{ type: 'image', data: PRIVATE_DETAIL, mediaType: 'image/png' },
],
})] });
const text = reply(await current.run());
assert.deepEqual(selectedMarkers(text), ['history_user_1']);
assert.match(text, /图片|image/iu);
assert.doesNotMatch(text, new RegExp(PRIVATE_DETAIL, 'u'));
});
test('history sorts and deduplicates sequence numbers before selecting messages', async () => {
const events = dialogue(2);
const current = fixture({ events: [
events[5], events[3], events[0], events[2], events[4], events[1],
structuredClone(events[4]),
] });
assert.deepEqual(selectedMarkers(reply(await current.run('/history 5'))), [
'history_user_1', 'history_assistant_1', 'history_user_2', 'history_assistant_2',
]);
});
test('history combines older pages with later terminal events and stops once enough is known', async () => {
const pages = [
{ events: [end(32, 3)], hasMore: true },
{ events: [
user(20, 'history_user_2'),
assistant(21, 2, 'history_assistant_2'),
end(22, 2),
user(30, 'history_user_3'),
assistant(31, 3, 'history_assistant_3'),
], hasMore: true },
];
const current = fixture({ read: (_options, call) => {
assert.ok(call <= pages.length, 'must not fetch beyond the requested messages');
return pages[call - 1];
} });
const text = reply(await current.run());
assert.deepEqual(selectedMarkers(text), [
'history_assistant_2', 'history_user_3', 'history_assistant_3',
]);
assert.equal(current.calls.length, 2);
assert.deepEqual(current.calls.map(({ options }) => options.beforeSeq), [undefined, 32]);
assert.ok(current.calls.every(({ options }) => options.maxMessages === 50));
});
test('a multi-page tool turn contributes only its final reply beside the next running input', async () => {
const pages = [
{ events: [
assistant(90, 1, 'history_assistant_1', { step: 50 }),
end(91, 1),
user(100, 'history_user_2'),
assistant(101, 2, PRIVATE_DETAIL),
], hasMore: true },
{ events: [
assistant(60, 1, PRIVATE_DETAIL, { step: 30 }),
assistant(70, 1, PRIVATE_DETAIL, { step: 40 }),
], hasMore: true },
{ events: [
user(1, 'history_user_1'),
assistant(2, 1, PRIVATE_DETAIL),
], hasMore: false },
];
for (const count of [1, 3, 5]) {
const current = fixture({ read: (_options, call) => pages[call - 1] });
const text = reply(await current.run(`/history ${count}`));
assert.deepEqual(selectedMarkers(text), [
'history_user_1', 'history_assistant_1', 'history_user_2',
].slice(-count));
assert.equal(current.calls.length, count === 1 ? 1 : 3);
assert.doesNotMatch(text, new RegExp(PRIVATE_DETAIL, 'u'));
}
});
test('history makes at most three backward reads and returns the confirmed records it has', async () => {
const current = fixture({ read: (_options, call) => ({
events: [user(100 - call, `history_user_${4 - call}`)], hasMore: true,
}) });
const text = reply(await current.run('/history 5'));
assert.deepEqual(selectedMarkers(text), ['history_user_1', 'history_user_2', 'history_user_3']);
assert.equal(current.calls.length, 3);
assert.deepEqual(current.calls.map(({ options }) => options.beforeSeq), [undefined, 99, 98]);
});
test('history rejects conflicting or invalid event sequences without echoing any response data', async () => {
for (const events of [
[user(1, PRIVATE_DETAIL), user(1, `${PRIVATE_DETAIL}_conflict`)],
[user(-1, PRIVATE_DETAIL)],
[user(1.5, PRIVATE_DETAIL)],
[user('1', PRIVATE_DETAIL)],
[{ event: { type: 'user/message', data: { text: PRIVATE_DETAIL } } }],
]) {
const current = fixture({ events });
const text = reply(await current.run());
assert.match(text, SAFE_FAILURE);
assert.doesNotMatch(text, new RegExp(PRIVATE_DETAIL, 'u'));
}
});
test('history refuses a non-advancing cursor or a later event in a backward page', async () => {
for (const nextSeq of [50, 51]) {
const current = fixture({ read: (_options, call) => ({
events: [user(call === 1 ? 50 : nextSeq, PRIVATE_DETAIL)], hasMore: true,
}) });
const text = reply(await current.run('/history 5'));
assert.match(text, SAFE_FAILURE);
assert.doesNotMatch(text, new RegExp(PRIVATE_DETAIL, 'u'));
assert.ok(current.calls.length <= 2);
}
});
test('backfill keeps the first tail snapshot even if the Host includes newly appended events', async () => {
const current = fixture({ read: (_options, call) => call === 1
? { events: [user(50, 'history_user_2')], hasMore: true }
: { events: [user(40, 'history_user_1'), user(60, PRIVATE_DETAIL)], hasMore: false }
});
const text = reply(await current.run());
assert.deepEqual(selectedMarkers(text), ['history_user_1', 'history_user_2']);
assert.doesNotMatch(text, new RegExp(PRIVATE_DETAIL, 'u'));
assert.equal(current.calls.length, 2);
});
test('history returns a safe error for malformed pages and unsupported history handles', async () => {
for (const page of [null, {}, { events: PRIVATE_DETAIL }, { events: [null], hasMore: false }]) {
const current = fixture({ read: () => page });
const text = reply(await current.run());
assert.match(text, SAFE_FAILURE);
assert.doesNotMatch(text, new RegExp(PRIVATE_DETAIL, 'u'));
}
const current = fixture();
delete current.session.readHistory;
assert.match(reply(await current.run()), SAFE_FAILURE);
});
test('history discards a fetched snapshot if the chat is rebound during the read', async () => {
const current = fixture({ read: () => {
current.bind('another-session');
return { events: [user(1, PRIVATE_DETAIL)], hasMore: false };
} });
const text = reply(await current.run());
assert.match(text, SAFE_FAILURE);
assert.doesNotMatch(text, new RegExp(PRIVATE_DETAIL, 'u'));
});
test('workspace invalidation, missing Sessions and Host errors never reveal internal details', async () => {
for (const code of [
'workspace-session-stale', 'workspace-bot-not-found', 'session-not-found',
'harness-api-not-found', 'harness-timeout', 'unrecognized-host-error',
]) {
const current = fixture({ read: () => { throw Object.assign(new Error(PRIVATE_DETAIL), { code }); } });
const text = reply(await current.run());
assert.match(text, SAFE_FAILURE, code);
assert.doesNotMatch(text, new RegExp(PRIVATE_DETAIL, 'u'), code);
}
});
test('a stale backfill discards every earlier page instead of returning partial private data', async () => {
const current = fixture({ read: (_options, call) => {
if (call === 1) return { events: [user(30, PRIVATE_DETAIL)], hasMore: true };
throw Object.assign(new Error(PRIVATE_DETAIL), { code: 'workspace-session-stale' });
} });
const text = reply(await current.run('/history 5'));
assert.equal(current.calls.length, 2);
assert.match(text, SAFE_FAILURE);
assert.doesNotMatch(text, new RegExp(PRIVATE_DETAIL, 'u'));
});
test('history uses bounded read options and honors cancellation without exposing old data', async () => {
const controller = new AbortController();
const current = fixture({ read: (options) => {
assert.ok(options.signal instanceof AbortSignal);
if (options.timeoutMs !== undefined) {
assert.ok(options.timeoutMs > 0 && options.timeoutMs <= 10_000);
}
controller.abort(new Error(PRIVATE_DETAIL));
return { events: [user(1, PRIVATE_DETAIL)], hasMore: false };
} });
const text = reply(await current.run('/history', { signal: controller.signal }));
assert.doesNotMatch(text, new RegExp(PRIVATE_DETAIL, 'u'));
assert.match(text, SAFE_FAILURE);
});
test('an already cancelled history request does not query the Host', async () => {
const controller = new AbortController();
controller.abort(new Error(PRIVATE_DETAIL));
const current = fixture();
const text = reply(await current.run('/history', { signal: controller.signal }));
assert.equal(current.calls.length, 0);
assert.match(text, SAFE_FAILURE);
assert.doesNotMatch(text, new RegExp(PRIVATE_DETAIL, 'u'));
});
test('history applies one ten-second deadline across backfill requests', async (t) => {
let now = 1_000;
t.mock.method(Date, 'now', () => now);
const current = fixture({ read: () => {
now += 10_001;
return { events: [user(1, PRIVATE_DETAIL)], hasMore: true };
} });
const text = reply(await current.run('/history 5'));
assert.equal(current.calls.length, 1);
assert.match(text, SAFE_FAILURE);
assert.doesNotMatch(text, new RegExp(PRIVATE_DETAIL, 'u'));
});
test('history truncates long Unicode and code text within shared message and chunk budgets', async () => {
const events = Array.from({ length: 6 }, (_, index) => user(index + 1,
`history_user_${index + 1}\n\`\`\`js\n${'中文🙂\n'.repeat(600)}\n\`\`\``,
));
const current = fixture({ events });
const result = await current.run('/history 100');
const text = reply(result);
assert.deepEqual(selectedMarkers(text), [
'history_user_2', 'history_user_3', 'history_user_4', 'history_user_5', 'history_user_6',
]);
assert.match(text, /截断|truncat/iu);
assert.equal(text.isWellFormed(), true);
assert.ok(result.messages.every((part) => part.isWellFormed()));
assert.ok(text.length <= 3_000);
assert.ok(result.messages.length <= 3);
assert.ok([...text.matchAll(/中文/gu)].length <= 500,
'body text should be cut to the shared per-record budget, not merely split into more messages');
});
test('each history body has its own five-hundred-character bound', async () => {
const current = fixture({ events: [user(1, `${'Z'.repeat(499)}🙂${'Z'.repeat(2_000)}`)] });
const text = reply(await current.run('/history 1'));
assert.ok((text.match(/Z/gu) ?? []).length <= 500);
assert.match(text, /截断|truncat/iu);
assert.equal(text.isWellFormed(), true);
});
test('history English messages translate the command envelope without translating saved content', async () => {
const previousLanguage = getImHostLanguage();
setImHostLanguage('en');
try {
const current = fixture({ events: [user(1, 'Saved user text.')] });
const text = reply(await current.run());
assert.match(text, /history/iu);
assert.match(text, /user/iu);
assert.match(text, /Saved user text\./u);
assert.doesNotMatch(text, /[一-鿿]/u);
const invalid = reply(await current.run('/history 0'));
assert.match(invalid, /usage/iu);
assert.match(invalid, /\/history/u);
assert.doesNotMatch(invalid, /[一-鿿]/u);
} finally {
setImHostLanguage(previousLanguage);
}
});