fix: prevent config overwrite, session history race, and streaming message loss (#663)
Co-authored-by: zuolingxuan <zuolingxuan@bytedance.com> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
committed by
GitHub
Unverified
parent
8c3a6a5f7a
commit
83858fdf73
@@ -142,6 +142,46 @@ describe('chat runtime event handlers', () => {
|
||||
expect(next.streamingTools).toEqual([]);
|
||||
});
|
||||
|
||||
it('delta with empty object does not overwrite existing streamingMessage', async () => {
|
||||
// Regression test for multi-model fallback: Gateway emits {} during model switch.
|
||||
// The existing streamingMessage content must be preserved.
|
||||
const { handleRuntimeEventState } = await import('@/stores/chat/runtime-event-handlers');
|
||||
const existing = { role: 'assistant', content: [{ type: 'text', text: 'hello' }] };
|
||||
const h = makeHarness({ streamingMessage: existing });
|
||||
|
||||
handleRuntimeEventState(h.set as never, h.get as never, { message: {} }, 'delta', 'run-x');
|
||||
expect(h.read().streamingMessage).toEqual(existing);
|
||||
});
|
||||
|
||||
it('delta with role-only object does not overwrite existing streamingMessage', async () => {
|
||||
const { handleRuntimeEventState } = await import('@/stores/chat/runtime-event-handlers');
|
||||
const existing = { role: 'assistant', content: [{ type: 'text', text: 'partial' }] };
|
||||
const h = makeHarness({ streamingMessage: existing });
|
||||
|
||||
handleRuntimeEventState(h.set as never, h.get as never, { message: { role: 'assistant' } }, 'delta', 'run-x');
|
||||
expect(h.read().streamingMessage).toEqual(existing);
|
||||
});
|
||||
|
||||
it('delta with empty object is accepted when streamingMessage is null (initial state)', async () => {
|
||||
// When streaming hasn't started yet, even an empty delta should be let
|
||||
// through so the UI can show a typing indicator immediately.
|
||||
const { handleRuntimeEventState } = await import('@/stores/chat/runtime-event-handlers');
|
||||
const h = makeHarness({ streamingMessage: null });
|
||||
|
||||
handleRuntimeEventState(h.set as never, h.get as never, { message: { role: 'assistant' } }, 'delta', 'run-x');
|
||||
expect(h.read().streamingMessage).toEqual({ role: 'assistant' });
|
||||
});
|
||||
|
||||
it('delta with actual content replaces streamingMessage', async () => {
|
||||
const { handleRuntimeEventState } = await import('@/stores/chat/runtime-event-handlers');
|
||||
const existing = { role: 'assistant', content: [{ type: 'text', text: 'old' }] };
|
||||
const incoming = { role: 'assistant', content: [{ type: 'text', text: 'new' }] };
|
||||
const h = makeHarness({ streamingMessage: existing });
|
||||
|
||||
handleRuntimeEventState(h.set as never, h.get as never, { message: incoming }, 'delta', 'run-x');
|
||||
expect(h.read().streamingMessage).toEqual(incoming);
|
||||
});
|
||||
|
||||
it('clears runtime state on aborted event', async () => {
|
||||
const { handleRuntimeEventState } = await import('@/stores/chat/runtime-event-handlers');
|
||||
const h = makeHarness({
|
||||
|
||||
@@ -21,11 +21,11 @@ describe('host-events', () => {
|
||||
|
||||
it('subscribes through IPC for mapped host events', async () => {
|
||||
const onMock = vi.mocked(window.electron.ipcRenderer.on);
|
||||
const offMock = vi.mocked(window.electron.ipcRenderer.off);
|
||||
const captured: Array<(...args: unknown[]) => void> = [];
|
||||
const cleanupSpy = vi.fn();
|
||||
onMock.mockImplementation((_, cb: (...args: unknown[]) => void) => {
|
||||
captured.push(cb);
|
||||
return () => {};
|
||||
return cleanupSpy;
|
||||
});
|
||||
|
||||
const { subscribeHostEvent } = await import('@/lib/host-events');
|
||||
@@ -38,8 +38,10 @@ describe('host-events', () => {
|
||||
captured[0]({ state: 'running' });
|
||||
expect(handler).toHaveBeenCalledWith({ state: 'running' });
|
||||
|
||||
// unsubscribe should use the cleanup returned by ipc.on() — NOT ipc.off()
|
||||
// which would pass the wrong function reference (see preload wrapper mismatch)
|
||||
unsubscribe();
|
||||
expect(offMock).toHaveBeenCalledWith('gateway:status-changed', expect.any(Function));
|
||||
expect(cleanupSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not use SSE fallback by default for unknown events', async () => {
|
||||
|
||||
@@ -111,3 +111,93 @@ describe('saveProviderKeyToOpenClaw', () => {
|
||||
logSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('sanitizeOpenClawConfig', () => {
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
vi.restoreAllMocks();
|
||||
await rm(testHome, { recursive: true, force: true });
|
||||
await rm(testUserData, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('skips sanitization when openclaw.json does not exist', async () => {
|
||||
// Ensure the .openclaw dir doesn't exist at all
|
||||
const { sanitizeOpenClawConfig } = await import('@electron/utils/openclaw-auth');
|
||||
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
|
||||
// Should not throw and should not create the file
|
||||
await expect(sanitizeOpenClawConfig()).resolves.toBeUndefined();
|
||||
|
||||
const configPath = join(testHome, '.openclaw', 'openclaw.json');
|
||||
await expect(readFile(configPath, 'utf8')).rejects.toThrow();
|
||||
|
||||
logSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('skips sanitization when openclaw.json contains invalid JSON', async () => {
|
||||
// Simulate a corrupted file: readJsonFile returns null, sanitize must bail out
|
||||
const openclawDir = join(testHome, '.openclaw');
|
||||
await mkdir(openclawDir, { recursive: true });
|
||||
const configPath = join(openclawDir, 'openclaw.json');
|
||||
await writeFile(configPath, 'NOT VALID JSON {{{', 'utf8');
|
||||
const before = await readFile(configPath, 'utf8');
|
||||
|
||||
const { sanitizeOpenClawConfig } = await import('@electron/utils/openclaw-auth');
|
||||
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
|
||||
await sanitizeOpenClawConfig();
|
||||
|
||||
const after = await readFile(configPath, 'utf8');
|
||||
// Corrupt file must not be overwritten
|
||||
expect(after).toBe(before);
|
||||
|
||||
logSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('properly sanitizes a genuinely empty {} config (fresh install)', async () => {
|
||||
// A fresh install with {} is a valid config — sanitize should proceed
|
||||
// and enforce tools.profile, commands.restart, etc.
|
||||
await writeOpenClawJson({});
|
||||
|
||||
const { sanitizeOpenClawConfig } = await import('@electron/utils/openclaw-auth');
|
||||
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
|
||||
await sanitizeOpenClawConfig();
|
||||
|
||||
const configPath = join(testHome, '.openclaw', 'openclaw.json');
|
||||
const result = JSON.parse(await readFile(configPath, 'utf8')) as Record<string, unknown>;
|
||||
// Fresh install should get tools settings enforced
|
||||
const tools = result.tools as Record<string, unknown>;
|
||||
expect(tools.profile).toBe('full');
|
||||
|
||||
logSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('preserves user config (memory, agents, channels) when enforcing tools settings', async () => {
|
||||
await writeOpenClawJson({
|
||||
agents: { defaults: { model: { primary: 'openai/gpt-4' } } },
|
||||
channels: { discord: { token: 'tok', enabled: true } },
|
||||
memory: { enabled: true, limit: 100 },
|
||||
});
|
||||
|
||||
const { sanitizeOpenClawConfig } = await import('@electron/utils/openclaw-auth');
|
||||
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
|
||||
await sanitizeOpenClawConfig();
|
||||
|
||||
const configPath = join(testHome, '.openclaw', 'openclaw.json');
|
||||
const result = JSON.parse(await readFile(configPath, 'utf8')) as Record<string, unknown>;
|
||||
|
||||
// User-owned sections must survive the sanitize pass
|
||||
expect(result.memory).toEqual({ enabled: true, limit: 100 });
|
||||
expect(result.channels).toEqual({ discord: { token: 'tok', enabled: true } });
|
||||
expect((result.agents as Record<string, unknown>).defaults).toEqual({
|
||||
model: { primary: 'openai/gpt-4' },
|
||||
});
|
||||
// tools settings should now be enforced
|
||||
const tools = result.tools as Record<string, unknown>;
|
||||
expect(tools.profile).toBe('full');
|
||||
|
||||
logSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user