feat: prepare Zhinian desktop pilot
This commit is contained in:
@@ -18,6 +18,18 @@ describe('chat internal message filter', () => {
|
||||
expect(isInternalMessage({ role: 'assistant', content })).toBe(true);
|
||||
});
|
||||
|
||||
it('filters OpenClaw heartbeat poll user messages', () => {
|
||||
expect(isInternalMessage({ role: 'user', content: '[OpenClaw heartbeat poll]' })).toBe(true);
|
||||
expect(isInternalMessage({
|
||||
role: 'user',
|
||||
content: [
|
||||
'Read HEARTBEAT.md if it exists (workspace context). Follow it strictly.',
|
||||
'If nothing needs attention, reply HEARTBEAT_OK.',
|
||||
'When reading HEARTBEAT.md, use workspace file /Users/test/.openclaw/workspace/HEARTBEAT.md.',
|
||||
].join(' '),
|
||||
})).toBe(true);
|
||||
});
|
||||
|
||||
it('filters pure channel ingress metadata from Feishu', () => {
|
||||
const content = 'System: [2026-04-27 11:09:29 GMT+8] Feishu[default] DM | ou_256bec6880a8c77271bc610c5e42fe89 [msg:om_x100b51d9784e2908c144d6c6cde19a6]';
|
||||
|
||||
|
||||
@@ -117,7 +117,7 @@ describe('chat session actions', () => {
|
||||
expect(h.read().loadHistory).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('newSession creates a canonical session key and clears transient state', async () => {
|
||||
it('newSession creates a desktop canonical session key and clears transient state', async () => {
|
||||
const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(1711111111111);
|
||||
const { createSessionActions } = await import('@/stores/chat/session-actions');
|
||||
const h = makeHarness({
|
||||
@@ -132,8 +132,8 @@ describe('chat session actions', () => {
|
||||
|
||||
actions.newSession();
|
||||
const next = h.read();
|
||||
expect(next.currentSessionKey).toBe('agent:foo:session-1711111111111');
|
||||
expect(next.sessions.some((s) => s.key === 'agent:foo:session-1711111111111')).toBe(true);
|
||||
expect(next.currentSessionKey).toBe('agent:main:session-1711111111111');
|
||||
expect(next.sessions.some((s) => s.key === 'agent:main:session-1711111111111')).toBe(true);
|
||||
expect(next.messages).toEqual([]);
|
||||
expect(next.streamingText).toBe('');
|
||||
expect(next.activeRunId).toBeNull();
|
||||
@@ -174,4 +174,3 @@ describe('chat session actions', () => {
|
||||
expect(h.read().sessions.find((session) => session.key === 'agent:main:cron:job-1')?.updatedAt).toBe(1773281731621);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -2,6 +2,20 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const hostApiFetchMock = vi.fn();
|
||||
const subscribeHostEventMock = vi.fn();
|
||||
const chatStateMock = vi.hoisted(() => ({
|
||||
state: {
|
||||
currentSessionKey: 'session-1',
|
||||
sessions: [] as Array<{ key: string }>,
|
||||
sending: true,
|
||||
activeRunId: 'run-1' as string | null,
|
||||
pendingFinal: true,
|
||||
lastUserMessageAt: 123,
|
||||
error: null as string | null,
|
||||
loadHistory: vi.fn(),
|
||||
loadSessions: vi.fn(),
|
||||
handleChatEvent: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/host-api', () => ({
|
||||
hostApiFetch: (...args: unknown[]) => hostApiFetchMock(...args),
|
||||
@@ -11,10 +25,28 @@ vi.mock('@/lib/host-events', () => ({
|
||||
subscribeHostEvent: (...args: unknown[]) => subscribeHostEventMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock('@/stores/chat', () => ({
|
||||
useChatStore: {
|
||||
getState: () => chatStateMock.state,
|
||||
setState: (patch: Record<string, unknown>) => {
|
||||
Object.assign(chatStateMock.state, patch);
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
describe('gateway store event wiring', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.clearAllMocks();
|
||||
Object.assign(chatStateMock.state, {
|
||||
currentSessionKey: 'session-1',
|
||||
sessions: [],
|
||||
sending: true,
|
||||
activeRunId: 'run-1',
|
||||
pendingFinal: true,
|
||||
lastUserMessageAt: 123,
|
||||
error: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('subscribes to host events through subscribeHostEvent on init', async () => {
|
||||
@@ -76,4 +108,61 @@ describe('gateway store event wiring', () => {
|
||||
expect(status.gatewayReady).toBeUndefined();
|
||||
expect(status.state === 'running' && status.gatewayReady !== false).toBe(true);
|
||||
});
|
||||
|
||||
it('does not clear sending state on intermediate agent phase=end notifications', async () => {
|
||||
hostApiFetchMock.mockResolvedValueOnce({ state: 'running', port: 18789 });
|
||||
|
||||
const handlers = new Map<string, (payload: unknown) => void>();
|
||||
subscribeHostEventMock.mockImplementation((eventName: string, handler: (payload: unknown) => void) => {
|
||||
handlers.set(eventName, handler);
|
||||
return () => {};
|
||||
});
|
||||
|
||||
const { useGatewayStore } = await import('@/stores/gateway');
|
||||
await useGatewayStore.getState().init();
|
||||
|
||||
handlers.get('gateway:notification')?.({
|
||||
method: 'agent',
|
||||
params: {
|
||||
phase: 'end',
|
||||
runId: 'run-1',
|
||||
sessionKey: 'session-1',
|
||||
},
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(chatStateMock.state.loadHistory).toHaveBeenCalled();
|
||||
});
|
||||
expect(chatStateMock.state.sending).toBe(true);
|
||||
expect(chatStateMock.state.activeRunId).toBe('run-1');
|
||||
expect(chatStateMock.state.pendingFinal).toBe(true);
|
||||
});
|
||||
|
||||
it('clears sending state on terminal agent completion notifications', async () => {
|
||||
hostApiFetchMock.mockResolvedValueOnce({ state: 'running', port: 18789 });
|
||||
|
||||
const handlers = new Map<string, (payload: unknown) => void>();
|
||||
subscribeHostEventMock.mockImplementation((eventName: string, handler: (payload: unknown) => void) => {
|
||||
handlers.set(eventName, handler);
|
||||
return () => {};
|
||||
});
|
||||
|
||||
const { useGatewayStore } = await import('@/stores/gateway');
|
||||
await useGatewayStore.getState().init();
|
||||
|
||||
handlers.get('gateway:notification')?.({
|
||||
method: 'agent',
|
||||
params: {
|
||||
phase: 'completed',
|
||||
runId: 'run-1',
|
||||
sessionKey: 'session-1',
|
||||
},
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(chatStateMock.state.sending).toBe(false);
|
||||
});
|
||||
expect(chatStateMock.state.activeRunId).toBeNull();
|
||||
expect(chatStateMock.state.pendingFinal).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -47,9 +47,9 @@ describe('GatewayManager heartbeat recovery', () => {
|
||||
|
||||
(manager as unknown as { startPing: () => void }).startPing();
|
||||
|
||||
vi.advanceTimersByTime(120_000);
|
||||
vi.advanceTimersByTime(360_000);
|
||||
|
||||
expect(ws.ping).toHaveBeenCalledTimes(3);
|
||||
expect(ws.ping).toHaveBeenCalledTimes(5);
|
||||
expect(ws.terminate).not.toHaveBeenCalled();
|
||||
expect(restartSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
@@ -77,13 +77,13 @@ describe('GatewayManager heartbeat recovery', () => {
|
||||
|
||||
(manager as unknown as { startPing: () => void }).startPing();
|
||||
|
||||
vi.advanceTimersByTime(30_000); // ping #1
|
||||
vi.advanceTimersByTime(30_000); // miss #1 + ping #2
|
||||
vi.advanceTimersByTime(60_000); // ping #1
|
||||
vi.advanceTimersByTime(60_000); // miss #1 + ping #2
|
||||
(manager as unknown as { handleMessage: (message: unknown) => void }).handleMessage('alive');
|
||||
|
||||
vi.advanceTimersByTime(30_000); // recovered, ping #3
|
||||
vi.advanceTimersByTime(30_000); // miss #1 + ping #4
|
||||
vi.advanceTimersByTime(30_000); // miss #2 + ping #5
|
||||
vi.advanceTimersByTime(60_000); // recovered, ping #3
|
||||
vi.advanceTimersByTime(60_000); // miss #1 + ping #4
|
||||
vi.advanceTimersByTime(60_000); // miss #2 + ping #5
|
||||
|
||||
expect(ws.terminate).not.toHaveBeenCalled();
|
||||
expect(restartSpy).not.toHaveBeenCalled();
|
||||
@@ -112,7 +112,7 @@ describe('GatewayManager heartbeat recovery', () => {
|
||||
|
||||
(manager as unknown as { startPing: () => void }).startPing();
|
||||
|
||||
vi.advanceTimersByTime(120_000);
|
||||
vi.advanceTimersByTime(360_000);
|
||||
|
||||
expect(restartSpy).not.toHaveBeenCalled();
|
||||
|
||||
|
||||
@@ -4,12 +4,12 @@ import { resolveSupportedLanguage } from '../../shared/language';
|
||||
describe('resolveSupportedLanguage', () => {
|
||||
it('uses the base language for supported regional locales', () => {
|
||||
expect(resolveSupportedLanguage('zh-CN')).toBe('zh');
|
||||
expect(resolveSupportedLanguage('ja_JP')).toBe('ja');
|
||||
expect(resolveSupportedLanguage('en-US')).toBe('en');
|
||||
});
|
||||
|
||||
it('falls back to English for unsupported locales', () => {
|
||||
expect(resolveSupportedLanguage('fr-FR')).toBe('en');
|
||||
expect(resolveSupportedLanguage('ja_JP')).toBe('en');
|
||||
expect(resolveSupportedLanguage('ko')).toBe('en');
|
||||
});
|
||||
|
||||
|
||||
129
tests/unit/model-diagnostics.test.ts
Normal file
129
tests/unit/model-diagnostics.test.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
|
||||
const modelDiagnosticsMocks = vi.hoisted(() => ({
|
||||
config: {} as Record<string, unknown>,
|
||||
writeOpenClawConfig: vi.fn(),
|
||||
testOpenClawDir: '/tmp/clawx-tests/model-diagnostics-openclaw',
|
||||
}));
|
||||
|
||||
vi.mock('@electron/utils/channel-config', () => ({
|
||||
readOpenClawConfig: vi.fn(async () => modelDiagnosticsMocks.config),
|
||||
writeOpenClawConfig: vi.fn(async (config: Record<string, unknown>) => {
|
||||
modelDiagnosticsMocks.config = config;
|
||||
modelDiagnosticsMocks.writeOpenClawConfig(config);
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@electron/utils/paths', () => ({
|
||||
getOpenClawConfigDir: () => modelDiagnosticsMocks.testOpenClawDir,
|
||||
}));
|
||||
|
||||
vi.mock('@electron/utils/logger', () => ({
|
||||
logger: {
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
function authProfilesPath(homeDir: string): string {
|
||||
return join(homeDir, '.openclaw', 'agents', 'main', 'agent', 'auth-profiles.json');
|
||||
}
|
||||
|
||||
describe('Yinian model diagnostics', () => {
|
||||
const originalHome = process.env.HOME;
|
||||
const testHome = join(tmpdir(), 'clawx-tests', 'model-diagnostics-home');
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.clearAllMocks();
|
||||
process.env.HOME = testHome;
|
||||
rmSync(testHome, { recursive: true, force: true });
|
||||
rmSync(modelDiagnosticsMocks.testOpenClawDir, { recursive: true, force: true });
|
||||
mkdirSync(join(testHome, '.openclaw', 'agents', 'main', 'agent'), { recursive: true });
|
||||
mkdirSync(modelDiagnosticsMocks.testOpenClawDir, { recursive: true });
|
||||
modelDiagnosticsMocks.config = {
|
||||
models: {
|
||||
providers: {
|
||||
minimax: {
|
||||
baseUrl: 'https://api.minimaxi.com/anthropic',
|
||||
api: 'anthropic-messages',
|
||||
timeoutSeconds: 30,
|
||||
models: [{ id: 'MiniMax-M2.7' }],
|
||||
},
|
||||
},
|
||||
pricing: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
agents: {
|
||||
defaults: {
|
||||
model: {
|
||||
primary: 'minimax/MiniMax-M2.7',
|
||||
fallbacks: ['minimax/MiniMax-M2.5'],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
writeFileSync(authProfilesPath(testHome), JSON.stringify({
|
||||
version: 1,
|
||||
profiles: {
|
||||
'minimax-cn:default': {
|
||||
type: 'api_key',
|
||||
provider: 'minimax-cn',
|
||||
key: 'secret',
|
||||
},
|
||||
},
|
||||
}, null, 2));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env.HOME = originalHome;
|
||||
rmSync(testHome, { recursive: true, force: true });
|
||||
rmSync(modelDiagnosticsMocks.testOpenClawDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('repairs runtime model defaults and normalizes MiniMax auth profile aliases', async () => {
|
||||
const {
|
||||
buildYinianModelConfigDiagnostics,
|
||||
ensureYinianModelRuntimeConfigured,
|
||||
} = await import('@electron/utils/model-diagnostics');
|
||||
|
||||
await ensureYinianModelRuntimeConfigured();
|
||||
|
||||
expect(modelDiagnosticsMocks.writeOpenClawConfig).toHaveBeenCalledWith(expect.objectContaining({
|
||||
models: expect.objectContaining({
|
||||
pricing: expect.objectContaining({ enabled: false }),
|
||||
providers: expect.objectContaining({
|
||||
minimax: expect.objectContaining({ timeoutSeconds: 300 }),
|
||||
}),
|
||||
}),
|
||||
agents: expect.objectContaining({
|
||||
defaults: expect.objectContaining({
|
||||
heartbeat: expect.objectContaining({ every: '0m' }),
|
||||
}),
|
||||
}),
|
||||
}));
|
||||
|
||||
const authStore = JSON.parse(readFileSync(authProfilesPath(testHome), 'utf8')) as {
|
||||
profiles: Record<string, { provider?: string; key?: string }>;
|
||||
order?: Record<string, string[]>;
|
||||
lastGood?: Record<string, string>;
|
||||
};
|
||||
expect(authStore.profiles['minimax:default']).toMatchObject({
|
||||
provider: 'minimax',
|
||||
key: 'secret',
|
||||
});
|
||||
expect(authStore.order?.minimax).toContain('minimax:default');
|
||||
expect(authStore.lastGood?.minimax).toBe('minimax:default');
|
||||
|
||||
const diagnostics = await buildYinianModelConfigDiagnostics();
|
||||
expect(diagnostics.ok).toBe(true);
|
||||
expect(diagnostics.model.primary).toBe('minimax/MiniMax-M2.7');
|
||||
expect(diagnostics.runtime.pricingCatalogFetchDisabled).toBe(true);
|
||||
expect(diagnostics.checks.find((check) => check.id === 'auth-profile')?.status).toBe('ok');
|
||||
});
|
||||
});
|
||||
@@ -359,7 +359,7 @@ describe('sanitizeOpenClawConfig', () => {
|
||||
|
||||
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.
|
||||
// and enforce the desktop tools profile, commands.restart, etc.
|
||||
await writeOpenClawJson({});
|
||||
|
||||
const { sanitizeOpenClawConfig } = await import('@electron/utils/openclaw-auth');
|
||||
@@ -371,7 +371,35 @@ describe('sanitizeOpenClawConfig', () => {
|
||||
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');
|
||||
expect(tools.profile).toBe('coding');
|
||||
|
||||
logSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('removes deprecated plugins.bundledDiscovery before Gateway start', async () => {
|
||||
await writeOpenClawJson({
|
||||
plugins: {
|
||||
allow: ['agentbus'],
|
||||
bundledDiscovery: 'compat',
|
||||
entries: {
|
||||
agentbus: { enabled: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { sanitizeOpenClawConfig } = await import('@electron/utils/openclaw-auth');
|
||||
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
|
||||
await sanitizeOpenClawConfig();
|
||||
|
||||
const result = await readOpenClawJson();
|
||||
expect((result.plugins as Record<string, unknown>).bundledDiscovery).toBeUndefined();
|
||||
expect(result.plugins).toMatchObject({
|
||||
allow: ['agentbus'],
|
||||
entries: {
|
||||
agentbus: { enabled: true },
|
||||
},
|
||||
});
|
||||
|
||||
logSpy.mockRestore();
|
||||
});
|
||||
@@ -399,7 +427,37 @@ describe('sanitizeOpenClawConfig', () => {
|
||||
});
|
||||
// tools settings should now be enforced
|
||||
const tools = result.tools as Record<string, unknown>;
|
||||
expect(tools.profile).toBe('full');
|
||||
expect(tools.profile).toBe('coding');
|
||||
|
||||
logSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('sets Yinian managed agents to lightweight skill loading', async () => {
|
||||
await writeOpenClawJson({
|
||||
models: {
|
||||
providers: {
|
||||
minimax: {
|
||||
baseUrl: 'https://api.minimaxi.com/anthropic',
|
||||
api: 'anthropic-messages',
|
||||
},
|
||||
},
|
||||
},
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: 'minimax/MiniMax-M2.7' },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { sanitizeOpenClawConfig } = await import('@electron/utils/openclaw-auth');
|
||||
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
|
||||
await sanitizeOpenClawConfig();
|
||||
|
||||
const result = await readOpenClawJson();
|
||||
const defaults = (result.agents as Record<string, unknown>).defaults as Record<string, unknown>;
|
||||
expect(defaults.skills).toEqual([]);
|
||||
expect(defaults.heartbeat).toMatchObject({ every: '0m' });
|
||||
|
||||
logSpy.mockRestore();
|
||||
});
|
||||
@@ -505,7 +563,7 @@ describe('sanitizeOpenClawConfig', () => {
|
||||
expect(dingtalk.clientSecret).toBe('dt-secret');
|
||||
});
|
||||
|
||||
it('removes stale minimax-portal-auth plugin entries when merged minimax plugin is installed', async () => {
|
||||
it('trims Yinian managed plugins to the core desktop plugin surface', async () => {
|
||||
await writeOpenClawJson({
|
||||
plugins: {
|
||||
allow: ['minimax-portal-auth', 'custom-plugin'],
|
||||
@@ -550,11 +608,11 @@ describe('sanitizeOpenClawConfig', () => {
|
||||
const result = await readOpenClawJson();
|
||||
const plugins = result.plugins as Record<string, unknown>;
|
||||
const allow = plugins.allow as string[];
|
||||
const entries = plugins.entries as Record<string, Record<string, unknown>>;
|
||||
const entries = (plugins.entries || {}) as Record<string, Record<string, unknown>>;
|
||||
|
||||
expect(allow).toEqual(['custom-plugin']);
|
||||
expect(new Set(allow)).toEqual(new Set(['minimax', 'cloud-sync']));
|
||||
expect(entries['minimax-portal-auth']).toBeUndefined();
|
||||
expect(entries['custom-plugin']).toEqual({ enabled: true });
|
||||
expect(entries['custom-plugin']).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
36
tests/unit/optional-native-cleanup.test.ts
Normal file
36
tests/unit/optional-native-cleanup.test.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import { cleanupOpenClawRuntimeNativeClipboard } from '@electron/utils/optional-native-cleanup';
|
||||
|
||||
describe('optional native cleanup', () => {
|
||||
const root = join(tmpdir(), 'clawx-tests', 'optional-native-cleanup');
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('removes optional native clipboard packages from a managed OpenClaw runtime', () => {
|
||||
const nodeModules = join(root, 'openclaw', 'node_modules');
|
||||
const scope = join(nodeModules, '@mariozechner');
|
||||
const nativeClipboard = join(scope, 'clipboard-darwin-arm64');
|
||||
const genericClipboard = join(scope, 'clipboard');
|
||||
const codingAgent = join(scope, 'pi-coding-agent');
|
||||
const unrelated = join(nodeModules, '@example', 'clipboard-darwin-arm64');
|
||||
|
||||
for (const dir of [nativeClipboard, genericClipboard, codingAgent, unrelated]) {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
writeFileSync(join(dir, 'package.json'), '{}\n');
|
||||
}
|
||||
|
||||
const removed = cleanupOpenClawRuntimeNativeClipboard(join(root, 'openclaw'));
|
||||
|
||||
expect(removed).toBe(2);
|
||||
expect(existsSync(nativeClipboard)).toBe(false);
|
||||
expect(existsSync(genericClipboard)).toBe(false);
|
||||
expect(existsSync(codingAgent)).toBe(true);
|
||||
expect(existsSync(unrelated)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -4,7 +4,7 @@ import { stripProcessMessagePrefix } from '@/pages/Chat/message-utils';
|
||||
import type { RawMessage, ToolStatus } from '@/stores/chat';
|
||||
|
||||
describe('deriveTaskSteps', () => {
|
||||
it('builds running steps from streaming thinking and tool status', () => {
|
||||
it('builds running steps from streaming tool status without exposing thinking blocks', () => {
|
||||
const streamingTools: ToolStatus[] = [
|
||||
{
|
||||
name: 'web_search',
|
||||
@@ -27,12 +27,6 @@ describe('deriveTaskSteps', () => {
|
||||
});
|
||||
|
||||
expect(steps).toEqual([
|
||||
expect.objectContaining({
|
||||
id: 'stream-thinking-0',
|
||||
label: 'Thinking',
|
||||
status: 'running',
|
||||
kind: 'thinking',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
label: 'web_search',
|
||||
status: 'running',
|
||||
@@ -160,7 +154,7 @@ describe('deriveTaskSteps', () => {
|
||||
}));
|
||||
});
|
||||
|
||||
it('keeps recent completed steps from assistant history', () => {
|
||||
it('keeps recent completed tool steps from assistant history without exposing thinking blocks', () => {
|
||||
const messages: RawMessage[] = [
|
||||
{
|
||||
role: 'assistant',
|
||||
@@ -179,12 +173,6 @@ describe('deriveTaskSteps', () => {
|
||||
});
|
||||
|
||||
expect(steps).toEqual([
|
||||
expect.objectContaining({
|
||||
id: 'history-thinking-assistant-1-0',
|
||||
label: 'Thinking',
|
||||
status: 'completed',
|
||||
kind: 'thinking',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: 'tool-2',
|
||||
label: 'read_file',
|
||||
@@ -194,7 +182,7 @@ describe('deriveTaskSteps', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('splits cumulative streaming thinking into separate execution steps', () => {
|
||||
it('keeps thinking-only stream chunks out of the execution graph', () => {
|
||||
const steps = deriveTaskSteps({
|
||||
messages: [],
|
||||
streamingMessage: {
|
||||
@@ -208,23 +196,7 @@ describe('deriveTaskSteps', () => {
|
||||
streamingTools: [],
|
||||
});
|
||||
|
||||
expect(steps).toEqual([
|
||||
expect.objectContaining({
|
||||
id: 'stream-thinking-0',
|
||||
detail: 'Reviewing X.',
|
||||
status: 'completed',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: 'stream-thinking-1',
|
||||
detail: 'Comparing Y.',
|
||||
status: 'completed',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: 'stream-thinking-2',
|
||||
detail: 'Drafting answer.',
|
||||
status: 'running',
|
||||
}),
|
||||
]);
|
||||
expect(steps).toEqual([]);
|
||||
});
|
||||
|
||||
it('keeps earlier reply segments in the graph when the last streaming segment is rendered separately', () => {
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { act, render, screen } from '@testing-library/react';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { Today } from '@/pages/Today';
|
||||
import { useYinianStore } from '@/stores/yinian';
|
||||
import { useYinianSkillsStore } from '@/stores/yinian-skills';
|
||||
import { useSkillsStore } from '@/stores/skills';
|
||||
import { useChatStore } from '@/stores/chat';
|
||||
import { useGatewayStore } from '@/stores/gateway';
|
||||
import i18n from '@/i18n';
|
||||
import type { YinianConfigSnapshot, YinianLocalSkill } from '../../shared/yinian';
|
||||
|
||||
const hotelHangzhou = {
|
||||
@@ -53,9 +57,18 @@ function createLocalSkill(overrides: Partial<YinianLocalSkill>): YinianLocalSkil
|
||||
};
|
||||
}
|
||||
|
||||
function renderToday() {
|
||||
return render(
|
||||
<MemoryRouter>
|
||||
<Today />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('Today page', () => {
|
||||
beforeEach(() => {
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
await i18n.changeLanguage('zh');
|
||||
useYinianStore.setState({
|
||||
status: 'authenticated',
|
||||
session: {
|
||||
@@ -79,22 +92,36 @@ describe('Today page', () => {
|
||||
loading: false,
|
||||
error: null,
|
||||
});
|
||||
useChatStore.setState({
|
||||
sessions: [],
|
||||
sessionLabels: {},
|
||||
sessionLastActivity: {},
|
||||
});
|
||||
useGatewayStore.setState({
|
||||
status: {
|
||||
state: 'stopped',
|
||||
port: 18789,
|
||||
gatewayReady: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('shows quick actions and plain status cards when workspace has no entitlements', () => {
|
||||
render(<Today />);
|
||||
it('shows work entry, pinned apps, and quick tools when workspace has no entitlements', () => {
|
||||
renderToday();
|
||||
|
||||
expect(screen.getAllByText('智念企业组织空间').length).toBeGreaterThan(0);
|
||||
expect(screen.getByText('开始对话')).toBeInTheDocument();
|
||||
expect(screen.getByText('管理知识库')).toBeInTheDocument();
|
||||
expect(screen.getByText('管理应用')).toBeInTheDocument();
|
||||
expect(screen.getByText('今天想先做什么?')).toBeInTheDocument();
|
||||
expect(screen.getByText('开始工作')).toBeInTheDocument();
|
||||
expect(screen.getByText('常用应用')).toBeInTheDocument();
|
||||
expect(screen.getByText('智念视频助手')).toBeInTheDocument();
|
||||
expect(screen.getByText('定时任务')).toBeInTheDocument();
|
||||
expect(screen.getByText('知识库')).toBeInTheDocument();
|
||||
expect(screen.queryByText('0/0')).not.toBeInTheDocument();
|
||||
expect(screen.getAllByText('0 个').length).toBeGreaterThan(0);
|
||||
expect(screen.queryByText('需要关注')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('最近会话')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('最近对话')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('summarizes entitlements with local registry readiness', () => {
|
||||
it('shows recent conversations before generic empty history', () => {
|
||||
useYinianStore.setState({
|
||||
config: createConfig({
|
||||
entitlements: [
|
||||
@@ -162,44 +189,66 @@ describe('Today page', () => {
|
||||
},
|
||||
],
|
||||
});
|
||||
useChatStore.setState({
|
||||
sessions: [
|
||||
{ key: 'agent:main:session-a', updatedAt: Date.now() - 2 * 60_000 },
|
||||
{ key: 'agent:main:session-b', updatedAt: Date.now() - 60 * 60_000 },
|
||||
],
|
||||
sessionLabels: {
|
||||
'agent:main:session-a': '整理本周门店内容',
|
||||
'agent:main:session-b': '又见乌江宣传片',
|
||||
},
|
||||
sessionLastActivity: {
|
||||
'agent:main:session-a': Date.now() - 2 * 60_000,
|
||||
'agent:main:session-b': Date.now() - 60 * 60_000,
|
||||
},
|
||||
});
|
||||
|
||||
render(<Today />);
|
||||
renderToday();
|
||||
|
||||
expect(screen.getByText('管理应用')).toBeInTheDocument();
|
||||
expect(screen.queryByText('2/2')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('下发 2 个 · 本地 2 个')).not.toBeInTheDocument();
|
||||
expect(screen.getAllByText('2 个').length).toBeGreaterThanOrEqual(2);
|
||||
expect(screen.queryByText(/待处理 1 个/)).not.toBeInTheDocument();
|
||||
expect(screen.getAllByText(/日报生成助手/).length).toBeGreaterThan(0);
|
||||
expect(screen.getByText('整理本周门店内容')).toBeInTheDocument();
|
||||
expect(screen.getByText('又见乌江宣传片')).toBeInTheDocument();
|
||||
expect(screen.queryByText('数据检查助手 有新版本')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('updates displayed workspace and counters after hotel switch refreshes config', () => {
|
||||
const { rerender } = render(<Today />);
|
||||
const { rerender } = render(
|
||||
<MemoryRouter>
|
||||
<Today />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
expect(screen.getAllByText('智念企业组织空间').length).toBeGreaterThan(0);
|
||||
|
||||
useYinianStore.setState({
|
||||
config: createConfig({
|
||||
hotel: hotelShanghai,
|
||||
entitlements: [
|
||||
{
|
||||
skillId: 'customer-reply-helper',
|
||||
name: '客户回复助手',
|
||||
version: '0.9.0',
|
||||
enabled: true,
|
||||
category: 'guest-comm',
|
||||
triggers: ['manual'],
|
||||
},
|
||||
],
|
||||
}),
|
||||
act(() => {
|
||||
useYinianStore.setState({
|
||||
config: createConfig({
|
||||
hotel: hotelShanghai,
|
||||
entitlements: [
|
||||
{
|
||||
skillId: 'customer-reply-helper',
|
||||
name: '客户回复助手',
|
||||
version: '0.9.0',
|
||||
enabled: true,
|
||||
category: 'guest-comm',
|
||||
triggers: ['manual'],
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
useYinianSkillsStore.setState({ localSkills: [] });
|
||||
});
|
||||
useYinianSkillsStore.setState({ localSkills: [] });
|
||||
|
||||
rerender(<Today />);
|
||||
rerender(
|
||||
<MemoryRouter>
|
||||
<Today />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
expect(screen.getAllByText('智念增长组织空间').length).toBeGreaterThan(0);
|
||||
expect(screen.queryByText('1/0')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('客户回复助手')).toBeInTheDocument();
|
||||
expect(screen.queryByText(/待处理 1 个/)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('智念企业组织空间')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -4,6 +4,8 @@ import { YinianSkills } from '@/pages/YinianSkills';
|
||||
import { useYinianStore } from '@/stores/yinian';
|
||||
import { useYinianSkillsStore } from '@/stores/yinian-skills';
|
||||
import { useSkillsStore } from '@/stores/skills';
|
||||
import { useQuickTasksStore } from '@/stores/quick-tasks';
|
||||
import i18n from '@/i18n';
|
||||
import type { YinianConfigSnapshot, YinianLocalSkill } from '../../shared/yinian';
|
||||
|
||||
const toastSuccessMock = vi.fn();
|
||||
@@ -62,9 +64,14 @@ function createLocalSkill(overrides: Partial<YinianLocalSkill>): YinianLocalSkil
|
||||
};
|
||||
}
|
||||
|
||||
function clickCapabilityTab(label: '服务下发' | '本地安装') {
|
||||
fireEvent.click(screen.getByRole('button', { name: new RegExp(`^${label}\\s+\\d+$`) }));
|
||||
}
|
||||
|
||||
describe('YinianSkills page', () => {
|
||||
beforeEach(() => {
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
await i18n.changeLanguage('zh');
|
||||
useYinianStore.setState({
|
||||
status: 'authenticated',
|
||||
session: {
|
||||
@@ -89,6 +96,7 @@ describe('YinianSkills page', () => {
|
||||
error: null,
|
||||
fetchSkills: vi.fn().mockResolvedValue(undefined),
|
||||
});
|
||||
useQuickTasksStore.setState({ tasks: [] });
|
||||
vi.mocked(window.yinian.skills.getRegistry).mockReset();
|
||||
vi.mocked(window.yinian.skills.getRegistry).mockResolvedValue(undefined);
|
||||
vi.mocked(window.yinian.skills.sync).mockReset();
|
||||
@@ -97,8 +105,9 @@ describe('YinianSkills page', () => {
|
||||
it('shows empty entitlement state', () => {
|
||||
render(<YinianSkills />);
|
||||
|
||||
expect(screen.getByText('智念企业组织空间')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('yinian-skills-empty-entitlements')).toHaveTextContent('当前组织空间尚未开通应用');
|
||||
expect(screen.getByText('业务能力包')).toBeInTheDocument();
|
||||
clickCapabilityTab('服务下发');
|
||||
expect(screen.getByTestId('yinian-skills-empty-entitlements')).toHaveTextContent('当前组织空间尚未开通能力包');
|
||||
});
|
||||
|
||||
it('maps entitlement and registry states for installed, update, disabled, and failed skills', () => {
|
||||
@@ -149,6 +158,7 @@ describe('YinianSkills page', () => {
|
||||
});
|
||||
|
||||
render(<YinianSkills />);
|
||||
clickCapabilityTab('服务下发');
|
||||
|
||||
expect(screen.getByTestId('yinian-skill-state-daily-report')).toHaveTextContent('已是最新');
|
||||
expect(screen.getByTestId('yinian-skill-state-data-check')).toHaveTextContent('有更新');
|
||||
@@ -179,7 +189,7 @@ describe('YinianSkills page', () => {
|
||||
});
|
||||
|
||||
render(<YinianSkills />);
|
||||
fireEvent.click(screen.getByRole('button', { name: /本地安装/ }));
|
||||
clickCapabilityTab('本地安装');
|
||||
|
||||
expect(screen.getByText('本地总结助手')).toBeInTheDocument();
|
||||
expect(screen.getByText('整理本地会话内容')).toBeInTheDocument();
|
||||
@@ -187,6 +197,52 @@ describe('YinianSkills page', () => {
|
||||
expect(screen.getByText('/tmp/openclaw/skills/local-summary')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides OpenClaw built-in skills from local capability packs by default', () => {
|
||||
useSkillsStore.setState({
|
||||
skills: [
|
||||
{
|
||||
id: 'builtin-search',
|
||||
slug: 'builtin-search',
|
||||
name: 'OpenClaw 内置搜索',
|
||||
description: 'OpenClaw 自带能力',
|
||||
enabled: true,
|
||||
version: '1.0.0',
|
||||
isBundled: true,
|
||||
source: 'openclaw-bundled',
|
||||
},
|
||||
{
|
||||
id: 'extra-helper',
|
||||
slug: 'extra-helper',
|
||||
name: 'OpenClaw 扩展目录助手',
|
||||
description: 'OpenClaw 扩展目录能力',
|
||||
enabled: true,
|
||||
version: '1.0.0',
|
||||
source: 'openclaw-extra',
|
||||
},
|
||||
{
|
||||
id: 'local-summary',
|
||||
slug: 'local-summary',
|
||||
name: '本地总结助手',
|
||||
description: '整理本地会话内容',
|
||||
enabled: true,
|
||||
version: '0.2.0',
|
||||
source: 'openclaw-managed',
|
||||
baseDir: '/tmp/openclaw/skills/local-summary',
|
||||
},
|
||||
],
|
||||
loading: false,
|
||||
error: null,
|
||||
fetchSkills: vi.fn().mockResolvedValue(undefined),
|
||||
});
|
||||
|
||||
render(<YinianSkills />);
|
||||
clickCapabilityTab('本地安装');
|
||||
|
||||
expect(screen.queryByText('OpenClaw 内置搜索')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('OpenClaw 扩展目录助手')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('本地总结助手')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows empty registry warning when entitlements exist but no local skills are installed', () => {
|
||||
useYinianStore.setState({
|
||||
config: createConfig({
|
||||
@@ -204,9 +260,10 @@ describe('YinianSkills page', () => {
|
||||
});
|
||||
|
||||
render(<YinianSkills />);
|
||||
clickCapabilityTab('服务下发');
|
||||
|
||||
expect(screen.getByTestId('yinian-skill-state-daily-report')).toHaveTextContent('已开通未同步');
|
||||
expect(screen.getByTestId('yinian-skills-empty-registry')).toHaveTextContent('这台电脑还没有准备好应用');
|
||||
expect(screen.getByTestId('yinian-skills-empty-registry')).toHaveTextContent('这台电脑还没有准备好能力包');
|
||||
});
|
||||
|
||||
it('sync button stores result and shows success toast', async () => {
|
||||
@@ -231,7 +288,7 @@ describe('YinianSkills page', () => {
|
||||
});
|
||||
|
||||
render(<YinianSkills />);
|
||||
const syncButton = screen.getByRole('button', { name: '同步应用' });
|
||||
const syncButton = screen.getByRole('button', { name: '同步能力包' });
|
||||
await waitFor(() => {
|
||||
expect(syncButton).not.toBeDisabled();
|
||||
});
|
||||
@@ -239,7 +296,7 @@ describe('YinianSkills page', () => {
|
||||
|
||||
await waitFor(() => {
|
||||
expect(window.yinian.skills.sync).toHaveBeenCalled();
|
||||
expect(toastSuccessMock).toHaveBeenCalledWith('应用已同步');
|
||||
expect(toastSuccessMock).toHaveBeenCalledWith('能力包已同步');
|
||||
});
|
||||
expect(useYinianSkillsStore.getState().localSkills).toHaveLength(1);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user