Skip to content

Commit be345f3

Browse files
zvzuolalimityan
authored andcommitted
fix(web-ui): close PR 2428 review gaps
1 parent 0a0c949 commit be345f3

5 files changed

Lines changed: 266 additions & 50 deletions

File tree

src/web-ui/src/app/scenes/agents/AgentsScene.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -595,15 +595,17 @@ const AgentsHomeView: React.FC = () => {
595595
await CustomAgentAPI.deleteCustomAgent(id, workspacePath || undefined);
596596
notification.success(t('agentsOverview.deleteSuccess', { name }));
597597
closeAgentDetails();
598-
await loadAgents();
598+
// CustomAgentAPI emits `custom-agent:updated` after the delete; the
599+
// useAgentsList subscriber owns the single refresh so two overlapping
600+
// catalog loads cannot race their status snapshots.
599601
} catch (e) {
600602
notification.error(
601603
`${t('agentsOverview.deleteFailed')}${e instanceof Error ? e.message : String(e)}`,
602604
);
603605
} finally {
604606
setDeletingAgent(false);
605607
}
606-
}, [selectedAgent, closeAgentDetails, loadAgents, notification, t, workspacePath]);
608+
}, [selectedAgent, closeAgentDetails, notification, t, workspacePath]);
607609

608610
const canManageCustomAgent = Boolean(
609611
selectedAgent

src/web-ui/src/app/scenes/agents/hooks/useAgentsList.ts

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,11 @@ export interface ToolInfo {
5050
dynamic_info?: DynamicToolInfo;
5151
}
5252

53+
interface ToolCatalogLoadResult {
54+
tools: ToolInfo[];
55+
status: ToolCatalogStatus;
56+
}
57+
5358
interface UseAgentsListOptions {
5459
searchQuery: string;
5560
filterLevel: FilterLevel;
@@ -225,25 +230,25 @@ export function useAgentsList({
225230
// See PR #2428 #3.
226231
const surfaceTag = renderedPeerDeviceId ?? 'controller';
227232

228-
const fetchTools = async (): Promise<ToolInfo[]> => {
233+
const fetchTools = async (): Promise<ToolCatalogLoadResult> => {
229234
if (!canQueryToolCatalog) {
230235
toolLog.info('Tool catalog unsupported on the current peer host; leaving the list empty', { surface: surfaceTag });
231-
setToolCatalogStatus('unsupported');
232-
return [];
236+
return { tools: [], status: 'unsupported' };
233237
}
234238
try {
235239
const tools = await api.invoke<ToolInfo[]>('get_all_tools_info');
236-
setToolCatalogStatus(tools.length > 0 ? 'available' : 'empty');
237-
return tools;
240+
return {
241+
tools,
242+
status: tools.length > 0 ? 'available' : 'empty',
243+
};
238244
} catch (error) {
239245
toolLog.error('Failed to load tool catalog', { error });
240-
setToolCatalogStatus('failed');
241-
return [];
246+
return { tools: [], status: 'failed' };
242247
}
243248
};
244249

245250
try {
246-
const [modes, subagents, tools, configs, reviewTeamDefinition, modelConfigs] = await Promise.all([
251+
const [modes, subagents, toolCatalog, configs, reviewTeamDefinition, modelConfigs] = await Promise.all([
247252
agentAPI.getAvailableModes().catch(() => []),
248253
SubagentAPI.listSubagents({ workspacePath: workspacePath || undefined }).catch(() => []),
249254
fetchTools(),
@@ -341,7 +346,8 @@ export function useAgentsList({
341346
});
342347

343348
setAllAgents([...modeAgents, ...subAgents]);
344-
setAvailableTools(tools);
349+
setAvailableTools(toolCatalog.tools);
350+
setToolCatalogStatus(toolCatalog.status);
345351
setConfiguredModels(models);
346352
setModeProfiles(profileMap);
347353
setAgentSkills(Object.fromEntries(skillEntries));

src/web-ui/src/app/scenes/profile/views/AssistantDefaultsPage.tsx

Lines changed: 36 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import React, { useCallback, useEffect, useMemo, useState } from 'react';
1+
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
22
import { useTranslation } from 'react-i18next';
33
import {
44
ArrowLeft,
@@ -106,6 +106,7 @@ const AssistantDefaultsPage: React.FC = () => {
106106
const [loading, setLoading] = useState(true);
107107
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set());
108108
const [detail, setDetail] = useState<TemplateDetail | null>(null);
109+
const loadRequestIdRef = useRef(0);
109110

110111
// Whether the current host advertises the `tool_catalog` capability. Local
111112
// always does; a peer host must answer `peer_mode_ping` with tool_catalog.
@@ -128,6 +129,7 @@ const AssistantDefaultsPage: React.FC = () => {
128129
// writes enabled because there is nothing to toggle anyway. See PR #2428
129130
// round 5 #2.
130131
const toolCatalogWritable = toolCatalogStatus === 'available' || toolCatalogStatus === 'empty';
132+
const toolCatalogUnavailable = toolCatalogStatus === 'unsupported' || toolCatalogStatus === 'failed';
131133

132134
const skillsEnabled = useMemo(
133135
() => modeSkills.filter((skill) => skill.effectiveEnabled),
@@ -217,43 +219,54 @@ const AssistantDefaultsPage: React.FC = () => {
217219
}, [mcpToolsByServer, mcpServers]);
218220

219221
useEffect(() => {
222+
const requestId = ++loadRequestIdRef.current;
223+
220224
(async () => {
221225
setLoading(true);
222226
try {
223227
// Skip the tool catalog invoke when the peer host cannot answer it,
224228
// instead of swallowing the unsupported error as an empty list. The
225229
// empty list then means "this host doesn't expose a catalog", not
226230
// "the runtime has no tools".
227-
let toolsPromise: Promise<ToolInfo[]>;
231+
let toolsPromise: Promise<{
232+
tools: ToolInfo[];
233+
status: 'available' | 'unsupported' | 'failed' | 'empty';
234+
}>;
228235
if (canQueryToolCatalog) {
229236
toolsPromise = api.invoke<ToolInfo[]>('get_all_tools_info')
230-
.then((tools) => {
231-
setToolCatalogStatus(tools.length > 0 ? 'available' : 'empty');
232-
return tools;
233-
})
237+
.then((tools) => ({
238+
tools,
239+
status: tools.length > 0 ? 'available' as const : 'empty' as const,
240+
}))
234241
.catch((error) => {
235242
log.error('Failed to load tool catalog', { error });
236-
setToolCatalogStatus('failed');
237-
return [] as ToolInfo[];
243+
return { tools: [] as ToolInfo[], status: 'failed' as const };
238244
});
239245
} else {
240-
setToolCatalogStatus('unsupported');
241-
toolsPromise = Promise.resolve([] as ToolInfo[]);
246+
toolsPromise = Promise.resolve({
247+
tools: [] as ToolInfo[],
248+
status: 'unsupported' as const,
249+
});
242250
}
243-
const [modeConf, tools, skillList, servers] = await Promise.all([
251+
const [modeConf, toolCatalog, skillList, servers] = await Promise.all([
244252
configAPI.getAgentProfileConfig(ASSISTANT_MODE_ID).catch(() => null as AgentProfileConfigItem | null),
245253
toolsPromise,
246254
configAPI.getModeSkillConfigs({ modeId: ASSISTANT_MODE_ID }).catch(() => [] as ModeSkillInfo[]),
247255
MCPAPI.getServers().catch(() => [] as MCPServerInfo[]),
248256
]);
257+
if (requestId !== loadRequestIdRef.current) return;
258+
249259
setAssistantModeConfig(modeConf);
250-
setAvailableTools(tools);
260+
setAvailableTools(toolCatalog.tools);
261+
setToolCatalogStatus(toolCatalog.status);
251262
setModeSkills(skillList ?? []);
252263
setMcpServers(servers ?? []);
253264
} catch (e) {
254265
log.error('Failed to load assistant defaults config', e);
255266
} finally {
256-
setLoading(false);
267+
if (requestId === loadRequestIdRef.current) {
268+
setLoading(false);
269+
}
257270
}
258271
})();
259272
}, [canQueryToolCatalog, renderedPeerDeviceId]);
@@ -790,22 +803,22 @@ const AssistantDefaultsPage: React.FC = () => {
790803
<GalleryZone
791804
title={t('nursery.template.mcpToolsSection')}
792805
>
793-
{mcpServerIds.size === 0 ? (
806+
{toolCatalogUnavailable ? (
794807
<div className="tc-mcp-empty">
795808
<Plug2 size={20} className="tc-mcp-empty__icon" />
796-
{/* The MCP catalog comes from the same get_all_tools_info read
797-
as built-in tools, so an unsupported/failed host affects it
798-
the same way: don't mask it as "no MCP servers". */}
799809
<span className="tc-mcp-empty__text">
800810
{toolCatalogStatus === 'unsupported'
801811
? t('empty.toolsUnsupported')
802-
: toolCatalogStatus === 'failed'
803-
? t('empty.toolsFailed')
804-
: t('nursery.template.mcpEmptyTitle')}
812+
: t('empty.toolsFailed')}
813+
</span>
814+
</div>
815+
) : mcpServerIds.size === 0 ? (
816+
<div className="tc-mcp-empty">
817+
<Plug2 size={20} className="tc-mcp-empty__icon" />
818+
<span className="tc-mcp-empty__text">
819+
{t('nursery.template.mcpEmptyTitle')}
805820
</span>
806-
{toolCatalogStatus !== 'unsupported' && toolCatalogStatus !== 'failed' ? (
807-
<span className="tc-mcp-empty__hint">{t('nursery.template.mcpEmptyHint')}</span>
808-
) : null}
821+
<span className="tc-mcp-empty__hint">{t('nursery.template.mcpEmptyHint')}</span>
809822
</div>
810823
) : (
811824
<div className="tc-tool-groups">

src/web-ui/src/infrastructure/peer-device/PeerConnectionManager.test.ts

Lines changed: 145 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -33,18 +33,41 @@ describe('PeerConnectionManager attach', () => {
3333
expect(manager.get('peer-1')).toBe(connection);
3434
});
3535

36-
it('reports new capability fields as null (unknown) when the host omits them', async () => {
37-
// An older host's peer_mode_ping does not advertise cancel_tool /
38-
// tool_catalog; coercing those to `false` would hide a working capability
39-
// on an older Desktop (Interrupt button / tool list). They must parse to
40-
// `null` so consumers stay optimistic. See PR #2428 #4.
41-
const rpc = createRpc();
36+
it('classifies a truly old Desktop from its supported tool catalog probe', async () => {
37+
const rpc = createLegacyRpc('desktop');
4238
const manager = createManager(rpc.deviceRpc);
4339

4440
const connection = await manager.connect('peer-1', 'Studio');
45-
const caps = connection.getState().capabilities;
46-
expect(caps.cancelTool).toBeNull();
47-
expect(caps.toolCatalog).toBeNull();
41+
expect(connection.getState().capabilities).toMatchObject({
42+
cancelTool: true,
43+
toolCatalog: true,
44+
hostKind: 'desktop',
45+
});
46+
expect(rpc.commands()).toEqual([
47+
'peer_mode_ping',
48+
'get_all_tools_info',
49+
'peer_control_attach',
50+
]);
51+
52+
await vi.advanceTimersByTimeAsync(KEEPALIVE_MS);
53+
expect(rpc.commands().filter(command => command === 'get_all_tools_info')).toHaveLength(1);
54+
});
55+
56+
it('classifies a truly old CLI from an unsupported tool catalog probe', async () => {
57+
const rpc = createLegacyRpc('cli');
58+
const manager = createManager(rpc.deviceRpc);
59+
60+
const connection = await manager.connect('peer-1', 'CLI');
61+
expect(connection.getState().capabilities).toMatchObject({
62+
cancelTool: false,
63+
toolCatalog: false,
64+
hostKind: 'cli',
65+
});
66+
expect(rpc.commands()).toEqual([
67+
'peer_mode_ping',
68+
'get_all_tools_info',
69+
'peer_control_attach',
70+
]);
4871
});
4972

5073
it('parses advertised new capability fields as true', async () => {
@@ -417,6 +440,70 @@ describe('PeerConnectionManager disposal', () => {
417440
// A snapshot was published for the capability change while staying ready.
418441
expect(snapshots.length).toBeGreaterThan(0);
419442
});
443+
444+
it('does not let a stale health probe classify a replacement connection', async () => {
445+
const healthCatalog = deferred<string>();
446+
const commands: string[] = [];
447+
let catalogCallCount = 0;
448+
const deviceRpc = vi.fn(async (_target: string, commandJson: string): Promise<string> => {
449+
const command = (JSON.parse(commandJson) as { command?: string }).command ?? 'unknown';
450+
commands.push(command);
451+
452+
if (command === 'peer_mode_ping') {
453+
return JSON.stringify({
454+
resp: 'host_invoke_result',
455+
ok: true,
456+
value: { capabilities: {} },
457+
});
458+
}
459+
if (command === 'get_all_tools_info') {
460+
catalogCallCount += 1;
461+
if (catalogCallCount === 1) {
462+
throw new Error('relay unavailable');
463+
}
464+
if (catalogCallCount === 2) {
465+
return healthCatalog.promise;
466+
}
467+
throw new Error('relay unavailable');
468+
}
469+
return JSON.stringify({ resp: 'host_invoke_result', ok: true, value: null });
470+
});
471+
const manager = createManager(deviceRpc);
472+
473+
const first = await manager.connect('peer-1', 'Studio');
474+
expect(first.getState().capabilities).toMatchObject({
475+
cancelTool: null,
476+
toolCatalog: null,
477+
hostKind: null,
478+
});
479+
480+
await vi.advanceTimersByTimeAsync(KEEPALIVE_MS);
481+
await vi.waitFor(() => expect(commands.filter(command => command === 'get_all_tools_info')).toHaveLength(2));
482+
483+
await manager.dispose('peer-1', { notifyPeer: false });
484+
const replacement = await manager.connect('peer-1', 'Studio');
485+
expect(replacement.getState().capabilities).toMatchObject({
486+
cancelTool: null,
487+
toolCatalog: null,
488+
hostKind: null,
489+
});
490+
491+
expect(catalogCallCount).toBe(3);
492+
healthCatalog.resolve(JSON.stringify({
493+
resp: 'host_invoke_result',
494+
ok: true,
495+
value: [],
496+
}));
497+
await Promise.resolve();
498+
499+
await vi.advanceTimersByTimeAsync(KEEPALIVE_MS);
500+
expect(catalogCallCount).toBe(4);
501+
expect(replacement.getState().capabilities).toMatchObject({
502+
cancelTool: null,
503+
toolCatalog: null,
504+
hostKind: null,
505+
});
506+
});
420507
});
421508

422509
function createManager(
@@ -469,9 +556,12 @@ function createRpc(options: { failCommands?: Set<string> } = {}) {
469556
resp: 'host_invoke_result',
470557
ok: true,
471558
value: {
559+
host_type: 'desktop',
472560
capabilities: {
473561
idempotent_dialog_submit: true,
474562
token_usage_statistics: true,
563+
cancel_tool: true,
564+
tool_catalog: true,
475565
},
476566
},
477567
});
@@ -495,6 +585,52 @@ function createRpc(options: { failCommands?: Set<string> } = {}) {
495585
};
496586
}
497587

588+
function createLegacyRpc(kind: 'desktop' | 'cli') {
589+
const calls: RpcCall[] = [];
590+
const deviceRpc = vi.fn(async (_target: string, commandJson: string): Promise<string> => {
591+
const parsed = JSON.parse(commandJson) as { command?: string; args?: Record<string, unknown> };
592+
const command = parsed.command ?? 'unknown';
593+
calls.push({ command, args: parsed.args ?? {} });
594+
595+
if (command === 'peer_mode_ping') {
596+
return JSON.stringify({
597+
resp: 'host_invoke_result',
598+
ok: true,
599+
value: {
600+
ok: true,
601+
peer: true,
602+
device_id: 'legacy-peer',
603+
capabilities: {
604+
idempotent_dialog_submit: true,
605+
targeted_session_rollback: true,
606+
token_usage_statistics: true,
607+
},
608+
},
609+
});
610+
}
611+
if (command === 'get_all_tools_info') {
612+
if (kind === 'cli') {
613+
return JSON.stringify({
614+
resp: 'host_invoke_result',
615+
ok: false,
616+
error: "command 'get_all_tools_info' is not supported on CLI peer host",
617+
});
618+
}
619+
return JSON.stringify({
620+
resp: 'host_invoke_result',
621+
ok: true,
622+
value: [],
623+
});
624+
}
625+
return JSON.stringify({ resp: 'host_invoke_result', ok: true, value: null });
626+
});
627+
628+
return {
629+
deviceRpc,
630+
commands: () => calls.map(call => call.command),
631+
};
632+
}
633+
498634
function deferred<T>() {
499635
let resolve!: (value: T) => void;
500636
const promise = new Promise<T>(res => {

0 commit comments

Comments
 (0)